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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user