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

268 lines
9.8 KiB
Swift

import SwiftUI
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
@State private var animateContent = false
@State private var showCelebration = false
@State private var showContinueButton = false
private var habits: [ArcHabit] {
store.habits(for: ritual)
}
private var hasAnyCheckIn: Bool {
habits.contains { store.isHabitCompletedToday($0) }
}
var body: some View {
ZStack {
VStack(spacing: Design.Spacing.xxLarge) {
Spacer()
.frame(height: Design.Spacing.large)
if showCelebration {
// Celebration state
celebrationView
} else {
// Check-in tutorial
tutorialView
}
}
// Confetti overlay
if showCelebration {
ConfettiView(
colors: [AppAccent.primary, AppAccent.light, AppStatus.success, .yellow, .orange],
count: 60
)
}
}
.onChange(of: hasAnyCheckIn) { _, completed in
if completed && !hasCompletedCheckIn {
triggerCelebration()
}
}
}
// MARK: - Tutorial View
private var tutorialView: some View {
VStack(spacing: Design.Spacing.xxLarge) {
// Header
Text(String(localized: "Let's try it out!"))
.styled(.title2Bold, emphasis: .primary)
.multilineTextAlignment(.center)
.opacity(animateContent ? 1 : 0)
// Ritual card with habits
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
// Header
HStack(spacing: Design.Spacing.small) {
SymbolIcon(ritual.iconName, size: .row, color: AppAccent.primary)
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "Day 1 of \(ritual.durationDays)")).styled(.caption, emphasis: .secondary)
Text(ritual.title).styled(.heading, emphasis: .primary)
}
Spacer()
}
.padding(.bottom, Design.Spacing.small)
// Habits list
ForEach(Array(habits.enumerated()), id: \.element.id) { index, habit in
OnboardingHabitRowView(
title: habit.title,
symbolName: habit.symbolName,
isCompleted: store.isHabitCompletedToday(habit),
isHighlighted: index == 0 && !hasAnyCheckIn,
action: { store.toggleHabitCompletion(habit) }
)
}
}
.padding(Design.Spacing.large)
.background(AppSurface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.padding(.horizontal, Design.Spacing.large)
.opacity(animateContent ? 1 : 0)
.offset(y: animateContent ? 0 : 20)
// Instruction
Text(String(localized: "Tap a habit to check in")).styled(.subheading, emphasis: .secondary)
.opacity(animateContent ? 1 : 0)
Spacer()
VStack(spacing: Design.Spacing.medium) {
Button(action: onComplete) {
Text(String(localized: "Continue")).styled(.heading, emphasis: .inverse)
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.Size.buttonHeight)
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.accessibilityIdentifier("onboarding.firstCheckInContinue")
Button(action: onComplete) {
Text(String(localized: "Skip for now")).styled(.subheading, emphasis: .secondary)
}
.accessibilityIdentifier("onboarding.firstCheckInSkip")
}
.padding(.horizontal, Design.Spacing.xxLarge)
Spacer()
.frame(height: Design.Spacing.xxLarge)
}
.onAppear {
withOptionalAnimation(.easeOut(duration: 0.5), reduceMotion: reduceMotion) {
animateContent = true
}
}
}
// MARK: - Celebration View
private var celebrationView: some View {
VStack(spacing: Design.Spacing.xxLarge) {
Spacer()
// Success icon
SymbolIcon("checkmark.circle.fill", size: .hero, color: AppStatus.success)
.scaleEffect(showCelebration ? 1 : 0.5)
.opacity(showCelebration ? 1 : 0)
VStack(spacing: Design.Spacing.medium) {
Text(String(localized: "Nice work!")).styled(.heroBold, emphasis: .primary)
Text(String(localized: "You completed your first check-in"))
.styled(.title3, emphasis: .secondary)
.multilineTextAlignment(.center)
}
Spacer()
// Continue button
if showContinueButton {
Button(action: onComplete) {
Text(String(localized: "Continue to Rituals")).styled(.heading, emphasis: .inverse)
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.Size.buttonHeight)
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.accessibilityIdentifier("onboarding.firstCheckInContinueToRituals")
.padding(.horizontal, Design.Spacing.xxLarge)
.transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
}
Spacer()
.frame(height: Design.Spacing.xxLarge)
}
}
// MARK: - Actions
private func triggerCelebration() {
hasCompletedCheckIn = true
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)) {
showContinueButton = true
}
}
}
}
/// 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
let isHighlighted: Bool
let action: () -> Void
@State private var pulseAnimation = false
var body: some View {
Button(action: action) {
HStack(spacing: Design.Spacing.medium) {
SymbolIcon(symbolName, size: .row, color: isCompleted ? AppStatus.success : AppAccent.primary)
.frame(width: AppMetrics.Size.iconLarge)
Text(title).styled(.body, emphasis: .primary)
Spacer(minLength: Design.Spacing.medium)
SymbolIcon(isCompleted ? "checkmark.circle.fill" : "circle", size: .row, color: isCompleted ? AppStatus.success : AppBorder.subtle)
}
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(isHighlighted ? AppAccent.primary.opacity(0.1) : AppSurface.tertiary)
)
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.stroke(
isHighlighted ? AppAccent.primary : Color.clear,
lineWidth: 2
)
.scaleEffect(pulseAnimation && isHighlighted && !reduceMotion ? 1.02 : 1.0)
.opacity(pulseAnimation && isHighlighted && !reduceMotion ? 0.5 : 1.0)
)
}
.buttonStyle(.plain)
.onAppear {
if isHighlighted && !reduceMotion {
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
pulseAnimation = true
}
}
}
}
}
#if DEBUG
struct FirstCheckInStepView_Previews: PreviewProvider {
static var previews: some View {
let store = RitualStore.preview
if let ritual = store.currentRituals.first {
ZStack {
LinearGradient(
colors: [AppSurface.primary, AppSurface.secondary],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
FirstCheckInStepView(
store: store,
ritual: ritual,
hasCompletedCheckIn: .constant(false),
onComplete: {}
)
}
}
}
}
#endif