From 3937abeb6ee25b37b0164fcb30ee9f816daa2e7e Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Thu, 26 Feb 2026 10:50:09 +0100 Subject: [PATCH] Rework onboarding flow and move premium prompt post-setup --- MealMood.xcodeproj/project.pbxproj | 8 +- .../Resources/en.lproj/Localizable.strings | 26 ++-- .../Resources/es.lproj/Localizable.strings | 26 ++-- MealMood/ViewModels/OnboardingViewModel.swift | 16 +- MealMood/Views/Home/HomeView.swift | 56 +++++++ .../Views/Onboarding/OnboardingView.swift | 144 +----------------- .../Views/Onboarding/WelcomeStepView.swift | 20 +-- 7 files changed, 114 insertions(+), 182 deletions(-) diff --git a/MealMood.xcodeproj/project.pbxproj b/MealMood.xcodeproj/project.pbxproj index 9b7e9f5..8e2af9d 100644 --- a/MealMood.xcodeproj/project.pbxproj +++ b/MealMood.xcodeproj/project.pbxproj @@ -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; diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index cf0195e..8e1f206 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -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"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index 205c8ae..db0785c 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -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"; diff --git a/MealMood/ViewModels/OnboardingViewModel.swift b/MealMood/ViewModels/OnboardingViewModel.swift index 0f4d3a9..e9d39b4 100644 --- a/MealMood/ViewModels/OnboardingViewModel.swift +++ b/MealMood/ViewModels/OnboardingViewModel.swift @@ -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)") diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index 7192c7e..ca816cf 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -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 { diff --git a/MealMood/Views/Onboarding/OnboardingView.swift b/MealMood/Views/Onboarding/OnboardingView.swift index 9fcfe95..bab6574 100644 --- a/MealMood/Views/Onboarding/OnboardingView.swift +++ b/MealMood/Views/Onboarding/OnboardingView.swift @@ -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() - 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") - } - } -} diff --git a/MealMood/Views/Onboarding/WelcomeStepView.swift b/MealMood/Views/Onboarding/WelcomeStepView.swift index 9d287f4..da635f0 100644 --- a/MealMood/Views/Onboarding/WelcomeStepView.swift +++ b/MealMood/Views/Onboarding/WelcomeStepView.swift @@ -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)