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
@@ -33,6 +33,24 @@ final class WatchConnectivityBridge: NSObject {
/// this to follow a phone-driven transition; it's never persisted. /// this to follow a phone-driven transition; it's never persisted.
private(set) var liveIncoming: LiveProgress? private(set) var liveIncoming: LiveProgress?
/// The run currently open in the watch's navigated driver. When the incoming frame is for
/// it, the watch follows inline there and suppresses the follower cover (so it never stacks
/// on top of a run the user already has open).
var navigatedRunID: String?
/// A run the user dismissed the follower cover for; suppressed until that run ends.
private var mutedLogID: String?
/// The frame to present as a follower cover when the phone drives a run the watch isn't
/// already showing: the latest, unless the user dismissed it or has that run open inline.
var presentable: LiveProgress? {
guard let f = liveIncoming, f.logID != mutedLogID, f.logID != navigatedRunID else { return nil }
return f
}
/// The user dismissed the follower cover; don't re-present this run until it ends.
func muteLive() { mutedLogID = liveIncoming?.logID }
private var context: ModelContext { container.mainContext } private var context: ModelContext { container.mainContext }
init(container: ModelContainer) { init(container: ModelContainer) {
@@ -96,9 +114,10 @@ final class WatchConnectivityBridge: NSObject {
liveIncoming = frame liveIncoming = frame
} }
/// The phone left the run stop following it. /// The phone left the run stop following it (and clear any dismiss for it).
private func endIncomingLive(logID: String) { private func endIncomingLive(logID: String) {
if liveIncoming?.logID == logID { liveIncoming = nil } if liveIncoming?.logID == logID { liveIncoming = nil }
if mutedLogID == logID { mutedLogID = nil }
} }
// MARK: - Internal // MARK: - Internal
+11
View File
@@ -8,7 +8,18 @@
import SwiftUI import SwiftUI
struct ContentView: View { struct ContentView: View {
@Environment(WatchConnectivityBridge.self) private var bridge
var body: some View { var body: some View {
ActiveWorkoutGateView() ActiveWorkoutGateView()
// The iPhone started driving a run we're not already in follow it live here.
.fullScreenCover(isPresented: Binding(
get: { bridge.presentable != nil },
set: { presenting in if !presenting { bridge.muteLive() } }
)) {
if let frame = bridge.presentable {
LiveRunCoverView(frame: frame) { bridge.muteLive() }
}
}
} }
} }
@@ -0,0 +1,82 @@
//
// LiveRunCoverView.swift
// Workouts Watch App
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
/// Follower surface shown on the watch when the *iPhone* starts driving a live exercise (see
/// `LiveProgress`) and the watch isn't already in that run. It presents the watch's real
/// `ExerciseProgressView`, seeded to the phone's current page so the watch follows along and
/// the user can take over and drive from the wrist, mirroring the iPhone's `LiveRunCoverView`.
///
/// Incoming phone frames jump this driver's page (it never re-broadcasts them).
/// A human transition *here* broadcasts back to the phone and persists through the bridge.
struct LiveRunCoverView: View {
/// The frame that triggered presentation fixes which workout/log this cover follows.
let frame: LiveProgress
/// Dismiss + suppress re-presentation of this run (the user closed it).
let onClose: () -> Void
@Environment(WatchConnectivityBridge.self) private var bridge
@Environment(\.modelContext) private var modelContext
/// The live workout entity, filtered to this run's id and observed so durable updates the
/// phone drove flow back into our working copy.
@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? {
bridge.liveIncoming.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: { bridge.sendLiveProgress($0) },
onLiveEnded: {
bridge.sendLiveEnded(workoutID: frame.workoutID, logID: frame.logID)
},
incomingFrame: incomingFrame
)
} else {
// The workout hasn't resolved from the cache yet (or has vanished).
ProgressView()
}
}
}
.onAppear {
if doc == nil, let workout { doc = WorkoutDocument(from: workout) }
}
.onChange(of: workout?.updatedAt) { _, _ in
// Absorb the durable progress the phone drove. 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 }
bridge.update(workout: doc)
}
}
@@ -95,6 +95,9 @@ struct WorkoutLogListView: View {
// Follow the phone when it drives this same run from its mirror. // Follow the phone when it drives this same run from its mirror.
incomingFrame: bridge.liveIncoming.flatMap { $0.logID == logID ? $0 : nil } incomingFrame: bridge.liveIncoming.flatMap { $0.logID == logID ? $0 : nil }
) )
// We're driving this run inline suppress the follower cover for it.
.onAppear { bridge.navigatedRunID = logID }
.onDisappear { if bridge.navigatedRunID == logID { bridge.navigatedRunID = nil } }
} }
.sheet(isPresented: $showingExercisePicker) { .sheet(isPresented: $showingExercisePicker) {
ExercisePickerView(exercises: availableExercises) { exercise in ExercisePickerView(exercises: availableExercises) { exercise in
+8 -2
View File
@@ -22,9 +22,15 @@ final class LiveRunState {
/// A log the user manually closed the mirror for; suppressed until that run ends. /// A log the user manually closed the mirror for; suppressed until that run ends.
private var mutedLogID: String? private var mutedLogID: String?
/// The frame to actually present, honoring a manual dismiss. /// The run currently open in a non-cover driver on *this* device (its in-list/navigated
/// `ExerciseProgressView`). When an incoming frame is for that run, the device follows it
/// inline in that driver, so the mirror cover doesn't stack on top of a run already open.
var navigatedRunID: String?
/// The frame to actually present as a cover: the latest frame, unless the user dismissed it
/// or already has that run open inline.
var presentable: LiveProgress? { var presentable: LiveProgress? {
guard let c = current, c.logID != mutedLogID else { return nil } guard let c = current, c.logID != mutedLogID, c.logID != navigatedRunID else { return nil }
return c return c
} }
@@ -12,6 +12,8 @@ import SwiftData
struct WorkoutLogListView: View { struct WorkoutLogListView: View {
@Environment(SyncEngine.self) private var sync @Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
@Environment(LiveRunState.self) private var liveRun
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
let workout: Workout let workout: Workout
@@ -69,7 +71,7 @@ struct WorkoutLogListView: View {
Section(header: Text(label)) { Section(header: Text(label)) {
ForEach(sortedLogs) { log in ForEach(sortedLogs) { log in
NavigationLink { NavigationLink {
ExerciseProgressView(doc: $doc, logID: log.id, onChange: { save() }) progressView(logID: log.id)
} label: { } label: {
CheckboxListItem( CheckboxListItem(
status: workoutStatus(log).checkboxStatus, status: workoutStatus(log).checkboxStatus,
@@ -112,7 +114,7 @@ struct WorkoutLogListView: View {
// A freshly-added exercise drops straight into its progress flow. The new // 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 // log already lives in our working doc, so the binding has it before the
// cache catches up. // cache catches up.
ExerciseProgressView(doc: $doc, logID: route.id, onChange: { save() }) progressView(logID: route.id)
} }
.navigationDestination(item: $logToEdit) { route in .navigationDestination(item: $logToEdit) { route in
// The Edit swipe opens the detail/edit screen, seeded with our working // 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 // MARK: - Derived
private var label: String { private var label: String {