311 lines
10 KiB
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: {}
|
|
)
|
|
}
|