Compare commits

...

5 Commits

Author SHA1 Message Date
950af793c0 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2026-02-09 11:34:33 -06:00
ce27a4473e fixes for testing and debug
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2026-02-09 09:14:08 -06:00
c1625e4c54 add capabilty
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2026-02-09 08:57:12 -06:00
167725cfae Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2026-02-09 08:55:43 -06:00
b5c351f313 accessibility motion support
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2026-02-09 08:42:26 -06:00
23 changed files with 6455 additions and 6257 deletions

View File

@ -63,6 +63,13 @@
/* 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 = (
@ -89,6 +96,7 @@
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;
@ -550,6 +558,7 @@
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;
@ -586,6 +595,7 @@
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;

View File

@ -10,6 +10,8 @@
<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>

View File

@ -5,6 +5,7 @@ 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
@ -15,6 +16,10 @@ 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,
@ -24,43 +29,14 @@ struct AndromidaApp: App {
) )
Theme.register(border: AppBorder.self) Theme.register(border: AppBorder.self)
let environment = ProcessInfo.processInfo.environment let launchContext = AppLaunchContext()
let isRunningTests = environment["XCTestConfigurationFilePath"] != nil self.launchContext = launchContext
let isUITesting = environment["UITEST_MODE"] == "1" launchContext.applyUserDefaultsOverrides(bundleIdentifier: Bundle.main.bundleIdentifier)
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: ModelConfiguration let configuration = launchContext.modelConfiguration(for: schema)
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 {
@ -73,15 +49,13 @@ struct AndromidaApp: App {
_settingsStore = State(initialValue: settings) _settingsStore = State(initialValue: settings)
_categoryStore = State(initialValue: CategoryStore()) _categoryStore = State(initialValue: CategoryStore())
let ritualStore = RitualStore(modelContext: container.mainContext, seedService: RitualSeedService(), settingsStore: settings) let ritualStore = RitualStore(
if isUITesting, environment["UITEST_SEED_THREE_PRESETS"] == "1" { modelContext: container.mainContext,
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[0]) // morning seedService: RitualSeedService(),
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[1]) // midday settingsStore: settings,
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[3]) // evening isRunningTests: launchContext.isRunningTests
} )
if isUITesting, environment["UITEST_PRELOAD_DEMO_DATA"] == "1" { launchContext.applyUITestSeeding(to: ritualStore)
ritualStore.preloadDemoData()
}
_store = State(initialValue: ritualStore) _store = State(initialValue: ritualStore)
} }
@ -92,13 +66,12 @@ 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: uiTestInitialTab ?? (justCompletedWizard ? .rituals : .today) initialTab: launchContext.initialTabOverride ?? (justCompletedWizard ? .rituals : .today)
) )
.transition(.opacity) .transition(.opacity)
} else { } else {
@ -110,10 +83,14 @@ 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
} }
} }
}
) )
} }
} }
@ -122,16 +99,4 @@ 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
}
}
} }

View File

@ -1,7 +1,9 @@
{ {
"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,
@ -1680,6 +1682,7 @@
} }
}, },
"Branding Preview" : { "Branding Preview" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -2098,6 +2101,7 @@
} }
}, },
"Clear All Completions" : { "Clear All Completions" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"es-MX" : { "es-MX" : {
"stringUnit" : { "stringUnit" : {
@ -2348,6 +2352,7 @@
} }
}, },
"Complete First Active Arc (Test Renewal)" : { "Complete First Active Arc (Test Renewal)" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"es-MX" : { "es-MX" : {
"stringUnit" : { "stringUnit" : {
@ -2944,6 +2949,7 @@
} }
}, },
"Debug" : { "Debug" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -3951,6 +3957,7 @@
} }
}, },
"Generate the app icon" : { "Generate the app icon" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -4464,6 +4471,7 @@
} }
}, },
"Icon Generator" : { "Icon Generator" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -6086,6 +6094,7 @@
} }
}, },
"Preload 6 Months Demo Data" : { "Preload 6 Months Demo Data" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"es-MX" : { "es-MX" : {
"stringUnit" : { "stringUnit" : {
@ -6210,6 +6219,7 @@
} }
}, },
"Preview launch and icon" : { "Preview launch and icon" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -6327,6 +6337,7 @@
}, },
"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" : {
@ -6345,6 +6356,7 @@
}, },
"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" : {
@ -6456,6 +6468,7 @@
} }
}, },
"Reload Widget Timelines" : { "Reload Widget Timelines" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"es-MX" : { "es-MX" : {
"stringUnit" : { "stringUnit" : {
@ -6545,6 +6558,7 @@
}, },
"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" : {
@ -7231,6 +7245,7 @@
}, },
"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" : {
@ -7249,6 +7264,7 @@
}, },
"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" : {
@ -8497,6 +8513,7 @@
}, },
"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" : {

View File

@ -1,6 +1,7 @@
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.
@ -85,7 +86,7 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
willPresent notification: UNNotification, willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) { ) {
print("🔔 Notification will present in foreground: \(notification.request.identifier)") Design.debugLog("🔔 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])
} }
@ -95,7 +96,7 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
didReceive response: UNNotificationResponse, didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void withCompletionHandler completionHandler: @escaping () -> Void
) { ) {
print("🔔 Notification received/tapped: \(response.notification.request.identifier)") Design.debugLog("🔔 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
@ -127,7 +128,7 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
await refreshAuthorizationStatus() await refreshAuthorizationStatus()
return granted return granted
} catch { } catch {
print("Notification authorization error: \(error)") Design.debugLog("Notification authorization error: \(error)")
return false return false
} }
} }
@ -147,7 +148,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() {
print("🔔 Attempting to schedule test notification...") Design.debugLog("🔔 Attempting to schedule test notification...")
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = String(localized: "Test Notification") content.title = String(localized: "Test Notification")
@ -164,9 +165,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 {
print("❌ Failed to schedule test notification: \(error)") Design.debugLog("❌ Failed to schedule test notification: \(error)")
} else { } else {
print("✅ Test notification scheduled successfully! It should appear in 5 seconds.") Design.debugLog("✅ Test notification scheduled successfully! It should appear in 5 seconds.")
} }
} }
} }
@ -257,7 +258,7 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
do { do {
try await UNUserNotificationCenter.current().add(request) try await UNUserNotificationCenter.current().add(request)
} catch { } catch {
print("Failed to schedule \(slot.rawValue) reminder: \(error)") Design.debugLog("Failed to schedule \(slot.rawValue) reminder: \(error)")
} }
} }
} }

View File

@ -11,6 +11,11 @@ extension RitualStore {
} catch { } catch {
fatalError("Preview container failed: \(error)") fatalError("Preview container failed: \(error)")
} }
return RitualStore(modelContext: container.mainContext, seedService: RitualSeedService(), settingsStore: SettingsStore()) return RitualStore(
modelContext: container.mainContext,
seedService: RitualSeedService(),
settingsStore: SettingsStore(),
isRunningTests: false
)
} }
} }

View File

@ -59,6 +59,7 @@ 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
) { ) {
@ -67,7 +68,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 = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil self.isRunningTests = isRunningTests
self.dayFormatter = DateFormatter() self.dayFormatter = DateFormatter()
self.displayFormatter = DateFormatter() self.displayFormatter = DateFormatter()
dayFormatter.calendar = calendar dayFormatter.calendar = calendar
@ -1556,7 +1557,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 {
print("No active arcs to complete") Design.debugLog("No active arcs to complete")
return return
} }
@ -1573,7 +1574,7 @@ final class RitualStore: RitualStoreProviding {
// Trigger the completion check - this will set ritualNeedingRenewal // Trigger the completion check - this will set ritualNeedingRenewal
checkForCompletedArcs() checkForCompletedArcs()
print("Arc '\(ritual.title)' marked as completed. Navigate to Today tab to see renewal prompt.") Design.debugLog("Arc '\(ritual.title)' marked as completed. Navigate to Today tab to see renewal prompt.")
} }
#endif #endif
} }

View File

@ -0,0 +1,105 @@
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())
}
}

View File

@ -4,6 +4,7 @@ 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
@ -121,7 +122,7 @@ struct FirstCheckInStepView: View {
.frame(height: Design.Spacing.xxLarge) .frame(height: Design.Spacing.xxLarge)
} }
.onAppear { .onAppear {
withAnimation(.easeOut(duration: 0.5)) { withOptionalAnimation(.easeOut(duration: 0.5), reduceMotion: reduceMotion) {
animateContent = true animateContent = true
} }
} }
@ -159,7 +160,7 @@ struct FirstCheckInStepView: View {
} }
.accessibilityIdentifier("onboarding.firstCheckInContinueToRituals") .accessibilityIdentifier("onboarding.firstCheckInContinueToRituals")
.padding(.horizontal, Design.Spacing.xxLarge) .padding(.horizontal, Design.Spacing.xxLarge)
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
} }
Spacer() Spacer()
@ -172,10 +173,15 @@ struct FirstCheckInStepView: View {
private func triggerCelebration() { private func triggerCelebration() {
hasCompletedCheckIn = true hasCompletedCheckIn = true
withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) { withOptionalAnimation(.spring(response: 0.5, dampingFraction: 0.7), reduceMotion: reduceMotion) {
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)) {
@ -187,6 +193,7 @@ 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
@ -219,13 +226,13 @@ private struct OnboardingHabitRowView: View {
isHighlighted ? AppAccent.primary : Color.clear, isHighlighted ? AppAccent.primary : Color.clear,
lineWidth: 2 lineWidth: 2
) )
.scaleEffect(pulseAnimation && isHighlighted ? 1.02 : 1.0) .scaleEffect(pulseAnimation && isHighlighted && !reduceMotion ? 1.02 : 1.0)
.opacity(pulseAnimation && isHighlighted ? 0.5 : 1.0) .opacity(pulseAnimation && isHighlighted && !reduceMotion ? 0.5 : 1.0)
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.onAppear { .onAppear {
if isHighlighted { if isHighlighted && !reduceMotion {
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) { withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
pulseAnimation = true pulseAnimation = true
} }

View File

@ -4,6 +4,7 @@ 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
@ -34,16 +35,17 @@ struct GoalSelectionStepView: View {
goal: goal, goal: goal,
isSelected: selectedGoals.contains(goal), isSelected: selectedGoals.contains(goal),
onTap: { onTap: {
withAnimation(.easeInOut(duration: Design.Animation.quick)) { withOptionalAnimation(.easeInOut(duration: Design.Animation.quick), reduceMotion: reduceMotion) {
toggleGoalSelection(goal) toggleGoalSelection(goal)
} }
} }
) )
.opacity(animateCards ? 1 : 0) .opacity(animateCards ? 1 : 0)
.offset(y: animateCards ? 0 : 20) .offset(y: animateCards ? 0 : 20)
.animation( .optionalAnimation(
.easeOut(duration: 0.4).delay(Double(index) * 0.1), .easeOut(duration: 0.4).delay(Double(index) * 0.1),
value: animateCards value: animateCards,
reduceMotion: reduceMotion
) )
} }
} }
@ -65,13 +67,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(.move(edge: .bottom).combined(with: .opacity)) .transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
} }
} }
.animation(.easeInOut(duration: Design.Animation.quick), value: !selectedGoals.isEmpty) .optionalAnimation(.easeInOut(duration: Design.Animation.quick), value: !selectedGoals.isEmpty, reduceMotion: reduceMotion)
.accessibilityIdentifier("onboarding.goalSelection") .accessibilityIdentifier("onboarding.goalSelection")
.onAppear { .onAppear {
withAnimation { withOptionalAnimation(reduceMotion: reduceMotion) {
animateCards = true animateCards = true
} }
} }

View File

@ -4,6 +4,7 @@ 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
@ -117,20 +118,22 @@ 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 ? 1.1 : 1.0) .scaleEffect(animateIcon && !reduceMotion ? 1.1 : 1.0)
.animation( .optionalAnimation(
.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 ? 1.05 : 1.0) .scaleEffect(animateIcon && !reduceMotion ? 1.05 : 1.0)
.animation( .optionalAnimation(
.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
@ -142,10 +145,11 @@ 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 ? 10 : -10)) .rotationEffect(.degrees(animateIcon && !reduceMotion ? 10 : 0))
.animation( .optionalAnimation(
.easeInOut(duration: 0.5).repeatForever(autoreverses: true), .easeInOut(duration: 0.5).repeatForever(autoreverses: true),
value: animateIcon value: animateIcon,
reduceMotion: reduceMotion
) )
} }
} }
@ -173,6 +177,13 @@ 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

View File

@ -3,6 +3,7 @@ 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
@ -105,7 +106,7 @@ struct RitualPreviewStepView: View {
.frame(height: Design.Spacing.xxLarge) .frame(height: Design.Spacing.xxLarge)
} }
.onAppear { .onAppear {
withAnimation(.easeOut(duration: 0.5)) { withOptionalAnimation(.easeOut(duration: 0.5), reduceMotion: reduceMotion) {
animateContent = true animateContent = true
} }
} }

View File

@ -6,6 +6,7 @@ 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
@ -136,13 +137,10 @@ struct SetupWizardView: View {
} }
} }
.adaptiveContentWidth(maxWidth: Design.Size.maxContentWidthPortrait) .adaptiveContentWidth(maxWidth: Design.Size.maxContentWidthPortrait)
.transition(.asymmetric( .transition(stepTransition)
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
))
} }
} }
.animation(.easeInOut(duration: Design.Animation.standard), value: currentStep) .optionalAnimation(.easeInOut(duration: Design.Animation.standard), value: currentStep, reduceMotion: reduceMotion)
.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) {
@ -198,12 +196,22 @@ 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)
.animation(.easeInOut(duration: Design.Animation.standard), value: currentStep) .optionalAnimation(.easeInOut(duration: Design.Animation.standard), value: currentStep, reduceMotion: reduceMotion)
} }
} }
.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 {
@ -228,14 +236,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 }
withAnimation { withOptionalAnimation(reduceMotion: reduceMotion) {
currentStep = nextStep currentStep = nextStep
} }
} }
private func goBack() { private func goBack() {
if currentStep == .ritualPreview, currentPresetIndex > 0 { if currentStep == .ritualPreview, currentPresetIndex > 0 {
withAnimation { withOptionalAnimation(reduceMotion: reduceMotion) {
currentPresetIndex -= 1 currentPresetIndex -= 1
} }
return return
@ -244,19 +252,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 }
withAnimation { withOptionalAnimation(reduceMotion: reduceMotion) {
currentStep = previousStep currentStep = previousStep
} }
} }
private func advanceToNotifications() { private func advanceToNotifications() {
withAnimation { withOptionalAnimation(reduceMotion: reduceMotion) {
currentStep = .notifications currentStep = .notifications
} }
} }
private func advanceToWhatsNext() { private func advanceToWhatsNext() {
withAnimation { withOptionalAnimation(reduceMotion: reduceMotion) {
currentStep = .whatsNext currentStep = .whatsNext
} }
} }
@ -287,7 +295,7 @@ struct SetupWizardView: View {
} }
} }
withAnimation { withOptionalAnimation(reduceMotion: reduceMotion) {
pendingPresets = presets pendingPresets = presets
currentPresetIndex = 0 currentPresetIndex = 0
createdRituals = [] createdRituals = []
@ -315,7 +323,7 @@ struct SetupWizardView: View {
} }
private func advanceFromPreview() { private func advanceFromPreview() {
withAnimation { withOptionalAnimation(reduceMotion: reduceMotion) {
if currentPresetIndex + 1 < pendingPresets.count { if currentPresetIndex + 1 < pendingPresets.count {
currentPresetIndex += 1 currentPresetIndex += 1
} else if hasCreatedRitual { } else if hasCreatedRitual {

View File

@ -4,6 +4,7 @@ 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
@ -39,9 +40,10 @@ struct TimeSelectionStepView: View {
) )
.opacity(animateCards ? 1 : 0) .opacity(animateCards ? 1 : 0)
.offset(y: animateCards ? 0 : 20) .offset(y: animateCards ? 0 : 20)
.animation( .optionalAnimation(
.easeOut(duration: 0.4).delay(Double(index) * 0.1), .easeOut(duration: 0.4).delay(Double(index) * 0.1),
value: animateCards value: animateCards,
reduceMotion: reduceMotion
) )
} }
} }
@ -65,20 +67,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(.move(edge: .bottom).combined(with: .opacity)) .transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
} }
} }
.animation(.easeInOut(duration: Design.Animation.quick), value: !selectedTimes.isEmpty) .optionalAnimation(.easeInOut(duration: Design.Animation.quick), value: !selectedTimes.isEmpty, reduceMotion: reduceMotion)
.accessibilityIdentifier("onboarding.timeSelection") .accessibilityIdentifier("onboarding.timeSelection")
.onAppear { .onAppear {
withAnimation { withOptionalAnimation(reduceMotion: reduceMotion) {
animateCards = true animateCards = true
} }
} }
} }
private func toggleSelection(_ time: OnboardingTimePreference) { private func toggleSelection(_ time: OnboardingTimePreference) {
withAnimation(.easeInOut(duration: Design.Animation.quick)) { withOptionalAnimation(.easeInOut(duration: Design.Animation.quick), reduceMotion: reduceMotion) {
if selectedTimes.contains(time) { if selectedTimes.contains(time) {
selectedTimes.remove(time) selectedTimes.remove(time)
} else { } else {

View File

@ -3,6 +3,7 @@ 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
@ -70,10 +71,11 @@ 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 ? 360 : 0)) .rotationEffect(.degrees(animateRings && !reduceMotion ? 360 : 0))
.animation( .optionalAnimation(
.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
@ -84,10 +86,11 @@ 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 ? -360 : 0)) .rotationEffect(.degrees(animateRings && !reduceMotion ? -360 : 0))
.animation( .optionalAnimation(
.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
@ -98,18 +101,20 @@ 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 ? 360 : 0)) .rotationEffect(.degrees(animateRings && !reduceMotion ? 360 : 0))
.animation( .optionalAnimation(
.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 ? 1.1 : 1.0) .scaleEffect(animateRings && !reduceMotion ? 1.1 : 1.0)
.animation( .optionalAnimation(
.easeInOut(duration: 1.5).repeatForever(autoreverses: true), .easeInOut(duration: 1.5).repeatForever(autoreverses: true),
value: animateRings value: animateRings,
reduceMotion: reduceMotion
) )
} }
} }
@ -117,6 +122,13 @@ 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

View File

@ -3,6 +3,7 @@ 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
@ -37,7 +38,7 @@ struct WhatsNextStepView: View {
) )
.opacity(animateContent ? 1 : 0) .opacity(animateContent ? 1 : 0)
.offset(y: animateContent ? 0 : 20) .offset(y: animateContent ? 0 : 20)
.animation(.easeOut(duration: 0.4).delay(0.1), value: animateContent) .optionalAnimation(.easeOut(duration: 0.4).delay(0.1), value: animateContent, reduceMotion: reduceMotion)
FeatureHelpCard( FeatureHelpCard(
icon: "sparkles", icon: "sparkles",
@ -46,7 +47,7 @@ struct WhatsNextStepView: View {
) )
.opacity(animateContent ? 1 : 0) .opacity(animateContent ? 1 : 0)
.offset(y: animateContent ? 0 : 20) .offset(y: animateContent ? 0 : 20)
.animation(.easeOut(duration: 0.4).delay(0.2), value: animateContent) .optionalAnimation(.easeOut(duration: 0.4).delay(0.2), value: animateContent, reduceMotion: reduceMotion)
FeatureHelpCard( FeatureHelpCard(
icon: "chart.bar.fill", icon: "chart.bar.fill",
@ -55,12 +56,12 @@ struct WhatsNextStepView: View {
) )
.opacity(animateContent ? 1 : 0) .opacity(animateContent ? 1 : 0)
.offset(y: animateContent ? 0 : 20) .offset(y: animateContent ? 0 : 20)
.animation(.easeOut(duration: 0.4).delay(0.3), value: animateContent) .optionalAnimation(.easeOut(duration: 0.4).delay(0.3), value: animateContent, reduceMotion: reduceMotion)
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)
.animation(.easeOut(duration: 0.4).delay(0.4), value: animateContent) .optionalAnimation(.easeOut(duration: 0.4).delay(0.4), value: animateContent, reduceMotion: reduceMotion)
} }
.padding(.horizontal, Design.Spacing.large) .padding(.horizontal, Design.Spacing.large)
@ -77,13 +78,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)
.animation(.easeOut(duration: 0.4).delay(0.4), value: animateContent) .optionalAnimation(.easeOut(duration: 0.4).delay(0.4), value: animateContent, reduceMotion: reduceMotion)
Spacer() Spacer()
.frame(height: Design.Spacing.xxLarge) .frame(height: Design.Spacing.xxLarge)
} }
.onAppear { .onAppear {
withAnimation(.easeOut(duration: 0.5)) { withOptionalAnimation(.easeOut(duration: 0.5), reduceMotion: reduceMotion) {
animateContent = true animateContent = true
} }
} }

View File

@ -6,6 +6,7 @@ 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
@ -98,8 +99,13 @@ struct RootView: View {
} }
.tint(AppAccent.primary) .tint(AppAccent.primary)
.background(AppSurface.primary.ignoresSafeArea()) .background(AppSurface.primary.ignoresSafeArea())
.animation(.easeInOut(duration: 0.12), value: isForegroundRefreshing) .optionalAnimation(.easeInOut(duration: 0.12), value: isForegroundRefreshing, reduceMotion: reduceMotion)
.animation(.easeIn(duration: 0.05), value: isResumingFromBackground) .optionalAnimation(.easeIn(duration: 0.05), value: isResumingFromBackground, reduceMotion: reduceMotion)
.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()

10
Andromida/Info.plist Normal file
View File

@ -0,0 +1,10 @@
<?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>

View File

@ -0,0 +1,25 @@
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)
}
}

View File

@ -47,7 +47,8 @@ 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()

View File

@ -433,6 +433,7 @@ 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
View File

@ -214,6 +214,8 @@ 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 |
@ -263,6 +265,7 @@ URL scheme support for navigation.
| 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

View File

@ -192,7 +192,9 @@ 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.