Port the exercise run screen's true resume to the watch
Re-entering an in-progress exercise on the watch now computes its landing (page + wall-clock anchor) from the log's durable timestamps in init — mid-set resumes the stopwatch from startedAt, mid-rest lands on the rest page with the countdown continuing and auto-advancing at the true boundary, and a spent rest anchors the next set at the rest's computed end. Timestamps are clamped to now against peer clock skew; legacy logs without them keep the old first-unfinished-set behavior. broadcastLive forwards the resume anchor so a mirroring phone's timer lines up. Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
**July 2026**
|
||||
|
||||
Reopening an exercise on the watch now also continues its timer where it left off — even mid-rest — instead of restarting from zero.
|
||||
|
||||
A renamed exercise's headline now shows both identities on one line, like "Cardio · Warm Up".
|
||||
|
||||
Renamed exercises in workouts recorded by older versions get their animated figure and guide back.
|
||||
|
||||
@@ -121,6 +121,10 @@ struct ExerciseProgressView: View {
|
||||
/// remote-flip observer below doesn't mistake our own terminal write for the phone's.
|
||||
@State private var locallyResolved = false
|
||||
|
||||
/// Where the resume landed, computed once at init (with its timer anchor) so the
|
||||
/// post-layout re-assert jumps to the same page the anchor was built for.
|
||||
@State private var resumeTargetPage: Int?
|
||||
|
||||
/// 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?
|
||||
@@ -156,6 +160,18 @@ struct ExerciseProgressView: View {
|
||||
let completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1)
|
||||
let resume = base + completed * 2
|
||||
_currentPage = State(initialValue: debugInitialPage ?? (notStarted ? 0 : resume))
|
||||
|
||||
// A resumed run refines the landing to the phase the exercise is actually in
|
||||
// (mid-rest lands on the rest page) with its wall-clock anchor, so the timer
|
||||
// continues instead of restarting. Computed here — not on appear — because the
|
||||
// paged TabView must *initialize* on this page: a post-layout backward jump
|
||||
// wedges its pager, which then ignores the next animated programmatic advance.
|
||||
if ready, !notStarted, log?.status == WorkoutStatus.inProgress.rawValue {
|
||||
let refined = computeResume()
|
||||
_currentPage = State(initialValue: refined.page)
|
||||
_resumeTargetPage = State(initialValue: refined.page)
|
||||
_pageAnchor = State(initialValue: refined.anchor)
|
||||
}
|
||||
}
|
||||
|
||||
private var log: WorkoutLogDocument? {
|
||||
@@ -407,9 +423,14 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
|
||||
/// Push the current flow position to a mirroring iPhone. The anchor is stamped *now* — the
|
||||
/// page just became active — so the mirror's timer lines up with this device's.
|
||||
/// page just became active — except when the page carries an anchor (a resumed run
|
||||
/// continuing its phase), which is forwarded so the phone's mirror timer lines up with ours.
|
||||
private func broadcastLive(for page: Int) {
|
||||
guard debugInitialPage == nil, let snapshot = liveSnapshot(for: page) else { return }
|
||||
guard debugInitialPage == nil, var snapshot = liveSnapshot(for: page) else { return }
|
||||
if let anchor = pageAnchor, anchor.page == page {
|
||||
snapshot.phaseStart = anchor.start
|
||||
snapshot.phaseEnd = anchor.end
|
||||
}
|
||||
onLive(snapshot)
|
||||
}
|
||||
|
||||
@@ -516,10 +537,51 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a resumed run re-opens: the page plus the wall-clock anchor that lets its
|
||||
/// timer continue from where the exercise actually is, rebuilt from the durable
|
||||
/// timestamps (there is no ephemeral state to restore — leaving the screen tears the
|
||||
/// live flow down).
|
||||
///
|
||||
/// • No sets recorded yet → the first work page, its stopwatch anchored to
|
||||
/// `startedAt` so it keeps counting.
|
||||
/// • k sets recorded and the rest after set k is still running (the k-th entry is
|
||||
/// stamped as its rest begins — see `recordProgress`) → that rest page, anchored
|
||||
/// to its true window so the countdown resumes mid-flight and still auto-advances
|
||||
/// at the real boundary.
|
||||
/// • Otherwise → set k+1's work page, anchored at the rest's computed end.
|
||||
///
|
||||
/// Logs without the timestamps (older files) fall back to today's behavior: the
|
||||
/// first unfinished set's work page, self-anchored at local `now`.
|
||||
private func computeResume(now: Date = Date()) -> (page: Int, anchor: PageAnchor?) {
|
||||
let fallback = resumePage
|
||||
guard let log else { return (fallback, nil) }
|
||||
let completed = min(max(0, log.currentStateIndex), setCount - 1)
|
||||
|
||||
func workAnchor(page: Int, start: Date) -> PageAnchor {
|
||||
PageAnchor(page: page, start: start,
|
||||
end: isDuration ? start.addingTimeInterval(Double(workDurationSeconds)) : nil)
|
||||
}
|
||||
|
||||
if completed == 0 {
|
||||
guard let started = log.startedAt else { return (fallback, nil) }
|
||||
// Clamp to `now` so a peer's clock skew can't stamp a future start and
|
||||
// make the stopwatch (or a timed set's window) run long.
|
||||
return (fallback, workAnchor(page: fallback, start: min(started, now)))
|
||||
}
|
||||
guard let entries = log.setEntries, entries.count >= completed else { return (fallback, nil) }
|
||||
let restStart = min(entries[completed - 1].completedAt, now)
|
||||
let restEnd = restStart.addingTimeInterval(Double(effectiveRest))
|
||||
if now < restEnd {
|
||||
let restPage = base + (completed - 1) * 2 + 1
|
||||
return (restPage, PageAnchor(page: restPage, start: restStart, end: restEnd))
|
||||
}
|
||||
return (fallback, workAnchor(page: fallback, start: restEnd))
|
||||
}
|
||||
|
||||
/// Move to the resume page without animation, only if we're not already there
|
||||
/// (so a re-assert after a TabView snap-to-0 is a no-op in the common case).
|
||||
private func jumpToResumePage() {
|
||||
let target = resumePage
|
||||
let target = resumeTargetPage ?? resumePage
|
||||
guard currentPage != target else { return }
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
|
||||
Reference in New Issue
Block a user