Files
FamilyMealPlanner/MealMood/Views/Onboarding/OnboardingView.swift
T

246 lines
9.2 KiB
Swift

import SwiftUI
import SwiftData
import StoreKit
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) {
// Progress indicator
if viewModel.currentStep > 0 {
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, 16)
}
TabView(selection: $viewModel.currentStep) {
WelcomeStepView(onNext: { viewModel.nextStep() })
.tag(0)
TrialStepView(
onContinue: { viewModel.nextStep() }
)
.tag(1)
MealWindowsStepView(
selection: $viewModel.selectedMealWindows,
onNext: { viewModel.nextStep() }
)
.tag(2)
WeekendsStepView(
includeWeekends: $viewModel.includeWeekends,
onNext: { viewModel.nextStep() }
)
.tag(3)
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() }
)
.tag(4)
FirstDishesStepView(
viewModel: viewModel,
tags: tags,
onFinish: {
if viewModel.completeOnboarding(context: context) {
onComplete()
}
}
)
.tag(5)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.animation(.easeInOut(duration: 0.3), value: viewModel.currentStep)
}
}
.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 struct TrialStepView: View {
@Environment(\.modelContext) private var context
@StateObject private var storeManager = StoreManager()
@State private var purchaseStatusMessageKey: String?
let onContinue: () -> Void
var body: some View {
VStack(spacing: 24) {
Spacer()
VStack(spacing: 12) {
Text("onboarding_trial_title")
.font(.mealMoodH2)
.foregroundColor(.mealMoodTextPrimary)
.multilineTextAlignment(.center)
Text("onboarding_trial_subtitle")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 24)
VStack(alignment: .leading, spacing: 10) {
Label("premium_no_ads", systemImage: "checkmark.circle.fill")
Label("premium_unlimited_dishes", systemImage: "checkmark.circle.fill")
Label("premium_advanced_rules", systemImage: "checkmark.circle.fill")
}
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextPrimary)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.mealMoodSurface)
.cornerRadius(14)
.padding(.horizontal, 24)
if let monthly = storeManager.monthlyProduct {
VStack(spacing: 10) {
Text(
String(
format: String(localized: "onboarding_trial_price_format"),
monthly.displayPrice
)
)
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
PrimaryButton(
title: storeManager.isLoading
? String(localized: "premium_processing")
: String(localized: "onboarding_trial_cta"),
action: { Task { await startTrial(with: monthly) } },
isEnabled: !storeManager.isLoading,
localizeTitle: false
)
}
.padding(.horizontal, 24)
} else {
let fallbackText = fallbackContent(for: storeManager.productLoadState)
VStack(spacing: 8) {
Text(fallbackText)
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
.multilineTextAlignment(.center)
SecondaryButton(
title: String(localized: "premium_retry_products"),
action: { Task { await storeManager.loadProducts() } },
localizeTitle: false
)
}
.padding(.horizontal, 24)
}
SecondaryButton(
title: String(localized: "onboarding_trial_skip"),
action: onContinue,
localizeTitle: false
)
.padding(.horizontal, 24)
.padding(.bottom, 40)
}
.alert("premium_title", isPresented: Binding(
get: { purchaseStatusMessageKey != nil },
set: { isPresented in
if !isPresented { purchaseStatusMessageKey = nil }
}
)) {
Button("dish_delete_blocked_ok", role: .cancel) {}
} message: {
Text(LocalizedStringKey(purchaseStatusMessageKey ?? ""))
}
}
private func startTrial(with product: Product) async {
let result = await storeManager.purchase(product)
switch result {
case .success:
let descriptor = FetchDescriptor<AppSettings>()
let settings = (try? context.fetch(descriptor))?.first ?? {
let created = AppSettings()
context.insert(created)
return created
}()
settings.isPremium = true
try? context.save()
onContinue()
case .pending:
purchaseStatusMessageKey = "premium_purchase_pending"
case .cancelled:
purchaseStatusMessageKey = "premium_purchase_cancelled"
case .failed:
purchaseStatusMessageKey = "premium_purchase_failed"
}
}
private func fallbackContent(for state: StoreManager.ProductLoadState) -> String {
switch state {
case .timedOut:
return String(localized: "premium_loading_timeout_hint")
case .notFound:
return String(localized: "premium_products_not_found_hint")
case .failed:
return String(localized: "premium_loading_failed_hint")
case .idle, .loading, .loaded:
return String(localized: "premium_loading_products_hint")
}
}
}