Dismiss stale exercise flows on remote resolution; unfreeze the cover

Two stale-UI traps in the run flow (BULLETPROOFING.md M2, M3):

A log resolved remotely (completed or skipped on the other device) left
the open exercise screen live - its next recorded set wrote the log
back to in-progress, resurrecting it through the per-log merge. Both
platforms' ExerciseProgressView now observe the log's status and
dismiss on a remote terminal flip (a locallyResolved flag exempts the
screen's own Done / flow hand-off). The watch also gains the phone's
startsSkipped terminal page, so opening a skipped exercise shows a
static badge instead of a live flow.

The live-mirror cover ran the flow engine with no onAdvance host, so an
auto-advance split's terminal between-exercise rest completed the
exercise then froze at 0:00. With no hand-off host it now dismisses
instead; the driver's next-exercise frame re-presents the cover.

Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
This commit is contained in:
2026-07-09 23:09:50 -04:00
parent 25a111a6b4
commit 62a2882f0e
4 changed files with 82 additions and 8 deletions
@@ -145,6 +145,10 @@ struct ExerciseProgressView: View {
/// `startsCompleted`.
@State private var startsSkipped: Bool
/// Set when *this* screen resolves the exercise (Done / flow hand-off), so the
/// remote-flip observer doesn't mistake our own terminal write for a peer's.
@State private var locallyResolved = false
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, onActivity: @escaping (LiveProgress) -> Void = { _ in }, onActivityEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, speechAnnouncer: SpeechAnnouncer? = nil, enteredViaFlow: Bool = false, onAdvance: ((String) -> Void)? = nil) {
self._doc = doc
self.logID = logID
@@ -367,6 +371,16 @@ struct ExerciseProgressView: View {
.onChange(of: log?.currentStateIndex) { _, _ in
repairFromDurable()
}
.onChange(of: log?.status) { _, raw in
// The log resolved out from under us (the run was completed or ended early on
// another device). Leave the live flow staying would let the next recorded
// set write a resolved log back to in-progress, resurrecting it through the
// merge. `locallyResolved` keeps our own Done / flow hand-off from re-dismissing.
guard didRestorePage, !locallyResolved,
let status = raw.flatMap(WorkoutStatus.init(rawValue:)),
status == .completed || status == .skipped else { return }
dismiss()
}
.onAppear {
guard !didRestorePage else { return }
if startsResumed {
@@ -577,8 +591,11 @@ struct ExerciseProgressView: View {
completeExercise()
// Re-resolve at fire time the next exercise may have been
// finished elsewhere while we rested. None left end the run.
if let next = nextUnfinishedLogID {
onAdvance?(next)
// No hand-off host (the live-mirror cover) close instead of
// freezing on a spent countdown; a follower re-presents on the
// driver's next-exercise frame.
if let next = nextUnfinishedLogID, let onAdvance {
onAdvance(next)
} else {
dismiss()
}
@@ -804,6 +821,7 @@ struct ExerciseProgressView: View {
}
private func completeExercise() {
locallyResolved = true
knownStateIndex = max(knownStateIndex, setCount)
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].currentStateIndex = setCount