340 lines
12 KiB
Swift
340 lines
12 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 reminderScheduler: ReminderScheduler
|
|
let onComplete: () -> Void
|
|
|
|
@State private var currentStep: WizardStep = .welcome
|
|
@State private var selectedGoals: [OnboardingGoal] = []
|
|
@State private var selectedTimes: Set<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
|
|
|
|
@State private var isShowingSkipConfirmation = false
|
|
|
|
enum WizardStep: Int, CaseIterable {
|
|
case welcome = 0
|
|
case goalSelection = 1
|
|
case timeSelection = 2
|
|
case ritualPreview = 3
|
|
case firstCheckIn = 4
|
|
case notifications = 5
|
|
case whatsNext = 6
|
|
}
|
|
|
|
/// 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, .notifications, .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, notifications, and whatsNext)
|
|
// Notifications step has its own "Maybe Later" option so skip button is redundant
|
|
if currentStep != .welcome && currentStep != .notifications && 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(
|
|
selectedTimes: $selectedTimes,
|
|
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: { advanceToNotifications() }
|
|
)
|
|
}
|
|
|
|
case .notifications:
|
|
NotificationStepView(
|
|
selectedTimes: selectedTimes,
|
|
reminderScheduler: reminderScheduler,
|
|
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)
|
|
.alert(String(localized: "Skip setup?"), isPresented: $isShowingSkipConfirmation) {
|
|
Button(String(localized: "Keep going"), role: .cancel) {}
|
|
Button(String(localized: "Skip"), role: .destructive) {
|
|
skipOnboarding()
|
|
}
|
|
} message: {
|
|
Text(String(localized: "You can complete setup later in Settings."))
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
Button(String(localized: "Skip")) {
|
|
isShowingSkipConfirmation = true
|
|
}
|
|
.font(.body)
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
}
|
|
|
|
// 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.2
|
|
case .timeSelection:
|
|
return 0.4
|
|
case .ritualPreview:
|
|
return 0.55
|
|
case .firstCheckIn:
|
|
return 0.7
|
|
case .notifications:
|
|
return 0.85
|
|
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 advanceToNotifications() {
|
|
withAnimation {
|
|
currentStep = .notifications
|
|
}
|
|
}
|
|
|
|
private func advanceToWhatsNext() {
|
|
withAnimation {
|
|
currentStep = .whatsNext
|
|
}
|
|
}
|
|
|
|
private func skipOnboarding() {
|
|
onComplete()
|
|
}
|
|
|
|
// MARK: - Time Selection Handler
|
|
|
|
private func handleTimeSelectionContinue() {
|
|
guard !selectedTimes.isEmpty, !selectedGoals.isEmpty else { return }
|
|
|
|
var presets: [RitualPreset] = []
|
|
for goal in selectedGoals {
|
|
// Collect all unique filters from all selected time preferences
|
|
let allFilters = selectedTimes.reduce(into: Set<TimeOfDay>()) { result, preference in
|
|
preference.timeOfDayFilters.forEach { result.insert($0) }
|
|
}
|
|
|
|
// Sort filters to maintain a logical order (morning -> night)
|
|
let sortedFilters = allFilters.sorted { $0.rawValue < $1.rawValue }
|
|
|
|
for filter in sortedFilters {
|
|
if let preset = OnboardingPresetRecommender.recommendedPreset(for: goal, timeOfDay: filter) {
|
|
presets.append(preset)
|
|
}
|
|
}
|
|
}
|
|
|
|
withAnimation {
|
|
pendingPresets = presets
|
|
currentPresetIndex = 0
|
|
createdRituals = []
|
|
hasCompletedFirstCheckIn = false
|
|
|
|
if presets.isEmpty {
|
|
currentStep = .notifications
|
|
} 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, skip to notifications
|
|
currentStep = .notifications
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
SetupWizardView(
|
|
store: RitualStore.preview,
|
|
categoryStore: CategoryStore.preview,
|
|
reminderScheduler: ReminderScheduler(),
|
|
onComplete: {}
|
|
)
|
|
}
|