e15ff93465
- MealType gains breakfast/snack (chronological order drives slot/row order). - AppSettings.activeMealTypes: single source of truth for enabled meals, backed by new optional enabledMealTypesRaw with legacy mealWindows fallback — additive migration, keeps pre-2.0 installs and iCloud snapshots working. Legacy field kept coherent by the setter. time(for:) resolves per-meal event times (breakfast 8:00 / snack 17:00 defaults on old stores). - Replaced the 5 duplicated mealWindows→[MealType] derivations (slot sync, default plan creation, week calendar, export view) with activeMealTypes. - CalendarService: per-type event names and times. - Export styles: per-type accent colors and gradients. - Widget: generic meals list (N rows) instead of hardcoded lunch/dinner; compact families fall back to main meals when >2 are active. - Settings: 4 meal toggles (min 1) replace the 3-option picker. - Onboarding: meal step is now multi-select over the 4 types. - iCloud sync: enabledMealTypesRaw synced (optional, pre-2.0 compatible). - Localized breakfast/snack in all 6 languages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
184 lines
7.3 KiB
Swift
184 lines
7.3 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
|
|
struct OnboardingView: View {
|
|
@Environment(\.modelContext) private var context
|
|
@StateObject private var viewModel = OnboardingViewModel()
|
|
@Query(sort: \Tag.sortOrder) private var tags: [Tag]
|
|
var onComplete: () -> Void
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.mealMoodBackground.ignoresSafeArea()
|
|
|
|
VStack(spacing: 0) {
|
|
onboardingHeader
|
|
|
|
HStack(spacing: 8) {
|
|
ForEach(1..<viewModel.totalSteps, id: \.self) { step in
|
|
Capsule()
|
|
.fill(step <= viewModel.currentStep ? Color.mealMoodCoral : Color(hex: "#E0E0E0"))
|
|
.frame(height: 4)
|
|
}
|
|
}
|
|
.padding(.horizontal, 24)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 12)
|
|
|
|
TabView(selection: $viewModel.currentStep) {
|
|
WelcomeStepView(onNext: { viewModel.nextStep() })
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
.tag(0)
|
|
|
|
MealWindowsStepView(
|
|
selection: $viewModel.selectedMealTypes,
|
|
onNext: { viewModel.nextStep() }
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
.tag(1)
|
|
|
|
WeekendsStepView(
|
|
includeWeekends: $viewModel.includeWeekends,
|
|
onNext: { viewModel.nextStep() }
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
.tag(2)
|
|
|
|
CalendarStepView(
|
|
iCloudSyncEnabled: $viewModel.syncICloud,
|
|
syncEnabled: $viewModel.syncCalendar,
|
|
selectedCalendarId: $viewModel.selectedCalendarId,
|
|
lunchTime: $viewModel.lunchTime,
|
|
dinnerTime: $viewModel.dinnerTime,
|
|
onEnableICloud: {
|
|
Task { await restoreFromICloudAndFinishIfNeeded() }
|
|
},
|
|
onNext: { viewModel.nextStep() },
|
|
onSkip: { viewModel.nextStep() }
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
.tag(3)
|
|
|
|
FirstDishesStepView(
|
|
viewModel: viewModel,
|
|
tags: tags,
|
|
onFinish: {
|
|
_ = viewModel.completeOnboarding(context: context)
|
|
viewModel.autoFillFirstWeek(context: context)
|
|
viewModel.nextStep()
|
|
}
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
.tag(4)
|
|
|
|
WeekReadyStepView(
|
|
filledCount: viewModel.autoFilledCount,
|
|
onContinue: { viewModel.nextStep() }
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
.tag(5)
|
|
|
|
PaywallStepView(
|
|
onSkip: { onComplete() },
|
|
onConverted: { onComplete() }
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
.tag(6)
|
|
}
|
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
|
.animation(.easeInOut(duration: 0.3), value: viewModel.currentStep)
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
}
|
|
}
|
|
.onAppear {
|
|
ensureDefaultTagsIfNeeded()
|
|
Task { await restoreFromICloudAndFinishIfNeeded() }
|
|
AnalyticsService.logOnboardingStepViewed(step: 0)
|
|
}
|
|
.onChange(of: viewModel.currentStep) { _, newStep in
|
|
AnalyticsService.logOnboardingStepViewed(step: newStep)
|
|
}
|
|
}
|
|
|
|
private func ensureDefaultTagsIfNeeded() {
|
|
let descriptor = FetchDescriptor<Tag>()
|
|
if (try? context.fetch(descriptor))?.isEmpty ?? true {
|
|
DefaultDataService.createDefaultTags(context: context)
|
|
}
|
|
}
|
|
|
|
private func restoreFromICloudAndFinishIfNeeded() async {
|
|
let settingsDescriptor = FetchDescriptor<AppSettings>()
|
|
let currentSettings = (try? context.fetch(settingsDescriptor))?.first
|
|
let iCloudEnabled = currentSettings?.iCloudSyncEnabledResolved ?? viewModel.syncICloud
|
|
guard iCloudEnabled else { return }
|
|
|
|
await ICloudSyncService.shared.pullRemoteIfNeeded(context: context)
|
|
|
|
guard shouldAutoCompleteOnboarding() else { return }
|
|
if let settings = (try? context.fetch(settingsDescriptor))?.first, !settings.onboardingCompleted {
|
|
settings.onboardingCompleted = true
|
|
try? context.save()
|
|
}
|
|
onComplete()
|
|
}
|
|
|
|
private func shouldAutoCompleteOnboarding() -> Bool {
|
|
let dishes = (try? context.fetch(FetchDescriptor<Dish>())) ?? []
|
|
if !dishes.isEmpty { return true }
|
|
|
|
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
|
|
return !plans.isEmpty
|
|
}
|
|
|
|
private var onboardingHeader: some View {
|
|
VStack(spacing: 6) {
|
|
HStack {
|
|
// No back navigation once onboarding data is committed (step 4 finish):
|
|
// re-finishing would duplicate dishes and the week plan.
|
|
if viewModel.currentStep > 0 && viewModel.currentStep < 5 {
|
|
Button {
|
|
viewModel.previousStep()
|
|
} label: {
|
|
Image(systemName: "chevron.left")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundColor(.mealMoodTextPrimary)
|
|
.frame(width: 32, height: 32)
|
|
.background(Color.mealMoodSurface)
|
|
.clipShape(Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
Color.clear.frame(width: 32, height: 32)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Text(headerTitle)
|
|
.font(.mealMoodBodyBold)
|
|
.foregroundColor(.mealMoodTextPrimary)
|
|
.lineLimit(1)
|
|
|
|
Spacer()
|
|
|
|
Text(stepCounterText)
|
|
.font(.mealMoodCaption)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
.frame(width: 70, alignment: .trailing)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 8)
|
|
}
|
|
|
|
private var headerTitle: String {
|
|
guard viewModel.currentStep > 0 else { return String(localized: "onboarding_nav_intro") }
|
|
return String(format: String(localized: "onboarding_nav_step_short"), viewModel.currentStep)
|
|
}
|
|
|
|
private var stepCounterText: String {
|
|
guard viewModel.currentStep > 0 else { return String(localized: "onboarding_nav_setup") }
|
|
return String(format: String(localized: "onboarding_nav_step_counter"), viewModel.currentStep, viewModel.totalSteps - 1)
|
|
}
|
|
}
|