accessibility motion support
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
222f8b04b4
commit
b5c351f313
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
25
Andromida/Shared/MotionSupport.swift
Normal file
25
Andromida/Shared/MotionSupport.swift
Normal 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
2
PRD.md
@ -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 |
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user