Files
workouts/Workouts Watch App/Views/LiveRunCoverView.swift
T
rzen fad629338e 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
2026-06-20 23:21:04 -04:00

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)
}
}