From fad629338e5facdff71af4a2b9c412395c31eeb0 Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 20 Jun 2026 23:21:04 -0400 Subject: [PATCH] Make live-run mirroring symmetric: phone-driven runs reach the watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../WatchConnectivityBridge.swift | 21 ++++- Workouts Watch App/ContentView.swift | 11 +++ .../Views/LiveRunCoverView.swift | 82 +++++++++++++++++++ .../Views/WorkoutLogListView.swift | 3 + Workouts/Connectivity/LiveRunState.swift | 10 ++- .../WorkoutLogs/WorkoutLogListView.swift | 23 +++++- 6 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 Workouts Watch App/Views/LiveRunCoverView.swift diff --git a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift index d632acd..5f7db34 100644 --- a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift +++ b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift @@ -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 diff --git a/Workouts Watch App/ContentView.swift b/Workouts Watch App/ContentView.swift index c0adea3..e977c64 100644 --- a/Workouts Watch App/ContentView.swift +++ b/Workouts Watch App/ContentView.swift @@ -8,7 +8,18 @@ import SwiftUI struct ContentView: View { + @Environment(WatchConnectivityBridge.self) private var bridge + var body: some View { 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() } + } + } } } diff --git a/Workouts Watch App/Views/LiveRunCoverView.swift b/Workouts Watch App/Views/LiveRunCoverView.swift new file mode 100644 index 0000000..8c4f99b --- /dev/null +++ b/Workouts Watch App/Views/LiveRunCoverView.swift @@ -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 { $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) + } +} diff --git a/Workouts Watch App/Views/WorkoutLogListView.swift b/Workouts Watch App/Views/WorkoutLogListView.swift index 829a15a..19c11f2 100644 --- a/Workouts Watch App/Views/WorkoutLogListView.swift +++ b/Workouts Watch App/Views/WorkoutLogListView.swift @@ -95,6 +95,9 @@ struct WorkoutLogListView: View { // Follow the phone when it drives this same run from its mirror. 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) { ExercisePickerView(exercises: availableExercises) { exercise in diff --git a/Workouts/Connectivity/LiveRunState.swift b/Workouts/Connectivity/LiveRunState.swift index f87e8bb..33c5851 100644 --- a/Workouts/Connectivity/LiveRunState.swift +++ b/Workouts/Connectivity/LiveRunState.swift @@ -22,9 +22,15 @@ final class LiveRunState { /// A log the user manually closed the mirror for; suppressed until that run ends. 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? { - 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 } diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift index 721d699..592f9ec 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift @@ -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 {