Andromida/Andromida/App/Views/Onboarding/TimeSelectionStepView.swift
Matt Bruce b5c351f313 accessibility motion support
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2026-02-09 08:42:26 -06:00

162 lines
6.2 KiB
Swift

import SwiftUI
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
var body: some View {
VStack(spacing: Design.Spacing.xxLarge) {
Spacer()
.frame(height: Design.Spacing.large)
// Header
VStack(spacing: Design.Spacing.small) {
Text(String(localized: "When do you want to build habits?"))
.typography(.title2Bold)
.foregroundStyle(AppTextColors.primary)
.multilineTextAlignment(.center)
Text(String(localized: "Select all that apply"))
.typography(.subheading)
.foregroundStyle(AppTextColors.secondary)
}
.padding(.horizontal, Design.Spacing.large)
// Time preference cards
ScrollView(showsIndicators: false) {
VStack(spacing: Design.Spacing.medium) {
ForEach(Array(OnboardingTimePreference.allCases.enumerated()), id: \.element.id) { index, time in
TimeCardView(
time: time,
isSelected: selectedTimes.contains(time),
onTap: {
toggleSelection(time)
}
)
.opacity(animateCards ? 1 : 0)
.offset(y: animateCards ? 0 : 20)
.optionalAnimation(
.easeOut(duration: 0.4).delay(Double(index) * 0.1),
value: animateCards,
reduceMotion: reduceMotion
)
}
}
.padding(.horizontal, Design.Spacing.large)
.padding(.bottom, Design.Spacing.large)
}
Spacer()
// Continue button (only shown when at least one time is selected)
if !selectedTimes.isEmpty {
Button(action: onContinue) {
Text(String(localized: "Continue"))
.typography(.heading)
.foregroundStyle(AppTextColors.inverse)
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.Size.buttonHeight)
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.accessibilityIdentifier("onboarding.timeContinue")
.padding(.horizontal, Design.Spacing.xxLarge)
.padding(.bottom, Design.Spacing.xxLarge)
.transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
}
}
.optionalAnimation(.easeInOut(duration: Design.Animation.quick), value: !selectedTimes.isEmpty, reduceMotion: reduceMotion)
.accessibilityIdentifier("onboarding.timeSelection")
.onAppear {
withOptionalAnimation(reduceMotion: reduceMotion) {
animateCards = true
}
}
}
private func toggleSelection(_ time: OnboardingTimePreference) {
withOptionalAnimation(.easeInOut(duration: Design.Animation.quick), reduceMotion: reduceMotion) {
if selectedTimes.contains(time) {
selectedTimes.remove(time)
} else {
selectedTimes.insert(time)
}
}
}
}
/// A single time preference card.
private struct TimeCardView: View {
let time: OnboardingTimePreference
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: Design.Spacing.medium) {
// Icon
SymbolIcon(time.symbolName, size: .rowContainer, color: isSelected ? AppAccent.primary : AppTextColors.secondary)
.frame(width: Design.Size.actionRowMinHeight)
// Text
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
Text(time.displayName)
.typography(.heading)
.foregroundStyle(AppTextColors.primary)
Text(time.subtitle)
.typography(.subheading)
.foregroundStyle(AppTextColors.secondary)
}
Spacer()
// Selection indicator
if isSelected {
SymbolIcon("checkmark.circle.fill", size: .rowContainer, color: AppAccent.primary)
}
}
.padding(Design.Spacing.large)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(isSelected ? AppAccent.primary.opacity(0.15) : AppSurface.card)
)
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.stroke(
isSelected ? AppAccent.primary : Color.clear,
lineWidth: 2
)
)
}
.buttonStyle(.plain)
.contentShape(.rect)
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityLabel(time.displayName)
.accessibilityHint(time.subtitle)
.accessibilityIdentifier("onboarding.time.\(time.rawValue)")
}
}
#Preview {
ZStack {
LinearGradient(
colors: [AppSurface.primary, AppSurface.secondary],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
TimeSelectionStepView(
selectedTimes: .constant([]),
onContinue: {}
)
}
}