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.
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 }
init(container: ModelContainer) {
@@ -96,9 +114,10 @@ final class WatchConnectivityBridge: NSObject {
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) {
if liveIncoming?.logID == logID { liveIncoming = nil }
if mutedLogID == logID { mutedLogID = nil }
}
// MARK: - Internal