Make the live-run mirror survive lost phone-to-watch frames
Live frames ride sendMessage, which is reachable-only — and phone→watch reachability drops exactly when the user is swiping on the phone (wrist down). A frame that failed to send was staged but never retried until a reachability edge, and a frame lost outright desynced the run until the next human transition — sometimes forever, since a reconnect could even re-send the stale staged frame and yank the peer backwards. Four fixes, symmetric on both bridges and both run screens: - A send that fails while nominally reachable now retries with a short backoff (a few times per staged message) instead of being swallowed. - Receiving a frame that outranks the staged outbound one drops the staged frame, so a reconnect re-send can't move the run backwards; a delivery the staged frame outranks is ignored as stale. - Every staleness comparison now tie-breaks the shared version sequence on the frame's wall-clock anchor (LiveProgress.isNewer) — after a lost frame both devices can mint the same version, and the later human action must win. - Durable repair: when the absorbed workout doc shows sets completed beyond anything the open run screen recorded or followed, it jumps forward to the first unfinished set's work page — a lost frame now degrades to a briefly-stale page instead of a stuck one.
This commit is contained in:
@@ -61,8 +61,15 @@ struct ExerciseProgressView: View {
|
||||
/// A human swipe leaves this `nil` (→ treated as human); programmatic moves set it.
|
||||
@State private var pageChangeCause: PageChangeCause?
|
||||
|
||||
/// Highest remote frame version we've applied, so a redelivery doesn't re-jump.
|
||||
/// Highest remote frame we've applied — version plus its anchor tie-break (see
|
||||
/// `LiveProgress.isNewer`) — so a redelivery or collided stale frame doesn't re-jump.
|
||||
@State private var lastAppliedVersion = 0
|
||||
@State private var lastAppliedStart = Date.distantPast
|
||||
|
||||
/// Highest completed-set count this screen has itself recorded or followed via a live
|
||||
/// frame — the baseline `repairFromDurable` compares an absorbed doc against, so a
|
||||
/// 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
|
||||
@@ -100,6 +107,7 @@ struct ExerciseProgressView: View {
|
||||
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
||||
let sets = max(1, log?.sets ?? 1)
|
||||
_setCount = State(initialValue: sets)
|
||||
_knownStateIndex = State(initialValue: max(0, log?.currentStateIndex ?? 0))
|
||||
|
||||
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
|
||||
// The Ready page always leads the flow (except in the screenshot host). A
|
||||
@@ -141,6 +149,12 @@ struct ExerciseProgressView: View {
|
||||
return base + completed * 2
|
||||
}
|
||||
|
||||
/// Completed-set count a given page implies — the same math `recordProgress` records
|
||||
/// (capped at `setCount − 1`; only an explicit Done marks the final set).
|
||||
private func completedSets(for pageIndex: Int) -> Int {
|
||||
min(max(0, (pageIndex - base + 1) / 2), setCount - 1)
|
||||
}
|
||||
|
||||
/// Position within the work/rest cycle for the current page — `nil` on the Ready
|
||||
/// and Finish pages (which show no dots).
|
||||
private var currentCycleIndex: Int? {
|
||||
@@ -257,6 +271,9 @@ struct ExerciseProgressView: View {
|
||||
.onChange(of: incomingFrame) { _, frame in
|
||||
if let frame { applyIncoming(frame) }
|
||||
}
|
||||
.onChange(of: log?.currentStateIndex) { _, _ in
|
||||
repairFromDurable()
|
||||
}
|
||||
.onAppear {
|
||||
guard !didRestorePage else { return }
|
||||
if startsResumed {
|
||||
@@ -305,10 +322,15 @@ struct ExerciseProgressView: View {
|
||||
/// for a remote One More) without re-broadcasting or re-recording it — the phone owns the
|
||||
/// durable write, which arrives separately over the document sync.
|
||||
private func applyIncoming(_ frame: LiveProgress) {
|
||||
guard didRestorePage, frame.logID == logID, frame.version > lastAppliedVersion else { return }
|
||||
guard didRestorePage, frame.logID == logID,
|
||||
frame.isNewer(thanVersion: lastAppliedVersion, start: lastAppliedStart) else { return }
|
||||
lastAppliedVersion = frame.version
|
||||
lastAppliedStart = frame.phaseStart
|
||||
if frame.setCount != setCount { setCount = frame.setCount }
|
||||
let target = page(forPhase: frame.phase, setIndex: frame.setIndex)
|
||||
// A followed frame is progress this screen now *knows*, so a durable echo of the
|
||||
// same transition can't re-trigger the repair jump (a remote reset re-baselines).
|
||||
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
|
||||
@@ -319,6 +341,27 @@ struct ExerciseProgressView: View {
|
||||
withAnimation { currentPage = target }
|
||||
}
|
||||
|
||||
/// Durable-state safety net for the ephemeral mirror. A peer's swipe frame can be lost
|
||||
/// (`sendMessage` is reachable-only and the retry can run dry), but its durable write still
|
||||
/// arrives via the application context and is absorbed into `doc` by the parent list. When
|
||||
/// the absorbed doc shows sets completed beyond anything this screen has recorded or
|
||||
/// followed, jump forward to the first unfinished set's work page so the flow can't stay
|
||||
/// stuck on a stale phase. Forward-only — progress is monotonic, so a same-or-behind doc
|
||||
/// is our own write echoing back — and only while the run is still in progress.
|
||||
private func repairFromDurable() {
|
||||
guard didRestorePage, let log,
|
||||
log.status == WorkoutStatus.inProgress.rawValue else { return }
|
||||
let completed = log.currentStateIndex
|
||||
guard completed > knownStateIndex else { return }
|
||||
knownStateIndex = completed
|
||||
if log.sets > setCount { setCount = log.sets }
|
||||
let target = base + min(max(0, completed), setCount - 1) * 2
|
||||
guard target > currentPage else { return }
|
||||
clearRemoteAnchor()
|
||||
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() {
|
||||
@@ -478,6 +521,7 @@ struct ExerciseProgressView: View {
|
||||
/// plan, and slide forward into the bonus set's work phase.
|
||||
private func addSet() {
|
||||
let newCount = setCount + 1
|
||||
knownStateIndex = max(knownStateIndex, newCount - 1)
|
||||
|
||||
if let i = doc.logs.firstIndex(where: { $0.id == logID }) {
|
||||
doc.logs[i].sets = newCount
|
||||
@@ -505,8 +549,8 @@ struct ExerciseProgressView: View {
|
||||
/// swiping back — or a transient TabView snap to page 0 — never un-counts a set.
|
||||
private func recordProgress(for pageIndex: Int) {
|
||||
if showsReady && pageIndex == 0 { return } // Ready page records nothing
|
||||
let cycleIndex = pageIndex - base
|
||||
let reached = min(max(0, (cycleIndex + 1) / 2), setCount - 1)
|
||||
let reached = completedSets(for: pageIndex)
|
||||
knownStateIndex = max(knownStateIndex, reached)
|
||||
|
||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||
guard reached > doc.logs[i].currentStateIndex else { return }
|
||||
@@ -527,6 +571,7 @@ struct ExerciseProgressView: View {
|
||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||
let log = doc.logs[i]
|
||||
// Skip the write if it's already pristine (e.g. landing on Ready before any set).
|
||||
knownStateIndex = 0
|
||||
guard log.currentStateIndex != 0
|
||||
|| log.status != WorkoutStatus.notStarted.rawValue else { return }
|
||||
doc.logs[i].currentStateIndex = 0
|
||||
@@ -537,6 +582,7 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
|
||||
private func completeExercise() {
|
||||
knownStateIndex = max(knownStateIndex, setCount)
|
||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||
doc.logs[i].currentStateIndex = setCount
|
||||
doc.logs[i].transition(to: .completed)
|
||||
|
||||
Reference in New Issue
Block a user