Anchor auto-advanced phases at the boundary, not at tick time
The rest/timed-work countdown deadline is shared by both devices, but the page flip crossing it is a local ticker event — and stamping the *next* phase's anchor at Date() when that event finally ran baked a sleeping watch's lateness (throttled wrist-down ticker) into its next count-up, leaving the two devices permanently offset with nothing on the wire to correct. Auto-advances now chain the anchor instead: the finished phase's computed end (passed out of CountdownPhaseView) becomes the next page's PageAnchor, with the next window derived from it via liveSnapshot(for:at:). A device arbitrarily late to a boundary shows exactly what the on-time device shows, and a stack of missed boundaries fast-forwards itself — each chained page lands already-elapsed and advances on its own next tick, skipping the start/stop haptics for boundaries that passed while asleep (only a just-crossed boundary buzzes). The remoteAnchor* fields are generalized into one PageAnchor (remote frames and chained auto-advances are the same concept: a page whose timer counts from a known instant); the phone's Live Activity emit honors it unchanged.
This commit is contained in:
@@ -92,13 +92,19 @@ struct ExerciseProgressView: View {
|
||||
/// durable echo of our own progress (or of a frame we followed) never triggers a jump.
|
||||
@State private var knownStateIndex: Int
|
||||
|
||||
/// When a remote frame drove us to a page, that page's timer anchors to the *sender's*
|
||||
/// wall clock (`remoteAnchorStart`/`End`) instead of local `now` — so delivery latency
|
||||
/// can't desync the mirror, the way the old read-only mirror counted off the frame's
|
||||
/// anchors. Scoped to `remoteAnchorPage`; any local transition to another page self-anchors.
|
||||
@State private var remoteAnchorPage: Int?
|
||||
@State private var remoteAnchorStart: Date?
|
||||
@State private var remoteAnchorEnd: Date?
|
||||
/// A page reached with a *known* wall-clock anchor counts its timer from that anchor
|
||||
/// instead of local `now`. Two sources: a remote frame (the sender's clock, so delivery
|
||||
/// latency can't desync the mirror) and a chained auto-advance (the finished phase's
|
||||
/// computed end, so a ticker that slept through the boundary — wrist-down throttling —
|
||||
/// can't bake its lateness into the next phase's timer). Scoped to one page; a human
|
||||
/// transition to another page self-anchors.
|
||||
@State private var pageAnchor: PageAnchor?
|
||||
|
||||
private struct PageAnchor {
|
||||
let page: Int
|
||||
let start: Date
|
||||
let end: Date?
|
||||
}
|
||||
|
||||
private enum PageChangeCause { case auto, remote }
|
||||
|
||||
@@ -294,13 +300,13 @@ struct ExerciseProgressView: View {
|
||||
case .auto:
|
||||
// Rest / timed-work auto-advance: record forward progress, but don't
|
||||
// broadcast — the watch reaches this point on its own synchronized timer.
|
||||
clearRemoteAnchor()
|
||||
// The chained anchor for the new page was set by `advance`, not cleared.
|
||||
recordProgress(for: newPage)
|
||||
case .none:
|
||||
// A human swipe. Swiping back from the first set to Ready wipes the run
|
||||
// (only the adjacent 1→0 swipe resets — a stray far jump never does); any
|
||||
// other page records forward progress. Human transitions are broadcast.
|
||||
clearRemoteAnchor()
|
||||
clearAnchor()
|
||||
if showsReady && newPage == 0 && oldPage == base {
|
||||
resetExercise()
|
||||
} else {
|
||||
@@ -374,9 +380,9 @@ struct ExerciseProgressView: View {
|
||||
/// reached by a watch frame, so the lock-screen countdown lines up with the mirror.
|
||||
private func emitActivity(for page: Int) {
|
||||
guard var snapshot = liveSnapshot(for: page) else { return }
|
||||
if page == remoteAnchorPage {
|
||||
if let start = remoteAnchorStart { snapshot.phaseStart = start }
|
||||
snapshot.phaseEnd = remoteAnchorEnd
|
||||
if let anchor = pageAnchor, anchor.page == page {
|
||||
snapshot.phaseStart = anchor.start
|
||||
snapshot.phaseEnd = anchor.end
|
||||
}
|
||||
onActivity(snapshot)
|
||||
}
|
||||
@@ -403,9 +409,7 @@ struct ExerciseProgressView: View {
|
||||
knownStateIndex = frame.phase == .ready ? 0 : max(knownStateIndex, completedSets(for: target))
|
||||
// Anchor the target page's timer to the sender's wall clock, not local now, so the
|
||||
// displayed countdown matches regardless of how long the frame took to arrive.
|
||||
remoteAnchorPage = target
|
||||
remoteAnchorStart = frame.phaseStart
|
||||
remoteAnchorEnd = frame.phaseEnd
|
||||
pageAnchor = PageAnchor(page: target, start: frame.phaseStart, end: frame.phaseEnd)
|
||||
guard target != currentPage else { return }
|
||||
pageChangeCause = .remote
|
||||
withAnimation { currentPage = target }
|
||||
@@ -427,17 +431,15 @@ struct ExerciseProgressView: View {
|
||||
if log.sets > setCount { setCount = log.sets }
|
||||
let target = base + min(max(0, completed), setCount - 1) * 2
|
||||
guard target > currentPage else { return }
|
||||
clearRemoteAnchor()
|
||||
clearAnchor()
|
||||
pageChangeCause = .remote
|
||||
withAnimation { currentPage = target }
|
||||
}
|
||||
|
||||
/// Drop the remote anchor when a local transition moves us off the remote-driven page,
|
||||
/// so the next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor).
|
||||
private func clearRemoteAnchor() {
|
||||
remoteAnchorPage = nil
|
||||
remoteAnchorStart = nil
|
||||
remoteAnchorEnd = nil
|
||||
/// Drop the page anchor when a human transition moves us off the anchored page, so the
|
||||
/// next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor).
|
||||
private func clearAnchor() {
|
||||
pageAnchor = nil
|
||||
}
|
||||
|
||||
/// Inverse of `liveSnapshot`'s page→frame mapping: a frame's phase/set → page index.
|
||||
@@ -454,9 +456,8 @@ struct ExerciseProgressView: View {
|
||||
/// Build the live-run frame for a given page: phase, the set it pertains to, and the
|
||||
/// wall-clock anchors the watch counts off. Count-down phases (rest, timed work, finish)
|
||||
/// carry an end anchor; a rep-based work set counts up and leaves it `nil`.
|
||||
private func liveSnapshot(for page: Int) -> LiveProgress? {
|
||||
private func liveSnapshot(for page: Int, at now: Date = Date()) -> LiveProgress? {
|
||||
guard let log else { return nil }
|
||||
let now = Date()
|
||||
|
||||
func frame(_ phase: LiveRunPhase, setIndex: Int, end: Date?) -> LiveProgress {
|
||||
LiveProgress(
|
||||
@@ -493,10 +494,10 @@ struct ExerciseProgressView: View {
|
||||
@ViewBuilder
|
||||
private func page(for index: Int) -> some View {
|
||||
let isActive = index == currentPage
|
||||
// Only the remote-driven page carries the sender's anchors; every other page (reached
|
||||
// locally by swipe or auto-advance) counts off its own `now`.
|
||||
let anchorStart = index == remoteAnchorPage ? remoteAnchorStart : nil
|
||||
let anchorEnd = index == remoteAnchorPage ? remoteAnchorEnd : nil
|
||||
// Only the anchored page (remote frame or chained auto-advance) carries anchors;
|
||||
// every other page (reached by a human transition) counts off its own `now`.
|
||||
let anchorStart = pageAnchor?.page == index ? pageAnchor?.start : nil
|
||||
let anchorEnd = pageAnchor?.page == index ? pageAnchor?.end : nil
|
||||
if showsReady && index == 0 {
|
||||
ReadyPhaseView(summary: readySummary, onStart: start)
|
||||
} else {
|
||||
@@ -524,8 +525,8 @@ struct ExerciseProgressView: View {
|
||||
isActive: isActive,
|
||||
anchorStart: anchorStart,
|
||||
anchorEnd: anchorEnd
|
||||
) {
|
||||
withAnimation { advance(from: index) }
|
||||
) { end in
|
||||
withAnimation { advance(from: index, phaseEndedAt: end) }
|
||||
}
|
||||
} else {
|
||||
// Rep-based work set — count up; the user swipes left when done.
|
||||
@@ -547,8 +548,8 @@ struct ExerciseProgressView: View {
|
||||
isActive: isActive,
|
||||
anchorStart: anchorStart,
|
||||
anchorEnd: anchorEnd
|
||||
) {
|
||||
withAnimation { advance(from: index) }
|
||||
) { end in
|
||||
withAnimation { advance(from: index, phaseEndedAt: end) }
|
||||
}
|
||||
adjustPill
|
||||
}
|
||||
@@ -616,11 +617,20 @@ struct ExerciseProgressView: View {
|
||||
withAnimation { currentPage = base }
|
||||
}
|
||||
|
||||
/// Programmatically move one page right (used by the rest auto-advance), guarding
|
||||
/// against overrun if the user swiped away in the meantime. Tagged `.auto` so the page
|
||||
/// observer records progress but doesn't broadcast it (the watch auto-advances too).
|
||||
private func advance(from index: Int) {
|
||||
/// Programmatically move one page right when a countdown phase ends, guarding against
|
||||
/// overrun if the user swiped away in the meantime. Tagged `.auto` so the page observer
|
||||
/// records progress but doesn't broadcast it (the watch auto-advances too).
|
||||
///
|
||||
/// `phaseEndedAt` is the finished phase's *computed* end — the next phase began then,
|
||||
/// not when this tick finally got runtime. Anchoring the next page there means a device
|
||||
/// that slept through the boundary (throttled wrist-down ticker) shows the same timer
|
||||
/// as one that crossed it on time — and a stack of missed boundaries fast-forwards
|
||||
/// itself, since each chained countdown lands already-elapsed and advances on its own
|
||||
/// next tick.
|
||||
private func advance(from index: Int, phaseEndedAt end: Date) {
|
||||
guard currentPage == index, index + 1 < totalPages else { return }
|
||||
pageAnchor = PageAnchor(page: index + 1, start: end,
|
||||
end: liveSnapshot(for: index + 1, at: end)?.phaseEnd)
|
||||
pageChangeCause = .auto
|
||||
currentPage = index + 1
|
||||
}
|
||||
@@ -1150,8 +1160,10 @@ private struct CountdownPhaseView: View {
|
||||
/// so the remaining time — and the auto-advance at zero — line up across both devices.
|
||||
var anchorStart: Date? = nil
|
||||
var anchorEnd: Date? = nil
|
||||
/// Invoked once the countdown reaches zero (auto-advance to the next page).
|
||||
let onFinished: () -> Void
|
||||
/// Invoked once the countdown reaches zero (auto-advance to the next page), passing the
|
||||
/// phase's *computed* end so the next phase can anchor at the boundary itself — not at
|
||||
/// whenever this tick got runtime (a device asleep at the boundary ticks late).
|
||||
let onFinished: (Date) -> Void
|
||||
|
||||
/// Wall-clock window for the countdown. SwiftUI renders the remaining time from this
|
||||
/// range, and the haptics + auto-advance below are derived from `endDate` rather than
|
||||
@@ -1181,7 +1193,9 @@ private struct CountdownPhaseView: View {
|
||||
endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds)))
|
||||
lastPingSecond = Int.max
|
||||
didFinish = false
|
||||
if haptic { WorkoutHaptic.start.play() }
|
||||
// No buzz for a chained catch-up page whose whole window already elapsed while
|
||||
// the device slept — it advances again on its next tick.
|
||||
if haptic, endDate > Date() { WorkoutHaptic.start.play() }
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
@@ -1191,8 +1205,10 @@ private struct CountdownPhaseView: View {
|
||||
|
||||
if remaining <= 0 {
|
||||
didFinish = true
|
||||
WorkoutHaptic.stop.play()
|
||||
onFinished()
|
||||
// Buzz only for a boundary that just happened — fast-forwarding through
|
||||
// boundaries that passed while the device slept stays silent.
|
||||
if Date().timeIntervalSince(endDate) < 3 { WorkoutHaptic.stop.play() }
|
||||
onFinished(endDate)
|
||||
} else if remaining <= 3 && remaining < lastPingSecond {
|
||||
// Once-per-second countdown ping for the final three seconds.
|
||||
lastPingSecond = remaining
|
||||
|
||||
Reference in New Issue
Block a user