Files
workouts/Workouts/Views/WorkoutLogs/LiveProgressMirrorView.swift
T
rzen a16e8ec270 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
2026-06-20 21:08:32 -04:00

242 lines
7.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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)
})
}