Fix watch freeze on the progress flow; make Ready? always reachable

Tapping an in-progress exercise on the watch froze the app in an infinite
SwiftUI re-render loop. WorkoutLogListView.body observed SwiftData two ways
the iPhone list deliberately avoids: a @Query-bound Split, and a traversal of
its `exercises` relationship during render (availableExercises). Reading an
observed model's relationship inside body keeps the view perpetually
subscribed and re-invalidating. Fix: fetch the split imperatively (not via
@Query), gate the Add-Exercise affordances on the value-type doc.splitID, and
evaluate availableExercises only from the picker sheet's closure. The list
body now depends solely on the value-type working doc.

Also remove the temporary on-screen diagnostics/PVDiag plumbing and restore
PhaseTimerLayout and the dot-row animation that were dropped while debugging.

Make the Ready? page always lead the exercise flow on both watch and iPhone
(previously only for not-started exercises), so a resumed run can swipe back
to it. A deliberate swipe back to Ready? resets the run; the transient paged
TabView snap-to-0 on open is guarded by a settle gate plus an adjacent-swipe
check, so an in-progress exercise lands on its set and is never reset on open.

Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
This commit is contained in:
2026-06-20 19:02:09 -04:00
parent 21ee05053e
commit 707d71eaf0
3 changed files with 91 additions and 90 deletions
@@ -44,13 +44,12 @@ struct ExerciseProgressView: View {
@State private var currentPage: Int
@State private var didRestorePage = false
/// True when this run opens on the lead-in **Ready?** page (the exercise hadn't
/// been started yet). Held in `@State` so it stays fixed for the life of the screen:
/// the parent list rebuilds (re-inits) this view whenever the workout file changes,
/// and once Start marks the exercise in-progress a recomputed `let` would flip to
/// `false` mid-run dropping `base` from 1 to 0 and remapping the current page onto
/// the wrong phase. Frozen here, all the page-index math below stays stable.
@State private var showsReady: Bool
/// True when this run opened on a resumed set (in-progress) rather than the Ready
/// page. Held in `@State` so it stays fixed for the life of the screen the parent
/// list re-inits this view whenever the workout file changes, and this must not flip
/// mid-run. Such a run re-asserts its resume page after the first layout and ignores
/// the transient TabView snap-to-0, so it isn't reset on open.
@State private var startsResumed: Bool
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void) {
self._doc = doc
@@ -61,20 +60,28 @@ struct ExerciseProgressView: View {
let sets = max(1, log?.sets ?? 1)
_setCount = State(initialValue: sets)
// The Ready page always leads the flow. A not-started run opens on it; an
// in-progress run opens on its first unfinished set and re-asserts that page past
// the TabView's snap-to-0 on first layout.
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
_showsReady = State(initialValue: notStarted)
_startsResumed = State(initialValue: !notStarted)
let base = notStarted ? 1 : 0
let base = 1
// Resume on the first unfinished set's work page (clamped to the last set).
let completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1)
let resume = notStarted ? 0 : base + completed * 2
_currentPage = State(initialValue: resume)
let resume = base + completed * 2
_currentPage = State(initialValue: notStarted ? 0 : resume)
}
private var log: WorkoutLogDocument? {
doc.logs.first { $0.id == logID }
}
/// The **Ready?** page always leads the flow, so a resumed run can swipe back to it
/// (which resets the exercise). (The watch additionally suppresses it for its
/// screenshot host; the iPhone has no such host, so it's always shown.)
private var showsReady: Bool { true }
/// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`.
private var base: Int { showsReady ? 1 : 0 }
@@ -84,7 +91,7 @@ struct ExerciseProgressView: View {
/// Ready (`base`) + cycle (`2N 1`) + Finish (`1`).
private var totalPages: Int { base + cycleCount + 1 }
/// The first unfinished set's work page (only used when resuming, so `base == 0`).
/// The first unfinished set's work page, used to resume an in-progress run.
private var resumePage: Int {
let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1)
return base + completed * 2
@@ -160,10 +167,14 @@ struct ExerciseProgressView: View {
}
.navigationTitle(log?.exerciseName ?? "")
.navigationBarTitleDisplayMode(.inline)
.onChange(of: currentPage) { _, newPage in
// Swiping all the way back to the Ready page wipes the run; any other page
// records forward progress.
if showsReady && newPage == 0 {
.onChange(of: currentPage) { oldPage, newPage in
// Ignore page changes until the initial resume settles, so the TabView's
// transient snap-to-0 on first layout can't reset an in-progress run.
guard didRestorePage else { return }
// A deliberate swipe back from the first set to the Ready page wipes the run
// (only the adjacent 10 swipe resets a stray far jump never does); any
// other page records forward progress.
if showsReady && newPage == 0 && oldPage == base {
resetExercise()
} else {
recordProgress(for: newPage)
@@ -172,14 +183,20 @@ struct ExerciseProgressView: View {
.onAppear {
// Keep the screen lit while logging a mid-workout sleep is annoying.
UIApplication.shared.isIdleTimerDisabled = true
// Jump to the first unfinished set. A paged TabView can settle on page 0 on
// first layout, so re-assert once more after this run loop. (The Ready page
// pins page 0 itself, so skip the jump there.)
guard !didRestorePage else { return }
didRestorePage = true
if !showsReady {
if startsResumed {
// Resume on the first unfinished set. A paged TabView can settle on page 0
// on first layout, so re-assert the resume page (now and once more after
// this run loop) before honoring page changes otherwise that snap would
// land on, and reset at, the Ready page.
jumpToResumePage()
Task { @MainActor in jumpToResumePage() }
Task { @MainActor in
jumpToResumePage()
didRestorePage = true
}
} else {
// Not-started opens on the Ready page.
didRestorePage = true
}
}
.onDisappear {