Files
rzen 8f69497b24 Make the live run two-way: drive from either device
The propped-up iPhone now runs the real ExerciseProgressView for a live
watch workout instead of a read-only mirror, and the live-run channel is
symmetric — either device can drive the flow and the other follows.

Each page transition is classified human / auto / remote: only human
transitions (swipe, Start, One More, swipe-back reset) are broadcast and
recorded by the actor; auto-advances (rest / timed-work countdown) record
locally but aren't sent, since both devices reach them independently off
the shared wall-clock anchors; an applied remote frame jumps the page
without re-recording or re-broadcasting. That rule is also what stops an
echo loop.

- PhoneConnectivityBridge gains sendLiveProgress/sendLiveEnded (the
  missing phone->watch direction); WatchConnectivityBridge receives
  frames into an observable liveIncoming via a new didReceiveMessage
  route. Both share one increasing per-run version sequence so the
  stale-frame guard works across the two devices' counters.
- Both ExerciseProgressViews gain an incomingFrame input + applyIncoming
  (syncing setCount for a remote One More); the iPhone one gains the
  liveSnapshot/broadcast machinery the watch already had.
- New LiveRunCoverView wraps the real driver for the cover (resolves the
  workout, persists via SyncEngine, wires the live channel + close);
  ContentView presents it; LiveProgressMirrorView is removed.

Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
2026-06-20 22:11:05 -04:00

99 lines
3.8 KiB
Swift

//
// LiveRunCoverView.swift
// Workouts
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
/// Full-screen surface shown on a propped-up iPhone while the Apple Watch is running a live
/// exercise (see `LiveProgress`). Rather than a read-only mirror, this presents the iPhone's
/// real `ExerciseProgressView` driver, seeded to the watch's current page so either device
/// can drive the run and the other follows:
///
/// Incoming watch frames jump this driver's page (it never re-broadcasts them).
/// A human transition *here* broadcasts to the watch and persists durably via `SyncEngine`,
/// exactly as the watch's own driver does.
///
/// Auto-advances (rest / timed-work end) are never sent either way both devices reach them
/// independently off the shared wall-clock anchors carried in each frame.
struct LiveRunCoverView: View {
/// The frame that triggered presentation fixes which workout/log this cover drives.
let frame: LiveProgress
/// Dismiss + suppress re-presentation of this run (the user tapped close).
let onClose: () -> Void
@Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
@Environment(LiveRunState.self) private var liveRun
@Environment(\.modelContext) private var modelContext
/// The live workout entity, observed so durable updates the watch drove flow back into our
/// working copy. Filtered to just this run's id.
@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? {
liveRun.current.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: { services.watchBridge.sendLiveProgress($0) },
onLiveEnded: {
services.watchBridge.sendLiveEnded(workoutID: frame.workoutID, logID: frame.logID)
},
incomingFrame: incomingFrame
)
} else {
// The workout hasn't resolved from the cache yet (or has vanished).
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(action: onClose) {
Image(systemName: "xmark")
}
.accessibilityLabel("Close")
}
}
}
.onAppear {
if doc == nil, let workout { doc = WorkoutDocument(from: workout) }
}
.onChange(of: workout?.updatedAt) { _, _ in
// Absorb the durable progress the watch drove (currentStateIndex / status). 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 }
let snapshot = doc
Task { await sync.save(workout: snapshot) }
}
}