// // ExerciseProgressView.swift // Workouts // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // import SwiftUI import UIKit /// Runs a single exercise as a horizontally-paged flow — the iPhone counterpart to the /// watch's `ExerciseProgressView`: /// /// [Ready] → Work₁ → Rest₁ → Work₂ → … → Workₙ → Finish /// /// A **Ready?** page leads in *only* when the exercise hasn't been started yet (a /// resumed exercise jumps straight to its first unfinished set). The **work** phase /// counts *up* (a stopwatch for the current set, tinted with the brand purple); the /// user swipes left when they're done. The **rest** phase counts *down* from the /// configurable rest time (light teal), buzzes once per second in the final three /// seconds, then auto-advances to the next work phase. Sliding past the final set /// reaches a **Finish** page offering **One More** (append a bonus set) and **Done** /// (which also auto-fires after a configurable countdown, completing the exercise). /// /// The paged flow occupies the **top half** of the screen; the bottom half shows the /// animated form-guide figure when the exercise has a bundled motion /// (`ExerciseFigureSlot`). A row of phase dots tracks progress: purple for work, /// teal for rest, with the current phase drawn as a wider dash. struct ExerciseProgressView: View { @Environment(\.dismiss) private var dismiss @Environment(\.verticalSizeClass) private var verticalSizeClass /// The shared working workout document owned by the parent list. We mutate the /// matching log in place and ask the parent to persist each change — driving the UI /// from this doc (not the cache) avoids losing rapid edits to the read-after-write /// race the cache update lags behind. @Binding var doc: WorkoutDocument let logID: String let onChange: () -> Void /// Broadcasts the current flow position so the watch (if it has this run open) can follow /// it live (ephemeral; see `LiveProgress`). `onLiveEnded` fires when we leave the flow. /// Only *human* transitions are broadcast — an auto-advance (rest/timed-work end) isn't, /// since the other device reaches it independently off the same wall-clock anchors. Default /// no-ops, so the in-list driver stays purely durable; the propped-phone cover wires these. let onLive: (LiveProgress) -> Void let onLiveEnded: () -> Void /// Reflects the run's current position onto the iOS Live Activity (lock screen + Dynamic /// Island). Unlike `onLive` (watch, *human* transitions only), this fires on **every** page /// settle — human, auto-advance, or a followed watch frame — plus the initial page, since the /// Live Activity must track the current phase and countdown no matter who drove it. /// `onActivityEnded` fires when the flow is left, so the activity can be dismissed. Both /// default to no-ops, so the in-list driver only wires them where an activity is wanted. let onActivity: (LiveProgress) -> Void let onActivityEnded: () -> Void /// The latest live-run frame the *watch* sent for this run, to follow when it drives a /// transition (ephemeral; nil when the watch isn't driving). Applying it jumps our page /// without re-broadcasting or re-recording — the originating device owns the durable write. var incomingFrame: LiveProgress? /// Rest length between sets, shared with the watch via the same defaults key. @AppStorage("restSeconds") private var restSeconds: Int = 45 /// Auto-Done countdown on the Finish page — read so a broadcast frame carries the same /// end anchor the watch's mirror counts off. @AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5 /// Display unit for the adjust affordance (stored weights stay unit-less). @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb /// Presents the compact adjust sheet for the just-completed set's entry. @State private var showingAdjust = false /// Planned set count for this run. `One More` bumps it (and the log's `sets`). @State private var setCount: Int @State private var currentPage: Int @State private var didRestorePage = false /// Why `currentPage` last changed, so the page observer knows whether to broadcast it. /// A human swipe leaves this `nil` (→ treated as human); programmatic moves set it. @State private var pageChangeCause: PageChangeCause? /// Highest remote frame we've applied — version plus its anchor tie-break (see /// `LiveProgress.isNewer`) — so a redelivery or collided stale frame doesn't re-jump. @State private var lastAppliedVersion = 0 @State private var lastAppliedStart = Date.distantPast /// Highest completed-set count this screen has itself recorded or followed via a live /// frame — the baseline `repairFromDurable` compares an absorbed doc against, so a /// durable echo of our own progress (or of a frame we followed) never triggers a jump. @State private var knownStateIndex: Int /// A page reached with a *known* wall-clock anchor counts its timer from that anchor /// instead of local `now`. Two sources: a remote frame (the sender's clock, so delivery /// latency can't desync the mirror) and a chained auto-advance (the finished phase's /// computed end, so a ticker that slept through the boundary — wrist-down throttling — /// can't bake its lateness into the next phase's timer). Scoped to one page; a human /// transition to another page self-anchors. @State private var pageAnchor: PageAnchor? private struct PageAnchor { let page: Int let start: Date let end: Date? } private enum PageChangeCause { case auto, remote } /// True when this run opened on a resumed set (in-progress) rather than the Ready /// page. Held in `@State` so it stays fixed for the life of the screen — the parent /// list re-inits this view whenever the workout file changes, and this must not flip /// mid-run. Such a run re-asserts its resume page after the first layout and ignores /// the transient TabView snap-to-0, so it isn't reset on open. @State private var startsResumed: Bool /// True when the exercise was already completed when this screen opened — it shows a /// static Completed page instead of dropping back into the timer flow. Fixed at init /// so completing the exercise from inside the flow doesn't swap the page mid-dismiss. @State private var startsCompleted: Bool /// True when the exercise was skipped (workout ended early) when this screen opened — /// it shows a static Skipped page instead of the timer flow. Fixed at init like /// `startsCompleted`. @State private var startsSkipped: Bool init(doc: Binding, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, onActivity: @escaping (LiveProgress) -> Void = { _ in }, onActivityEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) { self._doc = doc self.logID = logID self.onChange = onChange self.onLive = onLive self.onLiveEnded = onLiveEnded self.onActivity = onActivity self.onActivityEnded = onActivityEnded self.incomingFrame = incomingFrame let log = doc.wrappedValue.logs.first { $0.id == logID } let sets = max(1, log?.sets ?? 1) _setCount = State(initialValue: sets) _knownStateIndex = State(initialValue: max(0, log?.currentStateIndex ?? 0)) // The Ready page always leads the flow. A not-started run opens on it; an // in-progress run opens on its first unfinished set and re-asserts that page past // the TabView's snap-to-0 on first layout. let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue _startsResumed = State(initialValue: !notStarted) _startsCompleted = State(initialValue: log?.status == WorkoutStatus.completed.rawValue) _startsSkipped = State(initialValue: log?.status == WorkoutStatus.skipped.rawValue) let base = 1 // Resume on the first unfinished set's work page (clamped to the last set). let completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1) let resume = base + completed * 2 _currentPage = State(initialValue: notStarted ? 0 : resume) } private var log: WorkoutLogDocument? { doc.logs.first { $0.id == logID } } /// The **Ready?** page always leads the flow, so a resumed run can swipe back to it /// (which resets the exercise). (The watch additionally suppresses it for its /// screenshot host; the iPhone has no such host, so it's always shown.) private var showsReady: Bool { true } /// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`. private var base: Int { showsReady ? 1 : 0 } /// Work/rest pages: Work₁, Rest₁, …, Workₙ ⇒ N sets + (N−1) rests = 2N − 1. private var cycleCount: Int { setCount * 2 - 1 } /// Ready (`base`) + cycle (`2N − 1`) + Finish (`1`). private var totalPages: Int { base + cycleCount + 1 } /// The first unfinished set's work page, used to resume an in-progress run. private var resumePage: Int { let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1) return base + completed * 2 } /// Completed-set count a given page implies — the same math `recordProgress` records /// (capped at `setCount − 1`; only an explicit Done marks the final set). private func completedSets(for pageIndex: Int) -> Int { min(max(0, (pageIndex - base + 1) / 2), setCount - 1) } /// Position within the work/rest cycle for the current page — `nil` on the Ready /// and Finish pages (which show no dots). private var currentCycleIndex: Int? { let c = currentPage - base return (0.. knownStateIndex else { return } knownStateIndex = completed if log.sets > setCount { setCount = log.sets } let target = base + min(max(0, completed), setCount - 1) * 2 guard target > currentPage else { return } clearAnchor() pageChangeCause = .remote withAnimation { currentPage = target } } /// Drop the page anchor when a human transition moves us off the anchored page, so the /// next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor). private func clearAnchor() { pageAnchor = nil } /// Inverse of `liveSnapshot`'s page→frame mapping: a frame's phase/set → page index. private func page(forPhase phase: LiveRunPhase, setIndex: Int) -> Int { let set = min(max(0, setIndex), max(0, setCount - 1)) switch phase { case .ready: return showsReady ? 0 : base case .work: return base + set * 2 case .rest: return base + set * 2 + 1 case .finish: return base + cycleCount } } /// Build the live-run frame for a given page: phase, the set it pertains to, and the /// wall-clock anchors the watch counts off. Count-down phases (rest, timed work, finish) /// carry an end anchor; a rep-based work set counts up and leaves it `nil`. private func liveSnapshot(for page: Int, at now: Date = Date()) -> LiveProgress? { guard let log else { return nil } func frame(_ phase: LiveRunPhase, setIndex: Int, end: Date?) -> LiveProgress { LiveProgress( workoutID: doc.id, logID: logID, exerciseName: log.exerciseName, phase: phase, setIndex: setIndex, setCount: setCount, detail: detail, phaseStart: now, phaseEnd: end, version: 0 ) } if showsReady && page == 0 { return frame(.ready, setIndex: 0, end: nil) } let cycleIndex = page - base if cycleIndex == cycleCount { return frame(.finish, setIndex: max(0, setCount - 1), end: now.addingTimeInterval(Double(max(1, doneCountdownSeconds)))) } else if cycleIndex.isMultiple(of: 2) { let set = cycleIndex / 2 let end = isDuration ? now.addingTimeInterval(Double(workDurationSeconds)) : nil return frame(.work, setIndex: set, end: end) } else { let set = (cycleIndex - 1) / 2 // the rest follows this set return frame(.rest, setIndex: set, end: now.addingTimeInterval(Double(max(1, restSeconds)))) } } @ViewBuilder private func page(for index: Int) -> some View { let isActive = index == currentPage // Only the anchored page (remote frame or chained auto-advance) carries anchors; // every other page (reached by a human transition) counts off its own `now`. let anchorStart = pageAnchor?.page == index ? pageAnchor?.start : nil let anchorEnd = pageAnchor?.page == index ? pageAnchor?.end : nil if showsReady && index == 0 { ReadyPhaseView(summary: readySummary, onStart: start) } else { let cycleIndex = index - base if cycleIndex == cycleCount { // Finish page — confirm Done (auto-fires) or add One More. VStack(spacing: 0) { FinishPhaseView( isActive: isActive, onDone: { completeExercise(); dismiss() }, onOneMore: addSet, anchorEnd: anchorEnd ) adjustPill } } else if cycleIndex.isMultiple(of: 2) { let setNumber = cycleIndex / 2 + 1 if isDuration { // Timed work set — count down from the planned duration, then // auto-advance (and buzz) the same way a rest does. CountdownPhaseView( header: "\(setNumber) of \(setCount)", tint: .workTimer, seconds: workDurationSeconds, isActive: isActive, anchorStart: anchorStart, anchorEnd: anchorEnd ) { end in withAnimation { advance(from: index, phaseEndedAt: end) } } } else { // Rep-based work set — count up; the user swipes left when done. WorkPhaseView( setNumber: setNumber, totalSets: setCount, detail: detail, isActive: isActive, anchorStart: anchorStart ) } } else { // Rest phase. Auto-advances to the next work page when the timer hits zero. VStack(spacing: 0) { CountdownPhaseView( header: "Rest", tint: .restTimer, seconds: restSeconds, isActive: isActive, anchorStart: anchorStart, anchorEnd: anchorEnd ) { end in withAnimation { advance(from: index, phaseEndedAt: end) } } adjustPill } } } } // MARK: - Adjust the last set's entry /// The just-completed set's recorded entry, shown on the rest and finish pages /// as a tappable pill — the log-by-default record with a correct-by-exception /// affordance. Dead time by design: it never blocks the countdown or auto-advance. @ViewBuilder private var adjustPill: some View { if let entry = log?.setEntries?.last { Button { showingAdjust = true } label: { HStack(spacing: 6) { Text(SetEntryFormat.summary(entry, weightUnit: weightUnit)) Image(systemName: "pencil") .font(.caption) } .font(.subheadline) .foregroundStyle(.secondary) .padding(.horizontal, 14) .padding(.vertical, 7) .background(.quaternary.opacity(0.5), in: Capsule()) } .buttonStyle(.plain) .padding(.bottom, 32) // clear the phase-dot row overlaid at the bottom .sheet(isPresented: $showingAdjust) { adjustSheet } } } @ViewBuilder private var adjustSheet: some View { if let log, let entry = log.setEntries?.last { SetEntryAdjustSheet( entry: entry, setNumber: log.setEntries?.count ?? 1, loadType: LoadType(rawValue: log.loadType) ?? .weight, weightUnit: weightUnit, onChange: updateLastEntry ) .presentationDetents([.height(300)]) .presentationDragIndicator(.visible) } } /// Write an adjusted value back onto the last recorded entry and persist. private func updateLastEntry(_ entry: SetEntry) { guard let i = doc.logs.firstIndex(where: { $0.id == logID }), let last = doc.logs[i].setEntries?.indices.last else { return } doc.logs[i].setEntries?[last] = entry doc.logs[i].touch() doc.updatedAt = Date() onChange() } // MARK: - Mutations /// Leave the Ready page for the first work phase, marking the exercise started. private func start() { beginExercise() withAnimation { currentPage = base } } /// Programmatically move one page right when a countdown phase ends, guarding against /// overrun if the user swiped away in the meantime. Tagged `.auto` so the page observer /// records progress but doesn't broadcast it (the watch auto-advances too). /// /// `phaseEndedAt` is the finished phase's *computed* end — the next phase began then, /// not when this tick finally got runtime. Anchoring the next page there means a device /// that slept through the boundary (throttled wrist-down ticker) shows the same timer /// as one that crossed it on time — and a stack of missed boundaries fast-forwards /// itself, since each chained countdown lands already-elapsed and advances on its own /// next tick. private func advance(from index: Int, phaseEndedAt end: Date) { guard currentPage == index, index + 1 < totalPages else { return } pageAnchor = PageAnchor(page: index + 1, start: end, end: liveSnapshot(for: index + 1, at: end)?.phaseEnd) pageChangeCause = .auto currentPage = index + 1 } /// Flip a not-yet-started exercise to in-progress the moment the user taps Start. private func beginExercise() { guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } guard doc.logs[i].status == WorkoutStatus.notStarted.rawValue else { return } doc.logs[i].transition(to: .inProgress) recomputeWorkoutStatus() doc.updatedAt = Date() onChange() } /// Append a bonus set from the Finish page: mark every prior set done, grow the /// plan, and slide forward into the bonus set's work phase. private func addSet() { let newCount = setCount + 1 knownStateIndex = max(knownStateIndex, newCount - 1) if let i = doc.logs.firstIndex(where: { $0.id == logID }) { doc.logs[i].sets = newCount doc.logs[i].currentStateIndex = newCount - 1 // every prior set is now complete // The old final set just became a completed *prior* set — record its entry. // The bonus set's own entry lands when it completes, not now. doc.logs[i].fillSetEntries(upTo: newCount - 1, at: Date()) doc.logs[i].transition(to: .inProgress) recomputeWorkoutStatus() doc.updatedAt = Date() onChange() } withAnimation { setCount = newCount currentPage += 1 } } /// Map a page to completed-set count and record forward progress. /// /// Paging tops out at `setCount − 1` completed sets — the final set is marked done /// only by an explicit **Done** (`completeExercise`). Progress is **monotonic**: /// completing a work phase advances the count, but swiping back — or a transient /// TabView snap to page 0 — never un-counts a set. private func recordProgress(for pageIndex: Int) { if showsReady && pageIndex == 0 { return } // Ready page records nothing let reached = completedSets(for: pageIndex) knownStateIndex = max(knownStateIndex, reached) guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } guard reached > doc.logs[i].currentStateIndex else { return } doc.logs[i].currentStateIndex = reached // Log-by-default: each newly-completed set gets a plan-filled entry // (append-only — swiping back never removes one; adjust corrects the last). doc.logs[i].fillSetEntries(upTo: reached, at: Date()) doc.logs[i].transition(to: .inProgress) recomputeWorkoutStatus() doc.updatedAt = Date() onChange() } /// Swiping back to the **Ready?** page starts the exercise over from scratch. private func resetExercise() { guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } let log = doc.logs[i] knownStateIndex = 0 // Skip the write if it's already pristine (e.g. landing on Ready before any set). guard log.currentStateIndex != 0 || log.status != WorkoutStatus.notStarted.rawValue else { return } doc.logs[i].currentStateIndex = 0 doc.logs[i].transition(to: .notStarted) recomputeWorkoutStatus() doc.updatedAt = Date() onChange() } private func completeExercise() { knownStateIndex = max(knownStateIndex, setCount) guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } doc.logs[i].currentStateIndex = setCount doc.logs[i].transition(to: .completed) recomputeWorkoutStatus() doc.updatedAt = Date() onChange() } private func recomputeWorkoutStatus() { doc.recomputeStatusFromLogs() } // MARK: - Formatting nonisolated static func durationLabel(_ seconds: Int) -> String { let mins = seconds / 60 let secs = seconds % 60 if mins > 0 && secs > 0 { return "\(mins)m \(secs)s" } if mins > 0 { return "\(mins) min" } return "\(secs) sec" } } // MARK: - Set entry formatting /// Renders recorded `SetEntry` actuals for display (the pill, the Completed page). /// Which fields an entry carries follows the log's `LoadType`, so formatting keys /// off the populated fields rather than re-deriving the type. enum SetEntryFormat { /// One entry: "10 reps · 45 lb", "10 reps", or "45 sec". static func summary(_ entry: SetEntry, weightUnit: WeightUnit) -> String { var parts: [String] = [] if let reps = entry.reps { parts.append("\(reps) reps") } if let weight = entry.weight { parts.append(weightUnit.format(weight)) } if let seconds = entry.seconds { parts.append(ExerciseProgressView.durationLabel(seconds)) } return parts.joined(separator: " · ") } /// All of a log's entries on one line: "10 · 8 · 6 reps @ 45 lb" when the /// weight is uniform, a per-set "10 × 45 lb · 8 × 42.5 lb" list when it varies, /// "45 · 45 · 30 sec" for durations, "10 · 8 · 6 reps" for bodyweight. static func actualsLine(_ entries: [SetEntry], weightUnit: WeightUnit) -> String { guard !entries.isEmpty else { return "" } if entries.contains(where: { $0.seconds != nil }) { return entries.map { "\($0.seconds ?? 0)" }.joined(separator: " · ") + " sec" } let repsList = entries.map { "\($0.reps ?? 0)" }.joined(separator: " · ") let weights = entries.compactMap(\.weight) guard !weights.isEmpty else { return repsList + " reps" } if Set(weights).count == 1 { return "\(repsList) reps @ \(weightUnit.format(weights[0]))" } return entries.map { entry in guard let weight = entry.weight else { return "\(entry.reps ?? 0)" } return "\(entry.reps ?? 0) × \(weightUnit.format(weight))" }.joined(separator: " · ") } } // MARK: - Set entry adjust sheet /// Compact steppers for correcting the just-completed set's entry — reps and /// weight for a weighted set, reps alone for bodyweight, seconds for a timed /// hold. Changes apply live (via `onChange`), so a swipe-down needs no confirm. private struct SetEntryAdjustSheet: View { let setNumber: Int let loadType: LoadType let weightUnit: WeightUnit let onChange: (SetEntry) -> Void @State private var entry: SetEntry init(entry: SetEntry, setNumber: Int, loadType: LoadType, weightUnit: WeightUnit, onChange: @escaping (SetEntry) -> Void) { _entry = State(initialValue: entry) self.setNumber = setNumber self.loadType = loadType self.weightUnit = weightUnit self.onChange = onChange } /// Plate-math step in the *display* unit (the stored value stays unit-less). private var weightStep: Double { weightUnit == .kg ? 1.25 : 2.5 } var body: some View { VStack(spacing: 24) { Text("Set \(setNumber)") .font(.headline) .foregroundStyle(.secondary) switch loadType { case .weight: repsStepper weightStepper case .none: repsStepper case .duration: secondsStepper } } .padding(28) .frame(maxHeight: .infinity, alignment: .top) .onChange(of: entry) { _, updated in onChange(updated) } } private var repsStepper: some View { Stepper(value: Binding(get: { entry.reps ?? 0 }, set: { entry.reps = $0 }), in: 0...200) { Text("\(entry.reps ?? 0) reps") .font(.title3.weight(.semibold)) .monospacedDigit() } } private var weightStepper: some View { Stepper(value: Binding(get: { entry.weight ?? 0 }, set: { entry.weight = $0 }), in: 0...2000, step: weightStep) { Text(weightUnit.format(entry.weight ?? 0)) .font(.title3.weight(.semibold)) .monospacedDigit() } } private var secondsStepper: some View { Stepper(value: Binding(get: { entry.seconds ?? 0 }, set: { entry.seconds = $0 }), in: 0...3600, step: 5) { Text(ExerciseProgressView.durationLabel(entry.seconds ?? 0)) .font(.title3.weight(.semibold)) .monospacedDigit() } } } // MARK: - Haptics /// Maps the watch flow's haptic vocabulary onto UIKit feedback generators so the iPhone /// flow buzzes at the same beats (set start, countdown ping, rest end, done). private enum WorkoutHaptic { case start, tick, stop, success @MainActor func play() { switch self { case .start: UIImpactFeedbackGenerator(style: .medium).impactOccurred() case .tick: UIImpactFeedbackGenerator(style: .light).impactOccurred() case .stop: UIImpactFeedbackGenerator(style: .rigid).impactOccurred() case .success: UINotificationFeedbackGenerator().notificationOccurred(.success) } } } // MARK: - Phase Colors private extension Color { /// Count-up work tint — brand purple, brightened in dark mode for contrast on /// black and deepened in light mode for contrast on white. static let workTimer = 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) }) /// Count-down rest tint — a light gray that deepens in light mode so it stays /// legible on a white background. static let restTimer = Color(UIColor { traits in traits.userInterfaceStyle == .dark ? UIColor(white: 0.74, alpha: 1) : UIColor(white: 0.52, alpha: 1) }) } // MARK: - Phase Timer Layout /// Shared skeleton for the work and rest pages so their timers use an identical font /// and land at exactly the same spot: a header line, the big timer, then a footer line. /// The footer reserves its height even when empty, keeping the timer centered the same /// way on both pages. private struct PhaseTimerLayout: View { let header: String let footer: String let tint: Color @ViewBuilder var timer: Content /// Scales the big timer digits with Dynamic Type (falls back to `.minimumScaleFactor` /// so the largest sizes never overflow the top half). @ScaledMetric(relativeTo: .largeTitle) private var timerFontSize: CGFloat = 108 var body: some View { VStack(spacing: 10) { Text(header) .font(.title3) .foregroundStyle(.secondary) timer .font(.system(size: timerFontSize, 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) } } // MARK: - Work Phase Dots /// Progress row with one marker per work set. The set being worked is drawn as a wider /// dash; during a rest every marker is a plain dot and the gap straddling that rest /// grows to about double, hinting at the pause between the set that just ended and the /// next one. Completed sets are full strength, upcoming ones dimmed. private struct WorkPhaseDots: View { struct Model: Equatable { let setCount: Int /// The set currently being worked — drawn as a dash. `nil` during a rest. let activeSet: Int? /// The set just completed; the gap *after* its dot doubles. `nil` during work. let restAfterSet: Int? /// How many sets are fully done (for dimming the upcoming ones). let completed: Int } let model: Model // Geometry — tune freely. 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.workTimer) .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 Button Styling private extension View { /// Chunky, rounded, heavy treatment shared by the Start / Done / One More buttons: /// a plump label (echoing the counter digits) over a taller full-width body. func phaseButtonLabel() -> some View { self .font(.system(.title2, design: .rounded, weight: .heavy)) .frame(maxWidth: .infinity) .padding(.vertical, 14) } } // MARK: - Completed Phase /// Shown instead of the run flow when the exercise was already completed on open: /// the badge, then when it was completed, what was done, and how long it took /// (the timing lines appear only on logs that carry the timestamps). private struct CompletedPhaseView: View { let log: WorkoutLogDocument? @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb /// Scales the completion badge with Dynamic Type. @ScaledMetric(relativeTo: .largeTitle) private var badgeSize: CGFloat = 96 /// "4 sets × 12 reps" / "3 sets × 45 sec". private var planSummary: String { guard let log else { return "" } let setsText = "\(log.sets) set\(log.sets == 1 ? "" : "s")" if LoadType(rawValue: log.loadType) == .duration { return "\(setsText) × \(ExerciseProgressView.durationLabel(log.durationSeconds))" } return "\(setsText) × \(log.reps) reps" } /// Start-to-done wall time, when both timestamps were recorded. private var elapsed: String? { guard let log, let start = log.startedAt, let end = log.completedAt else { return nil } let seconds = Int(end.timeIntervalSince(start)) guard seconds > 0 else { return nil } return ExerciseProgressView.durationLabel(seconds) } var body: some View { VStack(spacing: 14) { Image(systemName: "checkmark.circle.fill") .font(.system(size: badgeSize, weight: .bold)) .foregroundStyle(Color.accentColor) Text("Completed") .font(.system(.title, design: .rounded, weight: .heavy)) .foregroundStyle(Color.accentColor) if let log { VStack(spacing: 4) { if let completedAt = log.completedAt { Text(completedAt.formattedRelativeDateTime()) } // Recorded actuals ("10 · 8 · 6 reps @ 45 lb") beat the plan // summary; legacy logs without entries keep showing the plan. if let entries = log.setEntries, !entries.isEmpty { Text(SetEntryFormat.actualsLine(entries, weightUnit: weightUnit)) } else { Text(planSummary) } if let elapsed { Text("in \(elapsed)") } } .font(.callout) .foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } } // MARK: - Skipped Phase /// Shown instead of the run flow when the exercise was skipped (the workout was /// ended early with this one unfinished). Same badge treatment as Completed, /// in gray, matching the list row's skipped icon. private struct SkippedPhaseView: View { /// Scales the skipped badge with Dynamic Type. @ScaledMetric(relativeTo: .largeTitle) private var badgeSize: CGFloat = 96 var body: some View { VStack(spacing: 14) { Image(systemName: "wrongwaysign") .font(.system(size: badgeSize, weight: .bold)) .foregroundStyle(.gray) Text("Skipped") .font(.system(.title, design: .rounded, weight: .heavy)) .foregroundStyle(.gray) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } // MARK: - Ready Phase private struct ReadyPhaseView: View { let summary: String let onStart: () -> Void /// Scales the "Ready?" headline with Dynamic Type. @ScaledMetric(relativeTo: .largeTitle) private var readyFontSize: CGFloat = 44 var body: some View { VStack(spacing: 14) { Text("Ready?") .font(.system(size: readyFontSize, weight: .bold, design: .rounded)) .lineLimit(1) .minimumScaleFactor(0.5) if !summary.isEmpty { Text(summary) .font(.title3) .foregroundStyle(.secondary) } Button(action: onStart) { Text("Start") .phaseButtonLabel() } .buttonStyle(.borderedProminent) .tint(.workTimer) .buttonBorderShape(.capsule) .padding(.top, 8) .padding(.horizontal, 40) } .padding(.horizontal) .frame(maxWidth: .infinity, maxHeight: .infinity) } } // MARK: - Work Phase private struct WorkPhaseView: View { let setNumber: Int let totalSets: Int let detail: String let isActive: Bool /// When the page was reached by a remote frame, anchor the count-up to the sender's /// wall clock instead of local `now`, so the mirror lines up regardless of delivery lag. var anchorStart: Date? = nil /// Wall-clock anchor for the count-up stopwatch. Driving the display from a fixed /// start date (rendered by SwiftUI's timer text) instead of incrementing a counter /// keeps it advancing without a run-loop timer. @State private var startDate = Date() var body: some View { PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer) { Text(startDate, style: .timer) } .onAppear { if isActive { restart(haptic: true) } } .onChange(of: isActive) { _, active in if active { restart(haptic: true) } } // A later frame for this same page re-anchors the timer without re-buzzing. .onChange(of: anchorStart) { _, _ in if isActive { restart(haptic: false) } } } private func restart(haptic: Bool) { startDate = anchorStart ?? Date() if haptic { WorkoutHaptic.start.play() } } } // MARK: - Countdown Phase /// A count-down phase used for both rests and timed work sets: counts down from /// `seconds`, pings once per second in the final three, then buzzes and auto-advances at /// zero. The header/tint distinguish the two uses (purple "N of M" work vs. gray "Rest"). private struct CountdownPhaseView: View { let header: String var footer: String = "" let tint: Color let seconds: Int let isActive: Bool /// When the page was reached by a remote frame, anchor the countdown window to the /// sender's wall clock (`anchorStart`…`anchorEnd`) instead of local `now`…`now+seconds`, /// so the remaining time — and the auto-advance at zero — line up across both devices. var anchorStart: Date? = nil var anchorEnd: Date? = nil /// Invoked once the countdown reaches zero (auto-advance to the next page), passing the /// phase's *computed* end so the next phase can anchor at the boundary itself — not at /// whenever this tick got runtime (a device asleep at the boundary ticks late). let onFinished: (Date) -> Void /// Wall-clock window for the countdown. SwiftUI renders the remaining time from this /// range, and the haptics + auto-advance below are derived from `endDate` rather than /// a decremented counter so they stay correct even if a tick is delayed. @State private var startDate = Date() @State private var endDate = Date() /// Lowest remaining-second we've already pinged, so a burst of catch-up ticks doesn't /// double-buzz. @State private var lastPingSecond = Int.max /// Guards the auto-advance so it fires exactly once even if ticks pile up. @State private var didFinish = false private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() var body: some View { PhaseTimerLayout(header: header, footer: footer, tint: tint) { Text(timerInterval: startDate...endDate, countsDown: true) } .onAppear { if isActive { start(haptic: true) } } .onChange(of: isActive) { _, active in if active { start(haptic: true) } } // A later frame for this same page re-anchors the window without re-buzzing. .onChange(of: anchorEnd) { _, _ in if isActive { start(haptic: false) } } .onReceive(ticker) { _ in tick() } } private func start(haptic: Bool) { startDate = anchorStart ?? Date() endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds))) lastPingSecond = Int.max didFinish = false // No buzz for a chained catch-up page whose whole window already elapsed while // the device slept — it advances again on its next tick. if haptic, endDate > Date() { WorkoutHaptic.start.play() } } private func tick() { guard isActive, !didFinish else { return } // Round up so the final whole second still pings before we reach zero. let remaining = Int(ceil(endDate.timeIntervalSinceNow)) if remaining <= 0 { didFinish = true // Buzz only for a boundary that just happened — fast-forwarding through // boundaries that passed while the device slept stays silent. if Date().timeIntervalSince(endDate) < 3 { WorkoutHaptic.stop.play() } onFinished(endDate) } else if remaining <= 3 && remaining < lastPingSecond { // Once-per-second countdown ping for the final three seconds. lastPingSecond = remaining WorkoutHaptic.tick.play() } } } // MARK: - Finish Phase /// Terminal page after the last set. **Done** completes the exercise — and fires /// automatically after a configurable countdown so the user doesn't have to tap with /// sweaty hands. **One More** appends a bonus set instead. private struct FinishPhaseView: View { let isActive: Bool let onDone: () -> Void let onOneMore: () -> Void /// When the page was reached by a remote frame, anchor the auto-Done deadline to the /// sender's wall clock instead of local `now + countdown`, so both devices fire together. var anchorEnd: Date? = nil @AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5 /// Wall-clock deadline for the auto-Done. `remaining` is what the Done button shows. @State private var endDate = Date() @State private var remaining = 0 /// Fires the auto-Done exactly once, and latches off while the page isn't active. @State private var didFire = false private let ticker = Timer.publish(every: 0.25, on: .main, in: .common).autoconnect() var body: some View { VStack(spacing: 14) { Button(action: fire) { Label(remaining > 0 ? "Done · \(remaining)" : "Done", systemImage: "checkmark") .phaseButtonLabel() } .buttonStyle(.borderedProminent) .tint(Color.workTimer) .buttonBorderShape(.capsule) Button(action: onOneMore) { Label("One More", systemImage: "plus") .phaseButtonLabel() } .buttonStyle(.bordered) .buttonBorderShape(.capsule) } .padding(.horizontal, 40) .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { if isActive { start() } } .onChange(of: isActive) { _, active in active ? start() : (didFire = true) } .onChange(of: anchorEnd) { _, _ in if isActive { start() } } .onReceive(ticker) { _ in tick() } } private func start() { endDate = anchorEnd ?? Date().addingTimeInterval(Double(max(1, doneCountdownSeconds))) remaining = max(0, Int(ceil(endDate.timeIntervalSinceNow))) didFire = false } private func tick() { guard isActive, !didFire else { return } let r = Int(ceil(endDate.timeIntervalSinceNow)) remaining = max(0, r) if r <= 0 { fire() } } private func fire() { guard !didFire else { return } didFire = true WorkoutHaptic.success.play() onDone() } }