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:
2026-06-20 22:11:05 -04:00
parent b911818587
commit 8f69497b24
10 changed files with 407 additions and 276 deletions
@@ -23,10 +23,16 @@ final class WatchConnectivityBridge: NSObject {
private(set) var editingWorkoutID: String?
private(set) var editingSplitID: String?
/// Monotonic sequence stamped on each live-run frame, so the phone mirror can drop a
/// stale / out-of-order delivery.
/// Monotonic sequence stamped on each live-run frame we send. Bumped to stay ahead of any
/// frame we *receive*, so the two devices share one increasing per-run sequence and either
/// side can drop a stale / out-of-order delivery (see `LiveProgress.version`).
private var liveVersion = 0
/// The latest live-run frame the *phone* sent, for the run we currently have open to apply
/// (ephemeral; nil when the phone isn't driving). The watch's `ExerciseProgressView` reads
/// this to follow a phone-driven transition; it's never persisted.
private(set) var liveIncoming: LiveProgress?
private var context: ModelContext { container.mainContext }
init(container: ModelContainer) {
@@ -82,6 +88,19 @@ final class WatchConnectivityBridge: NSObject {
replyHandler: nil, errorHandler: { _ in })
}
/// Apply a live-run frame the phone sent. Drops a stale one for the same run, and catches
/// our send counter up so the next frame we send outranks it (shared per-run sequence).
private func applyIncomingLive(_ frame: LiveProgress) {
liveVersion = max(liveVersion, frame.version)
if let current = liveIncoming, current.logID == frame.logID, frame.version < current.version { return }
liveIncoming = frame
}
/// The phone left the run stop following it.
private func endIncomingLive(logID: String) {
if liveIncoming?.logID == logID { liveIncoming = nil }
}
// MARK: - Internal
private func sendToPhone(_ doc: WorkoutDocument) {
@@ -133,6 +152,23 @@ extension WatchConnectivityBridge: WCSessionDelegate {
Task { @MainActor in self.requestSync() }
}
/// Live-run frames arrive as messages (reachable-only), distinct from the latest-wins
/// application context that carries durable state.
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
switch message[WCPayload.typeKey] as? String {
case WCPayload.liveProgressType:
if let frame = WCPayload.decodeLiveProgress(message) {
Task { @MainActor in self.applyIncomingLive(frame) }
}
case WCPayload.liveEndedType:
if let logID = message[WCPayload.lpLogIDKey] as? String {
Task { @MainActor in self.endIncomingLive(logID: logID) }
}
default:
break
}
}
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
let splits = WCPayload.decodeSplits(applicationContext)
let workouts = WCPayload.decodeWorkouts(applicationContext)
@@ -34,10 +34,17 @@ struct ExerciseProgressView: View {
let onChange: () -> Void
/// Broadcasts the current flow position so a propped-up iPhone can mirror the run live
/// (ephemeral; see `LiveProgress`). `onLiveEnded` fires when we leave the flow.
/// (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 mirror reaches it independently off the same wall-clock anchors.
let onLive: (LiveProgress) -> Void
let onLiveEnded: () -> Void
/// The latest live-run frame the *phone* sent for this run, to follow when it drives a
/// transition (ephemeral; nil when the phone 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 phone via the same defaults key.
@AppStorage("restSeconds") private var restSeconds: Int = 45
@@ -50,6 +57,15 @@ struct ExerciseProgressView: View {
@State private var showingCancelConfirm = false
@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. Such a run re-asserts its resume page after the first layout and ignores the
/// transient TabView snap-to-0, so it isn't reset on open.
@@ -59,12 +75,13 @@ struct ExerciseProgressView: View {
/// also suppresses the Ready page so the index is a plain work/rest cycle offset.
private let debugInitialPage: Int?
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, debugInitialPage: Int? = nil) {
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, debugInitialPage: Int? = nil) {
self._doc = doc
self.logID = logID
self.onChange = onChange
self.onLive = onLive
self.onLiveEnded = onLiveEnded
self.incomingFrame = incomingFrame
self.debugInitialPage = debugInitialPage
let log = doc.wrappedValue.logs.first { $0.id == logID }
@@ -187,15 +204,30 @@ 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 10 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 phone 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 mirror 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 10 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)
}
broadcastLive(for: newPage)
}
.onChange(of: incomingFrame) { _, frame in
if let frame { applyIncoming(frame) }
}
.onAppear {
guard !didRestorePage else { return }
@@ -207,13 +239,11 @@ struct ExerciseProgressView: View {
jumpToResumePage()
Task { @MainActor in
jumpToResumePage()
didRestorePage = true
broadcastLive(for: currentPage)
finishRestore()
}
} else {
// Not-started opens on the Ready page; the screenshot host pins its own.
didRestorePage = true
broadcastLive(for: currentPage)
finishRestore()
}
}
.onDisappear {
@@ -225,6 +255,17 @@ struct ExerciseProgressView: View {
// 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 mirror.
private func finishRestore() {
didRestorePage = true
if let frame = incomingFrame, frame.logID == logID {
applyIncoming(frame)
} else {
broadcastLive(for: currentPage)
}
}
/// Push the current flow position to a mirroring iPhone. The anchor is stamped *now* the
/// page just became active so the mirror's timer lines up with this device's.
private func broadcastLive(for page: Int) {
@@ -232,6 +273,30 @@ struct ExerciseProgressView: View {
onLive(snapshot)
}
/// Follow a transition the phone made: jump to the frame's page (matching its set count
/// 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 }
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 pageframe 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 mirror counts off. Count-down phases (rest, timed work, finish)
/// carry an end anchor; a rep-based work set counts up and leaves it `nil`.
@@ -340,9 +405,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 mirror auto-advances too).
private func advance(from index: Int) {
guard currentPage == index, index + 1 < totalPages else { return }
pageChangeCause = .auto
currentPage = index + 1
}
@@ -91,7 +91,9 @@ struct WorkoutLogListView: View {
logID: logID,
onChange: { bridge.update(workout: doc) },
onLive: { bridge.sendLiveProgress($0) },
onLiveEnded: { bridge.sendLiveEnded(workoutID: doc.id, logID: logID) }
onLiveEnded: { bridge.sendLiveEnded(workoutID: doc.id, logID: logID) },
// Follow the phone when it drives this same run from its mirror.
incomingFrame: bridge.liveIncoming.flatMap { $0.logID == logID ? $0 : nil }
)
}
.sheet(isPresented: $showingExercisePicker) {