Mirror a live Apple Watch run on a propped-up iPhone

Add an ephemeral live-run presence channel (separate from the durable
iCloud progress sync) so a propped-up iPhone can mirror the Watch's
Ready → work/rest → Finish flow in real time as the user swipes.

Watch drives, phone mirrors (read-only), so there's no echo loop:
- Watch's ExerciseProgressView broadcasts a LiveProgress frame on every
  phase transition (and an ended signal on leave) via sendMessage,
  reachable-only — throwaway presence, never written to iCloud.
- Timers ride as wall-clock anchors (Date kept native in the WC dict to
  preserve sub-second precision), so both devices count independently
  off shared start times and stay in lockstep without streaming ticks.
- Phone holds a transient LiveRunState; ContentView auto-presents a
  read-only LiveProgressMirrorView full-screen cover while a run is live.

Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
This commit is contained in:
2026-06-20 21:08:32 -04:00
parent 8ef0e96b31
commit a16e8ec270
13 changed files with 531 additions and 4 deletions
@@ -23,6 +23,10 @@ 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.
private var liveVersion = 0
private var context: ModelContext { container.mainContext }
init(container: ModelContainer) {
@@ -58,6 +62,26 @@ final class WatchConnectivityBridge: NSObject {
sendToPhone(doc)
}
// MARK: - Live run mirror (ephemeral; reachable-only)
/// Broadcast where the run flow currently is, so a propped-up iPhone can mirror it. Sent
/// over `sendMessage` only when the phone is reachable this is throwaway presence, so
/// there's no guaranteed-delivery fallback (a queued frame would be stale on arrival).
func sendLiveProgress(_ frame: LiveProgress) {
guard let session, session.activationState == .activated, session.isReachable else { return }
liveVersion += 1
var stamped = frame
stamped.version = liveVersion
session.sendMessage(WCPayload.encodeLiveProgress(stamped), replyHandler: nil, errorHandler: { _ in })
}
/// Tell the phone to stop mirroring this run (the user left the progress flow).
func sendLiveEnded(workoutID: String, logID: String) {
guard let session, session.activationState == .activated, session.isReachable else { return }
session.sendMessage(WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID),
replyHandler: nil, errorHandler: { _ in })
}
// MARK: - Internal
private func sendToPhone(_ doc: WorkoutDocument) {