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**
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
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.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -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 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)
|
||||
|
||||
@@ -66,4 +68,62 @@ enum WCPayload {
|
||||
}
|
||||
|
||||
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 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 }
|
||||
|
||||
init(container: ModelContainer) {
|
||||
@@ -58,6 +62,26 @@ final class WatchConnectivityBridge: NSObject {
|
||||
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
|
||||
|
||||
private func sendToPhone(_ doc: WorkoutDocument) {
|
||||
|
||||
@@ -33,9 +33,17 @@ struct ExerciseProgressView: View {
|
||||
let logID: String
|
||||
let onChange: () -> Void
|
||||
|
||||
/// Broadcasts the current flow position so a propped-up iPhone can mirror the run live
|
||||
/// (ephemeral; see `LiveProgress`). `onLiveEnded` fires when we leave the flow.
|
||||
let onLive: (LiveProgress) -> Void
|
||||
let onLiveEnded: () -> Void
|
||||
|
||||
/// Rest length between sets, shared with the phone via the same defaults key.
|
||||
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
||||
|
||||
/// Auto-Done countdown on the Finish page — read so the mirror can show the same timer.
|
||||
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||
|
||||
/// Planned set count for this run. `One More` bumps it (and the log's `sets`).
|
||||
@State private var setCount: Int
|
||||
@State private var currentPage: Int
|
||||
@@ -51,10 +59,12 @@ struct ExerciseProgressView: View {
|
||||
/// also suppresses the Ready page so the index is a plain work/rest cycle offset.
|
||||
private let debugInitialPage: Int?
|
||||
|
||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, debugInitialPage: Int? = nil) {
|
||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, debugInitialPage: Int? = nil) {
|
||||
self._doc = doc
|
||||
self.logID = logID
|
||||
self.onChange = onChange
|
||||
self.onLive = onLive
|
||||
self.onLiveEnded = onLiveEnded
|
||||
self.debugInitialPage = debugInitialPage
|
||||
|
||||
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
||||
@@ -185,6 +195,7 @@ struct ExerciseProgressView: View {
|
||||
} else {
|
||||
recordProgress(for: newPage)
|
||||
}
|
||||
broadcastLive(for: newPage)
|
||||
}
|
||||
.onAppear {
|
||||
guard !didRestorePage else { return }
|
||||
@@ -197,12 +208,67 @@ struct ExerciseProgressView: View {
|
||||
Task { @MainActor in
|
||||
jumpToResumePage()
|
||||
didRestorePage = true
|
||||
broadcastLive(for: currentPage)
|
||||
}
|
||||
} else {
|
||||
// Not-started opens on the Ready page; the screenshot host pins its own.
|
||||
didRestorePage = true
|
||||
broadcastLive(for: currentPage)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
// Leaving the flow (cancel / done / back) — stop the phone mirror.
|
||||
guard debugInitialPage == nil else { return }
|
||||
onLiveEnded()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live mirror
|
||||
|
||||
/// Push the current flow position to a mirroring iPhone. The anchor is stamped *now* — the
|
||||
/// page just became active — so the mirror's timer lines up with this device's.
|
||||
private func broadcastLive(for page: Int) {
|
||||
guard debugInitialPage == nil, let snapshot = liveSnapshot(for: page) else { return }
|
||||
onLive(snapshot)
|
||||
}
|
||||
|
||||
/// Build the live-run frame for a given page: phase, the set it pertains to, and the
|
||||
/// wall-clock anchors the mirror counts off. Count-down phases (rest, timed work, finish)
|
||||
/// carry an end anchor; a rep-based work set counts up and leaves it `nil`.
|
||||
private func liveSnapshot(for page: Int) -> LiveProgress? {
|
||||
guard let log else { return nil }
|
||||
let now = Date()
|
||||
|
||||
func frame(_ phase: LiveRunPhase, setIndex: Int, end: Date?) -> LiveProgress {
|
||||
LiveProgress(
|
||||
workoutID: doc.id,
|
||||
logID: logID,
|
||||
exerciseName: log.exerciseName,
|
||||
phase: phase,
|
||||
setIndex: setIndex,
|
||||
setCount: setCount,
|
||||
detail: detail,
|
||||
phaseStart: now,
|
||||
phaseEnd: end,
|
||||
version: 0
|
||||
)
|
||||
}
|
||||
|
||||
if showsReady && page == 0 {
|
||||
return frame(.ready, setIndex: 0, end: nil)
|
||||
}
|
||||
let cycleIndex = page - base
|
||||
if cycleIndex == cycleCount {
|
||||
return frame(.finish, setIndex: max(0, setCount - 1),
|
||||
end: now.addingTimeInterval(Double(max(1, doneCountdownSeconds))))
|
||||
} else if cycleIndex.isMultiple(of: 2) {
|
||||
let set = cycleIndex / 2
|
||||
let end = isDuration ? now.addingTimeInterval(Double(workDurationSeconds)) : nil
|
||||
return frame(.work, setIndex: set, end: end)
|
||||
} else {
|
||||
let set = (cycleIndex - 1) / 2 // the rest follows this set
|
||||
return frame(.rest, setIndex: set, end: now.addingTimeInterval(Double(max(1, restSeconds))))
|
||||
}
|
||||
}
|
||||
|
||||
/// Move to the resume page without animation, only if we're not already there
|
||||
|
||||
@@ -86,7 +86,13 @@ struct WorkoutLogListView: View {
|
||||
}
|
||||
.navigationTitle(doc.splitName ?? Split.unnamed)
|
||||
.navigationDestination(item: $selectedLogID) { logID in
|
||||
ExerciseProgressView(doc: $doc, logID: logID, onChange: { bridge.update(workout: doc) })
|
||||
ExerciseProgressView(
|
||||
doc: $doc,
|
||||
logID: logID,
|
||||
onChange: { bridge.update(workout: doc) },
|
||||
onLive: { bridge.sendLiveProgress($0) },
|
||||
onLiveEnded: { bridge.sendLiveEnded(workoutID: doc.id, logID: logID) }
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showingExercisePicker) {
|
||||
ExercisePickerView(exercises: availableExercises) { exercise in
|
||||
|
||||
@@ -13,13 +13,18 @@ final class AppServices {
|
||||
let watchBridge: PhoneConnectivityBridge
|
||||
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>?
|
||||
|
||||
init() {
|
||||
let container = WorkoutsModelContainer.make()
|
||||
self.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 ScreenshotSeed.isActive { ScreenshotSeed.populate(container.mainContext) }
|
||||
#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 {
|
||||
private let container: ModelContainer
|
||||
private let syncEngine: SyncEngine
|
||||
private let liveRunState: LiveRunState
|
||||
private var session: WCSession?
|
||||
|
||||
/// 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 }
|
||||
|
||||
init(container: ModelContainer, syncEngine: SyncEngine) {
|
||||
init(container: ModelContainer, syncEngine: SyncEngine, liveRunState: LiveRunState) {
|
||||
self.container = container
|
||||
self.syncEngine = syncEngine
|
||||
self.liveRunState = liveRunState
|
||||
super.init()
|
||||
}
|
||||
|
||||
@@ -90,6 +92,14 @@ final class PhoneConnectivityBridge: NSObject {
|
||||
if let doc = WCPayload.decodeWorkoutUpdate(dict) {
|
||||
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:
|
||||
break
|
||||
}
|
||||
|
||||
@@ -8,7 +8,18 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(LiveRunState.self) private var liveRun
|
||||
|
||||
var body: some View {
|
||||
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()
|
||||
.environment(services)
|
||||
.environment(services.syncEngine)
|
||||
.environment(services.liveRunState)
|
||||
.modelContainer(services.container)
|
||||
.task { await services.bootstrap() }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user