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
+6 -1
View File
@@ -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
+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 {
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
}
+11
View File
@@ -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)
})
}
+1
View File
@@ -30,6 +30,7 @@ struct WorkoutsApp: App {
RootGateView()
.environment(services)
.environment(services.syncEngine)
.environment(services.liveRunState)
.modelContainer(services.container)
.task { await services.bootstrap() }
}