8f69497b24
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
99 lines
3.8 KiB
Swift
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) }
|
|
}
|
|
}
|