fad629338e
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
83 lines
3.1 KiB
Swift
83 lines
3.1 KiB
Swift
//
|
|
// 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)
|
|
}
|
|
}
|