268 lines
9.8 KiB
Swift
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
|