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:
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user