Compare commits
No commits in common. "950af793c01fd49ed32f39d21133da3290986a20" and "222f8b04b4e638c1ce2f2803e7f63b9116de9786" have entirely different histories.
950af793c0
...
222f8b04b4
@ -63,13 +63,6 @@
|
|||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
EA7216D92F3A2CFD00118F4F /* Exceptions for "Andromida" folder in "Andromida" target */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
|
||||||
membershipExceptions = (
|
|
||||||
Info.plist,
|
|
||||||
);
|
|
||||||
target = EAC04A972F26BAE8007F87EA /* Andromida */;
|
|
||||||
};
|
|
||||||
EAC04D422F298D9C007F87EA /* Exceptions for "AndromidaWidget" folder in "AndromidaWidgetExtension" target */ = {
|
EAC04D422F298D9C007F87EA /* Exceptions for "AndromidaWidget" folder in "AndromidaWidgetExtension" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
@ -96,7 +89,6 @@
|
|||||||
EAC04A9A2F26BAE8007F87EA /* Andromida */ = {
|
EAC04A9A2F26BAE8007F87EA /* Andromida */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
exceptions = (
|
||||||
EA7216D92F3A2CFD00118F4F /* Exceptions for "Andromida" folder in "Andromida" target */,
|
|
||||||
EAC04D512F298EAE007F87EA /* Exceptions for "Andromida" folder in "AndromidaWidgetExtension" target */,
|
EAC04D512F298EAE007F87EA /* Exceptions for "Andromida" folder in "AndromidaWidgetExtension" target */,
|
||||||
);
|
);
|
||||||
path = Andromida;
|
path = Andromida;
|
||||||
@ -558,7 +550,6 @@
|
|||||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Andromida/Info.plist;
|
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
@ -595,7 +586,6 @@
|
|||||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Andromida/Info.plist;
|
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
|
|||||||
@ -10,8 +10,6 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>CloudKit</string>
|
<string>CloudKit</string>
|
||||||
</array>
|
</array>
|
||||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
|
||||||
<string>$(DEVELOPMENT_TEAM).$(APP_BUNDLE_IDENTIFIER)</string>
|
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(APP_GROUP_IDENTIFIER)</string>
|
<string>$(APP_GROUP_IDENTIFIER)</string>
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import Bedrock
|
|||||||
@main
|
@main
|
||||||
struct AndromidaApp: App {
|
struct AndromidaApp: App {
|
||||||
private let modelContainer: ModelContainer
|
private let modelContainer: ModelContainer
|
||||||
private let launchContext: AppLaunchContext
|
|
||||||
@State private var store: RitualStore
|
@State private var store: RitualStore
|
||||||
@State private var settingsStore: SettingsStore
|
@State private var settingsStore: SettingsStore
|
||||||
@State private var categoryStore: CategoryStore
|
@State private var categoryStore: CategoryStore
|
||||||
@ -16,10 +15,6 @@ struct AndromidaApp: App {
|
|||||||
@State private var isTransitioningToRoot = false
|
@State private var isTransitioningToRoot = false
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
#if !DEBUG
|
|
||||||
Design.showDebugLogs = false
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Register app's color theme for Bedrock components
|
// Register app's color theme for Bedrock components
|
||||||
Theme.register(
|
Theme.register(
|
||||||
text: AppTextColors.self,
|
text: AppTextColors.self,
|
||||||
@ -29,14 +24,43 @@ struct AndromidaApp: App {
|
|||||||
)
|
)
|
||||||
Theme.register(border: AppBorder.self)
|
Theme.register(border: AppBorder.self)
|
||||||
|
|
||||||
let launchContext = AppLaunchContext()
|
let environment = ProcessInfo.processInfo.environment
|
||||||
self.launchContext = launchContext
|
let isRunningTests = environment["XCTestConfigurationFilePath"] != nil
|
||||||
launchContext.applyUserDefaultsOverrides(bundleIdentifier: Bundle.main.bundleIdentifier)
|
let isUITesting = environment["UITEST_MODE"] == "1"
|
||||||
|
|
||||||
|
if isUITesting {
|
||||||
|
if environment["UITEST_RESET_USER_DEFAULTS"] == "1",
|
||||||
|
let bundleIdentifier = Bundle.main.bundleIdentifier {
|
||||||
|
UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
|
||||||
|
}
|
||||||
|
if let completedValue = environment["UITEST_HAS_COMPLETED_SETUP_WIZARD"] {
|
||||||
|
let hasCompleted = completedValue == "1" || completedValue.lowercased() == "true"
|
||||||
|
UserDefaults.standard.set(hasCompleted, forKey: "hasCompletedSetupWizard")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Include all models in schema - Ritual, RitualArc, and ArcHabit
|
// Include all models in schema - Ritual, RitualArc, and ArcHabit
|
||||||
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
|
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
|
||||||
|
|
||||||
let configuration = launchContext.modelConfiguration(for: schema)
|
let configuration: ModelConfiguration
|
||||||
|
if isUITesting {
|
||||||
|
// UI tests should always run with isolated in-memory persistence.
|
||||||
|
configuration = ModelConfiguration(
|
||||||
|
schema: schema,
|
||||||
|
isStoredInMemoryOnly: true,
|
||||||
|
cloudKitDatabase: .none
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Use App Group for shared container between app and widget.
|
||||||
|
// Disable CloudKit mirroring under XCTest to keep simulator tests deterministic.
|
||||||
|
let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
|
||||||
|
.appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite")
|
||||||
|
configuration = ModelConfiguration(
|
||||||
|
schema: schema,
|
||||||
|
url: storeURL,
|
||||||
|
cloudKitDatabase: isRunningTests ? .none : .private(AppIdentifiers.cloudKitContainerIdentifier)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let container: ModelContainer
|
let container: ModelContainer
|
||||||
do {
|
do {
|
||||||
@ -49,13 +73,15 @@ struct AndromidaApp: App {
|
|||||||
_settingsStore = State(initialValue: settings)
|
_settingsStore = State(initialValue: settings)
|
||||||
_categoryStore = State(initialValue: CategoryStore())
|
_categoryStore = State(initialValue: CategoryStore())
|
||||||
|
|
||||||
let ritualStore = RitualStore(
|
let ritualStore = RitualStore(modelContext: container.mainContext, seedService: RitualSeedService(), settingsStore: settings)
|
||||||
modelContext: container.mainContext,
|
if isUITesting, environment["UITEST_SEED_THREE_PRESETS"] == "1" {
|
||||||
seedService: RitualSeedService(),
|
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[0]) // morning
|
||||||
settingsStore: settings,
|
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[1]) // midday
|
||||||
isRunningTests: launchContext.isRunningTests
|
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[3]) // evening
|
||||||
)
|
}
|
||||||
launchContext.applyUITestSeeding(to: ritualStore)
|
if isUITesting, environment["UITEST_PRELOAD_DEMO_DATA"] == "1" {
|
||||||
|
ritualStore.preloadDemoData()
|
||||||
|
}
|
||||||
_store = State(initialValue: ritualStore)
|
_store = State(initialValue: ritualStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,12 +92,13 @@ struct AndromidaApp: App {
|
|||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
if hasCompletedSetupWizard {
|
if hasCompletedSetupWizard {
|
||||||
|
let uiTestInitialTab = uiTestRequestedInitialTab()
|
||||||
// Main app - start on Rituals tab if just completed wizard
|
// Main app - start on Rituals tab if just completed wizard
|
||||||
RootView(
|
RootView(
|
||||||
store: store,
|
store: store,
|
||||||
settingsStore: settingsStore,
|
settingsStore: settingsStore,
|
||||||
categoryStore: categoryStore,
|
categoryStore: categoryStore,
|
||||||
initialTab: launchContext.initialTabOverride ?? (justCompletedWizard ? .rituals : .today)
|
initialTab: uiTestInitialTab ?? (justCompletedWizard ? .rituals : .today)
|
||||||
)
|
)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
} else {
|
} else {
|
||||||
@ -83,14 +110,10 @@ struct AndromidaApp: App {
|
|||||||
reminderScheduler: store.reminderScheduler,
|
reminderScheduler: store.reminderScheduler,
|
||||||
onComplete: {
|
onComplete: {
|
||||||
justCompletedWizard = true
|
justCompletedWizard = true
|
||||||
if UIAccessibility.isReduceMotionEnabled {
|
|
||||||
hasCompletedSetupWizard = true
|
|
||||||
} else {
|
|
||||||
withAnimation(.easeInOut(duration: 0.5)) {
|
withAnimation(.easeInOut(duration: 0.5)) {
|
||||||
hasCompletedSetupWizard = true
|
hasCompletedSetupWizard = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -99,4 +122,16 @@ struct AndromidaApp: App {
|
|||||||
.preferredColorScheme(settingsStore.theme.colorScheme)
|
.preferredColorScheme(settingsStore.theme.colorScheme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func uiTestRequestedInitialTab() -> RootView.RootTab? {
|
||||||
|
guard ProcessInfo.processInfo.environment["UITEST_MODE"] == "1" else { return nil }
|
||||||
|
switch ProcessInfo.processInfo.environment["UITEST_INITIAL_TAB"]?.lowercased() {
|
||||||
|
case "today": return .today
|
||||||
|
case "rituals": return .rituals
|
||||||
|
case "insights": return .insights
|
||||||
|
case "history": return .history
|
||||||
|
case "settings": return .settings
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage": "en",
|
"sourceLanguage": "en",
|
||||||
"strings": {
|
"strings": {
|
||||||
"" : {
|
"": {},
|
||||||
|
|
||||||
},
|
|
||||||
" : ": {
|
" : ": {
|
||||||
"comment": "A separator between the time of day and the time range in the Ritual Detail View.",
|
"comment": "A separator between the time of day and the time range in the Ritual Detail View.",
|
||||||
"isCommentAutoGenerated": true,
|
"isCommentAutoGenerated": true,
|
||||||
@ -1682,7 +1680,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Branding Preview": {
|
"Branding Preview": {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@ -2101,7 +2098,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Clear All Completions": {
|
"Clear All Completions": {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"es-MX": {
|
"es-MX": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@ -2352,7 +2348,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Complete First Active Arc (Test Renewal)": {
|
"Complete First Active Arc (Test Renewal)": {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"es-MX": {
|
"es-MX": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@ -2949,7 +2944,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Debug": {
|
"Debug": {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@ -3957,7 +3951,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Generate the app icon": {
|
"Generate the app icon": {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@ -4471,7 +4464,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Icon Generator": {
|
"Icon Generator": {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@ -6094,7 +6086,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Preload 6 Months Demo Data": {
|
"Preload 6 Months Demo Data": {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"es-MX": {
|
"es-MX": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@ -6219,7 +6210,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Preview launch and icon": {
|
"Preview launch and icon": {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@ -6337,7 +6327,6 @@
|
|||||||
},
|
},
|
||||||
"Real": {
|
"Real": {
|
||||||
"comment": "The text for the \"Real\" option in the time of day picker.",
|
"comment": "The text for the \"Real\" option in the time of day picker.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated": true,
|
"isCommentAutoGenerated": true,
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"es-MX": {
|
"es-MX": {
|
||||||
@ -6356,7 +6345,6 @@
|
|||||||
},
|
},
|
||||||
"Real Time (%@)": {
|
"Real Time (%@)": {
|
||||||
"comment": "Text displayed in the debug picker to indicate whether it is showing the real time or a simulated time.",
|
"comment": "Text displayed in the debug picker to indicate whether it is showing the real time or a simulated time.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated": true,
|
"isCommentAutoGenerated": true,
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"es-MX": {
|
"es-MX": {
|
||||||
@ -6468,7 +6456,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Reload Widget Timelines": {
|
"Reload Widget Timelines": {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"es-MX": {
|
"es-MX": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@ -6558,7 +6545,6 @@
|
|||||||
},
|
},
|
||||||
"Reset Setup Wizard": {
|
"Reset Setup Wizard": {
|
||||||
"comment": "Title of a navigation row in the Settings view that resets the setup wizard state.",
|
"comment": "Title of a navigation row in the Settings view that resets the setup wizard state.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@ -7245,7 +7231,6 @@
|
|||||||
},
|
},
|
||||||
"Simulate Foreground Refresh": {
|
"Simulate Foreground Refresh": {
|
||||||
"comment": "Title of a settings option that simulates a foreground refresh of the app.",
|
"comment": "Title of a settings option that simulates a foreground refresh of the app.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated": true,
|
"isCommentAutoGenerated": true,
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"es-MX": {
|
"es-MX": {
|
||||||
@ -7264,7 +7249,6 @@
|
|||||||
},
|
},
|
||||||
"Simulate Time of Day": {
|
"Simulate Time of Day": {
|
||||||
"comment": "A label for the time of day picker.",
|
"comment": "A label for the time of day picker.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated": true,
|
"isCommentAutoGenerated": true,
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"es-MX": {
|
"es-MX": {
|
||||||
@ -8513,7 +8497,6 @@
|
|||||||
},
|
},
|
||||||
"Trigger Test Notification (5s)": {
|
"Trigger Test Notification (5s)": {
|
||||||
"comment": "Title of a settings option that triggers a test local notification.",
|
"comment": "Title of a settings option that triggers a test local notification.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated": true,
|
"isCommentAutoGenerated": true,
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"es-MX": {
|
"es-MX": {
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import Observation
|
import Observation
|
||||||
import Bedrock
|
|
||||||
|
|
||||||
/// Reminder time slots based on ritual TimeOfDay values.
|
/// Reminder time slots based on ritual TimeOfDay values.
|
||||||
/// Groups similar times to avoid excessive notifications.
|
/// Groups similar times to avoid excessive notifications.
|
||||||
@ -86,7 +85,7 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
willPresent notification: UNNotification,
|
willPresent notification: UNNotification,
|
||||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||||
) {
|
) {
|
||||||
Design.debugLog("🔔 Notification will present in foreground: \(notification.request.identifier)")
|
print("🔔 Notification will present in foreground: \(notification.request.identifier)")
|
||||||
// Show the notification even when the app is in the foreground
|
// Show the notification even when the app is in the foreground
|
||||||
completionHandler([.banner, .list, .sound, .badge])
|
completionHandler([.banner, .list, .sound, .badge])
|
||||||
}
|
}
|
||||||
@ -96,7 +95,7 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
didReceive response: UNNotificationResponse,
|
didReceive response: UNNotificationResponse,
|
||||||
withCompletionHandler completionHandler: @escaping () -> Void
|
withCompletionHandler completionHandler: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
Design.debugLog("🔔 Notification received/tapped: \(response.notification.request.identifier)")
|
print("🔔 Notification received/tapped: \(response.notification.request.identifier)")
|
||||||
// Clear badge when user interacts with notification
|
// Clear badge when user interacts with notification
|
||||||
clearBadge()
|
clearBadge()
|
||||||
shouldNavigateToToday = true
|
shouldNavigateToToday = true
|
||||||
@ -128,7 +127,7 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
await refreshAuthorizationStatus()
|
await refreshAuthorizationStatus()
|
||||||
return granted
|
return granted
|
||||||
} catch {
|
} catch {
|
||||||
Design.debugLog("Notification authorization error: \(error)")
|
print("Notification authorization error: \(error)")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -148,7 +147,7 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
|
|
||||||
/// Schedules a test notification to appear in 5 seconds.
|
/// Schedules a test notification to appear in 5 seconds.
|
||||||
func scheduleTestNotification() {
|
func scheduleTestNotification() {
|
||||||
Design.debugLog("🔔 Attempting to schedule test notification...")
|
print("🔔 Attempting to schedule test notification...")
|
||||||
|
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = String(localized: "Test Notification")
|
content.title = String(localized: "Test Notification")
|
||||||
@ -165,9 +164,9 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
|
|
||||||
UNUserNotificationCenter.current().add(request) { error in
|
UNUserNotificationCenter.current().add(request) { error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
Design.debugLog("❌ Failed to schedule test notification: \(error)")
|
print("❌ Failed to schedule test notification: \(error)")
|
||||||
} else {
|
} else {
|
||||||
Design.debugLog("✅ Test notification scheduled successfully! It should appear in 5 seconds.")
|
print("✅ Test notification scheduled successfully! It should appear in 5 seconds.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -258,7 +257,7 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
do {
|
do {
|
||||||
try await UNUserNotificationCenter.current().add(request)
|
try await UNUserNotificationCenter.current().add(request)
|
||||||
} catch {
|
} catch {
|
||||||
Design.debugLog("Failed to schedule \(slot.rawValue) reminder: \(error)")
|
print("Failed to schedule \(slot.rawValue) reminder: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,11 +11,6 @@ extension RitualStore {
|
|||||||
} catch {
|
} catch {
|
||||||
fatalError("Preview container failed: \(error)")
|
fatalError("Preview container failed: \(error)")
|
||||||
}
|
}
|
||||||
return RitualStore(
|
return RitualStore(modelContext: container.mainContext, seedService: RitualSeedService(), settingsStore: SettingsStore())
|
||||||
modelContext: container.mainContext,
|
|
||||||
seedService: RitualSeedService(),
|
|
||||||
settingsStore: SettingsStore(),
|
|
||||||
isRunningTests: false
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,7 +59,6 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
modelContext: ModelContext,
|
modelContext: ModelContext,
|
||||||
seedService: RitualSeedProviding,
|
seedService: RitualSeedProviding,
|
||||||
settingsStore: any RitualFeedbackSettingsProviding,
|
settingsStore: any RitualFeedbackSettingsProviding,
|
||||||
isRunningTests: Bool,
|
|
||||||
calendar: Calendar = .current,
|
calendar: Calendar = .current,
|
||||||
now: @escaping () -> Date = Date.init
|
now: @escaping () -> Date = Date.init
|
||||||
) {
|
) {
|
||||||
@ -68,7 +67,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
self.settingsStore = settingsStore
|
self.settingsStore = settingsStore
|
||||||
self.calendar = calendar
|
self.calendar = calendar
|
||||||
self.nowProvider = now
|
self.nowProvider = now
|
||||||
self.isRunningTests = isRunningTests
|
self.isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
|
||||||
self.dayFormatter = DateFormatter()
|
self.dayFormatter = DateFormatter()
|
||||||
self.displayFormatter = DateFormatter()
|
self.displayFormatter = DateFormatter()
|
||||||
dayFormatter.calendar = calendar
|
dayFormatter.calendar = calendar
|
||||||
@ -1557,7 +1556,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
// Find the first ritual with an active arc
|
// Find the first ritual with an active arc
|
||||||
guard let ritual = currentRituals.first,
|
guard let ritual = currentRituals.first,
|
||||||
let arc = ritual.activeArc(on: now()) else {
|
let arc = ritual.activeArc(on: now()) else {
|
||||||
Design.debugLog("No active arcs to complete")
|
print("No active arcs to complete")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1574,7 +1573,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
// Trigger the completion check - this will set ritualNeedingRenewal
|
// Trigger the completion check - this will set ritualNeedingRenewal
|
||||||
checkForCompletedArcs()
|
checkForCompletedArcs()
|
||||||
|
|
||||||
Design.debugLog("Arc '\(ritual.title)' marked as completed. Navigate to Today tab to see renewal prompt.")
|
print("Arc '\(ritual.title)' marked as completed. Navigate to Today tab to see renewal prompt.")
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
struct AppLaunchContext {
|
|
||||||
enum LaunchTab: String {
|
|
||||||
case today
|
|
||||||
case rituals
|
|
||||||
case insights
|
|
||||||
case history
|
|
||||||
case settings
|
|
||||||
}
|
|
||||||
|
|
||||||
let isRunningTests: Bool
|
|
||||||
let isUITesting: Bool
|
|
||||||
let shouldResetUserDefaults: Bool
|
|
||||||
let hasCompletedSetupWizardOverride: Bool?
|
|
||||||
let shouldSeedThreePresets: Bool
|
|
||||||
let shouldPreloadDemoData: Bool
|
|
||||||
let requestedInitialTab: LaunchTab?
|
|
||||||
|
|
||||||
init(environment: [String: String] = ProcessInfo.processInfo.environment) {
|
|
||||||
isRunningTests = environment["XCTestConfigurationFilePath"] != nil
|
|
||||||
isUITesting = Self.isEnabled(environment["UITEST_MODE"])
|
|
||||||
shouldResetUserDefaults = Self.isEnabled(environment["UITEST_RESET_USER_DEFAULTS"])
|
|
||||||
hasCompletedSetupWizardOverride = Self.optionalBool(environment["UITEST_HAS_COMPLETED_SETUP_WIZARD"])
|
|
||||||
shouldSeedThreePresets = Self.isEnabled(environment["UITEST_SEED_THREE_PRESETS"])
|
|
||||||
shouldPreloadDemoData = Self.isEnabled(environment["UITEST_PRELOAD_DEMO_DATA"])
|
|
||||||
requestedInitialTab = Self.parseTab(environment["UITEST_INITIAL_TAB"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyUserDefaultsOverrides(bundleIdentifier: String?) {
|
|
||||||
guard isUITesting else { return }
|
|
||||||
|
|
||||||
if shouldResetUserDefaults, let bundleIdentifier {
|
|
||||||
UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let hasCompletedSetupWizardOverride {
|
|
||||||
UserDefaults.standard.set(hasCompletedSetupWizardOverride, forKey: "hasCompletedSetupWizard")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func modelConfiguration(for schema: Schema) -> ModelConfiguration {
|
|
||||||
if isUITesting {
|
|
||||||
// UI tests should always run with isolated in-memory persistence.
|
|
||||||
return ModelConfiguration(
|
|
||||||
schema: schema,
|
|
||||||
isStoredInMemoryOnly: true,
|
|
||||||
cloudKitDatabase: .none
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use App Group for shared container between app and widget.
|
|
||||||
// Disable CloudKit mirroring under XCTest to keep simulator tests deterministic.
|
|
||||||
let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
|
|
||||||
.appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite")
|
|
||||||
|
|
||||||
return ModelConfiguration(
|
|
||||||
schema: schema,
|
|
||||||
url: storeURL,
|
|
||||||
cloudKitDatabase: isRunningTests ? .none : .private(AppIdentifiers.cloudKitContainerIdentifier)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyUITestSeeding(to ritualStore: RitualStore) {
|
|
||||||
guard isUITesting else { return }
|
|
||||||
|
|
||||||
if shouldSeedThreePresets {
|
|
||||||
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[0]) // morning
|
|
||||||
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[1]) // midday
|
|
||||||
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[3]) // evening
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
if shouldPreloadDemoData {
|
|
||||||
ritualStore.preloadDemoData()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
var initialTabOverride: RootView.RootTab? {
|
|
||||||
guard let requestedInitialTab else { return nil }
|
|
||||||
switch requestedInitialTab {
|
|
||||||
case .today: return .today
|
|
||||||
case .rituals: return .rituals
|
|
||||||
case .insights: return .insights
|
|
||||||
case .history: return .history
|
|
||||||
case .settings: return .settings
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isEnabled(_ value: String?) -> Bool {
|
|
||||||
optionalBool(value) ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func optionalBool(_ value: String?) -> Bool? {
|
|
||||||
guard let value else { return nil }
|
|
||||||
return value == "1" || value.lowercased() == "true"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func parseTab(_ value: String?) -> LaunchTab? {
|
|
||||||
guard let value else { return nil }
|
|
||||||
return LaunchTab(rawValue: value.lowercased())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,7 +4,6 @@ import Bedrock
|
|||||||
/// Interactive tutorial step where users complete their first habit check-in.
|
/// Interactive tutorial step where users complete their first habit check-in.
|
||||||
struct FirstCheckInStepView: View {
|
struct FirstCheckInStepView: View {
|
||||||
@Bindable var store: RitualStore
|
@Bindable var store: RitualStore
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
||||||
let ritual: Ritual
|
let ritual: Ritual
|
||||||
@Binding var hasCompletedCheckIn: Bool
|
@Binding var hasCompletedCheckIn: Bool
|
||||||
let onComplete: () -> Void
|
let onComplete: () -> Void
|
||||||
@ -122,7 +121,7 @@ struct FirstCheckInStepView: View {
|
|||||||
.frame(height: Design.Spacing.xxLarge)
|
.frame(height: Design.Spacing.xxLarge)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
withOptionalAnimation(.easeOut(duration: 0.5), reduceMotion: reduceMotion) {
|
withAnimation(.easeOut(duration: 0.5)) {
|
||||||
animateContent = true
|
animateContent = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -160,7 +159,7 @@ struct FirstCheckInStepView: View {
|
|||||||
}
|
}
|
||||||
.accessibilityIdentifier("onboarding.firstCheckInContinueToRituals")
|
.accessibilityIdentifier("onboarding.firstCheckInContinueToRituals")
|
||||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
.transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@ -173,15 +172,10 @@ struct FirstCheckInStepView: View {
|
|||||||
private func triggerCelebration() {
|
private func triggerCelebration() {
|
||||||
hasCompletedCheckIn = true
|
hasCompletedCheckIn = true
|
||||||
|
|
||||||
withOptionalAnimation(.spring(response: 0.5, dampingFraction: 0.7), reduceMotion: reduceMotion) {
|
withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
|
||||||
showCelebration = true
|
showCelebration = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if reduceMotion {
|
|
||||||
showContinueButton = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show continue button after celebration settles
|
// Show continue button after celebration settles
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
withAnimation(.easeOut(duration: 0.3)) {
|
withAnimation(.easeOut(duration: 0.3)) {
|
||||||
@ -193,7 +187,6 @@ struct FirstCheckInStepView: View {
|
|||||||
|
|
||||||
/// A habit row styled for the onboarding flow with optional highlight.
|
/// A habit row styled for the onboarding flow with optional highlight.
|
||||||
private struct OnboardingHabitRowView: View {
|
private struct OnboardingHabitRowView: View {
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
||||||
let title: String
|
let title: String
|
||||||
let symbolName: String
|
let symbolName: String
|
||||||
let isCompleted: Bool
|
let isCompleted: Bool
|
||||||
@ -226,13 +219,13 @@ private struct OnboardingHabitRowView: View {
|
|||||||
isHighlighted ? AppAccent.primary : Color.clear,
|
isHighlighted ? AppAccent.primary : Color.clear,
|
||||||
lineWidth: 2
|
lineWidth: 2
|
||||||
)
|
)
|
||||||
.scaleEffect(pulseAnimation && isHighlighted && !reduceMotion ? 1.02 : 1.0)
|
.scaleEffect(pulseAnimation && isHighlighted ? 1.02 : 1.0)
|
||||||
.opacity(pulseAnimation && isHighlighted && !reduceMotion ? 0.5 : 1.0)
|
.opacity(pulseAnimation && isHighlighted ? 0.5 : 1.0)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if isHighlighted && !reduceMotion {
|
if isHighlighted {
|
||||||
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
|
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
|
||||||
pulseAnimation = true
|
pulseAnimation = true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import Bedrock
|
|||||||
/// The goal selection screen where users choose what they want to focus on.
|
/// The goal selection screen where users choose what they want to focus on.
|
||||||
struct GoalSelectionStepView: View {
|
struct GoalSelectionStepView: View {
|
||||||
@Binding var selectedGoals: [OnboardingGoal]
|
@Binding var selectedGoals: [OnboardingGoal]
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
||||||
let onContinue: () -> Void
|
let onContinue: () -> Void
|
||||||
|
|
||||||
@State private var animateCards = false
|
@State private var animateCards = false
|
||||||
@ -35,17 +34,16 @@ struct GoalSelectionStepView: View {
|
|||||||
goal: goal,
|
goal: goal,
|
||||||
isSelected: selectedGoals.contains(goal),
|
isSelected: selectedGoals.contains(goal),
|
||||||
onTap: {
|
onTap: {
|
||||||
withOptionalAnimation(.easeInOut(duration: Design.Animation.quick), reduceMotion: reduceMotion) {
|
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
|
||||||
toggleGoalSelection(goal)
|
toggleGoalSelection(goal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.opacity(animateCards ? 1 : 0)
|
.opacity(animateCards ? 1 : 0)
|
||||||
.offset(y: animateCards ? 0 : 20)
|
.offset(y: animateCards ? 0 : 20)
|
||||||
.optionalAnimation(
|
.animation(
|
||||||
.easeOut(duration: 0.4).delay(Double(index) * 0.1),
|
.easeOut(duration: 0.4).delay(Double(index) * 0.1),
|
||||||
value: animateCards,
|
value: animateCards
|
||||||
reduceMotion: reduceMotion
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -67,13 +65,13 @@ struct GoalSelectionStepView: View {
|
|||||||
.accessibilityIdentifier("onboarding.goalContinue")
|
.accessibilityIdentifier("onboarding.goalContinue")
|
||||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
.padding(.bottom, Design.Spacing.xxLarge)
|
.padding(.bottom, Design.Spacing.xxLarge)
|
||||||
.transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.optionalAnimation(.easeInOut(duration: Design.Animation.quick), value: !selectedGoals.isEmpty, reduceMotion: reduceMotion)
|
.animation(.easeInOut(duration: Design.Animation.quick), value: !selectedGoals.isEmpty)
|
||||||
.accessibilityIdentifier("onboarding.goalSelection")
|
.accessibilityIdentifier("onboarding.goalSelection")
|
||||||
.onAppear {
|
.onAppear {
|
||||||
withOptionalAnimation(reduceMotion: reduceMotion) {
|
withAnimation {
|
||||||
animateCards = true
|
animateCards = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import Bedrock
|
|||||||
/// The notification permission screen where users can enable reminders.
|
/// The notification permission screen where users can enable reminders.
|
||||||
/// Shown after the first check-in to maximize conversion after experiencing value.
|
/// Shown after the first check-in to maximize conversion after experiencing value.
|
||||||
struct NotificationStepView: View {
|
struct NotificationStepView: View {
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
||||||
let selectedTimes: Set<OnboardingTimePreference>
|
let selectedTimes: Set<OnboardingTimePreference>
|
||||||
let reminderScheduler: ReminderScheduler
|
let reminderScheduler: ReminderScheduler
|
||||||
let onComplete: () -> Void
|
let onComplete: () -> Void
|
||||||
@ -118,22 +117,20 @@ struct NotificationStepView: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.fill(AppAccent.primary.opacity(0.1))
|
.fill(AppAccent.primary.opacity(0.1))
|
||||||
.frame(width: 160, height: 160)
|
.frame(width: 160, height: 160)
|
||||||
.scaleEffect(animateIcon && !reduceMotion ? 1.1 : 1.0)
|
.scaleEffect(animateIcon ? 1.1 : 1.0)
|
||||||
.optionalAnimation(
|
.animation(
|
||||||
.easeInOut(duration: 2).repeatForever(autoreverses: true),
|
.easeInOut(duration: 2).repeatForever(autoreverses: true),
|
||||||
value: animateIcon,
|
value: animateIcon
|
||||||
reduceMotion: reduceMotion
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Middle circle
|
// Middle circle
|
||||||
Circle()
|
Circle()
|
||||||
.fill(AppAccent.primary.opacity(0.15))
|
.fill(AppAccent.primary.opacity(0.15))
|
||||||
.frame(width: 120, height: 120)
|
.frame(width: 120, height: 120)
|
||||||
.scaleEffect(animateIcon && !reduceMotion ? 1.05 : 1.0)
|
.scaleEffect(animateIcon ? 1.05 : 1.0)
|
||||||
.optionalAnimation(
|
.animation(
|
||||||
.easeInOut(duration: 1.5).repeatForever(autoreverses: true).delay(0.2),
|
.easeInOut(duration: 1.5).repeatForever(autoreverses: true).delay(0.2),
|
||||||
value: animateIcon,
|
value: animateIcon
|
||||||
reduceMotion: reduceMotion
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Inner circle with bell
|
// Inner circle with bell
|
||||||
@ -145,11 +142,10 @@ struct NotificationStepView: View {
|
|||||||
Image(systemName: "bell.fill")
|
Image(systemName: "bell.fill")
|
||||||
.font(.system(size: 36, weight: .medium))
|
.font(.system(size: 36, weight: .medium))
|
||||||
.foregroundStyle(AppAccent.primary)
|
.foregroundStyle(AppAccent.primary)
|
||||||
.rotationEffect(.degrees(animateIcon && !reduceMotion ? 10 : 0))
|
.rotationEffect(.degrees(animateIcon ? 10 : -10))
|
||||||
.optionalAnimation(
|
.animation(
|
||||||
.easeInOut(duration: 0.5).repeatForever(autoreverses: true),
|
.easeInOut(duration: 0.5).repeatForever(autoreverses: true),
|
||||||
value: animateIcon,
|
value: animateIcon
|
||||||
reduceMotion: reduceMotion
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -177,13 +173,6 @@ struct NotificationStepView: View {
|
|||||||
// MARK: - Animations
|
// MARK: - Animations
|
||||||
|
|
||||||
private func startAnimations() {
|
private func startAnimations() {
|
||||||
if reduceMotion {
|
|
||||||
animateIcon = true
|
|
||||||
animateContent = true
|
|
||||||
animateButtons = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stagger the animations
|
// Stagger the animations
|
||||||
withAnimation(.easeOut(duration: 0.6)) {
|
withAnimation(.easeOut(duration: 0.6)) {
|
||||||
animateIcon = true
|
animateIcon = true
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import Bedrock
|
|||||||
|
|
||||||
/// Shows a preview of a ritual preset before creation.
|
/// Shows a preview of a ritual preset before creation.
|
||||||
struct RitualPreviewStepView: View {
|
struct RitualPreviewStepView: View {
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
||||||
let preset: RitualPreset
|
let preset: RitualPreset
|
||||||
let ritualIndex: Int? // e.g., 1 for "Ritual 1 of 2"
|
let ritualIndex: Int? // e.g., 1 for "Ritual 1 of 2"
|
||||||
let totalRituals: Int? // e.g., 2
|
let totalRituals: Int? // e.g., 2
|
||||||
@ -106,7 +105,7 @@ struct RitualPreviewStepView: View {
|
|||||||
.frame(height: Design.Spacing.xxLarge)
|
.frame(height: Design.Spacing.xxLarge)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
withOptionalAnimation(.easeOut(duration: 0.5), reduceMotion: reduceMotion) {
|
withAnimation(.easeOut(duration: 0.5)) {
|
||||||
animateContent = true
|
animateContent = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import Bedrock
|
|||||||
struct SetupWizardView: View {
|
struct SetupWizardView: View {
|
||||||
@Bindable var store: RitualStore
|
@Bindable var store: RitualStore
|
||||||
@Bindable var categoryStore: CategoryStore
|
@Bindable var categoryStore: CategoryStore
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
||||||
let reminderScheduler: ReminderScheduler
|
let reminderScheduler: ReminderScheduler
|
||||||
let onComplete: () -> Void
|
let onComplete: () -> Void
|
||||||
|
|
||||||
@ -137,10 +136,13 @@ struct SetupWizardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.adaptiveContentWidth(maxWidth: Design.Size.maxContentWidthPortrait)
|
.adaptiveContentWidth(maxWidth: Design.Size.maxContentWidthPortrait)
|
||||||
.transition(stepTransition)
|
.transition(.asymmetric(
|
||||||
|
insertion: .move(edge: .trailing).combined(with: .opacity),
|
||||||
|
removal: .move(edge: .leading).combined(with: .opacity)
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.optionalAnimation(.easeInOut(duration: Design.Animation.standard), value: currentStep, reduceMotion: reduceMotion)
|
.animation(.easeInOut(duration: Design.Animation.standard), value: currentStep)
|
||||||
.alert(String(localized: "Skip setup?"), isPresented: $isShowingSkipConfirmation) {
|
.alert(String(localized: "Skip setup?"), isPresented: $isShowingSkipConfirmation) {
|
||||||
Button(String(localized: "Keep going"), role: .cancel) {}
|
Button(String(localized: "Keep going"), role: .cancel) {}
|
||||||
Button(String(localized: "Skip"), role: .destructive) {
|
Button(String(localized: "Skip"), role: .destructive) {
|
||||||
@ -196,22 +198,12 @@ struct SetupWizardView: View {
|
|||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
|
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
|
||||||
.fill(AppAccent.primary)
|
.fill(AppAccent.primary)
|
||||||
.frame(width: geometry.size.width * progressValue, height: 4)
|
.frame(width: geometry.size.width * progressValue, height: 4)
|
||||||
.optionalAnimation(.easeInOut(duration: Design.Animation.standard), value: currentStep, reduceMotion: reduceMotion)
|
.animation(.easeInOut(duration: Design.Animation.standard), value: currentStep)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 4)
|
.frame(height: 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var stepTransition: AnyTransition {
|
|
||||||
if reduceMotion {
|
|
||||||
return .opacity
|
|
||||||
}
|
|
||||||
return .asymmetric(
|
|
||||||
insertion: .move(edge: .trailing).combined(with: .opacity),
|
|
||||||
removal: .move(edge: .leading).combined(with: .opacity)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adjusted progress value that accounts for skipped steps
|
/// Adjusted progress value that accounts for skipped steps
|
||||||
private var progressValue: Double {
|
private var progressValue: Double {
|
||||||
switch currentStep {
|
switch currentStep {
|
||||||
@ -236,14 +228,14 @@ struct SetupWizardView: View {
|
|||||||
|
|
||||||
private func advanceToNextStep() {
|
private func advanceToNextStep() {
|
||||||
guard let nextStep = WizardStep(rawValue: currentStep.rawValue + 1) else { return }
|
guard let nextStep = WizardStep(rawValue: currentStep.rawValue + 1) else { return }
|
||||||
withOptionalAnimation(reduceMotion: reduceMotion) {
|
withAnimation {
|
||||||
currentStep = nextStep
|
currentStep = nextStep
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func goBack() {
|
private func goBack() {
|
||||||
if currentStep == .ritualPreview, currentPresetIndex > 0 {
|
if currentStep == .ritualPreview, currentPresetIndex > 0 {
|
||||||
withOptionalAnimation(reduceMotion: reduceMotion) {
|
withAnimation {
|
||||||
currentPresetIndex -= 1
|
currentPresetIndex -= 1
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@ -252,19 +244,19 @@ struct SetupWizardView: View {
|
|||||||
let targetStep = currentStep.rawValue - 1
|
let targetStep = currentStep.rawValue - 1
|
||||||
guard targetStep >= 0,
|
guard targetStep >= 0,
|
||||||
let previousStep = WizardStep(rawValue: targetStep) else { return }
|
let previousStep = WizardStep(rawValue: targetStep) else { return }
|
||||||
withOptionalAnimation(reduceMotion: reduceMotion) {
|
withAnimation {
|
||||||
currentStep = previousStep
|
currentStep = previousStep
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func advanceToNotifications() {
|
private func advanceToNotifications() {
|
||||||
withOptionalAnimation(reduceMotion: reduceMotion) {
|
withAnimation {
|
||||||
currentStep = .notifications
|
currentStep = .notifications
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func advanceToWhatsNext() {
|
private func advanceToWhatsNext() {
|
||||||
withOptionalAnimation(reduceMotion: reduceMotion) {
|
withAnimation {
|
||||||
currentStep = .whatsNext
|
currentStep = .whatsNext
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -295,7 +287,7 @@ struct SetupWizardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
withOptionalAnimation(reduceMotion: reduceMotion) {
|
withAnimation {
|
||||||
pendingPresets = presets
|
pendingPresets = presets
|
||||||
currentPresetIndex = 0
|
currentPresetIndex = 0
|
||||||
createdRituals = []
|
createdRituals = []
|
||||||
@ -323,7 +315,7 @@ struct SetupWizardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func advanceFromPreview() {
|
private func advanceFromPreview() {
|
||||||
withOptionalAnimation(reduceMotion: reduceMotion) {
|
withAnimation {
|
||||||
if currentPresetIndex + 1 < pendingPresets.count {
|
if currentPresetIndex + 1 < pendingPresets.count {
|
||||||
currentPresetIndex += 1
|
currentPresetIndex += 1
|
||||||
} else if hasCreatedRitual {
|
} else if hasCreatedRitual {
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import Bedrock
|
|||||||
/// The time selection screen where users choose when they want to build habits.
|
/// The time selection screen where users choose when they want to build habits.
|
||||||
struct TimeSelectionStepView: View {
|
struct TimeSelectionStepView: View {
|
||||||
@Binding var selectedTimes: Set<OnboardingTimePreference>
|
@Binding var selectedTimes: Set<OnboardingTimePreference>
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
||||||
let onContinue: () -> Void
|
let onContinue: () -> Void
|
||||||
|
|
||||||
@State private var animateCards = false
|
@State private var animateCards = false
|
||||||
@ -40,10 +39,9 @@ struct TimeSelectionStepView: View {
|
|||||||
)
|
)
|
||||||
.opacity(animateCards ? 1 : 0)
|
.opacity(animateCards ? 1 : 0)
|
||||||
.offset(y: animateCards ? 0 : 20)
|
.offset(y: animateCards ? 0 : 20)
|
||||||
.optionalAnimation(
|
.animation(
|
||||||
.easeOut(duration: 0.4).delay(Double(index) * 0.1),
|
.easeOut(duration: 0.4).delay(Double(index) * 0.1),
|
||||||
value: animateCards,
|
value: animateCards
|
||||||
reduceMotion: reduceMotion
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -67,20 +65,20 @@ struct TimeSelectionStepView: View {
|
|||||||
.accessibilityIdentifier("onboarding.timeContinue")
|
.accessibilityIdentifier("onboarding.timeContinue")
|
||||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
.padding(.bottom, Design.Spacing.xxLarge)
|
.padding(.bottom, Design.Spacing.xxLarge)
|
||||||
.transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.optionalAnimation(.easeInOut(duration: Design.Animation.quick), value: !selectedTimes.isEmpty, reduceMotion: reduceMotion)
|
.animation(.easeInOut(duration: Design.Animation.quick), value: !selectedTimes.isEmpty)
|
||||||
.accessibilityIdentifier("onboarding.timeSelection")
|
.accessibilityIdentifier("onboarding.timeSelection")
|
||||||
.onAppear {
|
.onAppear {
|
||||||
withOptionalAnimation(reduceMotion: reduceMotion) {
|
withAnimation {
|
||||||
animateCards = true
|
animateCards = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleSelection(_ time: OnboardingTimePreference) {
|
private func toggleSelection(_ time: OnboardingTimePreference) {
|
||||||
withOptionalAnimation(.easeInOut(duration: Design.Animation.quick), reduceMotion: reduceMotion) {
|
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
|
||||||
if selectedTimes.contains(time) {
|
if selectedTimes.contains(time) {
|
||||||
selectedTimes.remove(time)
|
selectedTimes.remove(time)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import Bedrock
|
|||||||
|
|
||||||
/// The welcome screen shown as the first step of the setup wizard.
|
/// The welcome screen shown as the first step of the setup wizard.
|
||||||
struct WelcomeStepView: View {
|
struct WelcomeStepView: View {
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
||||||
let onContinue: () -> Void
|
let onContinue: () -> Void
|
||||||
|
|
||||||
@State private var animateRings = false
|
@State private var animateRings = false
|
||||||
@ -71,11 +70,10 @@ struct WelcomeStepView: View {
|
|||||||
style: StrokeStyle(lineWidth: 8, lineCap: .round)
|
style: StrokeStyle(lineWidth: 8, lineCap: .round)
|
||||||
)
|
)
|
||||||
.frame(width: 180, height: 180)
|
.frame(width: 180, height: 180)
|
||||||
.rotationEffect(.degrees(animateRings && !reduceMotion ? 360 : 0))
|
.rotationEffect(.degrees(animateRings ? 360 : 0))
|
||||||
.optionalAnimation(
|
.animation(
|
||||||
.linear(duration: 20).repeatForever(autoreverses: false),
|
.linear(duration: 20).repeatForever(autoreverses: false),
|
||||||
value: animateRings,
|
value: animateRings
|
||||||
reduceMotion: reduceMotion
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Middle ring with arc
|
// Middle ring with arc
|
||||||
@ -86,11 +84,10 @@ struct WelcomeStepView: View {
|
|||||||
style: StrokeStyle(lineWidth: 10, lineCap: .round)
|
style: StrokeStyle(lineWidth: 10, lineCap: .round)
|
||||||
)
|
)
|
||||||
.frame(width: 140, height: 140)
|
.frame(width: 140, height: 140)
|
||||||
.rotationEffect(.degrees(animateRings && !reduceMotion ? -360 : 0))
|
.rotationEffect(.degrees(animateRings ? -360 : 0))
|
||||||
.optionalAnimation(
|
.animation(
|
||||||
.linear(duration: 15).repeatForever(autoreverses: false),
|
.linear(duration: 15).repeatForever(autoreverses: false),
|
||||||
value: animateRings,
|
value: animateRings
|
||||||
reduceMotion: reduceMotion
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Inner ring with arc
|
// Inner ring with arc
|
||||||
@ -101,20 +98,18 @@ struct WelcomeStepView: View {
|
|||||||
style: StrokeStyle(lineWidth: 12, lineCap: .round)
|
style: StrokeStyle(lineWidth: 12, lineCap: .round)
|
||||||
)
|
)
|
||||||
.frame(width: 100, height: 100)
|
.frame(width: 100, height: 100)
|
||||||
.rotationEffect(.degrees(animateRings && !reduceMotion ? 360 : 0))
|
.rotationEffect(.degrees(animateRings ? 360 : 0))
|
||||||
.optionalAnimation(
|
.animation(
|
||||||
.linear(duration: 10).repeatForever(autoreverses: false),
|
.linear(duration: 10).repeatForever(autoreverses: false),
|
||||||
value: animateRings,
|
value: animateRings
|
||||||
reduceMotion: reduceMotion
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Center icon
|
// Center icon
|
||||||
SymbolIcon("sparkles", size: .card, color: AppAccent.primary)
|
SymbolIcon("sparkles", size: .card, color: AppAccent.primary)
|
||||||
.scaleEffect(animateRings && !reduceMotion ? 1.1 : 1.0)
|
.scaleEffect(animateRings ? 1.1 : 1.0)
|
||||||
.optionalAnimation(
|
.animation(
|
||||||
.easeInOut(duration: 1.5).repeatForever(autoreverses: true),
|
.easeInOut(duration: 1.5).repeatForever(autoreverses: true),
|
||||||
value: animateRings,
|
value: animateRings
|
||||||
reduceMotion: reduceMotion
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -122,13 +117,6 @@ struct WelcomeStepView: View {
|
|||||||
// MARK: - Animations
|
// MARK: - Animations
|
||||||
|
|
||||||
private func startAnimations() {
|
private func startAnimations() {
|
||||||
if reduceMotion {
|
|
||||||
animateRings = true
|
|
||||||
animateText = true
|
|
||||||
animateButton = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stagger the animations
|
// Stagger the animations
|
||||||
withAnimation(.easeOut(duration: 0.6)) {
|
withAnimation(.easeOut(duration: 0.6)) {
|
||||||
animateRings = true
|
animateRings = true
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import Bedrock
|
|||||||
|
|
||||||
/// Educational screen shown at the end of the wizard explaining the app's main features.
|
/// Educational screen shown at the end of the wizard explaining the app's main features.
|
||||||
struct WhatsNextStepView: View {
|
struct WhatsNextStepView: View {
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
||||||
let onComplete: () -> Void
|
let onComplete: () -> Void
|
||||||
|
|
||||||
@State private var animateContent = false
|
@State private var animateContent = false
|
||||||
@ -38,7 +37,7 @@ struct WhatsNextStepView: View {
|
|||||||
)
|
)
|
||||||
.opacity(animateContent ? 1 : 0)
|
.opacity(animateContent ? 1 : 0)
|
||||||
.offset(y: animateContent ? 0 : 20)
|
.offset(y: animateContent ? 0 : 20)
|
||||||
.optionalAnimation(.easeOut(duration: 0.4).delay(0.1), value: animateContent, reduceMotion: reduceMotion)
|
.animation(.easeOut(duration: 0.4).delay(0.1), value: animateContent)
|
||||||
|
|
||||||
FeatureHelpCard(
|
FeatureHelpCard(
|
||||||
icon: "sparkles",
|
icon: "sparkles",
|
||||||
@ -47,7 +46,7 @@ struct WhatsNextStepView: View {
|
|||||||
)
|
)
|
||||||
.opacity(animateContent ? 1 : 0)
|
.opacity(animateContent ? 1 : 0)
|
||||||
.offset(y: animateContent ? 0 : 20)
|
.offset(y: animateContent ? 0 : 20)
|
||||||
.optionalAnimation(.easeOut(duration: 0.4).delay(0.2), value: animateContent, reduceMotion: reduceMotion)
|
.animation(.easeOut(duration: 0.4).delay(0.2), value: animateContent)
|
||||||
|
|
||||||
FeatureHelpCard(
|
FeatureHelpCard(
|
||||||
icon: "chart.bar.fill",
|
icon: "chart.bar.fill",
|
||||||
@ -56,12 +55,12 @@ struct WhatsNextStepView: View {
|
|||||||
)
|
)
|
||||||
.opacity(animateContent ? 1 : 0)
|
.opacity(animateContent ? 1 : 0)
|
||||||
.offset(y: animateContent ? 0 : 20)
|
.offset(y: animateContent ? 0 : 20)
|
||||||
.optionalAnimation(.easeOut(duration: 0.4).delay(0.3), value: animateContent, reduceMotion: reduceMotion)
|
.animation(.easeOut(duration: 0.4).delay(0.3), value: animateContent)
|
||||||
|
|
||||||
WidgetDiscoveryCard(onLearnMore: { isShowingWidgetHelp = true })
|
WidgetDiscoveryCard(onLearnMore: { isShowingWidgetHelp = true })
|
||||||
.opacity(animateContent ? 1 : 0)
|
.opacity(animateContent ? 1 : 0)
|
||||||
.offset(y: animateContent ? 0 : 20)
|
.offset(y: animateContent ? 0 : 20)
|
||||||
.optionalAnimation(.easeOut(duration: 0.4).delay(0.4), value: animateContent, reduceMotion: reduceMotion)
|
.animation(.easeOut(duration: 0.4).delay(0.4), value: animateContent)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, Design.Spacing.large)
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
|
||||||
@ -78,13 +77,13 @@ struct WhatsNextStepView: View {
|
|||||||
.accessibilityIdentifier("onboarding.letsGo")
|
.accessibilityIdentifier("onboarding.letsGo")
|
||||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
.opacity(animateContent ? 1 : 0)
|
.opacity(animateContent ? 1 : 0)
|
||||||
.optionalAnimation(.easeOut(duration: 0.4).delay(0.4), value: animateContent, reduceMotion: reduceMotion)
|
.animation(.easeOut(duration: 0.4).delay(0.4), value: animateContent)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
.frame(height: Design.Spacing.xxLarge)
|
.frame(height: Design.Spacing.xxLarge)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
withOptionalAnimation(.easeOut(duration: 0.5), reduceMotion: reduceMotion) {
|
withAnimation(.easeOut(duration: 0.5)) {
|
||||||
animateContent = true
|
animateContent = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ struct RootView: View {
|
|||||||
@Bindable var settingsStore: SettingsStore
|
@Bindable var settingsStore: SettingsStore
|
||||||
@Bindable var categoryStore: CategoryStore
|
@Bindable var categoryStore: CategoryStore
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
||||||
@State private var selectedTab: RootTab
|
@State private var selectedTab: RootTab
|
||||||
@State private var analyticsPrewarmTask: Task<Void, Never>?
|
@State private var analyticsPrewarmTask: Task<Void, Never>?
|
||||||
@State private var isForegroundRefreshing = false
|
@State private var isForegroundRefreshing = false
|
||||||
@ -99,13 +98,8 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
.tint(AppAccent.primary)
|
.tint(AppAccent.primary)
|
||||||
.background(AppSurface.primary.ignoresSafeArea())
|
.background(AppSurface.primary.ignoresSafeArea())
|
||||||
.optionalAnimation(.easeInOut(duration: 0.12), value: isForegroundRefreshing, reduceMotion: reduceMotion)
|
.animation(.easeInOut(duration: 0.12), value: isForegroundRefreshing)
|
||||||
.optionalAnimation(.easeIn(duration: 0.05), value: isResumingFromBackground, reduceMotion: reduceMotion)
|
.animation(.easeIn(duration: 0.05), value: isResumingFromBackground)
|
||||||
.transaction { transaction in
|
|
||||||
if reduceMotion {
|
|
||||||
transaction.animation = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: scenePhase) { _, newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
if newPhase == .active {
|
if newPhase == .active {
|
||||||
store.reminderScheduler.clearBadge()
|
store.reminderScheduler.clearBadge()
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>UIBackgroundModes</key>
|
|
||||||
<array>
|
|
||||||
<string>remote-notification</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// Executes state updates with animation only when Reduce Motion is disabled.
|
|
||||||
func withOptionalAnimation(
|
|
||||||
_ animation: Animation? = .default,
|
|
||||||
reduceMotion: Bool,
|
|
||||||
_ updates: () -> Void
|
|
||||||
) {
|
|
||||||
if reduceMotion {
|
|
||||||
updates()
|
|
||||||
} else {
|
|
||||||
withAnimation(animation, updates)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension View {
|
|
||||||
/// Applies animation only when Reduce Motion is disabled.
|
|
||||||
func optionalAnimation<Value: Equatable>(
|
|
||||||
_ animation: Animation?,
|
|
||||||
value: Value,
|
|
||||||
reduceMotion: Bool
|
|
||||||
) -> some View {
|
|
||||||
self.animation(reduceMotion ? nil : animation, value: value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -47,8 +47,7 @@ struct AndromidaTests {
|
|||||||
let store = RitualStore(
|
let store = RitualStore(
|
||||||
modelContext: container.mainContext,
|
modelContext: container.mainContext,
|
||||||
seedService: EmptySeedService(),
|
seedService: EmptySeedService(),
|
||||||
settingsStore: TestFeedbackSettings(),
|
settingsStore: TestFeedbackSettings()
|
||||||
isRunningTests: true
|
|
||||||
)
|
)
|
||||||
|
|
||||||
store.createQuickRitual()
|
store.createQuickRitual()
|
||||||
|
|||||||
@ -433,7 +433,6 @@ private func makeStore(now: @escaping () -> Date = Date.init) -> RitualStore {
|
|||||||
modelContext: container.mainContext,
|
modelContext: container.mainContext,
|
||||||
seedService: EmptySeedService(),
|
seedService: EmptySeedService(),
|
||||||
settingsStore: TestFeedbackSettings(),
|
settingsStore: TestFeedbackSettings(),
|
||||||
isRunningTests: true,
|
|
||||||
now: now
|
now: now
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
3
PRD.md
3
PRD.md
@ -214,8 +214,6 @@ URL scheme support for navigation.
|
|||||||
| NFR-A11Y-03 | Ensure sufficient color contrast ratios |
|
| NFR-A11Y-03 | Ensure sufficient color contrast ratios |
|
||||||
| NFR-A11Y-04 | Support reduced motion preferences |
|
| NFR-A11Y-04 | Support reduced motion preferences |
|
||||||
|
|
||||||
Implementation note: Onboarding flows and root-level shell transitions should avoid motion-heavy animations when iOS Reduce Motion is enabled.
|
|
||||||
|
|
||||||
### 4.3 Localization
|
### 4.3 Localization
|
||||||
|
|
||||||
| Requirement | Description |
|
| Requirement | Description |
|
||||||
@ -265,7 +263,6 @@ Implementation note: Onboarding flows and root-level shell transitions should av
|
|||||||
| TR-DATA-03 | Use UserDefaults for user-created categories and preferences |
|
| TR-DATA-03 | Use UserDefaults for user-created categories and preferences |
|
||||||
| TR-DATA-04 | Use App Group shared container for widget data access |
|
| TR-DATA-04 | Use App Group shared container for widget data access |
|
||||||
| TR-DATA-05 | Run a startup integrity migration to normalize arc date ranges, in-progress arc state, and persisted sort indexes |
|
| TR-DATA-05 | Run a startup integrity migration to normalize arc date ranges, in-progress arc state, and persisted sort indexes |
|
||||||
| TR-DATA-06 | Enable iCloud runtime compatibility by shipping `com.apple.developer.ubiquity-kvstore-identifier` and `remote-notification` background mode when CloudKit/KVS sync is enabled |
|
|
||||||
|
|
||||||
### 5.4 Third-Party Dependencies
|
### 5.4 Third-Party Dependencies
|
||||||
|
|
||||||
|
|||||||
@ -192,9 +192,7 @@ String catalogs are used for English (en), Spanish (es-MX), and French (fr-CA):
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- App is configured with a dark theme; the root view enforces `.preferredColorScheme(.dark)` to ensure semantic text legibility.
|
- App is configured with a dark theme; the root view enforces `.preferredColorScheme(.dark)` to ensure semantic text legibility.
|
||||||
- Setup wizard and root shell animations respect the iOS **Reduce Motion** accessibility setting.
|
|
||||||
- The launch storyboard matches the branding primary color to avoid a white flash.
|
- The launch storyboard matches the branding primary color to avoid a white flash.
|
||||||
- iCloud sync configuration includes `com.apple.developer.ubiquity-kvstore-identifier`; enable `remote-notification` background mode via Xcode Capabilities when CloudKit push handling is needed.
|
|
||||||
- App icon generation is available in DEBUG builds from Settings.
|
- App icon generation is available in DEBUG builds from Settings.
|
||||||
- Fresh installs start with no rituals; users create their own from scratch or presets.
|
- Fresh installs start with no rituals; users create their own from scratch or presets.
|
||||||
- A startup data-integrity migration normalizes arc date ranges, in-progress arc state, and sort indexes.
|
- A startup data-integrity migration normalizes arc date ranges, in-progress arc state, and sort indexes.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user