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:
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// LiveRunCoverView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// Full-screen surface shown on a propped-up iPhone while the Apple Watch is running a live
|
||||
/// exercise (see `LiveProgress`). Rather than a read-only mirror, this presents the iPhone's
|
||||
/// real `ExerciseProgressView` driver, seeded to the watch's current page — so either device
|
||||
/// can drive the run and the other follows:
|
||||
///
|
||||
/// • Incoming watch frames jump this driver's page (it never re-broadcasts them).
|
||||
/// • A human transition *here* broadcasts to the watch and persists durably via `SyncEngine`,
|
||||
/// exactly as the watch's own driver does.
|
||||
///
|
||||
/// Auto-advances (rest / timed-work end) are never sent either way — both devices reach them
|
||||
/// independently off the shared wall-clock anchors carried in each frame.
|
||||
struct LiveRunCoverView: View {
|
||||
/// The frame that triggered presentation — fixes which workout/log this cover drives.
|
||||
let frame: LiveProgress
|
||||
/// Dismiss + suppress re-presentation of this run (the user tapped close).
|
||||
let onClose: () -> Void
|
||||
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(AppServices.self) private var services
|
||||
@Environment(LiveRunState.self) private var liveRun
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
/// The live workout entity, observed so durable updates the watch drove flow back into our
|
||||
/// working copy. Filtered to just this run's id.
|
||||
@Query private var workouts: [Workout]
|
||||
|
||||
/// Working copy the driver mutates; resolved from the cache on appear. `nil` until then.
|
||||
@State private var doc: WorkoutDocument?
|
||||
|
||||
init(frame: LiveProgress, onClose: @escaping () -> Void) {
|
||||
self.frame = frame
|
||||
self.onClose = onClose
|
||||
let workoutID = frame.workoutID
|
||||
_workouts = Query(filter: #Predicate<Workout> { $0.id == workoutID })
|
||||
}
|
||||
|
||||
private var workout: Workout? { workouts.first }
|
||||
|
||||
/// The latest frame to follow, scoped to this cover's run.
|
||||
private var incomingFrame: LiveProgress? {
|
||||
liveRun.current.flatMap { $0.logID == frame.logID ? $0 : nil }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if let binding = Binding($doc) {
|
||||
ExerciseProgressView(
|
||||
doc: binding,
|
||||
logID: frame.logID,
|
||||
onChange: { persist() },
|
||||
onLive: { services.watchBridge.sendLiveProgress($0) },
|
||||
onLiveEnded: {
|
||||
services.watchBridge.sendLiveEnded(workoutID: frame.workoutID, logID: frame.logID)
|
||||
},
|
||||
incomingFrame: incomingFrame
|
||||
)
|
||||
} else {
|
||||
// The workout hasn't resolved from the cache yet (or has vanished).
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if doc == nil, let workout { doc = WorkoutDocument(from: workout) }
|
||||
}
|
||||
.onChange(of: workout?.updatedAt) { _, _ in
|
||||
// Absorb the durable progress the watch drove (currentStateIndex / status). The
|
||||
// live *page* is driven by frames, not this — so re-seeding the doc is safe.
|
||||
if let workout { doc = WorkoutDocument(from: workout) }
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
guard let doc else { return }
|
||||
let snapshot = doc
|
||||
Task { await sync.save(workout: snapshot) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user