// // 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) }) }