accessibility motion support

Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
Matt Bruce 2026-02-09 08:42:26 -06:00
parent 222f8b04b4
commit b5c351f313
13 changed files with 144 additions and 62 deletions

View File

@ -110,8 +110,12 @@ struct AndromidaApp: App {
reminderScheduler: store.reminderScheduler, reminderScheduler: store.reminderScheduler,
onComplete: { onComplete: {
justCompletedWizard = true justCompletedWizard = true
withAnimation(.easeInOut(duration: 0.5)) { if UIAccessibility.isReduceMotionEnabled {
hasCompletedSetupWizard = true hasCompletedSetupWizard = true
} else {
withAnimation(.easeInOut(duration: 0.5)) {
hasCompletedSetupWizard = true
}
} }
} }
) )

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,11 +196,21 @@ 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 {
@ -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()

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)
}
}

2
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 |

View File

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