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:
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user