Andromida/Andromida/App/Views/Onboarding/SetupWizardView.swift

311 lines
10 KiB
Swift

import SwiftUI
import Bedrock
/// The setup wizard shown to new users on first launch.
/// Guides them through goal selection, time preference, and creates their first ritual(s).
struct SetupWizardView: View {
@Bindable var store: RitualStore
@Bindable var categoryStore: CategoryStore
let onComplete: () -> Void
@State private var currentStep: WizardStep = .welcome
@State private var selectedGoals: [OnboardingGoal] = []
@State private var selectedTime: OnboardingTimePreference?
// Track created rituals during onboarding
@State private var createdRituals: [Ritual] = []
@State private var hasCompletedFirstCheckIn = false
// Presets for preview flow
@State private var pendingPresets: [RitualPreset] = []
@State private var currentPresetIndex: Int = 0
enum WizardStep: Int, CaseIterable {
case welcome = 0
case goalSelection = 1
case timeSelection = 2
case ritualPreview = 3
case firstCheckIn = 4
case whatsNext = 5
}
/// Whether the user selected "Both" for time preference
private var isBothMode: Bool {
selectedTime == .both
}
/// The first ritual that was created (for first check-in)
private var firstCreatedRitual: Ritual? {
createdRituals.first
}
/// Whether any ritual was created
private var hasCreatedRitual: Bool {
!createdRituals.isEmpty
}
private var currentPreset: RitualPreset? {
guard currentPresetIndex < pendingPresets.count else { return nil }
return pendingPresets[currentPresetIndex]
}
private var totalPresets: Int {
pendingPresets.count
}
/// Whether to show the back button
private var canGoBack: Bool {
switch currentStep {
case .welcome, .firstCheckIn, .whatsNext:
return false
case .goalSelection:
return true
case .timeSelection:
return true
case .ritualPreview:
return true
}
}
var body: some View {
ZStack {
// Background gradient
LinearGradient(
colors: [AppSurface.primary, AppSurface.secondary],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
VStack(spacing: 0) {
// Header with back button and progress (hidden on welcome and whatsNext)
if currentStep != .welcome && currentStep != .whatsNext {
headerView
.padding(.top, Design.Spacing.medium)
.padding(.horizontal, Design.Spacing.large)
}
// Content - constrained width on iPad for better readability
Group {
switch currentStep {
case .welcome:
WelcomeStepView(onContinue: advanceToNextStep)
case .goalSelection:
GoalSelectionStepView(
selectedGoals: $selectedGoals,
onContinue: advanceToNextStep
)
case .timeSelection:
TimeSelectionStepView(
selectedTime: $selectedTime,
onContinue: handleTimeSelectionContinue
)
case .ritualPreview:
if let preset = currentPreset {
RitualPreviewStepView(
preset: preset,
ritualIndex: totalPresets > 1 ? currentPresetIndex + 1 : nil,
totalRituals: totalPresets > 1 ? totalPresets : nil,
onStartRitual: { createCurrentRitualAndAdvance() },
onSkip: { skipCurrentAndAdvance() }
)
}
case .firstCheckIn:
if let ritual = firstCreatedRitual {
FirstCheckInStepView(
store: store,
ritual: ritual,
hasCompletedCheckIn: $hasCompletedFirstCheckIn,
onComplete: { advanceToWhatsNext() }
)
}
case .whatsNext:
WhatsNextStepView(onComplete: onComplete)
}
}
.adaptiveContentWidth(maxWidth: Design.Size.maxContentWidthPortrait)
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
))
}
}
.animation(.easeInOut(duration: Design.Animation.standard), value: currentStep)
}
// MARK: - Header View
private var headerView: some View {
VStack(spacing: Design.Spacing.medium) {
// Back button row
HStack {
if canGoBack {
Button(action: goBack) {
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "chevron.left")
.font(.body.weight(.medium))
Text(String(localized: "Back"))
.font(.body)
}
.foregroundStyle(AppTextColors.secondary)
}
}
Spacer()
}
// Progress indicator
progressIndicator
.padding(.horizontal, Design.Spacing.small)
}
}
// MARK: - Progress Indicator
private var progressIndicator: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
// Background track
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(AppSurface.card)
.frame(height: 4)
// Progress fill
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(AppAccent.primary)
.frame(width: geometry.size.width * progressValue, height: 4)
.animation(.easeInOut(duration: Design.Animation.standard), value: currentStep)
}
}
.frame(height: 4)
}
/// Adjusted progress value that accounts for skipped steps
private var progressValue: Double {
switch currentStep {
case .welcome:
return 0.0
case .goalSelection:
return 0.25
case .timeSelection:
return 0.5
case .ritualPreview:
return 0.7
case .firstCheckIn:
return 0.9
case .whatsNext:
return 1.0
}
}
// MARK: - Navigation Actions
private func advanceToNextStep() {
guard let nextStep = WizardStep(rawValue: currentStep.rawValue + 1) else { return }
withAnimation {
currentStep = nextStep
}
}
private func goBack() {
if currentStep == .ritualPreview, currentPresetIndex > 0 {
withAnimation {
currentPresetIndex -= 1
}
return
}
let targetStep = currentStep.rawValue - 1
guard targetStep >= 0,
let previousStep = WizardStep(rawValue: targetStep) else { return }
withAnimation {
currentStep = previousStep
}
}
private func advanceToWhatsNext() {
withAnimation {
currentStep = .whatsNext
}
}
// MARK: - Time Selection Handler
private func handleTimeSelectionContinue() {
guard let time = selectedTime, !selectedGoals.isEmpty else { return }
var presets: [RitualPreset] = []
for goal in selectedGoals {
switch time {
case .morning:
if let preset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .morning) {
presets.append(preset)
}
case .evening:
if let preset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .evening) {
presets.append(preset)
}
case .both:
if let morningPreset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .morning) {
presets.append(morningPreset)
}
if let eveningPreset = OnboardingPresetRecommender.recommendedPreset(for: goal, time: .evening) {
presets.append(eveningPreset)
}
}
}
withAnimation {
pendingPresets = presets
currentPresetIndex = 0
createdRituals = []
hasCompletedFirstCheckIn = false
if presets.isEmpty {
currentStep = .whatsNext
} else {
currentStep = .ritualPreview
}
}
}
// MARK: - Ritual Preview Actions
private func createCurrentRitualAndAdvance() {
guard let preset = currentPreset else { return }
let ritual = store.createRitual(from: preset)
createdRituals.append(ritual)
advanceFromPreview()
}
private func skipCurrentAndAdvance() {
advanceFromPreview()
}
private func advanceFromPreview() {
withAnimation {
if currentPresetIndex + 1 < pendingPresets.count {
currentPresetIndex += 1
} else if hasCreatedRitual {
// Go to first check-in
currentStep = .firstCheckIn
} else {
// No rituals created, go to what's next
currentStep = .whatsNext
}
}
}
}
#Preview {
SetupWizardView(
store: RitualStore.preview,
categoryStore: CategoryStore.preview,
onComplete: {}
)
}