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
@@ -8,18 +8,6 @@
import SwiftUI
import WatchKit
// DIAGNOSTIC (temporary): records recent lifecycle/mutation events so the on-screen
// overlay can show what ran right before a freeze. Remove once the hang is solved.
enum PVDiag {
nonisolated(unsafe) static var initCount = 0
nonisolated(unsafe) static var lines: [String] = []
static func add(_ s: String) {
print("⌚️PV \(s)")
lines.append(s)
if lines.count > 7 { lines.removeFirst(lines.count - 7) }
}
}
/// Runs a single exercise as a horizontally-paged flow, mirroring the iPhone's:
///
/// [Ready] Work Rest Work Work Finish
@@ -54,11 +42,10 @@ struct ExerciseProgressView: View {
@State private var showingCancelConfirm = false
@State private var didRestorePage = false
/// True when this run opens on the lead-in **Ready?** page (the exercise hadn't been
/// started). A resumed exercise or the screenshot host skips it. Held in `@State`
/// so it stays fixed for the life of the screen (same reasoning as the iPhone view),
/// keeping the page-index math below stable.
@State private var showsReady: Bool
/// True when this run opened on a resumed set (in-progress) rather than the Ready
/// page. 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
/// Forces the starting page (used only by the DEBUG screenshot host). When set it
/// also suppresses the Ready page so the index is a plain work/rest cycle offset.
@@ -75,22 +62,29 @@ struct ExerciseProgressView: View {
_setCount = State(initialValue: sets)
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
let ready = debugInitialPage == nil && notStarted
_showsReady = State(initialValue: ready)
// The Ready page always leads the flow (except in the screenshot host). 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 ready = debugInitialPage == nil
_startsResumed = State(initialValue: ready && !notStarted)
let base = ready ? 1 : 0
// 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 = ready ? 0 : base + completed * 2
_currentPage = State(initialValue: debugInitialPage ?? resume)
PVDiag.initCount += 1
PVDiag.add("init: ready=\(ready) page=\(debugInitialPage ?? resume) sets=\(sets) duration=\(LoadType(rawValue: log?.loadType ?? -1) == .duration)")
let resume = base + completed * 2
_currentPage = State(initialValue: debugInitialPage ?? (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). Suppressed only for the screenshot host, which pins an
/// explicit page. Derived from the immutable `debugInitialPage`, so it stays stable for
/// the life of the screen the page-index math below depends on it.
private var showsReady: Bool { debugInitialPage == nil }
/// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`.
private var base: Int { showsReady ? 1 : 0 }
@@ -100,7 +94,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
@@ -166,20 +160,6 @@ struct ExerciseProgressView: View {
.padding(.bottom, 2)
}
}
// DIAGNOSTIC overlay (temporary): shows recent events; readable off a frozen screen.
.overlay(alignment: .top) {
VStack(alignment: .leading, spacing: 0) {
Text("init#\(PVDiag.initCount)").bold()
ForEach(Array(PVDiag.lines.enumerated()), id: \.offset) { _, line in
Text(line)
}
}
.font(.system(size: 8, design: .monospaced))
.foregroundStyle(.green)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.black.opacity(0.65))
.allowsHitTesting(false)
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
@@ -193,25 +173,34 @@ struct ExerciseProgressView: View {
Button("Cancel Exercise", role: .destructive) { dismiss() }
Button("Continue", role: .cancel) { }
}
.onChange(of: currentPage) { _, newPage in
PVDiag.add("currentPage -> \(newPage)")
// 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)
}
}
.onAppear {
// 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
// and the screenshot host both pin an explicit page, so skip the jump there.)
guard !didRestorePage else { return }
didRestorePage = true
if !showsReady && debugInitialPage == nil {
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; the screenshot host pins its own.
didRestorePage = true
}
}
}
@@ -293,7 +282,6 @@ struct ExerciseProgressView: View {
/// Flip a not-yet-started exercise to in-progress the moment the user taps Start.
private func beginExercise() {
PVDiag.add("beginExercise -> onChange (bridge.update)")
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
guard doc.logs[i].status == WorkoutStatus.notStarted.rawValue else { return }
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
@@ -337,7 +325,6 @@ struct ExerciseProgressView: View {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
guard reached > doc.logs[i].currentStateIndex else { return }
PVDiag.add("recordProgress reached=\(reached) -> onChange (bridge.update)")
doc.logs[i].currentStateIndex = reached
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
doc.logs[i].completed = false
@@ -355,7 +342,6 @@ struct ExerciseProgressView: View {
guard log.currentStateIndex != 0
|| log.status != WorkoutStatus.notStarted.rawValue
|| log.completed else { return }
PVDiag.add("resetExercise -> onChange (bridge.update)")
doc.logs[i].currentStateIndex = 0
doc.logs[i].status = WorkoutStatus.notStarted.rawValue
doc.logs[i].completed = false
@@ -580,7 +566,6 @@ private struct WorkPhaseView: View {
}
private func restart() {
PVDiag.add("work.restart set=\(setNumber) (beep)")
startDate = Date()
WorkoutHaptic.start.play()
}
@@ -624,7 +609,6 @@ private struct CountdownPhaseView: View {
}
private func start() {
PVDiag.add("countdown.start '\(header)' seconds=\(seconds) (beep)")
startDate = Date()
endDate = startDate.addingTimeInterval(Double(max(1, seconds)))
lastPingSecond = Int.max
@@ -640,7 +624,6 @@ private struct CountdownPhaseView: View {
if remaining <= 0 {
// Time's up final cue and slide on. If the wrist was down the timer may have
// stalled; this then fires on the first tick once the app gets runtime again.
PVDiag.add("countdown.finished '\(header)' -> advance")
didFinish = true
WorkoutHaptic.stop.play()
onFinished()