From 4a7def59b6ada129e12771e6495ba3f5c99356c2 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Sun, 26 Jul 2026 14:37:03 -0700 Subject: [PATCH] Harden simulator launch-arg tooling against stale nav routes Launching with -seedDemo YES -openWorkout YES could leave the NavigationPath holding duplicate/stale activityDetail entries: the root .task re-fires when the root Group swaps content (onboarding to main tabs), so reseed + push could run more than once per process, and a later SeedData.clearAll wiped the sessions that earlier route entries pointed at. Back then appeared dead because pops landed on identical stale copies. - Guard the launch-arg block with a didRunLaunchArgs @State flag so reseed + push runs at most once per process; liveWorkout.recover() and deep-link routing still run on every task pass. - Reset the NavigationPath before pushing the openWorkout route so Back always returns to the dashboard. - Upgrade ActivityDetailView's missing-session fallback to a full-screen ContentUnavailableView with an explicit Back to Dashboard action, so a stale id is never a dead-end page. Only affects DEBUG/simulator tooling paths, not production flows. --- PulseLoop/Views/RecordViews.swift | 15 ++++++++++++++- PulseLoop/Views/RootViews.swift | 32 ++++++++++++++++++++----------- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/PulseLoop/Views/RecordViews.swift b/PulseLoop/Views/RecordViews.swift index 9c7ea0e..3d26da8 100644 --- a/PulseLoop/Views/RecordViews.swift +++ b/PulseLoop/Views/RecordViews.swift @@ -239,7 +239,20 @@ struct ActivityDetailView: View { } } } else { - EmptyStateView(title: "Workout not found", body: "This session is no longer in local storage.") + // Stale route (e.g. a demo-data reseed wiped the session a pushed id points at): + // fill the screen with a clear fallback plus an explicit way back, so a missing + // workout is never a blank, dead-end page. + ContentUnavailableView { + Label("Workout not found", systemImage: "figure.run.circle") + } description: { + Text("This workout is no longer in local storage. It may have been deleted or replaced.") + } actions: { + Button("Back to Dashboard") { dismiss() } + .font(PulseFont.callout) + .foregroundStyle(PulseColors.accent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(PulseColors.background) } } diff --git a/PulseLoop/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index cd605e2..4a4c4a5 100644 --- a/PulseLoop/Views/RootViews.swift +++ b/PulseLoop/Views/RootViews.swift @@ -9,6 +9,10 @@ struct RootAppView: View { @Query private var profiles: [UserProfile] @State private var path = NavigationPath() @State private var didFinishForcedOnboarding = false + /// Launch-arg handling must run at most once per process: `.task` re-fires when the + /// root Group swaps content (onboarding → main tabs), and a second reseed would wipe + /// the sessions that routes already in `path` point at, stranding stale detail pages. + @State private var didRunLaunchArgs = false private var forceOnboardingForTesting: Bool { #if DEBUG @@ -37,17 +41,23 @@ struct RootAppView: View { // Demo data is opt-in: load it from Settings → "Reseed demo data", or via the // `-seedDemo YES` launch arg (test tooling only). Normal launches start empty. .task { - if UserDefaults.standard.bool(forKey: "seedDemo") { - SeedData.clearAll(modelContext) - SeedData.seedDemo(modelContext, completeOnboarding: true) - } - // Test tooling: deep-link straight to a seeded workout's detail (route map). - if UserDefaults.standard.bool(forKey: "openWorkout"), - let session = ActivityRepository.sessions(context: modelContext).first(where: { $0.status == .finished && $0.useGps }) { - path.append(AppRoute.activityDetail(session.id)) - } - if UserDefaults.standard.bool(forKey: "openRecord") { - path.append(AppRoute.recordSelect) + if !didRunLaunchArgs { + didRunLaunchArgs = true + if UserDefaults.standard.bool(forKey: "seedDemo") { + SeedData.clearAll(modelContext) + SeedData.seedDemo(modelContext, completeOnboarding: true) + } + // Test tooling: deep-link straight to a seeded workout's detail (route map). + if UserDefaults.standard.bool(forKey: "openWorkout"), + let session = ActivityRepository.sessions(context: modelContext).first(where: { $0.status == .finished && $0.useGps }) { + // Start from a clean stack so Back always lands on the dashboard — + // never on a stale detail route left over from an earlier push. + path = NavigationPath() + path.append(AppRoute.activityDetail(session.id)) + } + if UserDefaults.standard.bool(forKey: "openRecord") { + path.append(AppRoute.recordSelect) + } } // Re-attach to an in-progress workout left running across launches. liveWorkout.recover()