// // StartedWorkoutNavigator.swift // Workouts // // Copyright 2026 Rouslan Zenetl. All Rights Reserved. // import SwiftUI import SwiftData // MARK: - Started-Workout Navigation /// Drives a programmatic push into a freshly-started workout's log screen. Both start /// paths — the routine picker sheet and the Today board — mint a `WorkoutDocument`, /// write it, then hand its id here; the workout row only materializes once the /// file→observer→cache loop round-trips, so we poll the cache for the entity and push it /// the moment it lands. private struct StartedWorkoutNavigator: ViewModifier { /// The pushed state is a plain value wrapping the run's id — never a retained /// `@Model`. This screen stays pushed for the whole workout, and the run's entity /// can be deleted and re-imported underneath it (observer remove/re-add churn, a /// remote delete); a `@Model` held in `@State` would be *invalidated* by then — /// deleted-and-saved models read `isDeleted == false` yet trap on any property /// access (the TestFlight 2.3 (125) "crashed when watch ended an exercise" report). private struct RunRoute: Identifiable, Hashable { let id: String } @Environment(\.modelContext) private var modelContext @Environment(SyncEngine.self) private var sync @Binding var pendingWorkoutID: String? @State private var resolvedRun: RunRoute? func body(content: Content) -> some View { content .navigationDestination(item: $resolvedRun) { route in // Re-fetch on every build so the destination always maps a live // entity; a re-imported run resolves to its fresh instance. if let workout = CacheMapper.fetchWorkout(id: route.id, in: modelContext) { WorkoutLogListView(workout: workout) } else { ContentUnavailableView( "Workout Unavailable", systemImage: "xmark.circle", description: Text("This workout is no longer on this device.")) } } .onChange(of: pendingWorkoutID) { _, id in guard let id else { return } pollForWorkout(id: id) } // Backing out of a run that never started an exercise discards it — // tapping into a routine to look around must not persist a phantom // workout. Anything with real progress is kept. .onChange(of: resolvedRun) { old, new in guard let old, new == nil else { return } discardIfPristine(id: old.id) } } /// Delete the just-popped workout if it's still an untouched draft. Fetches /// fresh by id — never a retained entity — and re-checks liveness. private func discardIfPristine(id: String) { guard let workout = CacheMapper.fetchWorkout(id: id, in: modelContext), !workout.isDeleted, workout.isPristineDraft else { return } Task { await sync.delete(workout: workout) } } /// Poll for the entity after we write the document (the file→observer→cache loop /// typically completes in well under a second). Clear the pending id once it /// resolves, or silently after ~3 s if it never arrives. private func pollForWorkout(id: String) { Task { for _ in 0..<20 { try? await Task.sleep(for: .milliseconds(150)) if CacheMapper.fetchWorkout(id: id, in: modelContext) != nil { resolvedRun = RunRoute(id: id) pendingWorkoutID = nil return } } pendingWorkoutID = nil } } } extension View { /// Navigate into a workout's log screen once it appears in the cache after being /// started. Bind to the state you set to the new workout's id right after saving it. func navigatesToStartedWorkout(pendingWorkoutID: Binding) -> some View { modifier(StartedWorkoutNavigator(pendingWorkoutID: pendingWorkoutID)) } }