Anchor mirrored live-run timers to the sender's wall clock
The two-way live run unified the propped-phone mirror onto the real ExerciseProgressView driver, whose phase views anchor their timers to a local Date() captured when the page becomes active. For a remote-driven transition that meant anchoring at phaseStart + delivery-latency + render-time, so the receiver's countdown lagged the driver — visibly so iPhone->watch, where the watch is the slower receiver (apply -> SwiftUI re-render -> TabView animation -> phase view activates). The frame already carries phaseStart/phaseEnd wall-clock anchors (the way the old read-only mirror counted off them). Honor them: when a phase is reached by applying a remote frame, the active phase view anchors its timer to the frame's phaseStart/phaseEnd instead of local now. Scoped to the remote-driven page (remoteAnchorPage) so a local swipe or auto-advance still self-anchors; any local transition clears it. Applied symmetrically to both the iPhone and watch drivers. Delivery latency no longer shifts the displayed timer in either direction (limited only by sub-second inter-device clock skew), and the auto-advance at zero fires together since both share the same phaseEnd. Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
This commit is contained in:
@@ -64,6 +64,14 @@ struct ExerciseProgressView: View {
|
|||||||
/// Highest remote frame version we've applied, so a redelivery doesn't re-jump.
|
/// Highest remote frame version we've applied, so a redelivery doesn't re-jump.
|
||||||
@State private var lastAppliedVersion = 0
|
@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 }
|
private enum PageChangeCause { case auto, remote }
|
||||||
|
|
||||||
/// True when this run opened on a resumed set (in-progress) rather than the Ready
|
/// True when this run opened on a resumed set (in-progress) rather than the Ready
|
||||||
@@ -213,11 +221,13 @@ struct ExerciseProgressView: View {
|
|||||||
case .auto:
|
case .auto:
|
||||||
// Rest / timed-work auto-advance: record forward progress, but don't
|
// Rest / timed-work auto-advance: record forward progress, but don't
|
||||||
// broadcast — the mirror reaches this point on its own synchronized timer.
|
// broadcast — the mirror reaches this point on its own synchronized timer.
|
||||||
|
clearRemoteAnchor()
|
||||||
recordProgress(for: newPage)
|
recordProgress(for: newPage)
|
||||||
case .none:
|
case .none:
|
||||||
// A human swipe. Swiping back from the first set to Ready wipes the run
|
// 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
|
// (only the adjacent 1→0 swipe resets — a stray far jump never does); any
|
||||||
// other page records forward progress. Human transitions are broadcast.
|
// other page records forward progress. Human transitions are broadcast.
|
||||||
|
clearRemoteAnchor()
|
||||||
if showsReady && newPage == 0 && oldPage == base {
|
if showsReady && newPage == 0 && oldPage == base {
|
||||||
resetExercise()
|
resetExercise()
|
||||||
} else {
|
} else {
|
||||||
@@ -281,11 +291,24 @@ struct ExerciseProgressView: View {
|
|||||||
lastAppliedVersion = frame.version
|
lastAppliedVersion = frame.version
|
||||||
if frame.setCount != setCount { setCount = frame.setCount }
|
if frame.setCount != setCount { setCount = frame.setCount }
|
||||||
let target = page(forPhase: frame.phase, setIndex: frame.setIndex)
|
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 }
|
guard target != currentPage else { return }
|
||||||
pageChangeCause = .remote
|
pageChangeCause = .remote
|
||||||
withAnimation { currentPage = target }
|
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.
|
/// Inverse of `liveSnapshot`'s page→frame mapping: a frame's phase/set → page index.
|
||||||
private func page(forPhase phase: LiveRunPhase, setIndex: Int) -> Int {
|
private func page(forPhase phase: LiveRunPhase, setIndex: Int) -> Int {
|
||||||
let set = min(max(0, setIndex), max(0, setCount - 1))
|
let set = min(max(0, setIndex), max(0, setCount - 1))
|
||||||
@@ -349,6 +372,10 @@ struct ExerciseProgressView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func page(for index: Int) -> some View {
|
private func page(for index: Int) -> some View {
|
||||||
let isActive = index == currentPage
|
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 {
|
if showsReady && index == 0 {
|
||||||
ReadyPhaseView(summary: readySummary, onStart: start)
|
ReadyPhaseView(summary: readySummary, onStart: start)
|
||||||
} else {
|
} else {
|
||||||
@@ -358,7 +385,8 @@ struct ExerciseProgressView: View {
|
|||||||
FinishPhaseView(
|
FinishPhaseView(
|
||||||
isActive: isActive,
|
isActive: isActive,
|
||||||
onDone: { completeExercise(); dismiss() },
|
onDone: { completeExercise(); dismiss() },
|
||||||
onOneMore: addSet
|
onOneMore: addSet,
|
||||||
|
anchorEnd: anchorEnd
|
||||||
)
|
)
|
||||||
} else if cycleIndex.isMultiple(of: 2) {
|
} else if cycleIndex.isMultiple(of: 2) {
|
||||||
let setNumber = cycleIndex / 2 + 1
|
let setNumber = cycleIndex / 2 + 1
|
||||||
@@ -369,7 +397,9 @@ struct ExerciseProgressView: View {
|
|||||||
header: "\(setNumber) of \(setCount)",
|
header: "\(setNumber) of \(setCount)",
|
||||||
tint: .workTimer,
|
tint: .workTimer,
|
||||||
seconds: workDurationSeconds,
|
seconds: workDurationSeconds,
|
||||||
isActive: isActive
|
isActive: isActive,
|
||||||
|
anchorStart: anchorStart,
|
||||||
|
anchorEnd: anchorEnd
|
||||||
) {
|
) {
|
||||||
withAnimation { advance(from: index) }
|
withAnimation { advance(from: index) }
|
||||||
}
|
}
|
||||||
@@ -379,7 +409,8 @@ struct ExerciseProgressView: View {
|
|||||||
setNumber: setNumber,
|
setNumber: setNumber,
|
||||||
totalSets: setCount,
|
totalSets: setCount,
|
||||||
detail: detail,
|
detail: detail,
|
||||||
isActive: isActive
|
isActive: isActive,
|
||||||
|
anchorStart: anchorStart
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -388,7 +419,9 @@ struct ExerciseProgressView: View {
|
|||||||
header: "Rest",
|
header: "Rest",
|
||||||
tint: .restTimer,
|
tint: .restTimer,
|
||||||
seconds: restSeconds,
|
seconds: restSeconds,
|
||||||
isActive: isActive
|
isActive: isActive,
|
||||||
|
anchorStart: anchorStart,
|
||||||
|
anchorEnd: anchorEnd
|
||||||
) {
|
) {
|
||||||
withAnimation { advance(from: index) }
|
withAnimation { advance(from: index) }
|
||||||
}
|
}
|
||||||
@@ -683,6 +716,9 @@ private struct WorkPhaseView: View {
|
|||||||
let totalSets: Int
|
let totalSets: Int
|
||||||
let detail: String
|
let detail: String
|
||||||
let isActive: Bool
|
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
|
/// 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
|
/// 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) {
|
PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer) {
|
||||||
Text(startDate, style: .timer)
|
Text(startDate, style: .timer)
|
||||||
}
|
}
|
||||||
.onAppear { if isActive { restart() } }
|
.onAppear { if isActive { restart(haptic: true) } }
|
||||||
.onChange(of: isActive) { _, active in if active { restart() } }
|
.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() {
|
private func restart(haptic: Bool) {
|
||||||
startDate = Date()
|
startDate = anchorStart ?? Date()
|
||||||
WorkoutHaptic.start.play()
|
if haptic { WorkoutHaptic.start.play() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,6 +758,11 @@ private struct CountdownPhaseView: View {
|
|||||||
let tint: Color
|
let tint: Color
|
||||||
let seconds: Int
|
let seconds: Int
|
||||||
let isActive: Bool
|
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).
|
/// Invoked once the countdown reaches zero (auto-advance to the next page).
|
||||||
let onFinished: () -> Void
|
let onFinished: () -> Void
|
||||||
|
|
||||||
@@ -736,17 +779,19 @@ private struct CountdownPhaseView: View {
|
|||||||
PhaseTimerLayout(header: header, footer: footer, tint: tint) {
|
PhaseTimerLayout(header: header, footer: footer, tint: tint) {
|
||||||
Text(timerInterval: startDate...endDate, countsDown: true)
|
Text(timerInterval: startDate...endDate, countsDown: true)
|
||||||
}
|
}
|
||||||
.onAppear { if isActive { start() } }
|
.onAppear { if isActive { start(haptic: true) } }
|
||||||
.onChange(of: isActive) { _, active in if active { start() } }
|
.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() }
|
.onReceive(ticker) { _ in tick() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func start() {
|
private func start(haptic: Bool) {
|
||||||
startDate = Date()
|
startDate = anchorStart ?? Date()
|
||||||
endDate = startDate.addingTimeInterval(Double(max(1, seconds)))
|
endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds)))
|
||||||
lastPingSecond = Int.max
|
lastPingSecond = Int.max
|
||||||
didFinish = false
|
didFinish = false
|
||||||
WorkoutHaptic.start.play()
|
if haptic { WorkoutHaptic.start.play() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tick() {
|
private func tick() {
|
||||||
@@ -777,6 +822,9 @@ private struct FinishPhaseView: View {
|
|||||||
let isActive: Bool
|
let isActive: Bool
|
||||||
let onDone: () -> Void
|
let onDone: () -> Void
|
||||||
let onOneMore: () -> 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
|
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||||
|
|
||||||
@@ -809,13 +857,13 @@ private struct FinishPhaseView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.onAppear { if isActive { start() } }
|
.onAppear { if isActive { start() } }
|
||||||
.onChange(of: isActive) { _, active in active ? start() : (didFire = true) }
|
.onChange(of: isActive) { _, active in active ? start() : (didFire = true) }
|
||||||
|
.onChange(of: anchorEnd) { _, _ in if isActive { start() } }
|
||||||
.onReceive(ticker) { _ in tick() }
|
.onReceive(ticker) { _ in tick() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func start() {
|
private func start() {
|
||||||
let total = max(1, doneCountdownSeconds)
|
endDate = anchorEnd ?? Date().addingTimeInterval(Double(max(1, doneCountdownSeconds)))
|
||||||
endDate = Date().addingTimeInterval(Double(total))
|
remaining = max(0, Int(ceil(endDate.timeIntervalSinceNow)))
|
||||||
remaining = total
|
|
||||||
didFire = false
|
didFire = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ struct ExerciseProgressView: View {
|
|||||||
/// Highest remote frame version we've applied, so a redelivery doesn't re-jump.
|
/// Highest remote frame version we've applied, so a redelivery doesn't re-jump.
|
||||||
@State private var lastAppliedVersion = 0
|
@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 }
|
private enum PageChangeCause { case auto, remote }
|
||||||
|
|
||||||
/// True when this run opened on a resumed set (in-progress) rather than the Ready
|
/// True when this run opened on a resumed set (in-progress) rather than the Ready
|
||||||
@@ -209,11 +217,13 @@ struct ExerciseProgressView: View {
|
|||||||
case .auto:
|
case .auto:
|
||||||
// Rest / timed-work auto-advance: record forward progress, but don't
|
// Rest / timed-work auto-advance: record forward progress, but don't
|
||||||
// broadcast — the watch reaches this point on its own synchronized timer.
|
// broadcast — the watch reaches this point on its own synchronized timer.
|
||||||
|
clearRemoteAnchor()
|
||||||
recordProgress(for: newPage)
|
recordProgress(for: newPage)
|
||||||
case .none:
|
case .none:
|
||||||
// A human swipe. Swiping back from the first set to Ready wipes the run
|
// 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
|
// (only the adjacent 1→0 swipe resets — a stray far jump never does); any
|
||||||
// other page records forward progress. Human transitions are broadcast.
|
// other page records forward progress. Human transitions are broadcast.
|
||||||
|
clearRemoteAnchor()
|
||||||
if showsReady && newPage == 0 && oldPage == base {
|
if showsReady && newPage == 0 && oldPage == base {
|
||||||
resetExercise()
|
resetExercise()
|
||||||
} else {
|
} else {
|
||||||
@@ -286,11 +296,24 @@ struct ExerciseProgressView: View {
|
|||||||
lastAppliedVersion = frame.version
|
lastAppliedVersion = frame.version
|
||||||
if frame.setCount != setCount { setCount = frame.setCount }
|
if frame.setCount != setCount { setCount = frame.setCount }
|
||||||
let target = page(forPhase: frame.phase, setIndex: frame.setIndex)
|
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 }
|
guard target != currentPage else { return }
|
||||||
pageChangeCause = .remote
|
pageChangeCause = .remote
|
||||||
withAnimation { currentPage = target }
|
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.
|
/// Inverse of `liveSnapshot`'s page→frame mapping: a frame's phase/set → page index.
|
||||||
private func page(forPhase phase: LiveRunPhase, setIndex: Int) -> Int {
|
private func page(forPhase phase: LiveRunPhase, setIndex: Int) -> Int {
|
||||||
let set = min(max(0, setIndex), max(0, setCount - 1))
|
let set = min(max(0, setIndex), max(0, setCount - 1))
|
||||||
@@ -344,6 +367,10 @@ struct ExerciseProgressView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func page(for index: Int) -> some View {
|
private func page(for index: Int) -> some View {
|
||||||
let isActive = index == currentPage
|
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 {
|
if showsReady && index == 0 {
|
||||||
ReadyPhaseView(summary: readySummary, onStart: start)
|
ReadyPhaseView(summary: readySummary, onStart: start)
|
||||||
} else {
|
} else {
|
||||||
@@ -353,7 +380,8 @@ struct ExerciseProgressView: View {
|
|||||||
FinishPhaseView(
|
FinishPhaseView(
|
||||||
isActive: isActive,
|
isActive: isActive,
|
||||||
onDone: { completeExercise(); dismiss() },
|
onDone: { completeExercise(); dismiss() },
|
||||||
onOneMore: addSet
|
onOneMore: addSet,
|
||||||
|
anchorEnd: anchorEnd
|
||||||
)
|
)
|
||||||
} else if cycleIndex.isMultiple(of: 2) {
|
} else if cycleIndex.isMultiple(of: 2) {
|
||||||
let setNumber = cycleIndex / 2 + 1
|
let setNumber = cycleIndex / 2 + 1
|
||||||
@@ -364,7 +392,9 @@ struct ExerciseProgressView: View {
|
|||||||
header: "\(setNumber) of \(setCount)",
|
header: "\(setNumber) of \(setCount)",
|
||||||
tint: .workTimer,
|
tint: .workTimer,
|
||||||
seconds: workDurationSeconds,
|
seconds: workDurationSeconds,
|
||||||
isActive: isActive
|
isActive: isActive,
|
||||||
|
anchorStart: anchorStart,
|
||||||
|
anchorEnd: anchorEnd
|
||||||
) {
|
) {
|
||||||
withAnimation { advance(from: index) }
|
withAnimation { advance(from: index) }
|
||||||
}
|
}
|
||||||
@@ -374,7 +404,8 @@ struct ExerciseProgressView: View {
|
|||||||
setNumber: setNumber,
|
setNumber: setNumber,
|
||||||
totalSets: setCount,
|
totalSets: setCount,
|
||||||
detail: detail,
|
detail: detail,
|
||||||
isActive: isActive
|
isActive: isActive,
|
||||||
|
anchorStart: anchorStart
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -383,7 +414,9 @@ struct ExerciseProgressView: View {
|
|||||||
header: "Rest",
|
header: "Rest",
|
||||||
tint: .restTimer,
|
tint: .restTimer,
|
||||||
seconds: restSeconds,
|
seconds: restSeconds,
|
||||||
isActive: isActive
|
isActive: isActive,
|
||||||
|
anchorStart: anchorStart,
|
||||||
|
anchorEnd: anchorEnd
|
||||||
) {
|
) {
|
||||||
withAnimation { advance(from: index) }
|
withAnimation { advance(from: index) }
|
||||||
}
|
}
|
||||||
@@ -697,6 +730,9 @@ private struct WorkPhaseView: View {
|
|||||||
let totalSets: Int
|
let totalSets: Int
|
||||||
let detail: String
|
let detail: String
|
||||||
let isActive: Bool
|
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
|
/// 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
|
/// 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) {
|
PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer) {
|
||||||
Text(startDate, style: .timer)
|
Text(startDate, style: .timer)
|
||||||
}
|
}
|
||||||
.onAppear { if isActive { restart() } }
|
.onAppear { if isActive { restart(haptic: true) } }
|
||||||
.onChange(of: isActive) { _, active in if active { restart() } }
|
.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() {
|
private func restart(haptic: Bool) {
|
||||||
startDate = Date()
|
startDate = anchorStart ?? Date()
|
||||||
WorkoutHaptic.start.play()
|
if haptic { WorkoutHaptic.start.play() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,6 +766,11 @@ private struct CountdownPhaseView: View {
|
|||||||
let tint: Color
|
let tint: Color
|
||||||
let seconds: Int
|
let seconds: Int
|
||||||
let isActive: Bool
|
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).
|
/// Invoked once the countdown reaches zero (auto-advance to the next page).
|
||||||
let onFinished: () -> Void
|
let onFinished: () -> Void
|
||||||
|
|
||||||
@@ -747,17 +790,19 @@ private struct CountdownPhaseView: View {
|
|||||||
PhaseTimerLayout(header: header, footer: footer, tint: tint) {
|
PhaseTimerLayout(header: header, footer: footer, tint: tint) {
|
||||||
Text(timerInterval: startDate...endDate, countsDown: true)
|
Text(timerInterval: startDate...endDate, countsDown: true)
|
||||||
}
|
}
|
||||||
.onAppear { if isActive { start() } }
|
.onAppear { if isActive { start(haptic: true) } }
|
||||||
.onChange(of: isActive) { _, active in if active { start() } }
|
.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() }
|
.onReceive(ticker) { _ in tick() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func start() {
|
private func start(haptic: Bool) {
|
||||||
startDate = Date()
|
startDate = anchorStart ?? Date()
|
||||||
endDate = startDate.addingTimeInterval(Double(max(1, seconds)))
|
endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds)))
|
||||||
lastPingSecond = Int.max
|
lastPingSecond = Int.max
|
||||||
didFinish = false
|
didFinish = false
|
||||||
WorkoutHaptic.start.play()
|
if haptic { WorkoutHaptic.start.play() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tick() {
|
private func tick() {
|
||||||
@@ -786,6 +831,9 @@ private struct FinishPhaseView: View {
|
|||||||
let isActive: Bool
|
let isActive: Bool
|
||||||
let onDone: () -> Void
|
let onDone: () -> Void
|
||||||
let onOneMore: () -> 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
|
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||||
|
|
||||||
@@ -817,13 +865,13 @@ private struct FinishPhaseView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.onAppear { if isActive { start() } }
|
.onAppear { if isActive { start() } }
|
||||||
.onChange(of: isActive) { _, active in active ? start() : (didFire = true) }
|
.onChange(of: isActive) { _, active in active ? start() : (didFire = true) }
|
||||||
|
.onChange(of: anchorEnd) { _, _ in if isActive { start() } }
|
||||||
.onReceive(ticker) { _ in tick() }
|
.onReceive(ticker) { _ in tick() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func start() {
|
private func start() {
|
||||||
let total = max(1, doneCountdownSeconds)
|
endDate = anchorEnd ?? Date().addingTimeInterval(Double(max(1, doneCountdownSeconds)))
|
||||||
endDate = Date().addingTimeInterval(Double(total))
|
remaining = max(0, Int(ceil(endDate.timeIntervalSinceNow)))
|
||||||
remaining = total
|
|
||||||
didFire = false
|
didFire = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user