Rework onboarding flow and move premium prompt post-setup

This commit is contained in:
alexandrev-tibco
2026-02-26 10:50:09 +01:00
parent 6db6659330
commit 3937abeb6e
7 changed files with 114 additions and 182 deletions
+4 -4
View File
@@ -643,7 +643,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MealMood/Resources/Info.plist;
@@ -652,7 +652,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.0;
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.mealmood;
PRODUCT_NAME = MealMood;
SDKROOT = iphoneos;
@@ -747,7 +747,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MealMood/Resources/Info.plist;
@@ -756,7 +756,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.0;
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.mealmood;
PRODUCT_NAME = MealMood;
SDKROOT = iphoneos;
@@ -1,27 +1,28 @@
/* Onboarding */
"onboarding_welcome_title" = "MealMood";
"onboarding_welcome_subtitle" = "Plan your week, enjoy your meals";
"onboarding_welcome_benefit1" = "No stress";
"onboarding_welcome_benefit2" = "No calorie counting";
"onboarding_welcome_benefit3" = "Sync with your calendar";
"onboarding_welcome_title" = "Organize your meals in 1 minute";
"onboarding_welcome_subtitle" = "Answer 4 quick steps and start planning today.";
"onboarding_welcome_step_1" = "Choose if you want to plan lunches, dinners, or both";
"onboarding_welcome_step_2" = "Decide whether to include weekends";
"onboarding_welcome_step_3" = "Optionally connect your calendar";
"onboarding_welcome_step_4" = "Add your usual dishes to start fast";
"onboarding_start" = "Get Started";
"onboarding_continue" = "Continue";
"onboarding_skip" = "Skip";
"onboarding_finish" = "Finish";
/* Meal Windows */
"meal_windows_title" = "What do you want to plan?";
"meal_windows_title" = "Step 1: Lunches, dinners, or both?";
"meal_windows_dinner_only" = "Dinners only";
"meal_windows_lunch_only" = "Lunches only";
"meal_windows_both" = "Lunches and dinners";
/* Weekends */
"weekends_title" = "Include weekends?";
"weekends_title" = "Step 2: Also plan weekends?";
"weekends_toggle" = "Also plan Saturdays and Sundays";
"weekends_note" = "You can change this later in settings";
/* Calendar */
"calendar_title" = "Sync with your calendar?";
"calendar_title" = "Step 3: Connect your calendar?";
"calendar_sync" = "Sync";
"calendar_select" = "Calendar";
"calendar_lunch_time" = "Lunch time";
@@ -32,7 +33,7 @@
"calendar_permission_settings" = "Go to Settings";
/* First Dishes */
"first_dishes_title" = "Add your first dishes";
"first_dishes_title" = "Step 4: Add your go-to dishes";
"first_dishes_subtitle" = "Add at least 2 dishes to get started";
"first_dishes_name_placeholder" = "Dish name";
"first_dishes_add_tag" = "Add tag";
@@ -232,6 +233,13 @@
"review_feedback_title" = "Tell us what to improve";
"review_feedback_message" = "Send us your feedback and we'll use it to improve the app.";
"review_feedback_contact" = "Send feedback";
"onboarding_auto_assign_title" = "Want us to auto-assign your week?";
"onboarding_auto_assign_message" = "We can fill this week automatically using your dishes and rules.";
"onboarding_auto_assign_yes" = "Yes, auto-assign";
"onboarding_auto_assign_no" = "No, I'll do it";
"onboarding_premium_prompt_title" = "Want to unlock Premium?";
"onboarding_premium_prompt_message" = "Get no ads, unlimited dishes, advanced rules, and future-week planning.";
"onboarding_premium_prompt_cta" = "See Premium";
/* Notifications */
"notification_planning_title" = "Plan your next week";
@@ -1,27 +1,28 @@
/* Onboarding */
"onboarding_welcome_title" = "MealMood";
"onboarding_welcome_subtitle" = "Planifica tu semana, disfruta tus comidas";
"onboarding_welcome_benefit1" = "Sin estrés";
"onboarding_welcome_benefit2" = "Sin contar calorías";
"onboarding_welcome_benefit3" = "Sincroniza con tu calendario";
"onboarding_welcome_title" = "Organiza tus comidas en 1 minuto";
"onboarding_welcome_subtitle" = "Responde 4 pasos rápidos y empieza a planificar hoy.";
"onboarding_welcome_step_1" = "Elige si quieres planificar comidas, cenas o ambas";
"onboarding_welcome_step_2" = "Decide si incluir fines de semana";
"onboarding_welcome_step_3" = "Conecta tu calendario de forma opcional";
"onboarding_welcome_step_4" = "Añade tus platos habituales para empezar rápido";
"onboarding_start" = "Comenzar";
"onboarding_continue" = "Continuar";
"onboarding_skip" = "Omitir";
"onboarding_finish" = "Finalizar";
/* Meal Windows */
"meal_windows_title" = "¿Qué quieres planificar?";
"meal_windows_title" = "Paso 1: ¿Comidas, cenas o ambas?";
"meal_windows_dinner_only" = "Solo cenas";
"meal_windows_lunch_only" = "Solo comidas";
"meal_windows_both" = "Comidas y cenas";
/* Weekends */
"weekends_title" = "¿Incluir fines de semana?";
"weekends_title" = "Paso 2: ¿Planificamos también fines de semana?";
"weekends_toggle" = "Planificar también sábados y domingos";
"weekends_note" = "Puedes cambiar esto después en ajustes";
/* Calendar */
"calendar_title" = "¿Sincronizar con tu calendario?";
"calendar_title" = "Paso 3: ¿Lo integramos con tu calendario?";
"calendar_sync" = "Sincronizar";
"calendar_select" = "Calendario";
"calendar_lunch_time" = "Hora de comidas";
@@ -32,7 +33,7 @@
"calendar_permission_settings" = "Ir a Ajustes";
/* First Dishes */
"first_dishes_title" = "Añade tus primeros platos";
"first_dishes_title" = "Paso 4: Añade tus platos habituales";
"first_dishes_subtitle" = "Añade al menos 2 platos para empezar";
"first_dishes_name_placeholder" = "Nombre del plato";
"first_dishes_add_tag" = "Añadir etiqueta";
@@ -232,6 +233,13 @@
"review_feedback_title" = "Cuéntanos qué mejorar";
"review_feedback_message" = "Envíanos tu feedback y lo usaremos para mejorar la app.";
"review_feedback_contact" = "Enviar feedback";
"onboarding_auto_assign_title" = "¿Quieres que hagamos una asignación automática?";
"onboarding_auto_assign_message" = "Podemos completar esta semana automáticamente usando tus platos y reglas.";
"onboarding_auto_assign_yes" = "Sí, asignar automáticamente";
"onboarding_auto_assign_no" = "No, lo haré yo";
"onboarding_premium_prompt_title" = "¿Quieres desbloquear Premium?";
"onboarding_premium_prompt_message" = "Consigue sin anuncios, platos ilimitados, reglas avanzadas y planificación de semanas futuras.";
"onboarding_premium_prompt_cta" = "Ver Premium";
/* Notifications */
"notification_planning_title" = "Planifica tu próxima semana";
+10 -6
View File
@@ -3,6 +3,9 @@ import SwiftData
@MainActor
final class OnboardingViewModel: ObservableObject {
static let pendingAutoAssignPromptKey = "onboarding_pending_auto_assign_prompt"
static let pendingPremiumPromptKey = "onboarding_pending_premium_prompt"
@Published var currentStep: Int = 0
@Published var selectedMealWindows: MealWindows = .dinnerOnly
@Published var includeWeekends: Bool = true
@@ -24,16 +27,15 @@ final class OnboardingViewModel: ObservableObject {
@Published var addedDishes: [(name: String, tagIds: [UUID])] = []
let maxOnboardingDishes = 10
let totalSteps = 6
let totalSteps = 5
var canContinue: Bool {
switch currentStep {
case 0: return true // Welcome
case 1: return true // Trial
case 2: return true // Meal windows (always has selection)
case 3: return true // Weekends (toggle)
case 4: return true // Calendar (optional)
case 5: return addedDishes.count >= 2 // Need at least 2 dishes
case 1: return true // Meal windows (always has selection)
case 2: return true // Weekends (toggle)
case 3: return true // Calendar (optional)
case 4: return addedDishes.count >= 2 // Need at least 2 dishes
default: return false
}
}
@@ -149,6 +151,8 @@ final class OnboardingViewModel: ObservableObject {
do {
try context.save()
UserDefaults.standard.set(true, forKey: Self.pendingAutoAssignPromptKey)
UserDefaults.standard.set(true, forKey: Self.pendingPremiumPromptKey)
return true
} catch {
print("Onboarding save failed: \(error)")
+56
View File
@@ -21,6 +21,9 @@ struct HomeView: View {
@State private var showCopyPreviousConfirm: Bool = false
@State private var showReviewSentimentPrompt: Bool = false
@State private var showReviewSupportPrompt: Bool = false
@State private var showPostOnboardingAutoAssignPrompt: Bool = false
@State private var showPostOnboardingPremiumPrompt: Bool = false
@State private var hasEvaluatedPostOnboardingPrompts: Bool = false
private var settings: AppSettings? { allSettings.first }
@@ -251,6 +254,30 @@ struct HomeView: View {
secondaryButton: .cancel(Text("reset_cancel"))
)
}
.alert("onboarding_auto_assign_title", isPresented: $showPostOnboardingAutoAssignPrompt) {
Button("onboarding_auto_assign_yes") {
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings)
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoAssignPromptKey)
schedulePostOnboardingPremiumPromptIfNeeded(settings: settings)
}
Button("onboarding_auto_assign_no", role: .cancel) {
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoAssignPromptKey)
schedulePostOnboardingPremiumPromptIfNeeded(settings: settings)
}
} message: {
Text("onboarding_auto_assign_message")
}
.alert("onboarding_premium_prompt_title", isPresented: $showPostOnboardingPremiumPrompt) {
Button("onboarding_premium_prompt_cta") {
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey)
showPremiumFromExport = true
}
Button("reset_cancel", role: .cancel) {
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey)
}
} message: {
Text("onboarding_premium_prompt_message")
}
.alert("review_funnel_title", isPresented: $showReviewSentimentPrompt) {
Button("review_funnel_positive") {
ReviewPromptService.shared.requestReview()
@@ -374,6 +401,7 @@ struct HomeView: View {
language: settings.languageEnum.resolved()
)
wasWeekComplete = isWeekComplete(plan: plan)
evaluatePostOnboardingPromptsIfNeeded(plan: plan, settings: settings)
}
.onChange(of: plan.updatedAt) { _, _ in
let nowComplete = isWeekComplete(plan: plan)
@@ -643,6 +671,34 @@ struct HomeView: View {
ReviewPromptService.shared.markFunnelShown(completedWeeks: completedWeeks)
showReviewSentimentPrompt = true
}
private func evaluatePostOnboardingPromptsIfNeeded(plan: WeekPlan, settings: AppSettings) {
guard !hasEvaluatedPostOnboardingPrompts else { return }
hasEvaluatedPostOnboardingPrompts = true
let shouldAskAutoAssign = UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingAutoAssignPromptKey)
if shouldAskAutoAssign && plan.slots.contains(where: { $0.dishId == nil }) {
showPostOnboardingAutoAssignPrompt = true
return
}
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoAssignPromptKey)
schedulePostOnboardingPremiumPromptIfNeeded(settings: settings)
}
private func schedulePostOnboardingPremiumPromptIfNeeded(settings: AppSettings) {
guard !settings.isPremium else {
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey)
return
}
let shouldShowPremiumPrompt = UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingPremiumPromptKey)
guard shouldShowPremiumPrompt else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
showPostOnboardingPremiumPrompt = true
}
}
}
private struct MonthlyHistoryView: View {
+4 -140
View File
@@ -1,6 +1,5 @@
import SwiftUI
import SwiftData
import StoreKit
struct OnboardingView: View {
@Environment(\.modelContext) private var context
@@ -30,22 +29,17 @@ struct OnboardingView: View {
WelcomeStepView(onNext: { viewModel.nextStep() })
.tag(0)
TrialStepView(
onContinue: { viewModel.nextStep() }
)
.tag(1)
MealWindowsStepView(
selection: $viewModel.selectedMealWindows,
onNext: { viewModel.nextStep() }
)
.tag(2)
.tag(1)
WeekendsStepView(
includeWeekends: $viewModel.includeWeekends,
onNext: { viewModel.nextStep() }
)
.tag(3)
.tag(2)
CalendarStepView(
iCloudSyncEnabled: $viewModel.syncICloud,
@@ -59,7 +53,7 @@ struct OnboardingView: View {
onNext: { viewModel.nextStep() },
onSkip: { viewModel.nextStep() }
)
.tag(4)
.tag(3)
FirstDishesStepView(
viewModel: viewModel,
@@ -70,7 +64,7 @@ struct OnboardingView: View {
}
}
)
.tag(5)
.tag(4)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.animation(.easeInOut(duration: 0.3), value: viewModel.currentStep)
@@ -113,133 +107,3 @@ struct OnboardingView: View {
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")
}
}
}
@@ -13,28 +13,20 @@ struct WelcomeStepView: View {
Text("onboarding_welcome_title")
.font(.mealMoodH1)
.foregroundColor(.mealMoodTextPrimary)
.multilineTextAlignment(.center)
Text("onboarding_welcome_subtitle")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.multilineTextAlignment(.center)
}
Image(systemName: "figure.2.and.child.holdinghands")
.font(.system(size: 80))
.foregroundStyle(
LinearGradient(
colors: [.mealMoodCoral, .mealMoodMint],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.padding(.vertical, 20)
.padding(.horizontal, 24)
VStack(alignment: .leading, spacing: 16) {
BenefitRow(icon: "face.smiling", text: String(localized: "onboarding_welcome_benefit1"))
BenefitRow(icon: "scalemass", text: String(localized: "onboarding_welcome_benefit2"))
BenefitRow(icon: "calendar", text: String(localized: "onboarding_welcome_benefit3"))
BenefitRow(icon: "1.circle.fill", text: String(localized: "onboarding_welcome_step_1"))
BenefitRow(icon: "2.circle.fill", text: String(localized: "onboarding_welcome_step_2"))
BenefitRow(icon: "3.circle.fill", text: String(localized: "onboarding_welcome_step_3"))
BenefitRow(icon: "4.circle.fill", text: String(localized: "onboarding_welcome_step_4"))
}
.padding(.horizontal, 40)