Make live-run mirroring symmetric: phone-driven runs reach the watch

Two-way driving only worked watch->phone: the watch's navigated driver
broadcast and the phone auto-presented a follower cover. The reverse
failed on both ends — the phone's in-list ExerciseProgressView never
broadcast (only its cover did), and the watch had no surface to present
an incoming run.

- Wire the live channel into the phone's in-list driver (broadcast +
  follow) via a progressView(logID:) helper in WorkoutLogListView.
- Add a watch follower cover (LiveRunCoverView, mirroring the phone's),
  presented from ContentView when the phone drives a run the watch isn't
  already in; the watch bridge gains presentable / muteLive.
- Add a navigatedRunID guard on both sides so a device already in the run
  follows it inline rather than stacking a cover over itself.

Now starting or driving on either device surfaces the run on the other —
as a follower cover when idle, or inline when already in that run — and
either side can take over.

Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
This commit is contained in:
2026-06-20 23:21:04 -04:00
parent b2d670724a
commit fad629338e
6 changed files with 145 additions and 5 deletions
@@ -12,6 +12,8 @@ import SwiftData
struct WorkoutLogListView: View {
@Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
@Environment(LiveRunState.self) private var liveRun
@Environment(\.modelContext) private var modelContext
let workout: Workout
@@ -69,7 +71,7 @@ struct WorkoutLogListView: View {
Section(header: Text(label)) {
ForEach(sortedLogs) { log in
NavigationLink {
ExerciseProgressView(doc: $doc, logID: log.id, onChange: { save() })
progressView(logID: log.id)
} label: {
CheckboxListItem(
status: workoutStatus(log).checkboxStatus,
@@ -112,7 +114,7 @@ struct WorkoutLogListView: View {
// A freshly-added exercise drops straight into its progress flow. The new
// log already lives in our working doc, so the binding has it before the
// cache catches up.
ExerciseProgressView(doc: $doc, logID: route.id, onChange: { save() })
progressView(logID: route.id)
}
.navigationDestination(item: $logToEdit) { route in
// The Edit swipe opens the detail/edit screen, seeded with our working
@@ -161,6 +163,23 @@ struct WorkoutLogListView: View {
}
}
/// The paged run flow, fully wired into the live channel: it broadcasts this device's
/// human transitions to the watch, follows the watch's, and marks this run as open inline
/// (so the propped-phone mirror cover doesn't stack on top of it).
@ViewBuilder
private func progressView(logID: String) -> some View {
ExerciseProgressView(
doc: $doc,
logID: logID,
onChange: { save() },
onLive: { services.watchBridge.sendLiveProgress($0) },
onLiveEnded: { services.watchBridge.sendLiveEnded(workoutID: doc.id, logID: logID) },
incomingFrame: liveRun.current.flatMap { $0.logID == logID ? $0 : nil }
)
.onAppear { liveRun.navigatedRunID = logID }
.onDisappear { if liveRun.navigatedRunID == logID { liveRun.navigatedRunID = nil } }
}
// MARK: - Derived
private var label: String {