2.0: mostrar los dos ultimos pasos del onboarding (aha moment + paywall)
completeOnboarding escribia settings.onboardingCompleted al terminar el paso de platos. Ese flag es el que ContentView observa para cambiar de OnboardingView a HomeView, asi que el save destruia el flujo antes de que nextStep() pudiera pintar el paso 5. WeekReadyStepView (el aha moment de la 1.2.0) y PaywallStepView no se han visto nunca. GA4 lo confirma: 3 onboarding_completed en 28 dias y cero eventos con step 5 o 6. Se separa persistir los datos de marcar el onboarding como terminado: completeOnboarding(markFinished:) commitea platos y plan sin tocar el flag, y el nuevo finishOnboarding() lo escribe al salir del paywall. HomeView ya difiere su prompt premium a la segunda sesion, asi que no hay paywall duplicado. De paso, los nombres de paso en AnalyticsService estaban desalineados: el indice 5 decia "5_paywall" cuando el paso 5 es WeekReady, y el 6 no tenia nombre. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt
This commit is contained in:
@@ -111,7 +111,7 @@ enum AnalyticsService {
|
|||||||
// Keep in sync with the OnboardingView step order.
|
// Keep in sync with the OnboardingView step order.
|
||||||
private static let onboardingStepNames = [
|
private static let onboardingStepNames = [
|
||||||
"0_welcome", "1_meal_windows", "2_weekends",
|
"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) {
|
static func logOnboardingStepViewed(step: Int) {
|
||||||
|
|||||||
@@ -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
|
@discardableResult
|
||||||
func completeOnboarding(context: ModelContext) -> Bool {
|
func completeOnboarding(context: ModelContext, markFinished: Bool = true) -> Bool {
|
||||||
// Create or get settings
|
// Create or get settings
|
||||||
let descriptor = FetchDescriptor<AppSettings>()
|
let descriptor = FetchDescriptor<AppSettings>()
|
||||||
let settings = (try? context.fetch(descriptor))?.first ?? {
|
let settings = (try? context.fetch(descriptor))?.first ?? {
|
||||||
@@ -137,7 +144,9 @@ final class OnboardingViewModel: ObservableObject {
|
|||||||
settings.calendarId = selectedCalendarId
|
settings.calendarId = selectedCalendarId
|
||||||
settings.lunchTime = lunchTime
|
settings.lunchTime = lunchTime
|
||||||
settings.dinnerTime = dinnerTime
|
settings.dinnerTime = dinnerTime
|
||||||
|
if markFinished {
|
||||||
settings.onboardingCompleted = true
|
settings.onboardingCompleted = true
|
||||||
|
}
|
||||||
|
|
||||||
// Create default tags if not exist
|
// Create default tags if not exist
|
||||||
let tagDescriptor = FetchDescriptor<Tag>()
|
let tagDescriptor = FetchDescriptor<Tag>()
|
||||||
@@ -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<AppSettings>()
|
||||||
|
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
|
/// 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.
|
/// the same engine as the home auto-complete. Returns the number of filled slots.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ struct OnboardingView: View {
|
|||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
onFinish: {
|
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.autoFillFirstWeek(context: context)
|
||||||
viewModel.nextStep()
|
viewModel.nextStep()
|
||||||
}
|
}
|
||||||
@@ -79,8 +81,8 @@ struct OnboardingView: View {
|
|||||||
.tag(5)
|
.tag(5)
|
||||||
|
|
||||||
PaywallStepView(
|
PaywallStepView(
|
||||||
onSkip: { onComplete() },
|
onSkip: { finishOnboarding() },
|
||||||
onConverted: { onComplete() }
|
onConverted: { finishOnboarding() }
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
.tag(6)
|
.tag(6)
|
||||||
@@ -100,6 +102,11 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func finishOnboarding() {
|
||||||
|
viewModel.finishOnboarding(context: context)
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
|
||||||
private func ensureDefaultTagsIfNeeded() {
|
private func ensureDefaultTagsIfNeeded() {
|
||||||
let descriptor = FetchDescriptor<Tag>()
|
let descriptor = FetchDescriptor<Tag>()
|
||||||
if (try? context.fetch(descriptor))?.isEmpty ?? true {
|
if (try? context.fetch(descriptor))?.isEmpty ?? true {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
import SwiftData
|
||||||
@testable import MealMood
|
@testable import MealMood
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -41,4 +42,61 @@ final class OnboardingViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(vm.addedDishes.count, 1)
|
XCTAssertEqual(vm.addedDishes.count, 1)
|
||||||
XCTAssertEqual(vm.addedDishes.first?.tagIds, [fallback.id])
|
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<AppSettings>()).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<Dish>()).count, 1)
|
||||||
|
XCTAssertEqual(try context.fetch(FetchDescriptor<WeekPlan>()).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<AppSettings>()).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<AppSettings>()).first
|
||||||
|
XCTAssertTrue(stored?.onboardingCompleted ?? false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user