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 */
/* 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 */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
@ -89,6 +96,7 @@
EAC04A9A2F26BAE8007F87EA /* Andromida */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
EA7216D92F3A2CFD00118F4F /* Exceptions for "Andromida" folder in "Andromida" target */,
EAC04D512F298EAE007F87EA /* Exceptions for "Andromida" folder in "AndromidaWidgetExtension" target */,
);
path = Andromida;
@ -550,6 +558,7 @@
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Andromida/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
@ -586,6 +595,7 @@
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Andromida/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;

View File

@ -10,6 +10,8 @@
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(DEVELOPMENT_TEAM).$(APP_BUNDLE_IDENTIFIER)</string>
<key>com.apple.security.application-groups</key>
<array>
<string>$(APP_GROUP_IDENTIFIER)</string>

View File

@ -5,6 +5,7 @@ import Bedrock
@main
struct AndromidaApp: App {
private let modelContainer: ModelContainer
private let launchContext: AppLaunchContext
@State private var store: RitualStore
@State private var settingsStore: SettingsStore
@State private var categoryStore: CategoryStore
@ -15,6 +16,10 @@ struct AndromidaApp: App {
@State private var isTransitioningToRoot = false
init() {
#if !DEBUG
Design.showDebugLogs = false
#endif
// Register app's color theme for Bedrock components
Theme.register(
text: AppTextColors.self,
@ -23,44 +28,15 @@ struct AndromidaApp: App {
status: AppStatus.self
)
Theme.register(border: AppBorder.self)
let environment = ProcessInfo.processInfo.environment
let isRunningTests = environment["XCTestConfigurationFilePath"] != nil
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")
}
}
let launchContext = AppLaunchContext()
self.launchContext = launchContext
launchContext.applyUserDefaultsOverrides(bundleIdentifier: Bundle.main.bundleIdentifier)
// Include all models in schema - Ritual, RitualArc, and ArcHabit
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
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 configuration = launchContext.modelConfiguration(for: schema)
let container: ModelContainer
do {
@ -73,15 +49,13 @@ struct AndromidaApp: App {
_settingsStore = State(initialValue: settings)
_categoryStore = State(initialValue: CategoryStore())
let ritualStore = RitualStore(modelContext: container.mainContext, seedService: RitualSeedService(), settingsStore: settings)
if isUITesting, environment["UITEST_SEED_THREE_PRESETS"] == "1" {
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[0]) // morning
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[1]) // midday
ritualStore.createRitualFromPreset(RitualPresetLibrary.healthPresets[3]) // evening
}
if isUITesting, environment["UITEST_PRELOAD_DEMO_DATA"] == "1" {
ritualStore.preloadDemoData()
}
let ritualStore = RitualStore(
modelContext: container.mainContext,
seedService: RitualSeedService(),
settingsStore: settings,
isRunningTests: launchContext.isRunningTests
)
launchContext.applyUITestSeeding(to: ritualStore)
_store = State(initialValue: ritualStore)
}
@ -92,13 +66,12 @@ struct AndromidaApp: App {
.ignoresSafeArea()
if hasCompletedSetupWizard {
let uiTestInitialTab = uiTestRequestedInitialTab()
// Main app - start on Rituals tab if just completed wizard
RootView(
store: store,
settingsStore: settingsStore,
categoryStore: categoryStore,
initialTab: uiTestInitialTab ?? (justCompletedWizard ? .rituals : .today)
initialTab: launchContext.initialTabOverride ?? (justCompletedWizard ? .rituals : .today)
)
.transition(.opacity)
} else {
@ -110,8 +83,12 @@ struct AndromidaApp: App {
reminderScheduler: store.reminderScheduler,
onComplete: {
justCompletedWizard = true
withAnimation(.easeInOut(duration: 0.5)) {
if UIAccessibility.isReduceMotionEnabled {
hasCompletedSetupWizard = true
} else {
withAnimation(.easeInOut(duration: 0.5)) {
hasCompletedSetupWizard = true
}
}
}
)
@ -122,16 +99,4 @@ struct AndromidaApp: App {
.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
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
import Foundation
import UserNotifications
import Observation
import Bedrock
/// Reminder time slots based on ritual TimeOfDay values.
/// Groups similar times to avoid excessive notifications.
@ -85,7 +86,7 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
willPresent notification: UNNotification,
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
completionHandler([.banner, .list, .sound, .badge])
}
@ -95,7 +96,7 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
didReceive response: UNNotificationResponse,
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
clearBadge()
shouldNavigateToToday = true
@ -127,7 +128,7 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
await refreshAuthorizationStatus()
return granted
} catch {
print("Notification authorization error: \(error)")
Design.debugLog("Notification authorization error: \(error)")
return false
}
}
@ -147,7 +148,7 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
/// Schedules a test notification to appear in 5 seconds.
func scheduleTestNotification() {
print("🔔 Attempting to schedule test notification...")
Design.debugLog("🔔 Attempting to schedule test notification...")
let content = UNMutableNotificationContent()
content.title = String(localized: "Test Notification")
@ -164,9 +165,9 @@ final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("❌ Failed to schedule test notification: \(error)")
Design.debugLog("❌ Failed to schedule test notification: \(error)")
} 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 {
try await UNUserNotificationCenter.current().add(request)
} 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 {
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,
seedService: RitualSeedProviding,
settingsStore: any RitualFeedbackSettingsProviding,
isRunningTests: Bool,
calendar: Calendar = .current,
now: @escaping () -> Date = Date.init
) {
@ -67,7 +68,7 @@ final class RitualStore: RitualStoreProviding {
self.settingsStore = settingsStore
self.calendar = calendar
self.nowProvider = now
self.isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
self.isRunningTests = isRunningTests
self.dayFormatter = DateFormatter()
self.displayFormatter = DateFormatter()
dayFormatter.calendar = calendar
@ -1556,7 +1557,7 @@ final class RitualStore: RitualStoreProviding {
// Find the first ritual with an active arc
guard let ritual = currentRituals.first,
let arc = ritual.activeArc(on: now()) else {
print("No active arcs to complete")
Design.debugLog("No active arcs to complete")
return
}
@ -1573,7 +1574,7 @@ final class RitualStore: RitualStoreProviding {
// Trigger the completion check - this will set ritualNeedingRenewal
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
}

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.
struct FirstCheckInStepView: View {
@Bindable var store: RitualStore
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let ritual: Ritual
@Binding var hasCompletedCheckIn: Bool
let onComplete: () -> Void
@ -121,7 +122,7 @@ struct FirstCheckInStepView: View {
.frame(height: Design.Spacing.xxLarge)
}
.onAppear {
withAnimation(.easeOut(duration: 0.5)) {
withOptionalAnimation(.easeOut(duration: 0.5), reduceMotion: reduceMotion) {
animateContent = true
}
}
@ -159,7 +160,7 @@ struct FirstCheckInStepView: View {
}
.accessibilityIdentifier("onboarding.firstCheckInContinueToRituals")
.padding(.horizontal, Design.Spacing.xxLarge)
.transition(.move(edge: .bottom).combined(with: .opacity))
.transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
}
Spacer()
@ -172,10 +173,15 @@ struct FirstCheckInStepView: View {
private func triggerCelebration() {
hasCompletedCheckIn = true
withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
withOptionalAnimation(.spring(response: 0.5, dampingFraction: 0.7), reduceMotion: reduceMotion) {
showCelebration = true
}
if reduceMotion {
showContinueButton = true
return
}
// Show continue button after celebration settles
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
withAnimation(.easeOut(duration: 0.3)) {
@ -187,6 +193,7 @@ struct FirstCheckInStepView: View {
/// A habit row styled for the onboarding flow with optional highlight.
private struct OnboardingHabitRowView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let title: String
let symbolName: String
let isCompleted: Bool
@ -219,13 +226,13 @@ private struct OnboardingHabitRowView: View {
isHighlighted ? AppAccent.primary : Color.clear,
lineWidth: 2
)
.scaleEffect(pulseAnimation && isHighlighted ? 1.02 : 1.0)
.opacity(pulseAnimation && isHighlighted ? 0.5 : 1.0)
.scaleEffect(pulseAnimation && isHighlighted && !reduceMotion ? 1.02 : 1.0)
.opacity(pulseAnimation && isHighlighted && !reduceMotion ? 0.5 : 1.0)
)
}
.buttonStyle(.plain)
.onAppear {
if isHighlighted {
if isHighlighted && !reduceMotion {
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
pulseAnimation = true
}

View File

@ -4,6 +4,7 @@ import Bedrock
/// The goal selection screen where users choose what they want to focus on.
struct GoalSelectionStepView: View {
@Binding var selectedGoals: [OnboardingGoal]
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let onContinue: () -> Void
@State private var animateCards = false
@ -34,16 +35,17 @@ struct GoalSelectionStepView: View {
goal: goal,
isSelected: selectedGoals.contains(goal),
onTap: {
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
withOptionalAnimation(.easeInOut(duration: Design.Animation.quick), reduceMotion: reduceMotion) {
toggleGoalSelection(goal)
}
}
)
.opacity(animateCards ? 1 : 0)
.offset(y: animateCards ? 0 : 20)
.animation(
.optionalAnimation(
.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")
.padding(.horizontal, 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")
.onAppear {
withAnimation {
withOptionalAnimation(reduceMotion: reduceMotion) {
animateCards = true
}
}

View File

@ -4,6 +4,7 @@ import Bedrock
/// The notification permission screen where users can enable reminders.
/// Shown after the first check-in to maximize conversion after experiencing value.
struct NotificationStepView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let selectedTimes: Set<OnboardingTimePreference>
let reminderScheduler: ReminderScheduler
let onComplete: () -> Void
@ -117,20 +118,22 @@ struct NotificationStepView: View {
Circle()
.fill(AppAccent.primary.opacity(0.1))
.frame(width: 160, height: 160)
.scaleEffect(animateIcon ? 1.1 : 1.0)
.animation(
.scaleEffect(animateIcon && !reduceMotion ? 1.1 : 1.0)
.optionalAnimation(
.easeInOut(duration: 2).repeatForever(autoreverses: true),
value: animateIcon
value: animateIcon,
reduceMotion: reduceMotion
)
// Middle circle
Circle()
.fill(AppAccent.primary.opacity(0.15))
.frame(width: 120, height: 120)
.scaleEffect(animateIcon ? 1.05 : 1.0)
.animation(
.scaleEffect(animateIcon && !reduceMotion ? 1.05 : 1.0)
.optionalAnimation(
.easeInOut(duration: 1.5).repeatForever(autoreverses: true).delay(0.2),
value: animateIcon
value: animateIcon,
reduceMotion: reduceMotion
)
// Inner circle with bell
@ -142,10 +145,11 @@ struct NotificationStepView: View {
Image(systemName: "bell.fill")
.font(.system(size: 36, weight: .medium))
.foregroundStyle(AppAccent.primary)
.rotationEffect(.degrees(animateIcon ? 10 : -10))
.animation(
.rotationEffect(.degrees(animateIcon && !reduceMotion ? 10 : 0))
.optionalAnimation(
.easeInOut(duration: 0.5).repeatForever(autoreverses: true),
value: animateIcon
value: animateIcon,
reduceMotion: reduceMotion
)
}
}
@ -173,6 +177,13 @@ struct NotificationStepView: View {
// MARK: - Animations
private func startAnimations() {
if reduceMotion {
animateIcon = true
animateContent = true
animateButtons = true
return
}
// Stagger the animations
withAnimation(.easeOut(duration: 0.6)) {
animateIcon = true

View File

@ -3,6 +3,7 @@ import Bedrock
/// Shows a preview of a ritual preset before creation.
struct RitualPreviewStepView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let preset: RitualPreset
let ritualIndex: Int? // e.g., 1 for "Ritual 1 of 2"
let totalRituals: Int? // e.g., 2
@ -105,7 +106,7 @@ struct RitualPreviewStepView: View {
.frame(height: Design.Spacing.xxLarge)
}
.onAppear {
withAnimation(.easeOut(duration: 0.5)) {
withOptionalAnimation(.easeOut(duration: 0.5), reduceMotion: reduceMotion) {
animateContent = true
}
}

View File

@ -6,6 +6,7 @@ import Bedrock
struct SetupWizardView: View {
@Bindable var store: RitualStore
@Bindable var categoryStore: CategoryStore
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let reminderScheduler: ReminderScheduler
let onComplete: () -> Void
@ -136,13 +137,10 @@ struct SetupWizardView: View {
}
}
.adaptiveContentWidth(maxWidth: Design.Size.maxContentWidthPortrait)
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
))
.transition(stepTransition)
}
}
.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) {
Button(String(localized: "Keep going"), role: .cancel) {}
Button(String(localized: "Skip"), role: .destructive) {
@ -198,11 +196,21 @@ struct SetupWizardView: View {
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(AppAccent.primary)
.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)
}
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
private var progressValue: Double {
@ -228,14 +236,14 @@ struct SetupWizardView: View {
private func advanceToNextStep() {
guard let nextStep = WizardStep(rawValue: currentStep.rawValue + 1) else { return }
withAnimation {
withOptionalAnimation(reduceMotion: reduceMotion) {
currentStep = nextStep
}
}
private func goBack() {
if currentStep == .ritualPreview, currentPresetIndex > 0 {
withAnimation {
withOptionalAnimation(reduceMotion: reduceMotion) {
currentPresetIndex -= 1
}
return
@ -244,19 +252,19 @@ struct SetupWizardView: View {
let targetStep = currentStep.rawValue - 1
guard targetStep >= 0,
let previousStep = WizardStep(rawValue: targetStep) else { return }
withAnimation {
withOptionalAnimation(reduceMotion: reduceMotion) {
currentStep = previousStep
}
}
private func advanceToNotifications() {
withAnimation {
withOptionalAnimation(reduceMotion: reduceMotion) {
currentStep = .notifications
}
}
private func advanceToWhatsNext() {
withAnimation {
withOptionalAnimation(reduceMotion: reduceMotion) {
currentStep = .whatsNext
}
}
@ -287,7 +295,7 @@ struct SetupWizardView: View {
}
}
withAnimation {
withOptionalAnimation(reduceMotion: reduceMotion) {
pendingPresets = presets
currentPresetIndex = 0
createdRituals = []
@ -315,7 +323,7 @@ struct SetupWizardView: View {
}
private func advanceFromPreview() {
withAnimation {
withOptionalAnimation(reduceMotion: reduceMotion) {
if currentPresetIndex + 1 < pendingPresets.count {
currentPresetIndex += 1
} else if hasCreatedRitual {

View File

@ -4,6 +4,7 @@ import Bedrock
/// The time selection screen where users choose when they want to build habits.
struct TimeSelectionStepView: View {
@Binding var selectedTimes: Set<OnboardingTimePreference>
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let onContinue: () -> Void
@State private var animateCards = false
@ -39,9 +40,10 @@ struct TimeSelectionStepView: View {
)
.opacity(animateCards ? 1 : 0)
.offset(y: animateCards ? 0 : 20)
.animation(
.optionalAnimation(
.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")
.padding(.horizontal, 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")
.onAppear {
withAnimation {
withOptionalAnimation(reduceMotion: reduceMotion) {
animateCards = true
}
}
}
private func toggleSelection(_ time: OnboardingTimePreference) {
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
withOptionalAnimation(.easeInOut(duration: Design.Animation.quick), reduceMotion: reduceMotion) {
if selectedTimes.contains(time) {
selectedTimes.remove(time)
} else {

View File

@ -3,6 +3,7 @@ import Bedrock
/// The welcome screen shown as the first step of the setup wizard.
struct WelcomeStepView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let onContinue: () -> Void
@State private var animateRings = false
@ -70,10 +71,11 @@ struct WelcomeStepView: View {
style: StrokeStyle(lineWidth: 8, lineCap: .round)
)
.frame(width: 180, height: 180)
.rotationEffect(.degrees(animateRings ? 360 : 0))
.animation(
.rotationEffect(.degrees(animateRings && !reduceMotion ? 360 : 0))
.optionalAnimation(
.linear(duration: 20).repeatForever(autoreverses: false),
value: animateRings
value: animateRings,
reduceMotion: reduceMotion
)
// Middle ring with arc
@ -84,10 +86,11 @@ struct WelcomeStepView: View {
style: StrokeStyle(lineWidth: 10, lineCap: .round)
)
.frame(width: 140, height: 140)
.rotationEffect(.degrees(animateRings ? -360 : 0))
.animation(
.rotationEffect(.degrees(animateRings && !reduceMotion ? -360 : 0))
.optionalAnimation(
.linear(duration: 15).repeatForever(autoreverses: false),
value: animateRings
value: animateRings,
reduceMotion: reduceMotion
)
// Inner ring with arc
@ -98,18 +101,20 @@ struct WelcomeStepView: View {
style: StrokeStyle(lineWidth: 12, lineCap: .round)
)
.frame(width: 100, height: 100)
.rotationEffect(.degrees(animateRings ? 360 : 0))
.animation(
.rotationEffect(.degrees(animateRings && !reduceMotion ? 360 : 0))
.optionalAnimation(
.linear(duration: 10).repeatForever(autoreverses: false),
value: animateRings
value: animateRings,
reduceMotion: reduceMotion
)
// Center icon
SymbolIcon("sparkles", size: .card, color: AppAccent.primary)
.scaleEffect(animateRings ? 1.1 : 1.0)
.animation(
.scaleEffect(animateRings && !reduceMotion ? 1.1 : 1.0)
.optionalAnimation(
.easeInOut(duration: 1.5).repeatForever(autoreverses: true),
value: animateRings
value: animateRings,
reduceMotion: reduceMotion
)
}
}
@ -117,6 +122,13 @@ struct WelcomeStepView: View {
// MARK: - Animations
private func startAnimations() {
if reduceMotion {
animateRings = true
animateText = true
animateButton = true
return
}
// Stagger the animations
withAnimation(.easeOut(duration: 0.6)) {
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.
struct WhatsNextStepView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let onComplete: () -> Void
@State private var animateContent = false
@ -37,7 +38,7 @@ struct WhatsNextStepView: View {
)
.opacity(animateContent ? 1 : 0)
.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(
icon: "sparkles",
@ -46,7 +47,7 @@ struct WhatsNextStepView: View {
)
.opacity(animateContent ? 1 : 0)
.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(
icon: "chart.bar.fill",
@ -55,12 +56,12 @@ struct WhatsNextStepView: View {
)
.opacity(animateContent ? 1 : 0)
.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 })
.opacity(animateContent ? 1 : 0)
.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)
@ -77,13 +78,13 @@ struct WhatsNextStepView: View {
.accessibilityIdentifier("onboarding.letsGo")
.padding(.horizontal, Design.Spacing.xxLarge)
.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()
.frame(height: Design.Spacing.xxLarge)
}
.onAppear {
withAnimation(.easeOut(duration: 0.5)) {
withOptionalAnimation(.easeOut(duration: 0.5), reduceMotion: reduceMotion) {
animateContent = true
}
}

View File

@ -6,6 +6,7 @@ struct RootView: View {
@Bindable var settingsStore: SettingsStore
@Bindable var categoryStore: CategoryStore
@Environment(\.scenePhase) private var scenePhase
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var selectedTab: RootTab
@State private var analyticsPrewarmTask: Task<Void, Never>?
@State private var isForegroundRefreshing = false
@ -98,8 +99,13 @@ struct RootView: View {
}
.tint(AppAccent.primary)
.background(AppSurface.primary.ignoresSafeArea())
.animation(.easeInOut(duration: 0.12), value: isForegroundRefreshing)
.animation(.easeIn(duration: 0.05), value: isResumingFromBackground)
.optionalAnimation(.easeInOut(duration: 0.12), value: isForegroundRefreshing, reduceMotion: reduceMotion)
.optionalAnimation(.easeIn(duration: 0.05), value: isResumingFromBackground, reduceMotion: reduceMotion)
.transaction { transaction in
if reduceMotion {
transaction.animation = nil
}
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
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(
modelContext: container.mainContext,
seedService: EmptySeedService(),
settingsStore: TestFeedbackSettings()
settingsStore: TestFeedbackSettings(),
isRunningTests: true
)
store.createQuickRitual()

View File

@ -433,6 +433,7 @@ private func makeStore(now: @escaping () -> Date = Date.init) -> RitualStore {
modelContext: container.mainContext,
seedService: EmptySeedService(),
settingsStore: TestFeedbackSettings(),
isRunningTests: true,
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-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
| Requirement | Description |
@ -263,6 +265,7 @@ URL scheme support for navigation.
| 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-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

View File

@ -192,7 +192,9 @@ String catalogs are used for English (en), Spanish (es-MX), and French (fr-CA):
## Notes
- 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.
- 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.
- 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.