246 lines
9.2 KiB
Swift
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")
|
|
}
|
|
}
|
|
}
|