diff --git a/CHANGELOG.md b/CHANGELOG.md index b0bea97..8f169a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ **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. diff --git a/README.md b/README.md index 228f2ca..c6aa4df 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,11 @@ your own iCloud Drive. 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 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 - mirrors the run in real time: the same Ready → work/rest → Finish flow with live - timers, following the watch set by set. It's read-only — the watch stays in control, - and the timers stay in step because each device counts off shared start times. +- **Two-way live run** — prop your iPhone up during an Apple Watch workout and it runs + the same Ready → work/rest → Finish flow with live timers, in step with the watch. It's + bidirectional: drive from either device — swipe ahead, finish a set, add one — and the + 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 Drive, synced across devices and visible in the Files app. iCloud is required. diff --git a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift index 4191e14..d632acd 100644 --- a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift +++ b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift @@ -23,10 +23,16 @@ final class WatchConnectivityBridge: NSObject { private(set) var editingWorkoutID: String? private(set) var editingSplitID: String? - /// Monotonic sequence stamped on each live-run frame, so the phone mirror can drop a - /// stale / out-of-order delivery. + /// 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 per-run sequence and either + /// side can drop a stale / out-of-order delivery (see `LiveProgress.version`). 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 } init(container: ModelContainer) { @@ -82,6 +88,19 @@ final class WatchConnectivityBridge: NSObject { 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 private func sendToPhone(_ doc: WorkoutDocument) { @@ -133,6 +152,23 @@ extension WatchConnectivityBridge: WCSessionDelegate { 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]) { let splits = WCPayload.decodeSplits(applicationContext) let workouts = WCPayload.decodeWorkouts(applicationContext) diff --git a/Workouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/ExerciseProgressView.swift index ad72d30..12986db 100644 --- a/Workouts Watch App/Views/ExerciseProgressView.swift +++ b/Workouts Watch App/Views/ExerciseProgressView.swift @@ -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, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, debugInitialPage: Int? = nil) { + init(doc: Binding, 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 } diff --git a/Workouts Watch App/Views/WorkoutLogListView.swift b/Workouts Watch App/Views/WorkoutLogListView.swift index 3d50856..829a15a 100644 --- a/Workouts Watch App/Views/WorkoutLogListView.swift +++ b/Workouts Watch App/Views/WorkoutLogListView.swift @@ -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) { diff --git a/Workouts/Connectivity/PhoneConnectivityBridge.swift b/Workouts/Connectivity/PhoneConnectivityBridge.swift index ab063c1..7ec0149 100644 --- a/Workouts/Connectivity/PhoneConnectivityBridge.swift +++ b/Workouts/Connectivity/PhoneConnectivityBridge.swift @@ -23,6 +23,11 @@ final class PhoneConnectivityBridge: NSObject { private(set) var editingWorkoutID: 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 } init(container: ModelContainer, syncEngine: SyncEngine, liveRunState: LiveRunState) { @@ -82,6 +87,34 @@ final class PhoneConnectivityBridge: NSObject { 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, /// then hop to the MainActor with only Sendable values. nonisolated private func route(_ dict: [String: Any]) { @@ -94,7 +127,7 @@ final class PhoneConnectivityBridge: NSObject { } case WCPayload.liveProgressType: if let frame = WCPayload.decodeLiveProgress(dict) { - Task { @MainActor in self.liveRunState.apply(frame) } + Task { @MainActor in self.applyIncomingLive(frame) } } case WCPayload.liveEndedType: if let logID = dict[WCPayload.lpLogIDKey] as? String { diff --git a/Workouts/ContentView.swift b/Workouts/ContentView.swift index 8b08fb7..eb3754b 100644 --- a/Workouts/ContentView.swift +++ b/Workouts/ContentView.swift @@ -12,13 +12,14 @@ struct ContentView: View { var body: some View { 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( get: { liveRun.presentable != nil }, set: { presenting in if !presenting { liveRun.mute() } } )) { if let frame = liveRun.presentable { - LiveProgressMirrorView(progress: frame) { liveRun.mute() } + LiveRunCoverView(frame: frame) { liveRun.mute() } } } } diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index 1058592..a5c8210 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -36,14 +36,40 @@ struct ExerciseProgressView: View { let logID: String 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. @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`). @State private var setCount: Int @State private var currentPage: Int @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. 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 @@ -51,10 +77,13 @@ struct ExerciseProgressView: View { /// the transient TabView snap-to-0, so it isn't reset on open. @State private var startsResumed: Bool - init(doc: Binding, logID: String, onChange: @escaping () -> Void) { + init(doc: Binding, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) { self._doc = doc self.logID = logID self.onChange = onChange + self.onLive = onLive + self.onLiveEnded = onLiveEnded + self.incomingFrame = incomingFrame let log = doc.wrappedValue.logs.first { $0.id == logID } let sets = max(1, log?.sets ?? 1) @@ -171,15 +200,31 @@ 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 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 + // other page records forward progress. Human transitions are broadcast. + if showsReady && newPage == 0 && oldPage == base { + resetExercise() + } else { + recordProgress(for: newPage) + } + broadcastLive(for: newPage) } } + .onChange(of: incomingFrame) { _, frame in + if let frame { applyIncoming(frame) } + } .onAppear { guard !didRestorePage else { return } if startsResumed { @@ -190,13 +235,17 @@ struct ExerciseProgressView: View { jumpToResumePage() Task { @MainActor in jumpToResumePage() - didRestorePage = true + finishRestore() } } else { // 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 @@ -209,6 +258,89 @@ struct ExerciseProgressView: View { 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 private func page(for index: Int) -> some View { let isActive = index == currentPage @@ -268,9 +400,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 watch auto-advances too). private func advance(from index: Int) { guard currentPage == index, index + 1 < totalPages else { return } + pageChangeCause = .auto currentPage = index + 1 } diff --git a/Workouts/Views/WorkoutLogs/LiveProgressMirrorView.swift b/Workouts/Views/WorkoutLogs/LiveProgressMirrorView.swift deleted file mode 100644 index ddc048a..0000000 --- a/Workouts/Views/WorkoutLogs/LiveProgressMirrorView.swift +++ /dev/null @@ -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: 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.. 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) - }) -} diff --git a/Workouts/Views/WorkoutLogs/LiveRunCoverView.swift b/Workouts/Views/WorkoutLogs/LiveRunCoverView.swift new file mode 100644 index 0000000..725f8b7 --- /dev/null +++ b/Workouts/Views/WorkoutLogs/LiveRunCoverView.swift @@ -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 { $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) } + } +}