a16e8ec270
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
242 lines
7.7 KiB
Swift
242 lines
7.7 KiB
Swift
//
|
||
// 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)
|
||
})
|
||
}
|