Mirror a live Apple Watch run on a propped-up iPhone
Add an ephemeral live-run presence channel (separate from the durable iCloud progress sync) so a propped-up iPhone can mirror the Watch's Ready → work/rest → Finish flow in real time as the user swipes. Watch drives, phone mirrors (read-only), so there's no echo loop: - Watch's ExerciseProgressView broadcasts a LiveProgress frame on every phase transition (and an ended signal on leave) via sendMessage, reachable-only — throwaway presence, never written to iCloud. - Timers ride as wall-clock anchors (Date kept native in the WC dict to preserve sub-second precision), so both devices count independently off shared start times and stay in lockstep without streaming ticks. - Phone holds a transient LiveRunState; ContentView auto-presents a read-only LiveProgressMirrorView full-screen cover while a run is live. Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
This commit is contained in:
@@ -33,9 +33,17 @@ struct ExerciseProgressView: View {
|
||||
let logID: String
|
||||
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.
|
||||
let onLive: (LiveProgress) -> Void
|
||||
let onLiveEnded: () -> Void
|
||||
|
||||
/// Rest length between sets, shared with the phone via the same defaults key.
|
||||
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
||||
|
||||
/// Auto-Done countdown on the Finish page — read so the mirror can show the same timer.
|
||||
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||
|
||||
/// Planned set count for this run. `One More` bumps it (and the log's `sets`).
|
||||
@State private var setCount: Int
|
||||
@State private var currentPage: Int
|
||||
@@ -51,10 +59,12 @@ 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, debugInitialPage: Int? = nil) {
|
||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, debugInitialPage: Int? = nil) {
|
||||
self._doc = doc
|
||||
self.logID = logID
|
||||
self.onChange = onChange
|
||||
self.onLive = onLive
|
||||
self.onLiveEnded = onLiveEnded
|
||||
self.debugInitialPage = debugInitialPage
|
||||
|
||||
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
||||
@@ -185,6 +195,7 @@ struct ExerciseProgressView: View {
|
||||
} else {
|
||||
recordProgress(for: newPage)
|
||||
}
|
||||
broadcastLive(for: newPage)
|
||||
}
|
||||
.onAppear {
|
||||
guard !didRestorePage else { return }
|
||||
@@ -197,12 +208,67 @@ struct ExerciseProgressView: View {
|
||||
Task { @MainActor in
|
||||
jumpToResumePage()
|
||||
didRestorePage = true
|
||||
broadcastLive(for: currentPage)
|
||||
}
|
||||
} else {
|
||||
// Not-started opens on the Ready page; the screenshot host pins its own.
|
||||
didRestorePage = true
|
||||
broadcastLive(for: currentPage)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
// Leaving the flow (cancel / done / back) — stop the phone mirror.
|
||||
guard debugInitialPage == nil else { return }
|
||||
onLiveEnded()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live mirror
|
||||
|
||||
/// 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) {
|
||||
guard debugInitialPage == nil, let snapshot = liveSnapshot(for: page) else { return }
|
||||
onLive(snapshot)
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
private func liveSnapshot(for page: Int) -> LiveProgress? {
|
||||
guard let log else { return nil }
|
||||
let now = Date()
|
||||
|
||||
func frame(_ phase: LiveRunPhase, setIndex: Int, end: Date?) -> LiveProgress {
|
||||
LiveProgress(
|
||||
workoutID: doc.id,
|
||||
logID: logID,
|
||||
exerciseName: log.exerciseName,
|
||||
phase: phase,
|
||||
setIndex: setIndex,
|
||||
setCount: setCount,
|
||||
detail: detail,
|
||||
phaseStart: now,
|
||||
phaseEnd: end,
|
||||
version: 0
|
||||
)
|
||||
}
|
||||
|
||||
if showsReady && page == 0 {
|
||||
return frame(.ready, setIndex: 0, end: nil)
|
||||
}
|
||||
let cycleIndex = page - base
|
||||
if cycleIndex == cycleCount {
|
||||
return frame(.finish, setIndex: max(0, setCount - 1),
|
||||
end: now.addingTimeInterval(Double(max(1, doneCountdownSeconds))))
|
||||
} else if cycleIndex.isMultiple(of: 2) {
|
||||
let set = cycleIndex / 2
|
||||
let end = isDuration ? now.addingTimeInterval(Double(workDurationSeconds)) : nil
|
||||
return frame(.work, setIndex: set, end: end)
|
||||
} else {
|
||||
let set = (cycleIndex - 1) / 2 // the rest follows this set
|
||||
return frame(.rest, setIndex: set, end: now.addingTimeInterval(Double(max(1, restSeconds))))
|
||||
}
|
||||
}
|
||||
|
||||
/// Move to the resume page without animation, only if we're not already there
|
||||
|
||||
@@ -86,7 +86,13 @@ struct WorkoutLogListView: View {
|
||||
}
|
||||
.navigationTitle(doc.splitName ?? Split.unnamed)
|
||||
.navigationDestination(item: $selectedLogID) { logID in
|
||||
ExerciseProgressView(doc: $doc, logID: logID, onChange: { bridge.update(workout: doc) })
|
||||
ExerciseProgressView(
|
||||
doc: $doc,
|
||||
logID: logID,
|
||||
onChange: { bridge.update(workout: doc) },
|
||||
onLive: { bridge.sendLiveProgress($0) },
|
||||
onLiveEnded: { bridge.sendLiveEnded(workoutID: doc.id, logID: logID) }
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showingExercisePicker) {
|
||||
ExercisePickerView(exercises: availableExercises) { exercise in
|
||||
|
||||
Reference in New Issue
Block a user