From 9bdbf80e6792fa6347e715cca762e7ee21a64b14 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Thu, 26 Feb 2026 10:33:45 +0100 Subject: [PATCH 01/10] 1.0.1: add review funnel, trial onboarding, and app diagnostics --- .../Resources/en.lproj/Localizable.strings | 18 +++ .../Resources/es.lproj/Localizable.strings | 18 +++ MealMood/Services/ReviewPromptService.swift | 38 ++--- MealMood/ViewModels/OnboardingViewModel.swift | 11 +- MealMood/ViewModels/SettingsViewModel.swift | 8 + MealMood/Views/Home/HomeView.swift | 29 +++- .../Views/Onboarding/OnboardingView.swift | 144 +++++++++++++++++- MealMood/Views/Settings/SettingsView.swift | 14 ++ 8 files changed, 253 insertions(+), 27 deletions(-) diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index aee7272..9eb150e 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -149,6 +149,8 @@ "settings_website" = "Website"; "settings_support" = "Support email"; "settings_rate_app" = "Rate MealMood"; +"settings_app_version" = "Version"; +"settings_app_build" = "Build"; "settings_danger_zone" = "Danger zone"; "settings_reset_all_data" = "Reset all data"; "settings_reset_all_data_title" = "Reset all app data?"; @@ -215,6 +217,22 @@ "first_dishes_suggestion_added" = "Added"; "first_dishes_no_tags" = "Default tags are loading, please retry in a moment"; +/* Onboarding trial */ +"onboarding_trial_title" = "Try Premium free for 7 days"; +"onboarding_trial_subtitle" = "Unlock all premium features now. You can cancel anytime before billing starts."; +"onboarding_trial_cta" = "Start 7-day free trial"; +"onboarding_trial_skip" = "Continue with free plan"; +"onboarding_trial_price_format" = "Then %@ / month"; + +/* Review funnel */ +"review_funnel_title" = "How is MealMood going?"; +"review_funnel_message" = "Your opinion helps us improve MealMood."; +"review_funnel_positive" = "I like it"; +"review_funnel_negative" = "Needs improvement"; +"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"; + /* Notifications */ "notification_planning_title" = "Plan your next week"; "notification_planning_body" = "Your new week starts tomorrow and it is still not planned."; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index beb1c91..fa713bf 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -149,6 +149,8 @@ "settings_website" = "Web"; "settings_support" = "Correo de soporte"; "settings_rate_app" = "Valorar MealMood"; +"settings_app_version" = "Versión"; +"settings_app_build" = "Build"; "settings_danger_zone" = "Zona de peligro"; "settings_reset_all_data" = "Resetear todos los datos"; "settings_reset_all_data_title" = "¿Resetear todos los datos de la app?"; @@ -215,6 +217,22 @@ "first_dishes_suggestion_added" = "Añadido"; "first_dishes_no_tags" = "Las etiquetas por defecto se están cargando, inténtalo de nuevo en un momento"; +/* Onboarding trial */ +"onboarding_trial_title" = "Prueba Premium gratis durante 7 días"; +"onboarding_trial_subtitle" = "Desbloquea todas las funciones premium ahora. Puedes cancelar antes del primer cobro."; +"onboarding_trial_cta" = "Iniciar prueba gratuita de 7 días"; +"onboarding_trial_skip" = "Continuar con plan gratuito"; +"onboarding_trial_price_format" = "Después %@ / mes"; + +/* Review funnel */ +"review_funnel_title" = "¿Qué te está pareciendo MealMood?"; +"review_funnel_message" = "Tu opinión nos ayuda a mejorar MealMood."; +"review_funnel_positive" = "Me gusta"; +"review_funnel_negative" = "Se puede mejorar"; +"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"; + /* Notifications */ "notification_planning_title" = "Planifica tu próxima semana"; "notification_planning_body" = "Mañana empieza la semana y aún no está planificada."; diff --git a/MealMood/Services/ReviewPromptService.swift b/MealMood/Services/ReviewPromptService.swift index acfd34d..9566031 100644 --- a/MealMood/Services/ReviewPromptService.swift +++ b/MealMood/Services/ReviewPromptService.swift @@ -5,35 +5,39 @@ import StoreKit final class ReviewPromptService { static let shared = ReviewPromptService() - private let milestones = [1, 2, 4, 8] - private let minimumDaysBetweenPrompts: Double = 30 - private let promptedMilestoneKey = "review_prompted_milestone" - private let lastPromptDateKey = "review_prompt_last_date" + private let reviewCompletedKey = "review_funnel_completed" + private let lastPromptedCompletedWeeksKey = "review_funnel_last_prompted_completed_weeks" + private let minimumCompletedWeeksToStart = 2 + private let promptIntervalWeeks = 2 private init() {} - func considerPromptAfterWeekCompletion(completedWeeks: Int) { - guard let milestone = milestones.first(where: { completedWeeks >= $0 }) else { return } - let alreadyPrompted = UserDefaults.standard.integer(forKey: promptedMilestoneKey) - guard milestone > alreadyPrompted else { return } - guard canPromptNow() else { return } + func shouldShowFunnelAfterWeekCompletion(completedWeeks: Int) -> Bool { + guard !hasCompletedReviewFlow else { return false } + guard completedWeeks >= minimumCompletedWeeksToStart else { return false } + guard completedWeeks % promptIntervalWeeks == 0 else { return false } - requestReview() - UserDefaults.standard.set(milestone, forKey: promptedMilestoneKey) - UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastPromptDateKey) + let lastPrompted = UserDefaults.standard.integer(forKey: lastPromptedCompletedWeeksKey) + return completedWeeks > lastPrompted + } + + func markFunnelShown(completedWeeks: Int) { + UserDefaults.standard.set(completedWeeks, forKey: lastPromptedCompletedWeeksKey) + } + + func markReviewCompleted() { + UserDefaults.standard.set(true, forKey: reviewCompletedKey) } func requestFromSettings() { requestReview() } - private func canPromptNow() -> Bool { - let lastPrompt = UserDefaults.standard.double(forKey: lastPromptDateKey) - guard lastPrompt > 0 else { return true } - return Date().timeIntervalSince1970 - lastPrompt >= minimumDaysBetweenPrompts * 24 * 60 * 60 + var hasCompletedReviewFlow: Bool { + UserDefaults.standard.bool(forKey: reviewCompletedKey) } - private func requestReview() { + func requestReview() { guard let scene = UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }) .first(where: { $0.activationState == .foregroundActive }) else { return } diff --git a/MealMood/ViewModels/OnboardingViewModel.swift b/MealMood/ViewModels/OnboardingViewModel.swift index 66499b2..0f4d3a9 100644 --- a/MealMood/ViewModels/OnboardingViewModel.swift +++ b/MealMood/ViewModels/OnboardingViewModel.swift @@ -24,15 +24,16 @@ final class OnboardingViewModel: ObservableObject { @Published var addedDishes: [(name: String, tagIds: [UUID])] = [] let maxOnboardingDishes = 10 - let totalSteps = 5 + let totalSteps = 6 var canContinue: Bool { switch currentStep { case 0: return true // Welcome - 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 + 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 default: return false } } diff --git a/MealMood/ViewModels/SettingsViewModel.swift b/MealMood/ViewModels/SettingsViewModel.swift index 7095ac5..001f31c 100644 --- a/MealMood/ViewModels/SettingsViewModel.swift +++ b/MealMood/ViewModels/SettingsViewModel.swift @@ -9,6 +9,14 @@ final class SettingsViewModel: ObservableObject { @Published var showToast: Bool = false @Published var toastMessage: String = "" + var appVersion: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "-" + } + + var appBuild: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "-" + } + func loadCalendars() { availableCalendars = CalendarService.shared.availableCalendars() } diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index 9bc9392..7192c7e 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -4,6 +4,7 @@ import SwiftData struct HomeView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.modelContext) private var context + @Environment(\.openURL) private var openURL @Query private var dishes: [Dish] @Query private var tags: [Tag] @Query(sort: \WeekPlan.weekStartDate, order: .forward) private var weekPlans: [WeekPlan] @@ -18,6 +19,8 @@ struct HomeView: View { @State private var showWeekPicker: Bool = false @State private var weekPickerDate: Date = Date() @State private var showCopyPreviousConfirm: Bool = false + @State private var showReviewSentimentPrompt: Bool = false + @State private var showReviewSupportPrompt: Bool = false private var settings: AppSettings? { allSettings.first } @@ -248,6 +251,28 @@ struct HomeView: View { secondaryButton: .cancel(Text("reset_cancel")) ) } + .alert("review_funnel_title", isPresented: $showReviewSentimentPrompt) { + Button("review_funnel_positive") { + ReviewPromptService.shared.requestReview() + ReviewPromptService.shared.markReviewCompleted() + } + Button("review_funnel_negative", role: .destructive) { + showReviewSupportPrompt = true + } + Button("reset_cancel", role: .cancel) {} + } message: { + Text("review_funnel_message") + } + .alert("review_feedback_title", isPresented: $showReviewSupportPrompt) { + Button("review_feedback_contact") { + if let url = URL(string: "mailto:support@mealmood.app?subject=MealMood%20Feedback") { + openURL(url) + } + } + Button("reset_cancel", role: .cancel) {} + } message: { + Text("review_feedback_message") + } .sheet(isPresented: $viewModel.showDishForm) { DishFormView() } @@ -614,7 +639,9 @@ struct HomeView: View { let descriptor = FetchDescriptor() guard let plans = try? context.fetch(descriptor) else { return } let completedWeeks = plans.filter { !$0.slots.isEmpty && $0.slots.allSatisfy { $0.dishId != nil } }.count - ReviewPromptService.shared.considerPromptAfterWeekCompletion(completedWeeks: completedWeeks) + guard ReviewPromptService.shared.shouldShowFunnelAfterWeekCompletion(completedWeeks: completedWeeks) else { return } + ReviewPromptService.shared.markFunnelShown(completedWeeks: completedWeeks) + showReviewSentimentPrompt = true } } diff --git a/MealMood/Views/Onboarding/OnboardingView.swift b/MealMood/Views/Onboarding/OnboardingView.swift index bab6574..9fcfe95 100644 --- a/MealMood/Views/Onboarding/OnboardingView.swift +++ b/MealMood/Views/Onboarding/OnboardingView.swift @@ -1,5 +1,6 @@ import SwiftUI import SwiftData +import StoreKit struct OnboardingView: View { @Environment(\.modelContext) private var context @@ -29,17 +30,22 @@ struct OnboardingView: View { WelcomeStepView(onNext: { viewModel.nextStep() }) .tag(0) + TrialStepView( + onContinue: { viewModel.nextStep() } + ) + .tag(1) + MealWindowsStepView( selection: $viewModel.selectedMealWindows, onNext: { viewModel.nextStep() } ) - .tag(1) + .tag(2) WeekendsStepView( includeWeekends: $viewModel.includeWeekends, onNext: { viewModel.nextStep() } ) - .tag(2) + .tag(3) CalendarStepView( iCloudSyncEnabled: $viewModel.syncICloud, @@ -53,7 +59,7 @@ struct OnboardingView: View { onNext: { viewModel.nextStep() }, onSkip: { viewModel.nextStep() } ) - .tag(3) + .tag(4) FirstDishesStepView( viewModel: viewModel, @@ -64,7 +70,7 @@ struct OnboardingView: View { } } ) - .tag(4) + .tag(5) } .tabViewStyle(.page(indexDisplayMode: .never)) .animation(.easeInOut(duration: 0.3), value: viewModel.currentStep) @@ -107,3 +113,133 @@ 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/Settings/SettingsView.swift b/MealMood/Views/Settings/SettingsView.swift index 7bb9447..371de2f 100644 --- a/MealMood/Views/Settings/SettingsView.swift +++ b/MealMood/Views/Settings/SettingsView.swift @@ -194,6 +194,20 @@ struct SettingsView: View { Link(destination: URL(string: "mailto:support@mealmood.app")!) { Label("settings_support", systemImage: "envelope") } + + HStack { + Text("settings_app_version") + Spacer() + Text(viewModel.appVersion) + .foregroundColor(.mealMoodTextSecondary) + } + + HStack { + Text("settings_app_build") + Spacer() + Text(viewModel.appBuild) + .foregroundColor(.mealMoodTextSecondary) + } } header: { Label("settings_about", systemImage: "info.circle") } From c2dcaf07c7bb98373c103c6252b440a74969c040 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Thu, 26 Feb 2026 10:39:41 +0100 Subject: [PATCH 02/10] 1.0.1: remove trial wording from onboarding premium step --- MealMood/Resources/en.lproj/Localizable.strings | 8 ++++---- MealMood/Resources/es.lproj/Localizable.strings | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index 9eb150e..cf0195e 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -218,11 +218,11 @@ "first_dishes_no_tags" = "Default tags are loading, please retry in a moment"; /* Onboarding trial */ -"onboarding_trial_title" = "Try Premium free for 7 days"; -"onboarding_trial_subtitle" = "Unlock all premium features now. You can cancel anytime before billing starts."; -"onboarding_trial_cta" = "Start 7-day free trial"; +"onboarding_trial_title" = "Unlock Premium"; +"onboarding_trial_subtitle" = "Unlock all premium features now."; +"onboarding_trial_cta" = "Unlock Premium"; "onboarding_trial_skip" = "Continue with free plan"; -"onboarding_trial_price_format" = "Then %@ / month"; +"onboarding_trial_price_format" = "%@ / month"; /* Review funnel */ "review_funnel_title" = "How is MealMood going?"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index fa713bf..205c8ae 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -218,11 +218,11 @@ "first_dishes_no_tags" = "Las etiquetas por defecto se están cargando, inténtalo de nuevo en un momento"; /* Onboarding trial */ -"onboarding_trial_title" = "Prueba Premium gratis durante 7 días"; -"onboarding_trial_subtitle" = "Desbloquea todas las funciones premium ahora. Puedes cancelar antes del primer cobro."; -"onboarding_trial_cta" = "Iniciar prueba gratuita de 7 días"; +"onboarding_trial_title" = "Activa Premium"; +"onboarding_trial_subtitle" = "Desbloquea todas las funciones premium ahora."; +"onboarding_trial_cta" = "Activar Premium"; "onboarding_trial_skip" = "Continuar con plan gratuito"; -"onboarding_trial_price_format" = "Después %@ / mes"; +"onboarding_trial_price_format" = "%@ / mes"; /* Review funnel */ "review_funnel_title" = "¿Qué te está pareciendo MealMood?"; From 6db66593301605583278d67a0dee578919162683 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Thu, 26 Feb 2026 10:42:35 +0100 Subject: [PATCH 03/10] Fix Reset All Data crash by using safe cascade deletion --- MealMood/Services/DefaultDataService.swift | 6 ++++-- MealMood/ViewModels/SettingsViewModel.swift | 5 ++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/MealMood/Services/DefaultDataService.swift b/MealMood/Services/DefaultDataService.swift index fc1e783..91bee32 100644 --- a/MealMood/Services/DefaultDataService.swift +++ b/MealMood/Services/DefaultDataService.swift @@ -3,7 +3,7 @@ import SwiftData struct DefaultDataService { - static func createDefaultTags(context: ModelContext) { + static func createDefaultTags(context: ModelContext, saveImmediately: Bool = true) { let defaults: [(name: String, nameEN: String, color: String, maxPerWeek: Int?, noConsecutive: Bool, noDuplicateInDay: Bool, mealTypeRestriction: String?, sortOrder: Int)] = [ ("Carne", "Meat", "#E74C3C", 3, true, true, nil, 0), ("Pescado", "Fish", "#3498DB", 2, true, true, nil, 1), @@ -32,7 +32,9 @@ struct DefaultDataService { context.insert(tag) } - try? context.save() + if saveImmediately { + try? context.save() + } } static func createDefaultSettings(context: ModelContext) { diff --git a/MealMood/ViewModels/SettingsViewModel.swift b/MealMood/ViewModels/SettingsViewModel.swift index 001f31c..32f31ed 100644 --- a/MealMood/ViewModels/SettingsViewModel.swift +++ b/MealMood/ViewModels/SettingsViewModel.swift @@ -68,11 +68,11 @@ final class SettingsViewModel: ObservableObject { func resetAllData(context: ModelContext) { do { - try deleteAll(of: MealSlot.self, in: context) + // WeekPlan cascades MealSlot, so deleting both can corrupt the reset flow. try deleteAll(of: WeekPlan.self, in: context) try deleteAll(of: Dish.self, in: context) try deleteAll(of: Tag.self, in: context) - DefaultDataService.createDefaultTags(context: context) + DefaultDataService.createDefaultTags(context: context, saveImmediately: false) let settingsDescriptor = FetchDescriptor() if let existingSettings = try context.fetch(settingsDescriptor).first { @@ -98,7 +98,6 @@ final class SettingsViewModel: ObservableObject { for model in models { context.delete(model) } - try context.save() } private func showToastMessage(_ message: String) { From 3937abeb6ee25b37b0164fcb30ee9f816daa2e7e Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Thu, 26 Feb 2026 10:50:09 +0100 Subject: [PATCH 04/10] 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) From 3d72d53889536dbf64ad05cb3e974fb7a01795c0 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Thu, 26 Feb 2026 10:53:46 +0100 Subject: [PATCH 05/10] Polish onboarding layout with fixed header, back nav, and consistent step framing --- .../Resources/en.lproj/Localizable.strings | 3 + .../Resources/es.lproj/Localizable.strings | 3 + .../Views/Onboarding/CalendarStepView.swift | 8 +- .../Onboarding/FirstDishesStepView.swift | 18 ++--- .../Onboarding/MealWindowsStepView.swift | 6 -- .../Views/Onboarding/OnboardingView.swift | 75 ++++++++++++++++--- .../Views/Onboarding/WeekendsStepView.swift | 6 -- .../Views/Onboarding/WelcomeStepView.swift | 5 -- 8 files changed, 78 insertions(+), 46 deletions(-) diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index 8e1f206..9d691f1 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -5,6 +5,9 @@ "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_nav_intro" = "Quick setup"; +"onboarding_nav_setup" = "Setup"; +"onboarding_nav_step_counter" = "Step %d/%d"; "onboarding_start" = "Get Started"; "onboarding_continue" = "Continue"; "onboarding_skip" = "Skip"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index db0785c..06a57be 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -5,6 +5,9 @@ "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_nav_intro" = "Configuración rápida"; +"onboarding_nav_setup" = "Config"; +"onboarding_nav_step_counter" = "Paso %d/%d"; "onboarding_start" = "Comenzar"; "onboarding_continue" = "Continuar"; "onboarding_skip" = "Omitir"; diff --git a/MealMood/Views/Onboarding/CalendarStepView.swift b/MealMood/Views/Onboarding/CalendarStepView.swift index 2a6908f..5e93620 100644 --- a/MealMood/Views/Onboarding/CalendarStepView.swift +++ b/MealMood/Views/Onboarding/CalendarStepView.swift @@ -17,13 +17,6 @@ struct CalendarStepView: View { var body: some View { ScrollView { VStack(spacing: 24) { - Text("calendar_title") - .font(.mealMoodH2) - .foregroundColor(.mealMoodTextPrimary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 24) - .padding(.top, 32) - VStack(spacing: 16) { // iCloud sync toggle HStack { @@ -122,6 +115,7 @@ struct CalendarStepView: View { } } .padding(.horizontal, 24) + .padding(.top, 24) Text("calendar_optional") .font(.mealMoodSmall) diff --git a/MealMood/Views/Onboarding/FirstDishesStepView.swift b/MealMood/Views/Onboarding/FirstDishesStepView.swift index 1749700..1c9ecfe 100644 --- a/MealMood/Views/Onboarding/FirstDishesStepView.swift +++ b/MealMood/Views/Onboarding/FirstDishesStepView.swift @@ -34,18 +34,12 @@ struct FirstDishesStepView: View { var body: some View { ScrollView { VStack(spacing: 24) { - VStack(alignment: .leading, spacing: 8) { - Text("first_dishes_title") - .font(.mealMoodH2) - .foregroundColor(.mealMoodTextPrimary) - - Text("first_dishes_subtitle") - .font(.mealMoodSmall) - .foregroundColor(.mealMoodTextSecondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 24) - .padding(.top, 32) + Text("first_dishes_subtitle") + .font(.mealMoodSmall) + .foregroundColor(.mealMoodTextSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.top, 20) // New dish form VStack(spacing: 12) { diff --git a/MealMood/Views/Onboarding/MealWindowsStepView.swift b/MealMood/Views/Onboarding/MealWindowsStepView.swift index 692ddd4..3c96ffe 100644 --- a/MealMood/Views/Onboarding/MealWindowsStepView.swift +++ b/MealMood/Views/Onboarding/MealWindowsStepView.swift @@ -8,12 +8,6 @@ struct MealWindowsStepView: View { VStack(spacing: 24) { Spacer() - Text("meal_windows_title") - .font(.mealMoodH2) - .foregroundColor(.mealMoodTextPrimary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 24) - VStack(spacing: 12) { ForEach(MealWindows.allCases, id: \.self) { option in SelectionCard( diff --git a/MealMood/Views/Onboarding/OnboardingView.swift b/MealMood/Views/Onboarding/OnboardingView.swift index bab6574..51478a2 100644 --- a/MealMood/Views/Onboarding/OnboardingView.swift +++ b/MealMood/Views/Onboarding/OnboardingView.swift @@ -12,18 +12,18 @@ struct OnboardingView: View { Color.mealMoodBackground.ignoresSafeArea() VStack(spacing: 0) { - // Progress indicator - if viewModel.currentStep > 0 { - HStack(spacing: 8) { - ForEach(1..())) ?? [] 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 { + switch viewModel.currentStep { + case 0: return String(localized: "onboarding_nav_intro") + case 1: return String(localized: "meal_windows_title") + case 2: return String(localized: "weekends_title") + case 3: return String(localized: "calendar_title") + case 4: return String(localized: "first_dishes_title") + default: return "" + } + } + + 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) + } } diff --git a/MealMood/Views/Onboarding/WeekendsStepView.swift b/MealMood/Views/Onboarding/WeekendsStepView.swift index 63af5da..d1abcbe 100644 --- a/MealMood/Views/Onboarding/WeekendsStepView.swift +++ b/MealMood/Views/Onboarding/WeekendsStepView.swift @@ -8,12 +8,6 @@ struct WeekendsStepView: View { VStack(spacing: 24) { Spacer() - Text("weekends_title") - .font(.mealMoodH2) - .foregroundColor(.mealMoodTextPrimary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 24) - VStack(spacing: 16) { HStack { VStack(alignment: .leading, spacing: 4) { diff --git a/MealMood/Views/Onboarding/WelcomeStepView.swift b/MealMood/Views/Onboarding/WelcomeStepView.swift index da635f0..4b130d7 100644 --- a/MealMood/Views/Onboarding/WelcomeStepView.swift +++ b/MealMood/Views/Onboarding/WelcomeStepView.swift @@ -10,11 +10,6 @@ struct WelcomeStepView: View { AppIconPlaceholder(size: 120) VStack(spacing: 12) { - Text("onboarding_welcome_title") - .font(.mealMoodH1) - .foregroundColor(.mealMoodTextPrimary) - .multilineTextAlignment(.center) - Text("onboarding_welcome_subtitle") .font(.mealMoodBody) .foregroundColor(.mealMoodTextSecondary) From b0c7a41625e1db24730fb84a18236dd199ba59b7 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Thu, 26 Feb 2026 10:56:25 +0100 Subject: [PATCH 06/10] Adjust onboarding header to short step label and top-align step content --- MealMood/Resources/en.lproj/Localizable.strings | 1 + MealMood/Resources/es.lproj/Localizable.strings | 1 + MealMood/Views/Onboarding/CalendarStepView.swift | 9 ++++++++- MealMood/Views/Onboarding/FirstDishesStepView.swift | 8 +++++++- MealMood/Views/Onboarding/MealWindowsStepView.swift | 11 ++++++++--- MealMood/Views/Onboarding/OnboardingView.swift | 12 +++--------- MealMood/Views/Onboarding/WeekendsStepView.swift | 11 ++++++++--- 7 files changed, 36 insertions(+), 17 deletions(-) diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index 9d691f1..05ac925 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -7,6 +7,7 @@ "onboarding_welcome_step_4" = "Add your usual dishes to start fast"; "onboarding_nav_intro" = "Quick setup"; "onboarding_nav_setup" = "Setup"; +"onboarding_nav_step_short" = "Step %d"; "onboarding_nav_step_counter" = "Step %d/%d"; "onboarding_start" = "Get Started"; "onboarding_continue" = "Continue"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index 06a57be..33cb1ad 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -7,6 +7,7 @@ "onboarding_welcome_step_4" = "Añade tus platos habituales para empezar rápido"; "onboarding_nav_intro" = "Configuración rápida"; "onboarding_nav_setup" = "Config"; +"onboarding_nav_step_short" = "Paso %d"; "onboarding_nav_step_counter" = "Paso %d/%d"; "onboarding_start" = "Comenzar"; "onboarding_continue" = "Continuar"; diff --git a/MealMood/Views/Onboarding/CalendarStepView.swift b/MealMood/Views/Onboarding/CalendarStepView.swift index 5e93620..ab8d142 100644 --- a/MealMood/Views/Onboarding/CalendarStepView.swift +++ b/MealMood/Views/Onboarding/CalendarStepView.swift @@ -17,6 +17,14 @@ struct CalendarStepView: View { var body: some View { ScrollView { VStack(spacing: 24) { + Text("calendar_title") + .font(.mealMoodH1) + .foregroundColor(.mealMoodTextPrimary) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.top, 12) + VStack(spacing: 16) { // iCloud sync toggle HStack { @@ -115,7 +123,6 @@ struct CalendarStepView: View { } } .padding(.horizontal, 24) - .padding(.top, 24) Text("calendar_optional") .font(.mealMoodSmall) diff --git a/MealMood/Views/Onboarding/FirstDishesStepView.swift b/MealMood/Views/Onboarding/FirstDishesStepView.swift index 1c9ecfe..8dbb0e0 100644 --- a/MealMood/Views/Onboarding/FirstDishesStepView.swift +++ b/MealMood/Views/Onboarding/FirstDishesStepView.swift @@ -34,12 +34,18 @@ struct FirstDishesStepView: View { var body: some View { ScrollView { VStack(spacing: 24) { + Text("first_dishes_title") + .font(.mealMoodH1) + .foregroundColor(.mealMoodTextPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.top, 12) + Text("first_dishes_subtitle") .font(.mealMoodSmall) .foregroundColor(.mealMoodTextSecondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 24) - .padding(.top, 20) // New dish form VStack(spacing: 12) { diff --git a/MealMood/Views/Onboarding/MealWindowsStepView.swift b/MealMood/Views/Onboarding/MealWindowsStepView.swift index 3c96ffe..7a4ef72 100644 --- a/MealMood/Views/Onboarding/MealWindowsStepView.swift +++ b/MealMood/Views/Onboarding/MealWindowsStepView.swift @@ -6,7 +6,13 @@ struct MealWindowsStepView: View { var body: some View { VStack(spacing: 24) { - Spacer() + Text("meal_windows_title") + .font(.mealMoodH1) + .foregroundColor(.mealMoodTextPrimary) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.top, 12) VStack(spacing: 12) { ForEach(MealWindows.allCases, id: \.self) { option in @@ -24,10 +30,9 @@ struct MealWindowsStepView: View { } .padding(.horizontal, 24) - Spacer() - PrimaryButton(title: String(localized: "onboarding_continue"), action: onNext) .padding(.horizontal, 24) + .padding(.top, 8) .padding(.bottom, 40) } } diff --git a/MealMood/Views/Onboarding/OnboardingView.swift b/MealMood/Views/Onboarding/OnboardingView.swift index 51478a2..7efa892 100644 --- a/MealMood/Views/Onboarding/OnboardingView.swift +++ b/MealMood/Views/Onboarding/OnboardingView.swift @@ -68,7 +68,7 @@ struct OnboardingView: View { } .tabViewStyle(.page(indexDisplayMode: .never)) .animation(.easeInOut(duration: 0.3), value: viewModel.currentStep) - .frame(maxHeight: .infinity, alignment: .center) + .frame(maxHeight: .infinity, alignment: .top) } } .onAppear { @@ -147,14 +147,8 @@ struct OnboardingView: View { } private var headerTitle: String { - switch viewModel.currentStep { - case 0: return String(localized: "onboarding_nav_intro") - case 1: return String(localized: "meal_windows_title") - case 2: return String(localized: "weekends_title") - case 3: return String(localized: "calendar_title") - case 4: return String(localized: "first_dishes_title") - default: return "" - } + 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 { diff --git a/MealMood/Views/Onboarding/WeekendsStepView.swift b/MealMood/Views/Onboarding/WeekendsStepView.swift index d1abcbe..fe8baca 100644 --- a/MealMood/Views/Onboarding/WeekendsStepView.swift +++ b/MealMood/Views/Onboarding/WeekendsStepView.swift @@ -6,7 +6,13 @@ struct WeekendsStepView: View { var body: some View { VStack(spacing: 24) { - Spacer() + Text("weekends_title") + .font(.mealMoodH1) + .foregroundColor(.mealMoodTextPrimary) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.top, 12) VStack(spacing: 16) { HStack { @@ -34,10 +40,9 @@ struct WeekendsStepView: View { .font(.mealMoodSmall) .foregroundColor(.mealMoodTextSecondary) - Spacer() - PrimaryButton(title: String(localized: "onboarding_continue"), action: onNext) .padding(.horizontal, 24) + .padding(.top, 8) .padding(.bottom, 40) } } From 37c2e01a09e14fab9c01c59f5ac064cc04975c98 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Thu, 26 Feb 2026 10:58:24 +0100 Subject: [PATCH 07/10] Top-align onboarding content in all steps --- MealMood/Views/Onboarding/CalendarStepView.swift | 1 + MealMood/Views/Onboarding/FirstDishesStepView.swift | 1 + MealMood/Views/Onboarding/MealWindowsStepView.swift | 1 + MealMood/Views/Onboarding/OnboardingView.swift | 5 +++++ MealMood/Views/Onboarding/WeekendsStepView.swift | 1 + MealMood/Views/Onboarding/WelcomeStepView.swift | 1 + 6 files changed, 10 insertions(+) diff --git a/MealMood/Views/Onboarding/CalendarStepView.swift b/MealMood/Views/Onboarding/CalendarStepView.swift index ab8d142..61f8764 100644 --- a/MealMood/Views/Onboarding/CalendarStepView.swift +++ b/MealMood/Views/Onboarding/CalendarStepView.swift @@ -152,5 +152,6 @@ struct CalendarStepView: View { } message: { Text("calendar_permission_message") } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } } diff --git a/MealMood/Views/Onboarding/FirstDishesStepView.swift b/MealMood/Views/Onboarding/FirstDishesStepView.swift index 8dbb0e0..9f17e77 100644 --- a/MealMood/Views/Onboarding/FirstDishesStepView.swift +++ b/MealMood/Views/Onboarding/FirstDishesStepView.swift @@ -239,6 +239,7 @@ struct FirstDishesStepView: View { ensureDefaultTagsIfNeeded() } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } private func suggestedDishes() -> [(name: String, tagHints: [String])] { diff --git a/MealMood/Views/Onboarding/MealWindowsStepView.swift b/MealMood/Views/Onboarding/MealWindowsStepView.swift index 7a4ef72..bba6451 100644 --- a/MealMood/Views/Onboarding/MealWindowsStepView.swift +++ b/MealMood/Views/Onboarding/MealWindowsStepView.swift @@ -35,6 +35,7 @@ struct MealWindowsStepView: View { .padding(.top, 8) .padding(.bottom, 40) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } private func optionTitle(_ option: MealWindows) -> String { diff --git a/MealMood/Views/Onboarding/OnboardingView.swift b/MealMood/Views/Onboarding/OnboardingView.swift index 7efa892..19528d6 100644 --- a/MealMood/Views/Onboarding/OnboardingView.swift +++ b/MealMood/Views/Onboarding/OnboardingView.swift @@ -27,18 +27,21 @@ struct OnboardingView: View { 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( @@ -53,6 +56,7 @@ struct OnboardingView: View { onNext: { viewModel.nextStep() }, onSkip: { viewModel.nextStep() } ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .tag(3) FirstDishesStepView( @@ -64,6 +68,7 @@ struct OnboardingView: View { } } ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .tag(4) } .tabViewStyle(.page(indexDisplayMode: .never)) diff --git a/MealMood/Views/Onboarding/WeekendsStepView.swift b/MealMood/Views/Onboarding/WeekendsStepView.swift index fe8baca..4450fc2 100644 --- a/MealMood/Views/Onboarding/WeekendsStepView.swift +++ b/MealMood/Views/Onboarding/WeekendsStepView.swift @@ -45,5 +45,6 @@ struct WeekendsStepView: View { .padding(.top, 8) .padding(.bottom, 40) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } } diff --git a/MealMood/Views/Onboarding/WelcomeStepView.swift b/MealMood/Views/Onboarding/WelcomeStepView.swift index 4b130d7..989405a 100644 --- a/MealMood/Views/Onboarding/WelcomeStepView.swift +++ b/MealMood/Views/Onboarding/WelcomeStepView.swift @@ -31,6 +31,7 @@ struct WelcomeStepView: View { .padding(.horizontal, 24) .padding(.bottom, 40) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } } From 9f4cf7484d0598ec880cc9879b84b298a441dde9 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Thu, 26 Feb 2026 11:06:54 +0100 Subject: [PATCH 08/10] Fix dish deletion list flicker with stable identity/order and add regression test --- MealMood/Models/Dish.swift | 11 +++++++++++ MealMood/Views/Dishes/DishListView.swift | 2 +- MealMood/Views/Home/DishDrawerView.swift | 4 ++-- MealMood/Views/Home/HomeView.swift | 4 ++-- MealMoodTests/AutocompleteEngineTests.swift | 14 +++++++++++++- 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/MealMood/Models/Dish.swift b/MealMood/Models/Dish.swift index 7a4e800..75808df 100644 --- a/MealMood/Models/Dish.swift +++ b/MealMood/Models/Dish.swift @@ -23,3 +23,14 @@ final class Dish { self.createdAt = createdAt } } + +extension Dish { + static func stableSortedForDisplay(_ dishes: [Dish]) -> [Dish] { + dishes.sorted { lhs, rhs in + if lhs.createdAt != rhs.createdAt { + return lhs.createdAt > rhs.createdAt + } + return lhs.id.uuidString < rhs.id.uuidString + } + } +} diff --git a/MealMood/Views/Dishes/DishListView.swift b/MealMood/Views/Dishes/DishListView.swift index 59dfc35..3bd65f5 100644 --- a/MealMood/Views/Dishes/DishListView.swift +++ b/MealMood/Views/Dishes/DishListView.swift @@ -32,7 +32,7 @@ struct DishListView: View { } } else { List { - ForEach(dishes) { dish in + ForEach(dishes, id: \.id) { dish in let dishTags = tags.filter { dish.tagIds.contains($0.id) } Button { editingDish = dish diff --git a/MealMood/Views/Home/DishDrawerView.swift b/MealMood/Views/Home/DishDrawerView.swift index 574588f..3e797f6 100644 --- a/MealMood/Views/Home/DishDrawerView.swift +++ b/MealMood/Views/Home/DishDrawerView.swift @@ -15,7 +15,7 @@ struct DishDrawerView: View { private var filteredDishes: [Dish] { let term = searchText.trimmingCharacters(in: .whitespacesAndNewlines) - return dishes.filter { dish in + return Dish.stableSortedForDisplay(dishes).filter { dish in let matchesSearch = term.isEmpty || dish.name.localizedCaseInsensitiveContains(term) let matchesUsedFilter = !hideUsedThisWeek || !usedDishIds.contains(dish.id) return matchesSearch && matchesUsedFilter @@ -95,7 +95,7 @@ struct DishDrawerView: View { .buttonStyle(.plain) LazyVStack(spacing: 8) { - ForEach(filteredDishes) { dish in + ForEach(filteredDishes, id: \.id) { dish in DishCardView( dish: dish, tags: tags, diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index ca816cf..e88cbaf 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -5,7 +5,7 @@ struct HomeView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.modelContext) private var context @Environment(\.openURL) private var openURL - @Query private var dishes: [Dish] + @Query(sort: \Dish.createdAt, order: .reverse) private var dishes: [Dish] @Query private var tags: [Tag] @Query(sort: \WeekPlan.weekStartDate, order: .forward) private var weekPlans: [WeekPlan] @Query private var allSettings: [AppSettings] @@ -452,7 +452,7 @@ struct HomeView: View { .padding(24) } else { List { - ForEach(filteredDishes) { dish in + ForEach(filteredDishes, id: \.id) { dish in Button { onPickDish(dish) dismiss() diff --git a/MealMoodTests/AutocompleteEngineTests.swift b/MealMoodTests/AutocompleteEngineTests.swift index 11de064..1cb7cb6 100644 --- a/MealMoodTests/AutocompleteEngineTests.swift +++ b/MealMoodTests/AutocompleteEngineTests.swift @@ -59,5 +59,17 @@ final class AutocompleteEngineTests: XCTestCase { XCTAssertEqual(result.unfilledCount, 1) XCTAssertNil(tuesdayDinner.dishId) } -} + func testDishStableSortRemainsConsistentAfterDeletion() { + let base = Date() + let oldest = Dish(id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, name: "Old", createdAt: base.addingTimeInterval(-60)) + let newest = Dish(id: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, name: "New", createdAt: base) + let middle = Dish(id: UUID(uuidString: "00000000-0000-0000-0000-000000000003")!, name: "Middle", createdAt: base.addingTimeInterval(-30)) + + let initial = Dish.stableSortedForDisplay([oldest, newest, middle]).map(\.id) + XCTAssertEqual(initial, [newest.id, middle.id, oldest.id]) + + let afterDeletion = Dish.stableSortedForDisplay([newest, oldest]).map(\.id) + XCTAssertEqual(afterDeletion, [newest.id, oldest.id]) + } +} From ae40ec6f091b454e44f36368f98e20d9496a5085 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Thu, 26 Feb 2026 11:08:30 +0100 Subject: [PATCH 09/10] Make onboarding added dishes list scrollable to keep Finish button stable --- .../Onboarding/FirstDishesStepView.swift | 55 ++++++++++--------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/MealMood/Views/Onboarding/FirstDishesStepView.swift b/MealMood/Views/Onboarding/FirstDishesStepView.swift index 9f17e77..7116ebc 100644 --- a/MealMood/Views/Onboarding/FirstDishesStepView.swift +++ b/MealMood/Views/Onboarding/FirstDishesStepView.swift @@ -127,37 +127,42 @@ struct FirstDishesStepView: View { .foregroundColor(.mealMoodTextSecondary) .padding(.horizontal, 24) - ForEach(Array(viewModel.addedDishes.enumerated()), id: \.offset) { index, dish in - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.mealMoodSuccess) + ScrollView { + LazyVStack(spacing: 8) { + ForEach(Array(viewModel.addedDishes.enumerated()), id: \.offset) { index, dish in + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.mealMoodSuccess) - Text(dish.name) - .font(.mealMoodBody) - .foregroundColor(.mealMoodTextPrimary) + Text(dish.name) + .font(.mealMoodBody) + .foregroundColor(.mealMoodTextPrimary) - // Show tag pills - HStack(spacing: 4) { - ForEach(availableTags.filter { dish.tagIds.contains($0.id) }.prefix(2)) { tag in - TagPill(name: tag.localizedName(language: language), color: tag.color) + // Show tag pills + HStack(spacing: 4) { + ForEach(availableTags.filter { dish.tagIds.contains($0.id) }.prefix(2)) { tag in + TagPill(name: tag.localizedName(language: language), color: tag.color) + } + } + + Spacer() + + Button { + viewModel.removeDish(at: index) + } label: { + Image(systemName: "trash") + .font(.system(size: 14)) + .foregroundColor(.mealMoodError) + } } - } - - Spacer() - - Button { - viewModel.removeDish(at: index) - } label: { - Image(systemName: "trash") - .font(.system(size: 14)) - .foregroundColor(.mealMoodError) + .padding(12) + .background(Color.mealMoodSurface) + .cornerRadius(12) } } - .padding(12) - .background(Color.mealMoodSurface) - .cornerRadius(12) - .padding(.horizontal, 24) } + .frame(maxHeight: 190) + .padding(.horizontal, 24) } } From a532afbee60edc7341dd2529d4429526107a447f Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Fri, 27 Feb 2026 13:09:58 +0100 Subject: [PATCH 10/10] Stabilize planner UI, fix reset flow, and force Premium on TestFlight --- MealMood/ContentView.swift | 13 ++ MealMood/Models/MealSlot.swift | 16 ++ MealMood/ViewModels/HomeViewModel.swift | 28 +++- MealMood/ViewModels/SettingsViewModel.swift | 41 +++-- MealMood/Views/Home/HomeView.swift | 44 +++++- MealMood/Views/Home/WeekCalendarView.swift | 147 ++++++++++++++++-- .../Onboarding/FirstDishesStepView.swift | 119 +++++++------- MealMoodTests/AutocompleteEngineTests.swift | 8 + 8 files changed, 327 insertions(+), 89 deletions(-) diff --git a/MealMood/ContentView.swift b/MealMood/ContentView.swift index 2684c40..5258994 100644 --- a/MealMood/ContentView.swift +++ b/MealMood/ContentView.swift @@ -8,6 +8,10 @@ struct ContentView: View { @State private var isReady = false private var settings: AppSettings? { allSettings.first } + private var isTestFlightBuild: Bool { + guard let receiptURL = Bundle.main.appStoreReceiptURL else { return false } + return receiptURL.lastPathComponent == "sandboxReceipt" + } var body: some View { Group { @@ -72,6 +76,15 @@ struct ContentView: View { private func syncPremiumStatus() async { let descriptor = FetchDescriptor() guard let settings = try? context.fetch(descriptor).first else { return } + + if isTestFlightBuild { + if settings.isPremium != true { + settings.isPremium = true + try? context.save() + } + return + } + let premium = await StoreManager.hasActiveSubscription() if settings.isPremium != premium { settings.isPremium = premium diff --git a/MealMood/Models/MealSlot.swift b/MealMood/Models/MealSlot.swift index d25115a..40c7791 100644 --- a/MealMood/Models/MealSlot.swift +++ b/MealMood/Models/MealSlot.swift @@ -33,3 +33,19 @@ final class MealSlot { set { mealType = newValue.rawValue } } } + +extension MealSlot { + static func preferredForDuplicateResolution(_ slots: [MealSlot]) -> MealSlot { + slots.sorted { lhs, rhs in + let lhsHasDish = lhs.dishId != nil + let rhsHasDish = rhs.dishId != nil + if lhsHasDish != rhsHasDish { return lhsHasDish } + + let lhsHasEvent = lhs.calendarEventId != nil + let rhsHasEvent = rhs.calendarEventId != nil + if lhsHasEvent != rhsHasEvent { return lhsHasEvent } + + return lhs.id.uuidString < rhs.id.uuidString + }.first ?? slots[0] + } +} diff --git a/MealMood/ViewModels/HomeViewModel.swift b/MealMood/ViewModels/HomeViewModel.swift index 55400fd..c33315b 100644 --- a/MealMood/ViewModels/HomeViewModel.swift +++ b/MealMood/ViewModels/HomeViewModel.swift @@ -429,6 +429,33 @@ final class HomeViewModel: ObservableObject { } }() + // Clean up any legacy duplicates for the same day+mealType key to keep UI mapping stable. + var groupedByKey: [SlotKey: [MealSlot]] = [:] + for slot in plan.slots { + let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType) + groupedByKey[key, default: []].append(slot) + } + + var changed = false + for (_, duplicates) in groupedByKey where duplicates.count > 1 { + let keeper = MealSlot.preferredForDuplicateResolution(duplicates) + for duplicate in duplicates where duplicate.id != keeper.id { + if keeper.dishId == nil, let dishId = duplicate.dishId { + keeper.dishId = dishId + keeper.isRuleOverridden = duplicate.isRuleOverridden + if keeper.calendarEventId == nil { + keeper.calendarEventId = duplicate.calendarEventId + } + } else if let duplicateEventId = duplicate.calendarEventId { + CalendarService.shared.deleteEvent(eventId: duplicateEventId) + } + + plan.slots.removeAll { $0.id == duplicate.id } + context.delete(duplicate) + changed = true + } + } + var desired = Set() for day in 0...maxDay { for mealType in mealTypes { @@ -443,7 +470,6 @@ final class HomeViewModel: ObservableObject { existingByKey[key] = slot } } - var changed = false let toDelete = plan.slots.filter { slot in !desired.contains(SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)) diff --git a/MealMood/ViewModels/SettingsViewModel.swift b/MealMood/ViewModels/SettingsViewModel.swift index 32f31ed..2b372e8 100644 --- a/MealMood/ViewModels/SettingsViewModel.swift +++ b/MealMood/ViewModels/SettingsViewModel.swift @@ -68,26 +68,43 @@ final class SettingsViewModel: ObservableObject { func resetAllData(context: ModelContext) { do { - // WeekPlan cascades MealSlot, so deleting both can corrupt the reset flow. - try deleteAll(of: WeekPlan.self, in: context) - try deleteAll(of: Dish.self, in: context) - try deleteAll(of: Tag.self, in: context) - DefaultDataService.createDefaultTags(context: context, saveImmediately: false) - let settingsDescriptor = FetchDescriptor() + let targetSettings: AppSettings + if let existingSettings = try context.fetch(settingsDescriptor).first { - resetSettings(existingSettings) + targetSettings = existingSettings } else { - DefaultDataService.createDefaultSettings(context: context) - if let createdSettings = try context.fetch(settingsDescriptor).first { - resetSettings(createdSettings) - } + let created = AppSettings() + context.insert(created) + targetSettings = created } + // Phase 1: switch app flow out of Home before destructive deletes. + resetSettings(targetSettings) try context.save() + + Task { @MainActor in + do { + try? await Task.sleep(nanoseconds: 200_000_000) + + try deleteAll(of: WeekPlan.self, in: context) + try deleteAll(of: Dish.self, in: context) + try deleteAll(of: Tag.self, in: context) + DefaultDataService.createDefaultTags(context: context, saveImmediately: false) + + // Ensure a true "fresh launch" onboarding flow. + UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoAssignPromptKey) + UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingPremiumPromptKey) + try context.save() + } catch { + #if DEBUG + print("Failed to complete reset all data: \(error)") + #endif + } + } } catch { #if DEBUG - print("Failed to reset all data: \(error)") + print("Failed to start reset all data: \(error)") #endif } } diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index e88cbaf..ac5ec50 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -33,7 +33,7 @@ struct HomeView: View { Color.mealMoodBackground.ignoresSafeArea() if let settings = settings { - let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) + let plan = fetchWeekPlan(for: viewModel.currentWeekStart) if let plan { mainContent(plan: plan, settings: settings) } @@ -109,7 +109,7 @@ struct HomeView: View { ToolbarItem(placement: .navigationBarTrailing) { HStack(spacing: 10) { if let settings = settings, - let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) { + let plan = fetchWeekPlan(for: viewModel.currentWeekStart) { if viewModel.canEditCurrentWeek { Button { viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings) @@ -161,22 +161,22 @@ struct HomeView: View { } .onAppear { guard let settings = settings, - let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) else { return } + let plan = ensureCurrentWeekPlanExists(settings: settings) else { return } viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context) } .onChange(of: settings?.includeWeekends) { _, _ in guard let settings = settings, - let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) else { return } + let plan = ensureCurrentWeekPlanExists(settings: settings) else { return } viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context) } .onChange(of: settings?.mealWindows) { _, _ in guard let settings = settings, - let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) else { return } + let plan = ensureCurrentWeekPlanExists(settings: settings) else { return } viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context) } .onChange(of: viewModel.currentWeekStart) { _, _ in guard let settings = settings, - let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) else { return } + let plan = ensureCurrentWeekPlanExists(settings: settings) else { return } viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context) } } @@ -655,12 +655,42 @@ struct HomeView: View { } private func fetchWeekPlan(for weekStartDate: Date) -> WeekPlan? { + let matches = weekPlanCandidates(for: weekStartDate) + guard !matches.isEmpty else { return nil } + return preferredWeekPlan(from: matches) + } + + private func weekPlanCandidates(for weekStartDate: Date) -> [WeekPlan] { let descriptor = FetchDescriptor( predicate: #Predicate { plan in plan.weekStartDate == weekStartDate } ) - return try? context.fetch(descriptor).first + return (try? context.fetch(descriptor)) ?? [] + } + + private func preferredWeekPlan(from plans: [WeekPlan]) -> WeekPlan? { + plans.max { lhs, rhs in + let lhsAssigned = lhs.slots.filter { $0.dishId != nil }.count + let rhsAssigned = rhs.slots.filter { $0.dishId != nil }.count + if lhsAssigned != rhsAssigned { return lhsAssigned < rhsAssigned } + if lhs.slots.count != rhs.slots.count { return lhs.slots.count < rhs.slots.count } + return lhs.updatedAt < rhs.updatedAt + } + } + + private func ensureCurrentWeekPlanExists(settings: AppSettings) -> WeekPlan? { + let matches = weekPlanCandidates(for: viewModel.currentWeekStart) + if let preferred = preferredWeekPlan(from: matches) { + if matches.count > 1 { + for duplicate in matches where duplicate.id != preferred.id { + context.delete(duplicate) + } + try? context.save() + } + return preferred + } + return DefaultDataService.createWeekPlan(for: viewModel.currentWeekStart, settings: settings, context: context) } private func evaluateReviewPrompt() { diff --git a/MealMood/Views/Home/WeekCalendarView.swift b/MealMood/Views/Home/WeekCalendarView.swift index 8619508..d20fa8c 100644 --- a/MealMood/Views/Home/WeekCalendarView.swift +++ b/MealMood/Views/Home/WeekCalendarView.swift @@ -2,6 +2,8 @@ import SwiftUI struct WeekCalendarView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @State private var dishSnapshotsById: [UUID: DishSnapshot] = [:] + @State private var displaySlotsByKey: [String: DisplaySlot] = [:] let plan: WeekPlan let settings: AppSettings @@ -34,6 +36,23 @@ struct WeekCalendarView: View { compactLayout } } + .onAppear { + refreshDisplaySlots() + refreshDishSnapshots() + } + .onChange(of: dishesSnapshotKey) { _, _ in + refreshDishSnapshots() + } + .onChange(of: plan.updatedAt) { _, _ in + refreshDisplaySlots() + refreshDishSnapshots() + } + .onChange(of: settings.includeWeekends) { _, _ in + refreshDisplaySlots() + } + .onChange(of: settings.mealWindows) { _, _ in + refreshDisplaySlots() + } } private var compactLayout: some View { @@ -55,7 +74,7 @@ struct WeekCalendarView: View { ForEach(Array(mealTypes.enumerated()), id: \.element) { index, mealType in HStack(spacing: 0) { ForEach(Array(days.enumerated()), id: \.element) { index, day in - let slot = slotFor(day: day, mealType: mealType) + let slot = displayedSlot(day: day, mealType: mealType) draggableSlotView(slot: slot, mealType: mealType) .frame(width: 96) if index < days.count - 1 { @@ -111,7 +130,7 @@ struct WeekCalendarView: View { .padding(.trailing, spacing) ForEach(Array(days.enumerated()), id: \.element) { index, day in - let slot = slotFor(day: day, mealType: mealType) + let slot = displayedSlot(day: day, mealType: mealType) draggableSlotView(slot: slot, mealType: mealType) .frame(width: dayWidth, height: slotRowHeight) if index < days.count - 1 { @@ -186,16 +205,30 @@ struct WeekCalendarView: View { .cornerRadius(10) } - private func slotFor(day: Int, mealType: MealType) -> MealSlot? { - plan.slots.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue } + private func displayedSlot(day: Int, mealType: MealType) -> DisplaySlot? { + let key = slotKey(day: day, mealType: mealType.rawValue) + if let cached = displaySlotsByKey[key] { + return cached + } + let matching = plan.slots.filter { $0.dayOfWeek == day && $0.mealType == mealType.rawValue } + guard !matching.isEmpty else { return nil } + let preferred = MealSlot.preferredForDuplicateResolution(matching) + return DisplaySlot( + slotId: preferred.id, + dayOfWeek: day, + mealType: mealType.rawValue, + dishId: preferred.dishId, + isRuleOverridden: preferred.isRuleOverridden + ) } - private func draggableSlotView(slot: MealSlot?, mealType: MealType) -> some View { + private func draggableSlotView(slot: DisplaySlot?, mealType: MealType) -> some View { slotView(slot: slot, mealType: mealType) .dropDestination(for: String.self) { items, _ in guard let payloadRaw = items.first, let payload = parseDropPayload(payloadRaw), - let targetSlot = slot, + let targetDisplaySlot = slot, + let targetSlot = liveSlot(for: targetDisplaySlot), viewModel.canEditCurrentWeek else { return false } switch payload { @@ -230,27 +263,29 @@ struct WeekCalendarView: View { } @ViewBuilder - private func slotView(slot: MealSlot?, mealType: MealType) -> some View { + private func slotView(slot: DisplaySlot?, mealType: MealType) -> some View { if let slot = slot, let dishId = slot.dishId, - let dish = dishes.first(where: { $0.id == dishId }) { - let dishTags = tags.filter { dish.tagIds.contains($0.id) } + let dishData = dishData(for: dishId) { + let dishTags = tags.filter { dishData.tagIds.contains($0.id) } FilledSlotView( - dishName: dish.name, - dishDescription: dish.descriptionText, + dishName: dishData.name, + dishDescription: dishData.descriptionText, tags: dishTags.map { (name: $0.localizedName(language: settings.languageEnum.resolved()), color: $0.color) }, mealType: mealType, showsRuleWarning: slot.isRuleOverridden, onRemove: viewModel.canEditCurrentWeek ? { - viewModel.removeDish(from: slot, plan: plan, settings: settings) + guard let live = liveSlot(for: slot) else { return } + viewModel.removeDish(from: live, plan: plan, settings: settings) } : nil ) - .draggable("slot:\(slot.id.uuidString)") + .draggable("slot:\(slot.slotId.uuidString)") } else if let slot = slot { EmptySlotView(mealType: mealType) .contentShape(Rectangle()) .onTapGesture { guard viewModel.canEditCurrentWeek else { return } - onTapEmptySlot?(slot) + guard let live = liveSlot(for: slot) else { return } + onTapEmptySlot?(live) } } else { EmptySlotView(mealType: mealType) @@ -262,6 +297,90 @@ struct WeekCalendarView: View { case slot(UUID) } + private struct DishSnapshot { + let name: String + let descriptionText: String? + let tagIds: [UUID] + } + + private struct DisplaySlot { + let slotId: UUID + let dayOfWeek: Int + let mealType: String + let dishId: UUID? + let isRuleOverridden: Bool + } + + private var dishesSnapshotKey: String { + dishes + .map { "\($0.id.uuidString)|\($0.name)|\($0.tagIds.count)|\($0.descriptionText ?? "")" } + .joined(separator: ";") + } + + private func dishData(for dishId: UUID) -> DishSnapshot? { + if let live = dishes.first(where: { $0.id == dishId }) { + return DishSnapshot( + name: live.name, + descriptionText: live.descriptionText, + tagIds: live.tagIds + ) + } + return dishSnapshotsById[dishId] + } + + private func refreshDishSnapshots() { + let liveSnapshots = Dictionary(uniqueKeysWithValues: dishes.map { dish in + ( + dish.id, + DishSnapshot( + name: dish.name, + descriptionText: dish.descriptionText, + tagIds: dish.tagIds + ) + ) + }) + + dishSnapshotsById = dishSnapshotsById.merging(liveSnapshots) { _, new in new } + } + + private func refreshDisplaySlots() { + var resolved: [String: DisplaySlot] = [:] + let grouped = Dictionary(grouping: plan.slots, by: { slotKey(day: $0.dayOfWeek, mealType: $0.mealType) }) + for (key, candidates) in grouped { + guard !candidates.isEmpty else { continue } + let preferred = MealSlot.preferredForDuplicateResolution(candidates) + resolved[key] = DisplaySlot( + slotId: preferred.id, + dayOfWeek: preferred.dayOfWeek, + mealType: preferred.mealType, + dishId: preferred.dishId, + isRuleOverridden: preferred.isRuleOverridden + ) + } + + // Ignore incomplete transient relationship states that occur during SwiftData refreshes. + if resolved.count == expectedSlotCount || displaySlotsByKey.isEmpty { + displaySlotsByKey = resolved + } + } + + private var expectedSlotCount: Int { + days.count * mealTypes.count + } + + private func slotKey(day: Int, mealType: String) -> String { + "\(day)|\(mealType)" + } + + private func liveSlot(for display: DisplaySlot) -> MealSlot? { + if let exact = plan.slots.first(where: { $0.id == display.slotId }) { + return exact + } + let matching = plan.slots.filter { $0.dayOfWeek == display.dayOfWeek && $0.mealType == display.mealType } + guard !matching.isEmpty else { return nil } + return MealSlot.preferredForDuplicateResolution(matching) + } + private func parseDropPayload(_ raw: String) -> DropPayload? { let parts = raw.split(separator: ":", maxSplits: 1).map(String.init) guard parts.count == 2, let id = UUID(uuidString: parts[1]) else { return nil } diff --git a/MealMood/Views/Onboarding/FirstDishesStepView.swift b/MealMood/Views/Onboarding/FirstDishesStepView.swift index 7116ebc..e9fa3e6 100644 --- a/MealMood/Views/Onboarding/FirstDishesStepView.swift +++ b/MealMood/Views/Onboarding/FirstDishesStepView.swift @@ -30,34 +30,38 @@ struct FirstDishesStepView: View { } private let maxVisibleSuggestions = 3 + private var addedDishesReversed: [(index: Int, dish: (name: String, tagIds: [UUID]))] { + Array(viewModel.addedDishes.enumerated()).reversed().map { ($0.offset, $0.element) } + } var body: some View { ScrollView { - VStack(spacing: 24) { - Text("first_dishes_title") - .font(.mealMoodH1) - .foregroundColor(.mealMoodTextPrimary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 24) - .padding(.top, 12) + VStack(spacing: 0) { + VStack(spacing: 24) { + Text("first_dishes_title") + .font(.mealMoodH1) + .foregroundColor(.mealMoodTextPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.top, 12) - Text("first_dishes_subtitle") - .font(.mealMoodSmall) - .foregroundColor(.mealMoodTextSecondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 24) + Text("first_dishes_subtitle") + .font(.mealMoodSmall) + .foregroundColor(.mealMoodTextSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) - // New dish form - VStack(spacing: 12) { - TextField("first_dishes_name_placeholder", text: $viewModel.newDishName) - .font(.mealMoodBody) - .padding(14) - .background(Color.mealMoodSurface) - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color(hex: "#F0F0F0"), lineWidth: 1) - ) + // New dish form + VStack(spacing: 12) { + TextField("first_dishes_name_placeholder", text: $viewModel.newDishName) + .font(.mealMoodBody) + .padding(14) + .background(Color.mealMoodSurface) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color(hex: "#F0F0F0"), lineWidth: 1) + ) // Selected tags if !viewModel.newDishTags.isEmpty { @@ -117,26 +121,28 @@ struct FirstDishesStepView: View { } .disabled(!viewModel.canAddDish) } - .padding(.horizontal, 24) + .padding(.horizontal, 24) - // Added dishes list - if !viewModel.addedDishes.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("first_dishes_added") - .font(.mealMoodSmall) - .foregroundColor(.mealMoodTextSecondary) - .padding(.horizontal, 24) + // Added dishes list + if !viewModel.addedDishes.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("first_dishes_added") + .font(.mealMoodSmall) + .foregroundColor(.mealMoodTextSecondary) + .padding(.horizontal, 24) - ScrollView { - LazyVStack(spacing: 8) { - ForEach(Array(viewModel.addedDishes.enumerated()), id: \.offset) { index, dish in - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.mealMoodSuccess) + ScrollView { + LazyVStack(spacing: 8) { + ForEach(addedDishesReversed, id: \.index) { item in + let index = item.index + let dish = item.dish + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.mealMoodSuccess) - Text(dish.name) - .font(.mealMoodBody) - .foregroundColor(.mealMoodTextPrimary) + Text(dish.name) + .font(.mealMoodBody) + .foregroundColor(.mealMoodTextPrimary) // Show tag pills HStack(spacing: 4) { @@ -145,26 +151,26 @@ struct FirstDishesStepView: View { } } - Spacer() + Spacer() - Button { - viewModel.removeDish(at: index) - } label: { - Image(systemName: "trash") - .font(.system(size: 14)) - .foregroundColor(.mealMoodError) + Button { + viewModel.removeDish(at: index) + } label: { + Image(systemName: "trash") + .font(.system(size: 14)) + .foregroundColor(.mealMoodError) + } } + .padding(12) + .background(Color.mealMoodSurface) + .cornerRadius(12) } - .padding(12) - .background(Color.mealMoodSurface) - .cornerRadius(12) } } + .frame(maxHeight: 190) + .padding(.horizontal, 24) } - .frame(maxHeight: 190) - .padding(.horizontal, 24) } - } VStack(alignment: .leading, spacing: 10) { Text("first_dishes_suggestions") @@ -214,7 +220,9 @@ struct FirstDishesStepView: View { } } - Spacer(minLength: 40) + Spacer(minLength: 20) + } + .frame(maxWidth: .infinity, alignment: .top) PrimaryButton( title: String(localized: "onboarding_finish"), @@ -227,7 +235,8 @@ struct FirstDishesStepView: View { isEnabled: viewModel.addedDishes.count >= 2 ) .padding(.horizontal, 24) - .padding(.bottom, 40) + .padding(.top, 12) + .padding(.bottom, 24) } } .sheet(isPresented: $showTagSelector) { diff --git a/MealMoodTests/AutocompleteEngineTests.swift b/MealMoodTests/AutocompleteEngineTests.swift index 1cb7cb6..d75d09c 100644 --- a/MealMoodTests/AutocompleteEngineTests.swift +++ b/MealMoodTests/AutocompleteEngineTests.swift @@ -72,4 +72,12 @@ final class AutocompleteEngineTests: XCTestCase { let afterDeletion = Dish.stableSortedForDisplay([newest, oldest]).map(\.id) XCTAssertEqual(afterDeletion, [newest.id, oldest.id]) } + + func testPreferredDuplicateSlotKeepsAssignedDish() { + let duplicateWithDish = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue, dishId: UUID()) + let duplicateWithoutDish = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue, dishId: nil) + + let preferred = MealSlot.preferredForDuplicateResolution([duplicateWithoutDish, duplicateWithDish]) + XCTAssertEqual(preferred.id, duplicateWithDish.id) + } }