Make the live run two-way: drive from either device
The propped-up iPhone now runs the real ExerciseProgressView for a live watch workout instead of a read-only mirror, and the live-run channel is symmetric — either device can drive the flow and the other follows. Each page transition is classified human / auto / remote: only human transitions (swipe, Start, One More, swipe-back reset) are broadcast and recorded by the actor; auto-advances (rest / timed-work countdown) record locally but aren't sent, since both devices reach them independently off the shared wall-clock anchors; an applied remote frame jumps the page without re-recording or re-broadcasting. That rule is also what stops an echo loop. - PhoneConnectivityBridge gains sendLiveProgress/sendLiveEnded (the missing phone->watch direction); WatchConnectivityBridge receives frames into an observable liveIncoming via a new didReceiveMessage route. Both share one increasing per-run version sequence so the stale-frame guard works across the two devices' counters. - Both ExerciseProgressViews gain an incomingFrame input + applyIncoming (syncing setCount for a remote One More); the iPhone one gains the liveSnapshot/broadcast machinery the watch already had. - New LiveRunCoverView wraps the real driver for the cover (resolves the workout, persists via SyncEngine, wires the live channel + close); ContentView presents it; LiveProgressMirrorView is removed. Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
This commit is contained in:
@@ -36,14 +36,40 @@ struct ExerciseProgressView: View {
|
||||
let logID: String
|
||||
let onChange: () -> Void
|
||||
|
||||
/// Broadcasts the current flow position so the watch (if it has this run open) can follow
|
||||
/// it live (ephemeral; see `LiveProgress`). `onLiveEnded` fires when we leave the flow.
|
||||
/// Only *human* transitions are broadcast — an auto-advance (rest/timed-work end) isn't,
|
||||
/// since the other device reaches it independently off the same wall-clock anchors. Default
|
||||
/// no-ops, so the in-list driver stays purely durable; the propped-phone cover wires these.
|
||||
let onLive: (LiveProgress) -> Void
|
||||
let onLiveEnded: () -> Void
|
||||
|
||||
/// The latest live-run frame the *watch* sent for this run, to follow when it drives a
|
||||
/// transition (ephemeral; nil when the watch isn't driving). Applying it jumps our page
|
||||
/// without re-broadcasting or re-recording — the originating device owns the durable write.
|
||||
var incomingFrame: LiveProgress?
|
||||
|
||||
/// Rest length between sets, shared with the watch via the same defaults key.
|
||||
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
||||
|
||||
/// Auto-Done countdown on the Finish page — read so a broadcast frame carries the same
|
||||
/// end anchor the watch's mirror counts off.
|
||||
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||
|
||||
/// Planned set count for this run. `One More` bumps it (and the log's `sets`).
|
||||
@State private var setCount: Int
|
||||
@State private var currentPage: Int
|
||||
@State private var didRestorePage = false
|
||||
|
||||
/// Why `currentPage` last changed, so the page observer knows whether to broadcast it.
|
||||
/// 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.
|
||||
@State private var lastAppliedVersion = 0
|
||||
|
||||
private enum PageChangeCause { case auto, remote }
|
||||
|
||||
/// True when this run opened on a resumed set (in-progress) rather than the Ready
|
||||
/// page. Held in `@State` so it stays fixed for the life of the screen — the parent
|
||||
/// list re-inits this view whenever the workout file changes, and this must not flip
|
||||
@@ -51,10 +77,13 @@ struct ExerciseProgressView: View {
|
||||
/// the transient TabView snap-to-0, so it isn't reset on open.
|
||||
@State private var startsResumed: Bool
|
||||
|
||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void) {
|
||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) {
|
||||
self._doc = doc
|
||||
self.logID = logID
|
||||
self.onChange = onChange
|
||||
self.onLive = onLive
|
||||
self.onLiveEnded = onLiveEnded
|
||||
self.incomingFrame = incomingFrame
|
||||
|
||||
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
||||
let sets = max(1, log?.sets ?? 1)
|
||||
@@ -171,15 +200,31 @@ struct ExerciseProgressView: View {
|
||||
// Ignore page changes until the initial resume settles, so the TabView's
|
||||
// transient snap-to-0 on first layout can't reset an in-progress run.
|
||||
guard didRestorePage else { return }
|
||||
// A deliberate swipe back from the first set to the Ready page wipes the run
|
||||
// (only the adjacent 1→0 swipe resets — a stray far jump never does); any
|
||||
// other page records forward progress.
|
||||
if showsReady && newPage == 0 && oldPage == base {
|
||||
resetExercise()
|
||||
} else {
|
||||
let cause = pageChangeCause
|
||||
pageChangeCause = nil
|
||||
switch cause {
|
||||
case .remote:
|
||||
// The watch already recorded and owns this transition — just follow it.
|
||||
break
|
||||
case .auto:
|
||||
// Rest / timed-work auto-advance: record forward progress, but don't
|
||||
// broadcast — the watch reaches this point on its own synchronized timer.
|
||||
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.
|
||||
if showsReady && newPage == 0 && oldPage == base {
|
||||
resetExercise()
|
||||
} else {
|
||||
recordProgress(for: newPage)
|
||||
}
|
||||
broadcastLive(for: newPage)
|
||||
}
|
||||
}
|
||||
.onChange(of: incomingFrame) { _, frame in
|
||||
if let frame { applyIncoming(frame) }
|
||||
}
|
||||
.onAppear {
|
||||
guard !didRestorePage else { return }
|
||||
if startsResumed {
|
||||
@@ -190,13 +235,17 @@ struct ExerciseProgressView: View {
|
||||
jumpToResumePage()
|
||||
Task { @MainActor in
|
||||
jumpToResumePage()
|
||||
didRestorePage = true
|
||||
finishRestore()
|
||||
}
|
||||
} else {
|
||||
// Not-started opens on the Ready page.
|
||||
didRestorePage = true
|
||||
finishRestore()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
// Leaving the flow (back / done) — stop the watch from following.
|
||||
onLiveEnded()
|
||||
}
|
||||
}
|
||||
|
||||
/// Move to the resume page without animation, only if we're not already there
|
||||
@@ -209,6 +258,89 @@ struct ExerciseProgressView: View {
|
||||
withTransaction(transaction) { currentPage = target }
|
||||
}
|
||||
|
||||
// MARK: - Live mirror
|
||||
|
||||
/// Finish the initial-page restore, then either follow an in-progress remote driver or, if
|
||||
/// we're the one starting the run, announce our position to the watch.
|
||||
private func finishRestore() {
|
||||
didRestorePage = true
|
||||
if let frame = incomingFrame, frame.logID == logID {
|
||||
applyIncoming(frame)
|
||||
} else {
|
||||
broadcastLive(for: currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the current flow position to the watch. The anchor is stamped *now* — the page
|
||||
/// just became active — so the watch's mirror timer lines up with this device's.
|
||||
private func broadcastLive(for page: Int) {
|
||||
guard let snapshot = liveSnapshot(for: page) else { return }
|
||||
onLive(snapshot)
|
||||
}
|
||||
|
||||
/// Follow a transition the watch made: jump to the frame's page (matching its set count
|
||||
/// for a remote One More) without re-broadcasting or re-recording it — the watch 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 }
|
||||
lastAppliedVersion = frame.version
|
||||
if frame.setCount != setCount { setCount = frame.setCount }
|
||||
let target = page(forPhase: frame.phase, setIndex: frame.setIndex)
|
||||
guard target != currentPage else { return }
|
||||
pageChangeCause = .remote
|
||||
withAnimation { currentPage = target }
|
||||
}
|
||||
|
||||
/// 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))
|
||||
switch phase {
|
||||
case .ready: return showsReady ? 0 : base
|
||||
case .work: return base + set * 2
|
||||
case .rest: return base + set * 2 + 1
|
||||
case .finish: return base + cycleCount
|
||||
}
|
||||
}
|
||||
|
||||
/// 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? {
|
||||
guard let log else { return nil }
|
||||
let now = Date()
|
||||
|
||||
func frame(_ phase: LiveRunPhase, setIndex: Int, end: Date?) -> LiveProgress {
|
||||
LiveProgress(
|
||||
workoutID: doc.id,
|
||||
logID: logID,
|
||||
exerciseName: log.exerciseName,
|
||||
phase: phase,
|
||||
setIndex: setIndex,
|
||||
setCount: setCount,
|
||||
detail: detail,
|
||||
phaseStart: now,
|
||||
phaseEnd: end,
|
||||
version: 0
|
||||
)
|
||||
}
|
||||
|
||||
if showsReady && page == 0 {
|
||||
return frame(.ready, setIndex: 0, end: nil)
|
||||
}
|
||||
let cycleIndex = page - base
|
||||
if cycleIndex == cycleCount {
|
||||
return frame(.finish, setIndex: max(0, setCount - 1),
|
||||
end: now.addingTimeInterval(Double(max(1, doneCountdownSeconds))))
|
||||
} else if cycleIndex.isMultiple(of: 2) {
|
||||
let set = cycleIndex / 2
|
||||
let end = isDuration ? now.addingTimeInterval(Double(workDurationSeconds)) : nil
|
||||
return frame(.work, setIndex: set, end: end)
|
||||
} else {
|
||||
let set = (cycleIndex - 1) / 2 // the rest follows this set
|
||||
return frame(.rest, setIndex: set, end: now.addingTimeInterval(Double(max(1, restSeconds))))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func page(for index: Int) -> some View {
|
||||
let isActive = index == currentPage
|
||||
@@ -268,9 +400,11 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
|
||||
/// Programmatically move one page right (used by the rest auto-advance), guarding
|
||||
/// against overrun if the user swiped away in the meantime.
|
||||
/// 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) {
|
||||
guard currentPage == index, index + 1 < totalPages else { return }
|
||||
pageChangeCause = .auto
|
||||
currentPage = index + 1
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user