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:
@@ -105,6 +105,16 @@ struct ExerciseProgressView: View {
|
||||
/// so completing the exercise from inside the flow doesn't swap the page mid-dismiss.
|
||||
@State private var startsCompleted: Bool
|
||||
|
||||
/// True when the exercise was skipped (workout ended early) when this screen opened —
|
||||
/// it shows a static Skipped page instead of the timer flow. Without it, opening a
|
||||
/// skipped log dropped into the live flow, whose first recorded set would write the
|
||||
/// log back to in-progress — resurrecting a resolved run through the merge.
|
||||
@State private var startsSkipped: Bool
|
||||
|
||||
/// Set when *this* screen resolves the exercise (Done / flow hand-off), so the
|
||||
/// remote-flip observer below doesn't mistake our own terminal write for the phone's.
|
||||
@State private var locallyResolved = false
|
||||
|
||||
/// 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.
|
||||
private let debugInitialPage: Int?
|
||||
@@ -133,6 +143,7 @@ struct ExerciseProgressView: View {
|
||||
let ready = debugInitialPage == nil && !enteredViaFlow
|
||||
_startsResumed = State(initialValue: ready && !notStarted)
|
||||
_startsCompleted = State(initialValue: ready && log?.status == WorkoutStatus.completed.rawValue)
|
||||
_startsSkipped = State(initialValue: ready && log?.status == WorkoutStatus.skipped.rawValue)
|
||||
|
||||
let base = ready ? 1 : 0
|
||||
// Resume on the first unfinished set's work page (clamped to the last set).
|
||||
@@ -241,6 +252,8 @@ struct ExerciseProgressView: View {
|
||||
var body: some View {
|
||||
if startsCompleted {
|
||||
CompletedPhaseView()
|
||||
} else if startsSkipped {
|
||||
SkippedPhaseView()
|
||||
} else {
|
||||
flowBody
|
||||
}
|
||||
@@ -311,6 +324,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
|
||||
// the phone). 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 {
|
||||
@@ -500,8 +523,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()
|
||||
}
|
||||
@@ -657,6 +683,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
|
||||
@@ -883,6 +910,23 @@ private struct CompletedPhaseView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Skipped Phase
|
||||
|
||||
/// Shown instead of the run flow when the exercise was skipped (the workout was
|
||||
/// ended early) — in gray, matching the list row's skipped icon.
|
||||
private struct SkippedPhaseView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "forward.circle.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.gray)
|
||||
Text("Skipped")
|
||||
.font(.system(.title3, design: .rounded, weight: .heavy))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ready Phase
|
||||
|
||||
private struct ReadyPhaseView: View {
|
||||
|
||||
Reference in New Issue
Block a user