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
This commit is contained in:
@@ -34,10 +34,17 @@ struct ExerciseProgressView: View {
|
||||
let onChange: () -> Void
|
||||
|
||||
/// Broadcasts the current flow position so a propped-up iPhone can mirror the run live
|
||||
/// (ephemeral; see `LiveProgress`). `onLiveEnded` fires when we leave the flow.
|
||||
/// (ephemeral; see `LiveProgress`). `onLiveEnded` fires when we leave the flow. Only
|
||||
/// *human* transitions are broadcast — an auto-advance (rest/timed-work end) isn't, since
|
||||
/// the mirror reaches it independently off the same wall-clock anchors.
|
||||
let onLive: (LiveProgress) -> Void
|
||||
let onLiveEnded: () -> Void
|
||||
|
||||
/// The latest live-run frame the *phone* sent for this run, to follow when it drives a
|
||||
/// transition (ephemeral; nil when the phone isn't driving). Applying it jumps our page
|
||||
/// without re-broadcasting or re-recording — the originating device owns the durable write.
|
||||
var incomingFrame: LiveProgress?
|
||||
|
||||
/// Rest length between sets, shared with the phone via the same defaults key.
|
||||
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
||||
|
||||
@@ -50,6 +57,15 @@ struct ExerciseProgressView: View {
|
||||
@State private var showingCancelConfirm = false
|
||||
@State private var didRestorePage = false
|
||||
|
||||
/// Why `currentPage` last changed, so the page observer knows whether to broadcast it.
|
||||
/// A human swipe leaves this `nil` (→ treated as human); programmatic moves set it.
|
||||
@State private var pageChangeCause: PageChangeCause?
|
||||
|
||||
/// Highest remote frame version we've applied, so a redelivery doesn't re-jump.
|
||||
@State private var lastAppliedVersion = 0
|
||||
|
||||
private enum PageChangeCause { case auto, remote }
|
||||
|
||||
/// True when this run opened on a resumed set (in-progress) rather than the Ready
|
||||
/// page. Such a run re-asserts its resume page after the first layout and ignores the
|
||||
/// transient TabView snap-to-0, so it isn't reset on open.
|
||||
@@ -59,12 +75,13 @@ struct ExerciseProgressView: View {
|
||||
/// also suppresses the Ready page so the index is a plain work/rest cycle offset.
|
||||
private let debugInitialPage: Int?
|
||||
|
||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, debugInitialPage: Int? = nil) {
|
||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, debugInitialPage: Int? = nil) {
|
||||
self._doc = doc
|
||||
self.logID = logID
|
||||
self.onChange = onChange
|
||||
self.onLive = onLive
|
||||
self.onLiveEnded = onLiveEnded
|
||||
self.incomingFrame = incomingFrame
|
||||
self.debugInitialPage = debugInitialPage
|
||||
|
||||
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
||||
@@ -187,15 +204,30 @@ struct ExerciseProgressView: View {
|
||||
// Ignore page changes until the initial resume settles, so the TabView's
|
||||
// transient snap-to-0 on first layout can't reset an in-progress run.
|
||||
guard didRestorePage else { return }
|
||||
// A deliberate swipe back from the first set to the Ready page wipes the run
|
||||
// (only the adjacent 1→0 swipe resets — a stray far jump never does); any
|
||||
// other page records forward progress.
|
||||
if showsReady && newPage == 0 && oldPage == base {
|
||||
resetExercise()
|
||||
} else {
|
||||
let cause = pageChangeCause
|
||||
pageChangeCause = nil
|
||||
switch cause {
|
||||
case .remote:
|
||||
// The phone already recorded and owns this transition — just follow it.
|
||||
break
|
||||
case .auto:
|
||||
// Rest / timed-work auto-advance: record forward progress, but don't
|
||||
// broadcast — the mirror reaches this point on its own synchronized timer.
|
||||
recordProgress(for: newPage)
|
||||
case .none:
|
||||
// A human swipe. Swiping back from the first set to Ready wipes the run
|
||||
// (only the adjacent 1→0 swipe resets — a stray far jump never does); any
|
||||
// other page records forward progress. Human transitions are broadcast.
|
||||
if showsReady && newPage == 0 && oldPage == base {
|
||||
resetExercise()
|
||||
} else {
|
||||
recordProgress(for: newPage)
|
||||
}
|
||||
broadcastLive(for: newPage)
|
||||
}
|
||||
broadcastLive(for: newPage)
|
||||
}
|
||||
.onChange(of: incomingFrame) { _, frame in
|
||||
if let frame { applyIncoming(frame) }
|
||||
}
|
||||
.onAppear {
|
||||
guard !didRestorePage else { return }
|
||||
@@ -207,13 +239,11 @@ struct ExerciseProgressView: View {
|
||||
jumpToResumePage()
|
||||
Task { @MainActor in
|
||||
jumpToResumePage()
|
||||
didRestorePage = true
|
||||
broadcastLive(for: currentPage)
|
||||
finishRestore()
|
||||
}
|
||||
} else {
|
||||
// Not-started opens on the Ready page; the screenshot host pins its own.
|
||||
didRestorePage = true
|
||||
broadcastLive(for: currentPage)
|
||||
finishRestore()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
@@ -225,6 +255,17 @@ struct ExerciseProgressView: View {
|
||||
|
||||
// MARK: - Live mirror
|
||||
|
||||
/// Finish the initial-page restore, then either follow an in-progress remote driver or, if
|
||||
/// we're the one starting the run, announce our position to the mirror.
|
||||
private func finishRestore() {
|
||||
didRestorePage = true
|
||||
if let frame = incomingFrame, frame.logID == logID {
|
||||
applyIncoming(frame)
|
||||
} else {
|
||||
broadcastLive(for: currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the current flow position to a mirroring iPhone. The anchor is stamped *now* — the
|
||||
/// page just became active — so the mirror's timer lines up with this device's.
|
||||
private func broadcastLive(for page: Int) {
|
||||
@@ -232,6 +273,30 @@ struct ExerciseProgressView: View {
|
||||
onLive(snapshot)
|
||||
}
|
||||
|
||||
/// Follow a transition the phone made: jump to the frame's page (matching its set count
|
||||
/// for a remote One More) without re-broadcasting or re-recording it — the phone owns the
|
||||
/// durable write, which arrives separately over the document sync.
|
||||
private func applyIncoming(_ frame: LiveProgress) {
|
||||
guard didRestorePage, frame.logID == logID, frame.version > lastAppliedVersion else { return }
|
||||
lastAppliedVersion = frame.version
|
||||
if frame.setCount != setCount { setCount = frame.setCount }
|
||||
let target = page(forPhase: frame.phase, setIndex: frame.setIndex)
|
||||
guard target != currentPage else { return }
|
||||
pageChangeCause = .remote
|
||||
withAnimation { currentPage = target }
|
||||
}
|
||||
|
||||
/// Inverse of `liveSnapshot`'s page→frame mapping: a frame's phase/set → page index.
|
||||
private func page(forPhase phase: LiveRunPhase, setIndex: Int) -> Int {
|
||||
let set = min(max(0, setIndex), max(0, setCount - 1))
|
||||
switch phase {
|
||||
case .ready: return showsReady ? 0 : base
|
||||
case .work: return base + set * 2
|
||||
case .rest: return base + set * 2 + 1
|
||||
case .finish: return base + cycleCount
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the live-run frame for a given page: phase, the set it pertains to, and the
|
||||
/// wall-clock anchors the mirror counts off. Count-down phases (rest, timed work, finish)
|
||||
/// carry an end anchor; a rep-based work set counts up and leaves it `nil`.
|
||||
@@ -340,9 +405,11 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
|
||||
/// Programmatically move one page right (used by the rest auto-advance), guarding
|
||||
/// against overrun if the user swiped away in the meantime.
|
||||
/// against overrun if the user swiped away in the meantime. Tagged `.auto` so the page
|
||||
/// observer records progress but doesn't broadcast it (the mirror auto-advances too).
|
||||
private func advance(from index: Int) {
|
||||
guard currentPage == index, index + 1 < totalPages else { return }
|
||||
pageChangeCause = .auto
|
||||
currentPage = index + 1
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,9 @@ struct WorkoutLogListView: View {
|
||||
logID: logID,
|
||||
onChange: { bridge.update(workout: doc) },
|
||||
onLive: { bridge.sendLiveProgress($0) },
|
||||
onLiveEnded: { bridge.sendLiveEnded(workoutID: doc.id, logID: logID) }
|
||||
onLiveEnded: { bridge.sendLiveEnded(workoutID: doc.id, logID: logID) },
|
||||
// Follow the phone when it drives this same run from its mirror.
|
||||
incomingFrame: bridge.liveIncoming.flatMap { $0.logID == logID ? $0 : nil }
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showingExercisePicker) {
|
||||
|
||||
Reference in New Issue
Block a user