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:
@@ -1,5 +1,7 @@
|
|||||||
**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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
The app now wears its signature purple: it's the accent color throughout, and a completed exercise is marked with a purple check. In-progress exercises now read as a neutral gray.
|
The app now wears its signature purple: it's the accent color throughout, and a completed exercise is marked with a purple check. In-progress exercises now read as a neutral gray.
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ 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
|
||||||
|
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.
|
||||||
- **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.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
//
|
||||||
|
// LiveProgress.swift
|
||||||
|
// Workouts (Shared)
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Which page of the Ready → Work → Rest → Finish run flow the driving device is on.
|
||||||
|
enum LiveRunPhase: String, Sendable, Equatable {
|
||||||
|
case ready, work, rest, finish
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An *ephemeral* "live run" frame broadcast by the device actively driving an exercise so a
|
||||||
|
/// propped-up second device can mirror the run flow in real time.
|
||||||
|
///
|
||||||
|
/// This is deliberately **not** the durable `WorkoutDocument` sync. That path writes a file to
|
||||||
|
/// iCloud Drive at set-completion granularity; this one is a throwaway snapshot of *where in
|
||||||
|
/// the flow* the driver is, sent on every phase transition and never persisted (no SwiftData,
|
||||||
|
/// no iCloud).
|
||||||
|
///
|
||||||
|
/// Timers ride as wall-clock anchors, not a streamed countdown: the mirror renders the same
|
||||||
|
/// SwiftUI timer text off `phaseStart` / `phaseEnd`, so both devices count independently and
|
||||||
|
/// stay in lockstep without ticking over the wire. Paired-device clock skew is sub-second, so
|
||||||
|
/// the two displays agree in practice. A count-down phase (rest, timed work, the finish
|
||||||
|
/// auto-Done) carries `phaseEnd`; a rep-based work set counts *up* and leaves it `nil`.
|
||||||
|
struct LiveProgress: Sendable, Equatable {
|
||||||
|
var workoutID: String
|
||||||
|
var logID: String
|
||||||
|
var exerciseName: String
|
||||||
|
var phase: LiveRunPhase
|
||||||
|
|
||||||
|
/// 0-based set this frame pertains to: the set being worked (`work`/`finish`), or the set
|
||||||
|
/// just completed that this rest follows (`rest`). Drives the header and the progress dots.
|
||||||
|
var setIndex: Int
|
||||||
|
var setCount: Int
|
||||||
|
|
||||||
|
/// Footer line under the timer, e.g. "8 reps" or "30 sec".
|
||||||
|
var detail: String
|
||||||
|
|
||||||
|
/// Wall-clock anchor for the phase's timer. Work counts up from here; count-down phases
|
||||||
|
/// run from here to `phaseEnd`.
|
||||||
|
var phaseStart: Date
|
||||||
|
/// End anchor for count-down phases; `nil` for a rep-based work set (which counts up).
|
||||||
|
var phaseEnd: Date?
|
||||||
|
|
||||||
|
/// Monotonic per-run sequence, so the mirror can drop a stale / out-of-order delivery.
|
||||||
|
var version: Int
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ enum WCPayload {
|
|||||||
|
|
||||||
static let workoutUpdateType = "workoutUpdate" // watch → phone (one workout)
|
static let workoutUpdateType = "workoutUpdate" // watch → phone (one workout)
|
||||||
static let requestSyncType = "requestSync" // watch → phone (please push state)
|
static let requestSyncType = "requestSync" // watch → phone (please push state)
|
||||||
|
static let liveProgressType = "liveProgress" // watch → phone (ephemeral mirror frame)
|
||||||
|
static let liveEndedType = "liveEnded" // watch → phone (stop mirroring a run)
|
||||||
|
|
||||||
// MARK: - Phone → Watch (application context: latest-state-wins)
|
// MARK: - Phone → Watch (application context: latest-state-wins)
|
||||||
|
|
||||||
@@ -66,4 +68,62 @@ enum WCPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func requestSyncMessage() -> [String: Any] { [typeKey: requestSyncType] }
|
static func requestSyncMessage() -> [String: Any] { [typeKey: requestSyncType] }
|
||||||
|
|
||||||
|
// MARK: - Watch → Phone (ephemeral live-run mirror)
|
||||||
|
|
||||||
|
/// Anchor `Date`s ride as native plist values (not JSON), so they keep sub-second
|
||||||
|
/// precision — `DocumentCoder` is `.iso8601`, which would round the timer off.
|
||||||
|
static let lpWorkoutIDKey = "lpWorkoutID"
|
||||||
|
static let lpLogIDKey = "lpLogID"
|
||||||
|
static let lpNameKey = "lpName"
|
||||||
|
static let lpPhaseKey = "lpPhase"
|
||||||
|
static let lpSetIndexKey = "lpSetIndex"
|
||||||
|
static let lpSetCountKey = "lpSetCount"
|
||||||
|
static let lpDetailKey = "lpDetail"
|
||||||
|
static let lpStartKey = "lpStart"
|
||||||
|
static let lpEndKey = "lpEnd"
|
||||||
|
static let lpVersionKey = "lpVersion"
|
||||||
|
|
||||||
|
static func encodeLiveProgress(_ p: LiveProgress) -> [String: Any] {
|
||||||
|
var dict: [String: Any] = [typeKey: liveProgressType]
|
||||||
|
dict[lpWorkoutIDKey] = p.workoutID
|
||||||
|
dict[lpLogIDKey] = p.logID
|
||||||
|
dict[lpNameKey] = p.exerciseName
|
||||||
|
dict[lpPhaseKey] = p.phase.rawValue
|
||||||
|
dict[lpSetIndexKey] = p.setIndex
|
||||||
|
dict[lpSetCountKey] = p.setCount
|
||||||
|
dict[lpDetailKey] = p.detail
|
||||||
|
dict[lpStartKey] = p.phaseStart
|
||||||
|
if let end = p.phaseEnd { dict[lpEndKey] = end }
|
||||||
|
dict[lpVersionKey] = p.version
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decodeLiveProgress(_ dict: [String: Any]) -> LiveProgress? {
|
||||||
|
guard let workoutID = dict[lpWorkoutIDKey] as? String,
|
||||||
|
let logID = dict[lpLogIDKey] as? String,
|
||||||
|
let phaseRaw = dict[lpPhaseKey] as? String,
|
||||||
|
let phase = LiveRunPhase(rawValue: phaseRaw),
|
||||||
|
let setIndex = dict[lpSetIndexKey] as? Int,
|
||||||
|
let setCount = dict[lpSetCountKey] as? Int,
|
||||||
|
let start = dict[lpStartKey] as? Date,
|
||||||
|
let version = dict[lpVersionKey] as? Int
|
||||||
|
else { return nil }
|
||||||
|
return LiveProgress(
|
||||||
|
workoutID: workoutID,
|
||||||
|
logID: logID,
|
||||||
|
exerciseName: dict[lpNameKey] as? String ?? "",
|
||||||
|
phase: phase,
|
||||||
|
setIndex: setIndex,
|
||||||
|
setCount: setCount,
|
||||||
|
detail: dict[lpDetailKey] as? String ?? "",
|
||||||
|
phaseStart: start,
|
||||||
|
phaseEnd: dict[lpEndKey] as? Date,
|
||||||
|
version: version
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func encodeLiveEnded(workoutID: String, logID: String) -> [String: Any] {
|
||||||
|
[typeKey: liveEndedType, lpWorkoutIDKey: workoutID, lpLogIDKey: logID]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ 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
|
||||||
|
/// stale / out-of-order delivery.
|
||||||
|
private var liveVersion = 0
|
||||||
|
|
||||||
private var context: ModelContext { container.mainContext }
|
private var context: ModelContext { container.mainContext }
|
||||||
|
|
||||||
init(container: ModelContainer) {
|
init(container: ModelContainer) {
|
||||||
@@ -58,6 +62,26 @@ final class WatchConnectivityBridge: NSObject {
|
|||||||
sendToPhone(doc)
|
sendToPhone(doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Live run mirror (ephemeral; reachable-only)
|
||||||
|
|
||||||
|
/// Broadcast where the run flow currently is, so a propped-up iPhone can mirror it. Sent
|
||||||
|
/// over `sendMessage` only when the phone is reachable — this is throwaway presence, so
|
||||||
|
/// there's no guaranteed-delivery fallback (a queued frame would be stale on arrival).
|
||||||
|
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 phone to stop mirroring this run (the user left the progress flow).
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Internal
|
// MARK: - Internal
|
||||||
|
|
||||||
private func sendToPhone(_ doc: WorkoutDocument) {
|
private func sendToPhone(_ doc: WorkoutDocument) {
|
||||||
|
|||||||
@@ -33,9 +33,17 @@ struct ExerciseProgressView: View {
|
|||||||
let logID: String
|
let logID: String
|
||||||
let onChange: () -> Void
|
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.
|
/// 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
|
||||||
|
|
||||||
|
/// 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`).
|
/// 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
|
||||||
@@ -51,10 +59,12 @@ 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, 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._doc = doc
|
||||||
self.logID = logID
|
self.logID = logID
|
||||||
self.onChange = onChange
|
self.onChange = onChange
|
||||||
|
self.onLive = onLive
|
||||||
|
self.onLiveEnded = onLiveEnded
|
||||||
self.debugInitialPage = debugInitialPage
|
self.debugInitialPage = debugInitialPage
|
||||||
|
|
||||||
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
||||||
@@ -185,6 +195,7 @@ struct ExerciseProgressView: View {
|
|||||||
} else {
|
} else {
|
||||||
recordProgress(for: newPage)
|
recordProgress(for: newPage)
|
||||||
}
|
}
|
||||||
|
broadcastLive(for: newPage)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
guard !didRestorePage else { return }
|
guard !didRestorePage else { return }
|
||||||
@@ -197,12 +208,67 @@ struct ExerciseProgressView: View {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
jumpToResumePage()
|
jumpToResumePage()
|
||||||
didRestorePage = true
|
didRestorePage = true
|
||||||
|
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
|
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
|
/// 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)
|
.navigationTitle(doc.splitName ?? Split.unnamed)
|
||||||
.navigationDestination(item: $selectedLogID) { logID in
|
.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) {
|
.sheet(isPresented: $showingExercisePicker) {
|
||||||
ExercisePickerView(exercises: availableExercises) { exercise in
|
ExercisePickerView(exercises: availableExercises) { exercise in
|
||||||
|
|||||||
@@ -13,13 +13,18 @@ final class AppServices {
|
|||||||
let watchBridge: PhoneConnectivityBridge
|
let watchBridge: PhoneConnectivityBridge
|
||||||
let workoutLauncher = WorkoutLauncher()
|
let workoutLauncher = WorkoutLauncher()
|
||||||
|
|
||||||
|
/// Ephemeral live-run state fed by the watch, observed by the mirror UI. Not persisted.
|
||||||
|
let liveRunState: LiveRunState
|
||||||
|
|
||||||
private var bootstrapTask: Task<Void, Never>?
|
private var bootstrapTask: Task<Void, Never>?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let container = WorkoutsModelContainer.make()
|
let container = WorkoutsModelContainer.make()
|
||||||
self.container = container
|
self.container = container
|
||||||
self.syncEngine = SyncEngine(container: container)
|
self.syncEngine = SyncEngine(container: container)
|
||||||
self.watchBridge = PhoneConnectivityBridge(container: container, syncEngine: syncEngine)
|
let liveRunState = LiveRunState()
|
||||||
|
self.liveRunState = liveRunState
|
||||||
|
self.watchBridge = PhoneConnectivityBridge(container: container, syncEngine: syncEngine, liveRunState: liveRunState)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if ScreenshotSeed.isActive { ScreenshotSeed.populate(container.mainContext) }
|
if ScreenshotSeed.isActive { ScreenshotSeed.populate(container.mainContext) }
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//
|
||||||
|
// LiveRunState.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// Phone-side holder for the *ephemeral* live-run frames the watch broadcasts while it drives
|
||||||
|
/// an exercise (see `LiveProgress`). The mirror UI observes this; nothing here is persisted.
|
||||||
|
///
|
||||||
|
/// Phase 1 is watch-drives / phone-mirrors, so this is read-only state fed by the connectivity
|
||||||
|
/// bridge — the phone never sends back, which is why there's no echo loop to guard against yet.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class LiveRunState {
|
||||||
|
/// The latest frame from the driving device, or `nil` when no run is being mirrored.
|
||||||
|
private(set) var current: LiveProgress?
|
||||||
|
|
||||||
|
/// A log the user manually closed the mirror for; suppressed until that run ends.
|
||||||
|
private var mutedLogID: String?
|
||||||
|
|
||||||
|
/// The frame to actually present, honoring a manual dismiss.
|
||||||
|
var presentable: LiveProgress? {
|
||||||
|
guard let c = current, c.logID != mutedLogID else { return nil }
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply an incoming frame, dropping a stale one for the same run.
|
||||||
|
func apply(_ frame: LiveProgress) {
|
||||||
|
if let c = current, c.logID == frame.logID, frame.version < c.version { return }
|
||||||
|
current = frame
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The driver left the run (cancel / done / navigated away) — stop mirroring it.
|
||||||
|
func end(logID: String) {
|
||||||
|
if current?.logID == logID { current = nil }
|
||||||
|
if mutedLogID == logID { mutedLogID = nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user dismissed the mirror; don't re-present this run until it ends.
|
||||||
|
func mute() {
|
||||||
|
mutedLogID = current?.logID
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import WatchConnectivity
|
|||||||
final class PhoneConnectivityBridge: NSObject {
|
final class PhoneConnectivityBridge: NSObject {
|
||||||
private let container: ModelContainer
|
private let container: ModelContainer
|
||||||
private let syncEngine: SyncEngine
|
private let syncEngine: SyncEngine
|
||||||
|
private let liveRunState: LiveRunState
|
||||||
private var session: WCSession?
|
private var session: WCSession?
|
||||||
|
|
||||||
/// Exclusive-edit lock published to the watch. While the phone has a workout's
|
/// Exclusive-edit lock published to the watch. While the phone has a workout's
|
||||||
@@ -24,9 +25,10 @@ final class PhoneConnectivityBridge: NSObject {
|
|||||||
|
|
||||||
private var context: ModelContext { container.mainContext }
|
private var context: ModelContext { container.mainContext }
|
||||||
|
|
||||||
init(container: ModelContainer, syncEngine: SyncEngine) {
|
init(container: ModelContainer, syncEngine: SyncEngine, liveRunState: LiveRunState) {
|
||||||
self.container = container
|
self.container = container
|
||||||
self.syncEngine = syncEngine
|
self.syncEngine = syncEngine
|
||||||
|
self.liveRunState = liveRunState
|
||||||
super.init()
|
super.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +92,14 @@ final class PhoneConnectivityBridge: NSObject {
|
|||||||
if let doc = WCPayload.decodeWorkoutUpdate(dict) {
|
if let doc = WCPayload.decodeWorkoutUpdate(dict) {
|
||||||
Task { @MainActor in await self.syncEngine.ingestFromWatch(doc) }
|
Task { @MainActor in await self.syncEngine.ingestFromWatch(doc) }
|
||||||
}
|
}
|
||||||
|
case WCPayload.liveProgressType:
|
||||||
|
if let frame = WCPayload.decodeLiveProgress(dict) {
|
||||||
|
Task { @MainActor in self.liveRunState.apply(frame) }
|
||||||
|
}
|
||||||
|
case WCPayload.liveEndedType:
|
||||||
|
if let logID = dict[WCPayload.lpLogIDKey] as? String {
|
||||||
|
Task { @MainActor in self.liveRunState.end(logID: logID) }
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,18 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
|
@Environment(LiveRunState.self) private var liveRun
|
||||||
|
|
||||||
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.
|
||||||
|
.fullScreenCover(isPresented: Binding(
|
||||||
|
get: { liveRun.presentable != nil },
|
||||||
|
set: { presenting in if !presenting { liveRun.mute() } }
|
||||||
|
)) {
|
||||||
|
if let frame = liveRun.presentable {
|
||||||
|
LiveProgressMirrorView(progress: frame) { liveRun.mute() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
//
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ struct WorkoutsApp: App {
|
|||||||
RootGateView()
|
RootGateView()
|
||||||
.environment(services)
|
.environment(services)
|
||||||
.environment(services.syncEngine)
|
.environment(services.syncEngine)
|
||||||
|
.environment(services.liveRunState)
|
||||||
.modelContainer(services.container)
|
.modelContainer(services.container)
|
||||||
.task { await services.bootstrap() }
|
.task { await services.bootstrap() }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user