The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView becomes a Today / Progress / Settings TabView, "Routine" replaces "Split" in every user-facing string and view name (code-level types keep their names), and workout starting moves to shared WorkoutStarter / StartedWorkoutNavigator plumbing. - New Progress tab: weekly goal streaks, workout trends, per-exercise weight progression, achievements, and the full history list (WorkoutLogsView -> WorkoutHistoryView). - Goals: stable categories workouts roll up to, managed from Settings. - New Meditation exercise + starter routine; timed sits record to Apple Health as Mind & Body sessions. Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
92 lines
4.0 KiB
Swift
92 lines
4.0 KiB
Swift
//
|
|
// 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<String?>) -> some View {
|
|
modifier(StartedWorkoutNavigator(pendingWorkoutID: pendingWorkoutID))
|
|
}
|
|
}
|