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:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
**June 2026**
|
**June 2026**
|
||||||
|
|
||||||
Prop your iPhone up during an Apple Watch workout and it now mirrors the live run — the same Ready → work/rest → Finish flow with running timers — following along set by set as you swipe on the watch.
|
Prop your iPhone up during an Apple Watch workout and it now runs the same live flow side by side — Ready → work/rest → Finish with running timers — and you can drive from either device: swipe ahead, finish a set, or add one on whichever is closer, and the other follows along. Automatic moves, like a rest timer running out, advance both devices on their own.
|
||||||
|
|
||||||
Editing an exercise or split on iPhone now steps the Apple Watch out of that workout, showing it as "Editing on iPhone" until you're done — so the watch never keeps running an exercise whose plan you're changing.
|
Editing an exercise or split on iPhone now steps the Apple Watch out of that workout, showing it as "Editing on iPhone" until you're done — so the watch never keeps running an exercise whose plan you're changing.
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ your own iCloud Drive.
|
|||||||
page with **One More** and a **Done** that auto-completes after a countdown. A
|
page with **One More** and a **Done** that auto-completes after a countdown. A
|
||||||
phase-dot row (purple work, teal rest) tracks progress. Rest time and the
|
phase-dot row (purple work, teal rest) tracks progress. Rest time and the
|
||||||
auto-finish countdown are configurable; changes sync back to the phone.
|
auto-finish countdown are configurable; changes sync back to the phone.
|
||||||
- **Live workout mirror** — prop your iPhone up during an Apple Watch workout and it
|
- **Two-way live run** — prop your iPhone up during an Apple Watch workout and it runs
|
||||||
mirrors the run in real time: the same Ready → work/rest → Finish flow with live
|
the same Ready → work/rest → Finish flow with live timers, in step with the watch. It's
|
||||||
timers, following the watch set by set. It's read-only — the watch stays in control,
|
bidirectional: drive from either device — swipe ahead, finish a set, add one — and the
|
||||||
and the timers stay in step because each device counts off shared start times.
|
other follows. Only *human* transitions are sent; automatic ones (a rest timer ending)
|
||||||
|
advance both devices independently off shared start times, so they never fight.
|
||||||
- **iCloud Drive sync** — your data lives as human-readable JSON in your iCloud
|
- **iCloud Drive sync** — your data lives as human-readable JSON in your iCloud
|
||||||
Drive, synced across devices and visible in the Files app. iCloud is required.
|
Drive, synced across devices and visible in the Files app. iCloud is required.
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,16 @@ final class WatchConnectivityBridge: NSObject {
|
|||||||
private(set) var editingWorkoutID: String?
|
private(set) var editingWorkoutID: String?
|
||||||
private(set) var editingSplitID: String?
|
private(set) var editingSplitID: String?
|
||||||
|
|
||||||
/// Monotonic sequence stamped on each live-run frame, so the phone mirror can drop a
|
/// Monotonic sequence stamped on each live-run frame we send. Bumped to stay ahead of any
|
||||||
/// stale / out-of-order delivery.
|
/// frame we *receive*, so the two devices share one increasing per-run sequence and either
|
||||||
|
/// side can drop a stale / out-of-order delivery (see `LiveProgress.version`).
|
||||||
private var liveVersion = 0
|
private var liveVersion = 0
|
||||||
|
|
||||||
|
/// The latest live-run frame the *phone* sent, for the run we currently have open to apply
|
||||||
|
/// (ephemeral; nil when the phone isn't driving). The watch's `ExerciseProgressView` reads
|
||||||
|
/// this to follow a phone-driven transition; it's never persisted.
|
||||||
|
private(set) var liveIncoming: LiveProgress?
|
||||||
|
|
||||||
private var context: ModelContext { container.mainContext }
|
private var context: ModelContext { container.mainContext }
|
||||||
|
|
||||||
init(container: ModelContainer) {
|
init(container: ModelContainer) {
|
||||||
@@ -82,6 +88,19 @@ final class WatchConnectivityBridge: NSObject {
|
|||||||
replyHandler: nil, errorHandler: { _ in })
|
replyHandler: nil, errorHandler: { _ in })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply a live-run frame the phone sent. Drops a stale one for the same run, and catches
|
||||||
|
/// our send counter up so the next frame we send outranks it (shared per-run sequence).
|
||||||
|
private func applyIncomingLive(_ frame: LiveProgress) {
|
||||||
|
liveVersion = max(liveVersion, frame.version)
|
||||||
|
if let current = liveIncoming, current.logID == frame.logID, frame.version < current.version { return }
|
||||||
|
liveIncoming = frame
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The phone left the run — stop following it.
|
||||||
|
private func endIncomingLive(logID: String) {
|
||||||
|
if liveIncoming?.logID == logID { liveIncoming = nil }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Internal
|
// MARK: - Internal
|
||||||
|
|
||||||
private func sendToPhone(_ doc: WorkoutDocument) {
|
private func sendToPhone(_ doc: WorkoutDocument) {
|
||||||
@@ -133,6 +152,23 @@ extension WatchConnectivityBridge: WCSessionDelegate {
|
|||||||
Task { @MainActor in self.requestSync() }
|
Task { @MainActor in self.requestSync() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Live-run frames arrive as messages (reachable-only), distinct from the latest-wins
|
||||||
|
/// application context that carries durable state.
|
||||||
|
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||||
|
switch message[WCPayload.typeKey] as? String {
|
||||||
|
case WCPayload.liveProgressType:
|
||||||
|
if let frame = WCPayload.decodeLiveProgress(message) {
|
||||||
|
Task { @MainActor in self.applyIncomingLive(frame) }
|
||||||
|
}
|
||||||
|
case WCPayload.liveEndedType:
|
||||||
|
if let logID = message[WCPayload.lpLogIDKey] as? String {
|
||||||
|
Task { @MainActor in self.endIncomingLive(logID: logID) }
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||||
let splits = WCPayload.decodeSplits(applicationContext)
|
let splits = WCPayload.decodeSplits(applicationContext)
|
||||||
let workouts = WCPayload.decodeWorkouts(applicationContext)
|
let workouts = WCPayload.decodeWorkouts(applicationContext)
|
||||||
|
|||||||
@@ -34,10 +34,17 @@ struct ExerciseProgressView: View {
|
|||||||
let onChange: () -> Void
|
let onChange: () -> Void
|
||||||
|
|
||||||
/// Broadcasts the current flow position so a propped-up iPhone can mirror the run live
|
/// 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 onLive: (LiveProgress) -> Void
|
||||||
let onLiveEnded: () -> 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.
|
/// Rest length between sets, shared with the phone via the same defaults key.
|
||||||
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
||||||
|
|
||||||
@@ -50,6 +57,15 @@ struct ExerciseProgressView: View {
|
|||||||
@State private var showingCancelConfirm = false
|
@State private var showingCancelConfirm = false
|
||||||
@State private var didRestorePage = 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
|
/// 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
|
/// 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.
|
/// 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.
|
/// also suppresses the Ready page so the index is a plain work/rest cycle offset.
|
||||||
private let debugInitialPage: Int?
|
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._doc = doc
|
||||||
self.logID = logID
|
self.logID = logID
|
||||||
self.onChange = onChange
|
self.onChange = onChange
|
||||||
self.onLive = onLive
|
self.onLive = onLive
|
||||||
self.onLiveEnded = onLiveEnded
|
self.onLiveEnded = onLiveEnded
|
||||||
|
self.incomingFrame = incomingFrame
|
||||||
self.debugInitialPage = debugInitialPage
|
self.debugInitialPage = debugInitialPage
|
||||||
|
|
||||||
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
||||||
@@ -187,9 +204,20 @@ struct ExerciseProgressView: View {
|
|||||||
// Ignore page changes until the initial resume settles, so the TabView's
|
// 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.
|
// transient snap-to-0 on first layout can't reset an in-progress run.
|
||||||
guard didRestorePage else { return }
|
guard didRestorePage else { return }
|
||||||
// A deliberate swipe back from the first set to the Ready page wipes the run
|
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
|
// (only the adjacent 1→0 swipe resets — a stray far jump never does); any
|
||||||
// other page records forward progress.
|
// other page records forward progress. Human transitions are broadcast.
|
||||||
if showsReady && newPage == 0 && oldPage == base {
|
if showsReady && newPage == 0 && oldPage == base {
|
||||||
resetExercise()
|
resetExercise()
|
||||||
} else {
|
} else {
|
||||||
@@ -197,6 +225,10 @@ struct ExerciseProgressView: View {
|
|||||||
}
|
}
|
||||||
broadcastLive(for: newPage)
|
broadcastLive(for: newPage)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: incomingFrame) { _, frame in
|
||||||
|
if let frame { applyIncoming(frame) }
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
guard !didRestorePage else { return }
|
guard !didRestorePage else { return }
|
||||||
if startsResumed {
|
if startsResumed {
|
||||||
@@ -207,13 +239,11 @@ struct ExerciseProgressView: View {
|
|||||||
jumpToResumePage()
|
jumpToResumePage()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
jumpToResumePage()
|
jumpToResumePage()
|
||||||
didRestorePage = true
|
finishRestore()
|
||||||
broadcastLive(for: currentPage)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Not-started opens on the Ready page; the screenshot host pins its own.
|
// Not-started opens on the Ready page; the screenshot host pins its own.
|
||||||
didRestorePage = true
|
finishRestore()
|
||||||
broadcastLive(for: currentPage)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
@@ -225,6 +255,17 @@ struct ExerciseProgressView: View {
|
|||||||
|
|
||||||
// MARK: - Live mirror
|
// 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
|
/// 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.
|
/// page just became active — so the mirror's timer lines up with this device's.
|
||||||
private func broadcastLive(for page: Int) {
|
private func broadcastLive(for page: Int) {
|
||||||
@@ -232,6 +273,30 @@ struct ExerciseProgressView: View {
|
|||||||
onLive(snapshot)
|
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
|
/// 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)
|
/// 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`.
|
/// 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
|
/// 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) {
|
private func advance(from index: Int) {
|
||||||
guard currentPage == index, index + 1 < totalPages else { return }
|
guard currentPage == index, index + 1 < totalPages else { return }
|
||||||
|
pageChangeCause = .auto
|
||||||
currentPage = index + 1
|
currentPage = index + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ struct WorkoutLogListView: View {
|
|||||||
logID: logID,
|
logID: logID,
|
||||||
onChange: { bridge.update(workout: doc) },
|
onChange: { bridge.update(workout: doc) },
|
||||||
onLive: { bridge.sendLiveProgress($0) },
|
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) {
|
.sheet(isPresented: $showingExercisePicker) {
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ final class PhoneConnectivityBridge: NSObject {
|
|||||||
private(set) var editingWorkoutID: String?
|
private(set) var editingWorkoutID: String?
|
||||||
private(set) var editingSplitID: String?
|
private(set) var editingSplitID: String?
|
||||||
|
|
||||||
|
/// Monotonic sequence stamped on each live-run frame we send. Bumped to stay ahead of
|
||||||
|
/// any frame we *receive*, so the two devices share one increasing sequence per run and
|
||||||
|
/// either side can drop a stale / out-of-order delivery (see `LiveProgress.version`).
|
||||||
|
private var liveVersion = 0
|
||||||
|
|
||||||
private var context: ModelContext { container.mainContext }
|
private var context: ModelContext { container.mainContext }
|
||||||
|
|
||||||
init(container: ModelContainer, syncEngine: SyncEngine, liveRunState: LiveRunState) {
|
init(container: ModelContainer, syncEngine: SyncEngine, liveRunState: LiveRunState) {
|
||||||
@@ -82,6 +87,34 @@ final class PhoneConnectivityBridge: NSObject {
|
|||||||
pushAll()
|
pushAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Live run mirror (ephemeral; reachable-only)
|
||||||
|
|
||||||
|
/// Broadcast where the run flow currently is, so the watch (if it has this run open) can
|
||||||
|
/// follow it live. Sent over `sendMessage` only when reachable — this is throwaway
|
||||||
|
/// presence, so there's no guaranteed-delivery fallback (a queued frame would be stale on
|
||||||
|
/// arrival). Mirrors the watch's `sendLiveProgress`; only *human* transitions are sent.
|
||||||
|
func sendLiveProgress(_ frame: LiveProgress) {
|
||||||
|
guard let session, session.activationState == .activated, session.isReachable else { return }
|
||||||
|
liveVersion += 1
|
||||||
|
var stamped = frame
|
||||||
|
stamped.version = liveVersion
|
||||||
|
session.sendMessage(WCPayload.encodeLiveProgress(stamped), replyHandler: nil, errorHandler: { _ in })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell the watch we left the run flow (the cover closed / the run finished).
|
||||||
|
func sendLiveEnded(workoutID: String, logID: String) {
|
||||||
|
guard let session, session.activationState == .activated, session.isReachable else { return }
|
||||||
|
session.sendMessage(WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID),
|
||||||
|
replyHandler: nil, errorHandler: { _ in })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a frame the watch sent. Catch our send counter up to it first, so the next frame
|
||||||
|
/// we send outranks it and the shared per-run sequence keeps increasing across devices.
|
||||||
|
private func applyIncomingLive(_ frame: LiveProgress) {
|
||||||
|
liveVersion = max(liveVersion, frame.version)
|
||||||
|
liveRunState.apply(frame)
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse the (non-Sendable) WC dictionary in the nonisolated delegate context,
|
/// Parse the (non-Sendable) WC dictionary in the nonisolated delegate context,
|
||||||
/// then hop to the MainActor with only Sendable values.
|
/// then hop to the MainActor with only Sendable values.
|
||||||
nonisolated private func route(_ dict: [String: Any]) {
|
nonisolated private func route(_ dict: [String: Any]) {
|
||||||
@@ -94,7 +127,7 @@ final class PhoneConnectivityBridge: NSObject {
|
|||||||
}
|
}
|
||||||
case WCPayload.liveProgressType:
|
case WCPayload.liveProgressType:
|
||||||
if let frame = WCPayload.decodeLiveProgress(dict) {
|
if let frame = WCPayload.decodeLiveProgress(dict) {
|
||||||
Task { @MainActor in self.liveRunState.apply(frame) }
|
Task { @MainActor in self.applyIncomingLive(frame) }
|
||||||
}
|
}
|
||||||
case WCPayload.liveEndedType:
|
case WCPayload.liveEndedType:
|
||||||
if let logID = dict[WCPayload.lpLogIDKey] as? String {
|
if let logID = dict[WCPayload.lpLogIDKey] as? String {
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ struct ContentView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
WorkoutLogsView()
|
WorkoutLogsView()
|
||||||
// Prop the phone up and it mirrors a live workout running on the Apple Watch.
|
// Prop the phone up and it runs the live Apple Watch workout — two-way: drive from
|
||||||
|
// either device and the other follows.
|
||||||
.fullScreenCover(isPresented: Binding(
|
.fullScreenCover(isPresented: Binding(
|
||||||
get: { liveRun.presentable != nil },
|
get: { liveRun.presentable != nil },
|
||||||
set: { presenting in if !presenting { liveRun.mute() } }
|
set: { presenting in if !presenting { liveRun.mute() } }
|
||||||
)) {
|
)) {
|
||||||
if let frame = liveRun.presentable {
|
if let frame = liveRun.presentable {
|
||||||
LiveProgressMirrorView(progress: frame) { liveRun.mute() }
|
LiveRunCoverView(frame: frame) { liveRun.mute() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,14 +36,40 @@ struct ExerciseProgressView: View {
|
|||||||
let logID: String
|
let logID: String
|
||||||
let onChange: () -> Void
|
let onChange: () -> Void
|
||||||
|
|
||||||
|
/// Broadcasts the current flow position so the watch (if it has this run open) can follow
|
||||||
|
/// it live (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 other device reaches it independently off the same wall-clock anchors. Default
|
||||||
|
/// no-ops, so the in-list driver stays purely durable; the propped-phone cover wires these.
|
||||||
|
let onLive: (LiveProgress) -> Void
|
||||||
|
let onLiveEnded: () -> Void
|
||||||
|
|
||||||
|
/// The latest live-run frame the *watch* sent for this run, to follow when it drives a
|
||||||
|
/// transition (ephemeral; nil when the watch 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 watch via the same defaults key.
|
/// Rest length between sets, shared with the watch via the same defaults key.
|
||||||
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
||||||
|
|
||||||
|
/// Auto-Done countdown on the Finish page — read so a broadcast frame carries the same
|
||||||
|
/// end anchor the watch's mirror counts off.
|
||||||
|
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||||
|
|
||||||
/// Planned set count for this run. `One More` bumps it (and the log's `sets`).
|
/// Planned set count for this run. `One More` bumps it (and the log's `sets`).
|
||||||
@State private var setCount: Int
|
@State private var setCount: Int
|
||||||
@State private var currentPage: Int
|
@State private var currentPage: Int
|
||||||
@State private var didRestorePage = 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
|
/// True when this run opened on a resumed set (in-progress) rather than the Ready
|
||||||
/// page. Held in `@State` so it stays fixed for the life of the screen — the parent
|
/// page. Held in `@State` so it stays fixed for the life of the screen — the parent
|
||||||
/// list re-inits this view whenever the workout file changes, and this must not flip
|
/// list re-inits this view whenever the workout file changes, and this must not flip
|
||||||
@@ -51,10 +77,13 @@ struct ExerciseProgressView: View {
|
|||||||
/// the transient TabView snap-to-0, so it isn't reset on open.
|
/// the transient TabView snap-to-0, so it isn't reset on open.
|
||||||
@State private var startsResumed: Bool
|
@State private var startsResumed: Bool
|
||||||
|
|
||||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void) {
|
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) {
|
||||||
self._doc = doc
|
self._doc = doc
|
||||||
self.logID = logID
|
self.logID = logID
|
||||||
self.onChange = onChange
|
self.onChange = onChange
|
||||||
|
self.onLive = onLive
|
||||||
|
self.onLiveEnded = onLiveEnded
|
||||||
|
self.incomingFrame = incomingFrame
|
||||||
|
|
||||||
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
||||||
let sets = max(1, log?.sets ?? 1)
|
let sets = max(1, log?.sets ?? 1)
|
||||||
@@ -171,14 +200,30 @@ struct ExerciseProgressView: View {
|
|||||||
// Ignore page changes until the initial resume settles, so the TabView's
|
// 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.
|
// transient snap-to-0 on first layout can't reset an in-progress run.
|
||||||
guard didRestorePage else { return }
|
guard didRestorePage else { return }
|
||||||
// A deliberate swipe back from the first set to the Ready page wipes the run
|
let cause = pageChangeCause
|
||||||
|
pageChangeCause = nil
|
||||||
|
switch cause {
|
||||||
|
case .remote:
|
||||||
|
// The watch 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 watch 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
|
// (only the adjacent 1→0 swipe resets — a stray far jump never does); any
|
||||||
// other page records forward progress.
|
// other page records forward progress. Human transitions are broadcast.
|
||||||
if showsReady && newPage == 0 && oldPage == base {
|
if showsReady && newPage == 0 && oldPage == base {
|
||||||
resetExercise()
|
resetExercise()
|
||||||
} else {
|
} else {
|
||||||
recordProgress(for: newPage)
|
recordProgress(for: newPage)
|
||||||
}
|
}
|
||||||
|
broadcastLive(for: newPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: incomingFrame) { _, frame in
|
||||||
|
if let frame { applyIncoming(frame) }
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
guard !didRestorePage else { return }
|
guard !didRestorePage else { return }
|
||||||
@@ -190,13 +235,17 @@ struct ExerciseProgressView: View {
|
|||||||
jumpToResumePage()
|
jumpToResumePage()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
jumpToResumePage()
|
jumpToResumePage()
|
||||||
didRestorePage = true
|
finishRestore()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Not-started opens on the Ready page.
|
// Not-started opens on the Ready page.
|
||||||
didRestorePage = true
|
finishRestore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
// Leaving the flow (back / done) — stop the watch from following.
|
||||||
|
onLiveEnded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move to the resume page without animation, only if we're not already there
|
/// Move to the resume page without animation, only if we're not already there
|
||||||
@@ -209,6 +258,89 @@ struct ExerciseProgressView: View {
|
|||||||
withTransaction(transaction) { currentPage = target }
|
withTransaction(transaction) { currentPage = target }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 watch.
|
||||||
|
private func finishRestore() {
|
||||||
|
didRestorePage = true
|
||||||
|
if let frame = incomingFrame, frame.logID == logID {
|
||||||
|
applyIncoming(frame)
|
||||||
|
} else {
|
||||||
|
broadcastLive(for: currentPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push the current flow position to the watch. The anchor is stamped *now* — the page
|
||||||
|
/// just became active — so the watch's mirror timer lines up with this device's.
|
||||||
|
private func broadcastLive(for page: Int) {
|
||||||
|
guard let snapshot = liveSnapshot(for: page) else { return }
|
||||||
|
onLive(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follow a transition the watch made: jump to the frame's page (matching its set count
|
||||||
|
/// for a remote One More) without re-broadcasting or re-recording it — the watch 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 watch 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))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func page(for index: Int) -> some View {
|
private func page(for index: Int) -> some View {
|
||||||
let isActive = index == currentPage
|
let isActive = index == currentPage
|
||||||
@@ -268,9 +400,11 @@ struct ExerciseProgressView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Programmatically move one page right (used by the rest auto-advance), guarding
|
/// 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 watch auto-advances too).
|
||||||
private func advance(from index: Int) {
|
private func advance(from index: Int) {
|
||||||
guard currentPage == index, index + 1 < totalPages else { return }
|
guard currentPage == index, index + 1 < totalPages else { return }
|
||||||
|
pageChangeCause = .auto
|
||||||
currentPage = index + 1
|
currentPage = index + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
//
|
|
||||||
// LiveProgressMirrorView.swift
|
|
||||||
// Workouts
|
|
||||||
//
|
|
||||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
/// Read-only mirror of the watch's run flow, driven entirely by the latest `LiveProgress`
|
|
||||||
/// frame. It re-creates the look of the iPhone's own `ExerciseProgressView` — Ready / Work /
|
|
||||||
/// Rest / Finish with the same anchored timers — but takes no input: the user drives on the
|
|
||||||
/// watch, this just reflects it. The timers render off the frame's wall-clock anchors, so they
|
|
||||||
/// keep ticking smoothly between frames and stay in step with the watch without streaming.
|
|
||||||
///
|
|
||||||
/// The phase styling helpers below are intentionally a small standalone copy of the driver
|
|
||||||
/// flow's, so mirroring can't regress the shipping run experience.
|
|
||||||
struct LiveProgressMirrorView: View {
|
|
||||||
let progress: LiveProgress
|
|
||||||
let onClose: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
header
|
|
||||||
|
|
||||||
phaseContent
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.overlay(alignment: .bottom) {
|
|
||||||
if let model = dotsModel {
|
|
||||||
MirrorDots(model: model).padding(.bottom, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Label("Mirroring Apple Watch", systemImage: "applewatch")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.padding(.bottom, 12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var header: some View {
|
|
||||||
HStack {
|
|
||||||
Text(progress.exerciseName)
|
|
||||||
.font(.headline)
|
|
||||||
.lineLimit(1)
|
|
||||||
Spacer()
|
|
||||||
Button(action: onClose) {
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.symbolRenderingMode(.hierarchical)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Close")
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var phaseContent: some View {
|
|
||||||
switch progress.phase {
|
|
||||||
case .ready:
|
|
||||||
ReadyMirror(summary: readySummary)
|
|
||||||
|
|
||||||
case .work:
|
|
||||||
MirrorTimerLayout(
|
|
||||||
header: "\(progress.setIndex + 1) of \(progress.setCount)",
|
|
||||||
footer: progress.detail,
|
|
||||||
tint: .mirrorWork
|
|
||||||
) {
|
|
||||||
if let end = progress.phaseEnd {
|
|
||||||
// Timed work set — counts down.
|
|
||||||
Text(timerInterval: progress.phaseStart...end, countsDown: true)
|
|
||||||
} else {
|
|
||||||
// Rep-based work set — counts up.
|
|
||||||
Text(progress.phaseStart, style: .timer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case .rest:
|
|
||||||
MirrorTimerLayout(header: "Rest", footer: "", tint: .mirrorRest) {
|
|
||||||
if let end = progress.phaseEnd {
|
|
||||||
Text(timerInterval: progress.phaseStart...end, countsDown: true)
|
|
||||||
} else {
|
|
||||||
Text("0:00")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case .finish:
|
|
||||||
FinishMirror(start: progress.phaseStart, end: progress.phaseEnd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One-line plan summary for the Ready page, e.g. "4 sets × 8 reps".
|
|
||||||
private var readySummary: String {
|
|
||||||
let setsText = "\(progress.setCount) set\(progress.setCount == 1 ? "" : "s")"
|
|
||||||
return progress.detail.isEmpty ? setsText : "\(setsText) × \(progress.detail)"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dot-row model — matches the driver flow's `workDots` mapping.
|
|
||||||
private var dotsModel: MirrorDots.Model? {
|
|
||||||
switch progress.phase {
|
|
||||||
case .work:
|
|
||||||
return .init(setCount: progress.setCount, activeSet: progress.setIndex,
|
|
||||||
restAfterSet: nil, completed: progress.setIndex)
|
|
||||||
case .rest:
|
|
||||||
return .init(setCount: progress.setCount, activeSet: nil,
|
|
||||||
restAfterSet: progress.setIndex, completed: progress.setIndex + 1)
|
|
||||||
case .ready, .finish:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Phase Mirrors
|
|
||||||
|
|
||||||
private struct ReadyMirror: View {
|
|
||||||
let summary: String
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 14) {
|
|
||||||
Text("Ready?")
|
|
||||||
.font(.system(size: 44, weight: .bold, design: .rounded))
|
|
||||||
if !summary.isEmpty {
|
|
||||||
Text(summary)
|
|
||||||
.font(.title3)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct FinishMirror: View {
|
|
||||||
let start: Date
|
|
||||||
let end: Date?
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 14) {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.font(.system(size: 96))
|
|
||||||
.foregroundStyle(Color.mirrorWork)
|
|
||||||
if let end {
|
|
||||||
Text(timerInterval: start...end, countsDown: true)
|
|
||||||
.font(.system(size: 40, weight: .bold, design: .rounded))
|
|
||||||
.monospacedDigit()
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Text("Finishing on Apple Watch")
|
|
||||||
.font(.title3)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Shared Layout (standalone copy of the driver flow's styling)
|
|
||||||
|
|
||||||
private struct MirrorTimerLayout<Content: View>: View {
|
|
||||||
let header: String
|
|
||||||
let footer: String
|
|
||||||
let tint: Color
|
|
||||||
@ViewBuilder var timer: Content
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
Text(header)
|
|
||||||
.font(.title3)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
timer
|
|
||||||
.font(.system(size: 108, weight: .bold, design: .rounded))
|
|
||||||
.monospacedDigit()
|
|
||||||
.foregroundStyle(tint)
|
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.5)
|
|
||||||
|
|
||||||
Text(footer.isEmpty ? " " : footer)
|
|
||||||
.font(.title3)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct MirrorDots: View {
|
|
||||||
struct Model: Equatable {
|
|
||||||
let setCount: Int
|
|
||||||
let activeSet: Int?
|
|
||||||
let restAfterSet: Int?
|
|
||||||
let completed: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
let model: Model
|
|
||||||
|
|
||||||
private let dotWidth: CGFloat = 8
|
|
||||||
private let dashWidth: CGFloat = 20
|
|
||||||
private let markerHeight: CGFloat = 8
|
|
||||||
private let gap: CGFloat = 8
|
|
||||||
private var restGap: CGFloat { gap * 2 }
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
ForEach(0..<model.setCount, id: \.self) { i in
|
|
||||||
marker(for: i)
|
|
||||||
if i < model.setCount - 1 {
|
|
||||||
Color.clear.frame(width: gapWidth(after: i), height: markerHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animation(.easeInOut(duration: 0.3), value: model)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func marker(for i: Int) -> some View {
|
|
||||||
let isActive = model.activeSet == i
|
|
||||||
let isDone = i < model.completed
|
|
||||||
return Capsule()
|
|
||||||
.fill(Color.mirrorWork)
|
|
||||||
.frame(width: isActive ? dashWidth : dotWidth, height: markerHeight)
|
|
||||||
.opacity(isActive || isDone ? 1 : 0.45)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func gapWidth(after i: Int) -> CGFloat {
|
|
||||||
model.restAfterSet == i ? restGap : gap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Phase Colors (matched to the driver flow)
|
|
||||||
|
|
||||||
private extension Color {
|
|
||||||
static let mirrorWork = Color(UIColor { traits in
|
|
||||||
traits.userInterfaceStyle == .dark
|
|
||||||
? UIColor(red: 0.66, green: 0.45, blue: 0.96, alpha: 1)
|
|
||||||
: UIColor(red: 0.45, green: 0.18, blue: 0.78, alpha: 1)
|
|
||||||
})
|
|
||||||
|
|
||||||
static let mirrorRest = Color(UIColor { traits in
|
|
||||||
traits.userInterfaceStyle == .dark
|
|
||||||
? UIColor(white: 0.74, alpha: 1)
|
|
||||||
: UIColor(white: 0.52, alpha: 1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
//
|
||||||
|
// 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user