Field incident (2026-07-17): a Cardio flow run driven from a locked phone durably completed the 30-min Main Circuit ~5 minutes in, then fought the watch until manual swipes forced agreement. Second bulletproofing pass (BULLETPROOFING.md, "2026-07-18 — Live-run mirror & flow hand-off"): - H4: RunFlowView (both platforms) follows the driver's cross-log hand-off frame (forward-only by plan order, runnable target only), and a remote completion mid-flow hands off via onAdvance instead of dismissing the run (which tore down the Live Activity and stranded the flow in the mirror cover). - H5: wall-clock plausibility gate — a duration exercise never auto-completes before startedAt + sets × duration; the flow-terminal rest self-repairs to computeResume() and the Finish auto-Done holds for a human tap. Human Done taps stay ungated. - M6: both bridges persist liveVersion as a UserDefaults high-water mark so an app relaunch can't get every subsequent frame dropped as stale. - M7: phone list + mirror cover absorbs gain the watch's strictly-newer guard, so a stale cache echo can't regress the working doc. - Diagnostics: run-flow os_log breadcrumbs (page transitions, applied frames, repairs, hand-offs, gate trips) on both drivers. Open, documented: M5 (no reconciliation signal across a single-set duration phase), M8 (watch context apply lacks per-log staleness guard), L5 (spurious pager write-backs read as human swipes).
108 lines
4.5 KiB
Swift
108 lines
4.5 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)
|
|
},
|
|
onActivity: { services.liveActivity.update($0) },
|
|
onActivityEnded: { services.liveActivity.end() },
|
|
incomingFrame: incomingFrame,
|
|
// The run screen hides the nav bar (and this toolbar's close button
|
|
// with it); its own header hosts the close action instead.
|
|
closeSymbol: "xmark",
|
|
onClose: onClose
|
|
)
|
|
} 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) { _, incoming 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.
|
|
// Strictly newer only (matching the list absorbs): a stale cache echo must not
|
|
// regress the working copy, which the next persist would re-stamp as newest.
|
|
guard let incoming, incoming > (doc?.updatedAt ?? .distantPast), let workout else { return }
|
|
doc = WorkoutDocument(from: workout)
|
|
}
|
|
}
|
|
|
|
private func persist() {
|
|
guard let doc else { return }
|
|
let snapshot = doc
|
|
Task { await sync.save(workout: snapshot) }
|
|
}
|
|
}
|