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
@@ -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) {