diff --git a/Workouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/ExerciseProgressView.swift index 12986db..f382bd1 100644 --- a/Workouts Watch App/Views/ExerciseProgressView.swift +++ b/Workouts Watch App/Views/ExerciseProgressView.swift @@ -64,6 +64,14 @@ struct ExerciseProgressView: View { /// Highest remote frame version we've applied, so a redelivery doesn't re-jump. @State private var lastAppliedVersion = 0 + /// 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? + private enum PageChangeCause { case auto, remote } /// True when this run opened on a resumed set (in-progress) rather than the Ready @@ -213,11 +221,13 @@ struct ExerciseProgressView: View { case .auto: // Rest / timed-work auto-advance: record forward progress, but don't // broadcast — the mirror reaches this point on its own synchronized timer. + clearRemoteAnchor() 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() if showsReady && newPage == 0 && oldPage == base { resetExercise() } else { @@ -281,11 +291,24 @@ struct ExerciseProgressView: View { lastAppliedVersion = frame.version if frame.setCount != setCount { setCount = frame.setCount } let target = page(forPhase: frame.phase, setIndex: frame.setIndex) + // 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 guard target != currentPage else { return } 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 + } + /// Inverse of `liveSnapshot`'s page→frame mapping: a frame's phase/set → page index. private func page(forPhase phase: LiveRunPhase, setIndex: Int) -> Int { let set = min(max(0, setIndex), max(0, setCount - 1)) @@ -349,6 +372,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 if showsReady && index == 0 { ReadyPhaseView(summary: readySummary, onStart: start) } else { @@ -358,7 +385,8 @@ struct ExerciseProgressView: View { FinishPhaseView( isActive: isActive, onDone: { completeExercise(); dismiss() }, - onOneMore: addSet + onOneMore: addSet, + anchorEnd: anchorEnd ) } else if cycleIndex.isMultiple(of: 2) { let setNumber = cycleIndex / 2 + 1 @@ -369,7 +397,9 @@ struct ExerciseProgressView: View { header: "\(setNumber) of \(setCount)", tint: .workTimer, seconds: workDurationSeconds, - isActive: isActive + isActive: isActive, + anchorStart: anchorStart, + anchorEnd: anchorEnd ) { withAnimation { advance(from: index) } } @@ -379,7 +409,8 @@ struct ExerciseProgressView: View { setNumber: setNumber, totalSets: setCount, detail: detail, - isActive: isActive + isActive: isActive, + anchorStart: anchorStart ) } } else { @@ -388,7 +419,9 @@ struct ExerciseProgressView: View { header: "Rest", tint: .restTimer, seconds: restSeconds, - isActive: isActive + isActive: isActive, + anchorStart: anchorStart, + anchorEnd: anchorEnd ) { withAnimation { advance(from: index) } } @@ -683,6 +716,9 @@ private struct WorkPhaseView: View { let totalSets: Int let detail: String let isActive: Bool + /// When the page was reached by a remote frame, anchor the count-up to the sender's + /// wall clock instead of local `now`, so the mirror lines up regardless of delivery lag. + var anchorStart: Date? = nil /// Wall-clock anchor for the count-up stopwatch. Driving the display from a fixed /// start date (rendered by SwiftUI's timer text) instead of incrementing a counter @@ -694,13 +730,15 @@ private struct WorkPhaseView: View { PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer) { Text(startDate, style: .timer) } - .onAppear { if isActive { restart() } } - .onChange(of: isActive) { _, active in if active { restart() } } + .onAppear { if isActive { restart(haptic: true) } } + .onChange(of: isActive) { _, active in if active { restart(haptic: true) } } + // A later frame for this same page re-anchors the timer without re-buzzing. + .onChange(of: anchorStart) { _, _ in if isActive { restart(haptic: false) } } } - private func restart() { - startDate = Date() - WorkoutHaptic.start.play() + private func restart(haptic: Bool) { + startDate = anchorStart ?? Date() + if haptic { WorkoutHaptic.start.play() } } } @@ -720,6 +758,11 @@ private struct CountdownPhaseView: View { let tint: Color let seconds: Int let isActive: Bool + /// When the page was reached by a remote frame, anchor the countdown window to the + /// sender's wall clock (`anchorStart`…`anchorEnd`) instead of local `now`…`now+seconds`, + /// 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 @@ -736,17 +779,19 @@ private struct CountdownPhaseView: View { PhaseTimerLayout(header: header, footer: footer, tint: tint) { Text(timerInterval: startDate...endDate, countsDown: true) } - .onAppear { if isActive { start() } } - .onChange(of: isActive) { _, active in if active { start() } } + .onAppear { if isActive { start(haptic: true) } } + .onChange(of: isActive) { _, active in if active { start(haptic: true) } } + // A later frame for this same page re-anchors the window without re-buzzing. + .onChange(of: anchorEnd) { _, _ in if isActive { start(haptic: false) } } .onReceive(ticker) { _ in tick() } } - private func start() { - startDate = Date() - endDate = startDate.addingTimeInterval(Double(max(1, seconds))) + private func start(haptic: Bool) { + startDate = anchorStart ?? Date() + endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds))) lastPingSecond = Int.max didFinish = false - WorkoutHaptic.start.play() + if haptic { WorkoutHaptic.start.play() } } private func tick() { @@ -777,6 +822,9 @@ private struct FinishPhaseView: View { let isActive: Bool let onDone: () -> Void let onOneMore: () -> Void + /// When the page was reached by a remote frame, anchor the auto-Done deadline to the + /// sender's wall clock instead of local `now + countdown`, so both devices fire together. + var anchorEnd: Date? = nil @AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5 @@ -809,13 +857,13 @@ private struct FinishPhaseView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { if isActive { start() } } .onChange(of: isActive) { _, active in active ? start() : (didFire = true) } + .onChange(of: anchorEnd) { _, _ in if isActive { start() } } .onReceive(ticker) { _ in tick() } } private func start() { - let total = max(1, doneCountdownSeconds) - endDate = Date().addingTimeInterval(Double(total)) - remaining = total + endDate = anchorEnd ?? Date().addingTimeInterval(Double(max(1, doneCountdownSeconds))) + remaining = max(0, Int(ceil(endDate.timeIntervalSinceNow))) didFire = false } diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index a5c8210..c85e4d4 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -68,6 +68,14 @@ struct ExerciseProgressView: View { /// Highest remote frame version we've applied, so a redelivery doesn't re-jump. @State private var lastAppliedVersion = 0 + /// 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? + private enum PageChangeCause { case auto, remote } /// True when this run opened on a resumed set (in-progress) rather than the Ready @@ -209,11 +217,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() 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() if showsReady && newPage == 0 && oldPage == base { resetExercise() } else { @@ -286,11 +296,24 @@ struct ExerciseProgressView: View { lastAppliedVersion = frame.version if frame.setCount != setCount { setCount = frame.setCount } let target = page(forPhase: frame.phase, setIndex: frame.setIndex) + // 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 guard target != currentPage else { return } 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 + } + /// Inverse of `liveSnapshot`'s page→frame mapping: a frame's phase/set → page index. private func page(forPhase phase: LiveRunPhase, setIndex: Int) -> Int { let set = min(max(0, setIndex), max(0, setCount - 1)) @@ -344,6 +367,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 if showsReady && index == 0 { ReadyPhaseView(summary: readySummary, onStart: start) } else { @@ -353,7 +380,8 @@ struct ExerciseProgressView: View { FinishPhaseView( isActive: isActive, onDone: { completeExercise(); dismiss() }, - onOneMore: addSet + onOneMore: addSet, + anchorEnd: anchorEnd ) } else if cycleIndex.isMultiple(of: 2) { let setNumber = cycleIndex / 2 + 1 @@ -364,7 +392,9 @@ struct ExerciseProgressView: View { header: "\(setNumber) of \(setCount)", tint: .workTimer, seconds: workDurationSeconds, - isActive: isActive + isActive: isActive, + anchorStart: anchorStart, + anchorEnd: anchorEnd ) { withAnimation { advance(from: index) } } @@ -374,7 +404,8 @@ struct ExerciseProgressView: View { setNumber: setNumber, totalSets: setCount, detail: detail, - isActive: isActive + isActive: isActive, + anchorStart: anchorStart ) } } else { @@ -383,7 +414,9 @@ struct ExerciseProgressView: View { header: "Rest", tint: .restTimer, seconds: restSeconds, - isActive: isActive + isActive: isActive, + anchorStart: anchorStart, + anchorEnd: anchorEnd ) { withAnimation { advance(from: index) } } @@ -697,6 +730,9 @@ private struct WorkPhaseView: View { let totalSets: Int let detail: String let isActive: Bool + /// When the page was reached by a remote frame, anchor the count-up to the sender's + /// wall clock instead of local `now`, so the mirror lines up regardless of delivery lag. + var anchorStart: Date? = nil /// Wall-clock anchor for the count-up stopwatch. Driving the display from a fixed /// start date (rendered by SwiftUI's timer text) instead of incrementing a counter @@ -707,13 +743,15 @@ private struct WorkPhaseView: View { PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer) { Text(startDate, style: .timer) } - .onAppear { if isActive { restart() } } - .onChange(of: isActive) { _, active in if active { restart() } } + .onAppear { if isActive { restart(haptic: true) } } + .onChange(of: isActive) { _, active in if active { restart(haptic: true) } } + // A later frame for this same page re-anchors the timer without re-buzzing. + .onChange(of: anchorStart) { _, _ in if isActive { restart(haptic: false) } } } - private func restart() { - startDate = Date() - WorkoutHaptic.start.play() + private func restart(haptic: Bool) { + startDate = anchorStart ?? Date() + if haptic { WorkoutHaptic.start.play() } } } @@ -728,6 +766,11 @@ private struct CountdownPhaseView: View { let tint: Color let seconds: Int let isActive: Bool + /// When the page was reached by a remote frame, anchor the countdown window to the + /// sender's wall clock (`anchorStart`…`anchorEnd`) instead of local `now`…`now+seconds`, + /// 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 @@ -747,17 +790,19 @@ private struct CountdownPhaseView: View { PhaseTimerLayout(header: header, footer: footer, tint: tint) { Text(timerInterval: startDate...endDate, countsDown: true) } - .onAppear { if isActive { start() } } - .onChange(of: isActive) { _, active in if active { start() } } + .onAppear { if isActive { start(haptic: true) } } + .onChange(of: isActive) { _, active in if active { start(haptic: true) } } + // A later frame for this same page re-anchors the window without re-buzzing. + .onChange(of: anchorEnd) { _, _ in if isActive { start(haptic: false) } } .onReceive(ticker) { _ in tick() } } - private func start() { - startDate = Date() - endDate = startDate.addingTimeInterval(Double(max(1, seconds))) + private func start(haptic: Bool) { + startDate = anchorStart ?? Date() + endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds))) lastPingSecond = Int.max didFinish = false - WorkoutHaptic.start.play() + if haptic { WorkoutHaptic.start.play() } } private func tick() { @@ -786,6 +831,9 @@ private struct FinishPhaseView: View { let isActive: Bool let onDone: () -> Void let onOneMore: () -> Void + /// When the page was reached by a remote frame, anchor the auto-Done deadline to the + /// sender's wall clock instead of local `now + countdown`, so both devices fire together. + var anchorEnd: Date? = nil @AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5 @@ -817,13 +865,13 @@ private struct FinishPhaseView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { if isActive { start() } } .onChange(of: isActive) { _, active in active ? start() : (didFire = true) } + .onChange(of: anchorEnd) { _, _ in if isActive { start() } } .onReceive(ticker) { _ in tick() } } private func start() { - let total = max(1, doneCountdownSeconds) - endDate = Date().addingTimeInterval(Double(total)) - remaining = total + endDate = anchorEnd ?? Date().addingTimeInterval(Double(max(1, doneCountdownSeconds))) + remaining = max(0, Int(ceil(endDate.timeIntervalSinceNow))) didFire = false }