Files
FamilyMealPlanner/MealMood/Views/Onboarding/OnboardingView.swift
T
alexandrev-tibco b45cb2a530 1.1.5: conversion funnel overhaul
Paywall:
- Redesigned PremiumView with yearly/monthly/lifetime side by side
- 7-day free trial badge with savings % vs monthly
- Social proof line, BEST VALUE highlight on yearly
- StoreManager: new yearlyProduct, lifetimeProduct, yearlySavingsPercent, hasTrialAvailable

Funnel:
- Paywall step added to onboarding (PaywallStepView)
- Contextual modal at dish limit (replaces silent block)
- Visible "X/15 dishes" counter in DishListView
- BottomPromoBanner rotates AdMob with internal "Remove ads" CTA (1 in 4)
- Free dish limit lowered 20 → 15
- Review prompt earlier: 2 weeks → 1 week

Analytics:
- paywall_viewed/dismissed with source + seconds_on_screen
- dish_limit_hit, week_limit_hit, free_trial_started events
- Every PremiumView call site now passes its source

StoreKit:
- MealMood.storekit: added yearly $19.99 + 7d trial, monthly trial, lifetime $39.99
- AppStoreConnect-1.1.5-setup.md with full manual setup instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:21:36 +02:00

174 lines
6.7 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: {
_ = viewModel.completeOnboarding(context: context)
viewModel.nextStep()
}
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.tag(4)
PaywallStepView(
onSkip: { onComplete() },
onConverted: { onComplete() }
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.tag(5)
}
.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 {
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)
}
}