diff --git a/CHANGELOG.md b/CHANGELOG.md index 89a14e6..37e3ad8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 5dc0564..228f2ca 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Shared/Connectivity/LiveProgress.swift b/Shared/Connectivity/LiveProgress.swift new file mode 100644 index 0000000..1a42628 --- /dev/null +++ b/Shared/Connectivity/LiveProgress.swift @@ -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 +} diff --git a/Shared/Connectivity/WCPayload.swift b/Shared/Connectivity/WCPayload.swift index 155287a..e36fe91 100644 --- a/Shared/Connectivity/WCPayload.swift +++ b/Shared/Connectivity/WCPayload.swift @@ -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] + } } diff --git a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift index aedbc24..4191e14 100644 --- a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift +++ b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift @@ -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) { diff --git a/Workouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/ExerciseProgressView.swift index 22d8e29..ad72d30 100644 --- a/Workouts Watch App/Views/ExerciseProgressView.swift +++ b/Workouts Watch App/Views/ExerciseProgressView.swift @@ -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, logID: String, onChange: @escaping () -> Void, debugInitialPage: Int? = nil) { + init(doc: Binding, 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 diff --git a/Workouts Watch App/Views/WorkoutLogListView.swift b/Workouts Watch App/Views/WorkoutLogListView.swift index 259a1e5..3d50856 100644 --- a/Workouts Watch App/Views/WorkoutLogListView.swift +++ b/Workouts Watch App/Views/WorkoutLogListView.swift @@ -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 diff --git a/Workouts/AppServices.swift b/Workouts/AppServices.swift index 9daff94..97b8795 100644 --- a/Workouts/AppServices.swift +++ b/Workouts/AppServices.swift @@ -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? 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 diff --git a/Workouts/Connectivity/LiveRunState.swift b/Workouts/Connectivity/LiveRunState.swift new file mode 100644 index 0000000..f87e8bb --- /dev/null +++ b/Workouts/Connectivity/LiveRunState.swift @@ -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 + } +} diff --git a/Workouts/Connectivity/PhoneConnectivityBridge.swift b/Workouts/Connectivity/PhoneConnectivityBridge.swift index b69903a..ab063c1 100644 --- a/Workouts/Connectivity/PhoneConnectivityBridge.swift +++ b/Workouts/Connectivity/PhoneConnectivityBridge.swift @@ -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 } diff --git a/Workouts/ContentView.swift b/Workouts/ContentView.swift index 86800c9..8b08fb7 100644 --- a/Workouts/ContentView.swift +++ b/Workouts/ContentView.swift @@ -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() } + } + } } } diff --git a/Workouts/Views/WorkoutLogs/LiveProgressMirrorView.swift b/Workouts/Views/WorkoutLogs/LiveProgressMirrorView.swift new file mode 100644 index 0000000..ddc048a --- /dev/null +++ b/Workouts/Views/WorkoutLogs/LiveProgressMirrorView.swift @@ -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: 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/WorkoutsApp.swift b/Workouts/WorkoutsApp.swift index f8cce34..3c96586 100644 --- a/Workouts/WorkoutsApp.swift +++ b/Workouts/WorkoutsApp.swift @@ -30,6 +30,7 @@ struct WorkoutsApp: App { RootGateView() .environment(services) .environment(services.syncEngine) + .environment(services.liveRunState) .modelContainer(services.container) .task { await services.bootstrap() } }