164 lines
6.3 KiB
Swift
164 lines
6.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.selectedMealWindows,
|
|
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: {
|
|
if viewModel.completeOnboarding(context: context) {
|
|
onComplete()
|
|
}
|
|
}
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
.tag(4)
|
|
}
|
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
|
.animation(.easeInOut(duration: 0.3), value: viewModel.currentStep)
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
}
|
|
}
|
|
.onAppear {
|
|
ensureDefaultTagsIfNeeded()
|
|
Task { await restoreFromICloudAndFinishIfNeeded() }
|
|
}
|
|
}
|
|
|
|
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 {
|
|
if viewModel.currentStep > 0 {
|
|
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)
|
|
}
|
|
}
|