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
+50
View File
@@ -0,0 +1,50 @@
//
// LiveProgress.swift
// Workouts (Shared)
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
/// Which page of the Ready Work Rest Finish run flow the driving device is on.
enum LiveRunPhase: String, Sendable, Equatable {
case ready, work, rest, finish
}
/// An *ephemeral* "live run" frame broadcast by the device actively driving an exercise so a
/// propped-up second device can mirror the run flow in real time.
///
/// This is deliberately **not** the durable `WorkoutDocument` sync. That path writes a file to
/// iCloud Drive at set-completion granularity; this one is a throwaway snapshot of *where in
/// the flow* the driver is, sent on every phase transition and never persisted (no SwiftData,
/// no iCloud).
///
/// Timers ride as wall-clock anchors, not a streamed countdown: the mirror renders the same
/// SwiftUI timer text off `phaseStart` / `phaseEnd`, so both devices count independently and
/// stay in lockstep without ticking over the wire. Paired-device clock skew is sub-second, so
/// the two displays agree in practice. A count-down phase (rest, timed work, the finish
/// auto-Done) carries `phaseEnd`; a rep-based work set counts *up* and leaves it `nil`.
struct LiveProgress: Sendable, Equatable {
var workoutID: String
var logID: String
var exerciseName: String
var phase: LiveRunPhase
/// 0-based set this frame pertains to: the set being worked (`work`/`finish`), or the set
/// just completed that this rest follows (`rest`). Drives the header and the progress dots.
var setIndex: Int
var setCount: Int
/// Footer line under the timer, e.g. "8 reps" or "30 sec".
var detail: String
/// Wall-clock anchor for the phase's timer. Work counts up from here; count-down phases
/// run from here to `phaseEnd`.
var phaseStart: Date
/// End anchor for count-down phases; `nil` for a rep-based work set (which counts up).
var phaseEnd: Date?
/// Monotonic per-run sequence, so the mirror can drop a stale / out-of-order delivery.
var version: Int
}
+60
View File
@@ -16,6 +16,8 @@ enum WCPayload {
static let workoutUpdateType = "workoutUpdate" // watch phone (one workout)
static let requestSyncType = "requestSync" // watch phone (please push state)
static let liveProgressType = "liveProgress" // watch phone (ephemeral mirror frame)
static let liveEndedType = "liveEnded" // watch phone (stop mirroring a run)
// MARK: - Phone Watch (application context: latest-state-wins)
@@ -66,4 +68,62 @@ enum WCPayload {
}
static func requestSyncMessage() -> [String: Any] { [typeKey: requestSyncType] }
// MARK: - Watch Phone (ephemeral live-run mirror)
/// Anchor `Date`s ride as native plist values (not JSON), so they keep sub-second
/// precision `DocumentCoder` is `.iso8601`, which would round the timer off.
static let lpWorkoutIDKey = "lpWorkoutID"
static let lpLogIDKey = "lpLogID"
static let lpNameKey = "lpName"
static let lpPhaseKey = "lpPhase"
static let lpSetIndexKey = "lpSetIndex"
static let lpSetCountKey = "lpSetCount"
static let lpDetailKey = "lpDetail"
static let lpStartKey = "lpStart"
static let lpEndKey = "lpEnd"
static let lpVersionKey = "lpVersion"
static func encodeLiveProgress(_ p: LiveProgress) -> [String: Any] {
var dict: [String: Any] = [typeKey: liveProgressType]
dict[lpWorkoutIDKey] = p.workoutID
dict[lpLogIDKey] = p.logID
dict[lpNameKey] = p.exerciseName
dict[lpPhaseKey] = p.phase.rawValue
dict[lpSetIndexKey] = p.setIndex
dict[lpSetCountKey] = p.setCount
dict[lpDetailKey] = p.detail
dict[lpStartKey] = p.phaseStart
if let end = p.phaseEnd { dict[lpEndKey] = end }
dict[lpVersionKey] = p.version
return dict
}
static func decodeLiveProgress(_ dict: [String: Any]) -> LiveProgress? {
guard let workoutID = dict[lpWorkoutIDKey] as? String,
let logID = dict[lpLogIDKey] as? String,
let phaseRaw = dict[lpPhaseKey] as? String,
let phase = LiveRunPhase(rawValue: phaseRaw),
let setIndex = dict[lpSetIndexKey] as? Int,
let setCount = dict[lpSetCountKey] as? Int,
let start = dict[lpStartKey] as? Date,
let version = dict[lpVersionKey] as? Int
else { return nil }
return LiveProgress(
workoutID: workoutID,
logID: logID,
exerciseName: dict[lpNameKey] as? String ?? "",
phase: phase,
setIndex: setIndex,
setCount: setCount,
detail: dict[lpDetailKey] as? String ?? "",
phaseStart: start,
phaseEnd: dict[lpEndKey] as? Date,
version: version
)
}
static func encodeLiveEnded(workoutID: String, logID: String) -> [String: Any] {
[typeKey: liveEndedType, lpWorkoutIDKey: workoutID, lpLogIDKey: logID]
}
}