diff --git a/MealMood/Services/AnalyticsService.swift b/MealMood/Services/AnalyticsService.swift index df87efc..11d04f2 100644 --- a/MealMood/Services/AnalyticsService.swift +++ b/MealMood/Services/AnalyticsService.swift @@ -111,7 +111,7 @@ enum AnalyticsService { // Keep in sync with the OnboardingView step order. private static let onboardingStepNames = [ "0_welcome", "1_meal_windows", "2_weekends", - "3_calendar", "4_first_dishes", "5_paywall" + "3_calendar", "4_first_dishes", "5_week_ready", "6_paywall" ] static func logOnboardingStepViewed(step: Int) { diff --git a/MealMood/ViewModels/OnboardingViewModel.swift b/MealMood/ViewModels/OnboardingViewModel.swift index 26941da..4494b08 100644 --- a/MealMood/ViewModels/OnboardingViewModel.swift +++ b/MealMood/ViewModels/OnboardingViewModel.swift @@ -120,8 +120,15 @@ final class OnboardingViewModel: ObservableObject { } } + /// Persists the choices made during onboarding (settings, dishes, first week). + /// + /// `markFinished` must stay `false` while the flow is still on screen: the + /// `onboardingCompleted` flag is what ContentView watches to swap onboarding + /// for the home, so writing it here would tear the flow down before the last + /// two steps (Week Ready and paywall) get a chance to render. Call + /// `finishOnboarding(context:)` once the user leaves the final step. @discardableResult - func completeOnboarding(context: ModelContext) -> Bool { + func completeOnboarding(context: ModelContext, markFinished: Bool = true) -> Bool { // Create or get settings let descriptor = FetchDescriptor() let settings = (try? context.fetch(descriptor))?.first ?? { @@ -137,7 +144,9 @@ final class OnboardingViewModel: ObservableObject { settings.calendarId = selectedCalendarId settings.lunchTime = lunchTime settings.dinnerTime = dinnerTime - settings.onboardingCompleted = true + if markFinished { + settings.onboardingCompleted = true + } // Create default tags if not exist let tagDescriptor = FetchDescriptor() @@ -178,6 +187,23 @@ final class OnboardingViewModel: ObservableObject { } } + /// Flips the flag ContentView watches, handing the user over to the home. + /// Separate from `completeOnboarding` so the data can be committed at the + /// dishes step while the flow stays on screen for the last two steps. + func finishOnboarding(context: ModelContext) { + let descriptor = FetchDescriptor() + guard let settings = (try? context.fetch(descriptor))?.first else { return } + guard !settings.onboardingCompleted else { return } + + settings.onboardingCompleted = true + do { + try context.save() + } catch { + CrashlyticsService.record(error, context: "onboarding_finish") + print("Onboarding finish failed: \(error)") + } + } + /// Fills the first week right inside onboarding (the "aha moment" step) using /// the same engine as the home auto-complete. Returns the number of filled slots. @discardableResult diff --git a/MealMood/Views/Onboarding/OnboardingView.swift b/MealMood/Views/Onboarding/OnboardingView.swift index f14b974..20852f1 100644 --- a/MealMood/Views/Onboarding/OnboardingView.swift +++ b/MealMood/Views/Onboarding/OnboardingView.swift @@ -63,7 +63,9 @@ struct OnboardingView: View { viewModel: viewModel, tags: tags, onFinish: { - _ = viewModel.completeOnboarding(context: context) + // Commit the data but leave onboarding on screen: the flag + // that dismisses it is written when the paywall step exits. + _ = viewModel.completeOnboarding(context: context, markFinished: false) viewModel.autoFillFirstWeek(context: context) viewModel.nextStep() } @@ -79,8 +81,8 @@ struct OnboardingView: View { .tag(5) PaywallStepView( - onSkip: { onComplete() }, - onConverted: { onComplete() } + onSkip: { finishOnboarding() }, + onConverted: { finishOnboarding() } ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .tag(6) @@ -100,6 +102,11 @@ struct OnboardingView: View { } } + private func finishOnboarding() { + viewModel.finishOnboarding(context: context) + onComplete() + } + private func ensureDefaultTagsIfNeeded() { let descriptor = FetchDescriptor() if (try? context.fetch(descriptor))?.isEmpty ?? true { diff --git a/MealMoodTests/OnboardingViewModelTests.swift b/MealMoodTests/OnboardingViewModelTests.swift index e87aaa4..15e3678 100644 --- a/MealMoodTests/OnboardingViewModelTests.swift +++ b/MealMoodTests/OnboardingViewModelTests.swift @@ -1,4 +1,5 @@ import XCTest +import SwiftData @testable import MealMood @MainActor @@ -41,4 +42,61 @@ final class OnboardingViewModelTests: XCTestCase { XCTAssertEqual(vm.addedDishes.count, 1) XCTAssertEqual(vm.addedDishes.first?.tagIds, [fallback.id]) } + + // MARK: Onboarding completion flag + + /// Returns the container, not just its context: releasing the container + /// leaves `mainContext` dangling and SwiftData traps on the next access. + private func makeContainer() throws -> ModelContainer { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + return try ModelContainer( + for: AppSettings.self, Tag.self, Dish.self, WeekPlan.self, MealSlot.self, + ShoppingItem.self, + configurations: config + ) + } + + /// Regression: the flag used to be written at the dishes step, which tore the + /// flow down before the Week Ready and paywall steps could render — neither + /// was ever seen (confirmed in GA4: no step 5/6 events despite completions). + func testCompleteOnboardingKeepsFlowOnScreenWhenNotMarkedFinished() throws { + let container = try makeContainer() + let context = container.mainContext + let vm = OnboardingViewModel() + vm.newDishName = "Lentils" + vm.addDish(defaultTagId: UUID()) + + let saved = vm.completeOnboarding(context: context, markFinished: false) + + XCTAssertTrue(saved) + let settings = try context.fetch(FetchDescriptor()).first + XCTAssertNotNil(settings) + XCTAssertFalse(settings?.onboardingCompleted ?? true, + "The flag must stay false so the last two steps still render") + // The data itself is already committed at this point. + XCTAssertEqual(try context.fetch(FetchDescriptor()).count, 1) + XCTAssertEqual(try context.fetch(FetchDescriptor()).count, 1) + } + + func testFinishOnboardingSetsTheFlag() throws { + let container = try makeContainer() + let context = container.mainContext + let vm = OnboardingViewModel() + vm.completeOnboarding(context: context, markFinished: false) + + vm.finishOnboarding(context: context) + + let stored = try context.fetch(FetchDescriptor()).first + XCTAssertTrue(stored?.onboardingCompleted ?? false) + } + + func testCompleteOnboardingStillMarksFinishedByDefault() throws { + let container = try makeContainer() + let context = container.mainContext + + OnboardingViewModel().completeOnboarding(context: context) + + let stored = try context.fetch(FetchDescriptor()).first + XCTAssertTrue(stored?.onboardingCompleted ?? false) + } }