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:
2026-06-20 21:08:32 -04:00
parent 8ef0e96b31
commit a16e8ec270
13 changed files with 531 additions and 4 deletions
+2
View File
@@ -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.
+4
View File
@@ -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.
+50
View File
@@ -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
}
+60
View File
@@ -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
+6 -1
View File
@@ -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
+47
View File
@@ -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
} }
+11
View File
@@ -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)
})
}
+1
View File
@@ -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() }
} }