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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user