// // ExerciseProgressView.swift // Workouts Watch App // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // import SwiftUI import WatchKit /// Runs a single exercise as a horizontally-paged flow: /// /// [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), beeps once per second in the final three /// seconds, then auto-advances to the next work phase. Every work phase — including /// the last — looks the same; sliding past the final set reaches a **Finish** page /// offering **One More** (append a bonus set and keep going) and **Done** (which also /// auto-fires after a configurable countdown, completing the exercise). /// /// 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 /// The shared working workout document owned by the parent. We mutate the matching /// log in place and ask the parent to forward each change through the bridge — /// driving the UI from this doc (not the cache) avoids losing rapid edits to the /// read-after-write race. @Binding var doc: WorkoutDocument let logID: String let onChange: () -> Void /// 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 showingCancelConfirm = false @State private var didRestorePage = false /// True when this run opens on the lead-in **Ready?** page (the exercise hadn't /// been started). A resumed exercise — or the screenshot host — skips it. Constant /// for the lifetime of the view, so all the page-index math below is stable. private let showsReady: Bool /// Forces the starting page (used only by the DEBUG screenshot host). When set it /// also suppresses the Ready page so the index is a plain work/rest cycle offset. private let debugInitialPage: Int? init(doc: Binding, logID: String, onChange: @escaping () -> Void, debugInitialPage: Int? = nil) { self._doc = doc self.logID = logID self.onChange = onChange self.debugInitialPage = debugInitialPage let log = doc.wrappedValue.logs.first { $0.id == logID } let sets = max(1, log?.sets ?? 1) _setCount = State(initialValue: sets) let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue let ready = debugInitialPage == nil && notStarted self.showsReady = ready let base = ready ? 1 : 0 // 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 = ready ? 0 : base + completed * 2 _currentPage = State(initialValue: debugInitialPage ?? resume) } private var log: WorkoutLogDocument? { doc.logs.first { $0.id == logID } } /// 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 single Finish page sits just past the last work page. private var finishIndex: Int { base + cycleCount } /// The first unfinished set's work page (only used when resuming, so `base == 0`). private var resumePage: Int { let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1) return base + completed * 2 } /// 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.. some View { let isActive = index == currentPage 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. FinishPhaseView( isActive: isActive, onDone: { completeExercise(); dismiss() }, onOneMore: addSet ) } else if cycleIndex.isMultiple(of: 2) { // Work phase. WorkPhaseView( setNumber: cycleIndex / 2 + 1, totalSets: setCount, detail: detail, isActive: isActive ) } else { // Rest phase. Auto-advances to the next work page when the timer hits zero. RestPhaseView(isActive: isActive) { withAnimation { advance(from: index) } } } } } // 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 (used by the rest auto-advance), guarding /// against overrun if the user swiped away in the meantime. private func advance(from index: Int) { guard currentPage == index, index + 1 < totalPages else { return } 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].status = WorkoutStatus.inProgress.rawValue 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. /// /// Growing `setCount` and advancing `currentPage` in one animated transaction makes /// the new work phase slide in from the right. (We land on work, not a rest, because /// the Finish page's own auto-finish countdown already served as the between-set /// breather — and the rest page would otherwise sit at the very index the Finish /// page occupied, which a paged TabView can't animate to.) private func addSet() { let newCount = setCount + 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 doc.logs[i].status = WorkoutStatus.inProgress.rawValue doc.logs[i].completed = false 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 (and forwards it to the phone), 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 cycleIndex = pageIndex - base let reached = min(max(0, (cycleIndex + 1) / 2), setCount - 1) 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 doc.logs[i].status = WorkoutStatus.inProgress.rawValue doc.logs[i].completed = false recomputeWorkoutStatus() doc.updatedAt = Date() onChange() } private func completeExercise() { guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } doc.logs[i].currentStateIndex = setCount doc.logs[i].status = WorkoutStatus.completed.rawValue doc.logs[i].completed = true recomputeWorkoutStatus() doc.updatedAt = Date() onChange() } private func recomputeWorkoutStatus() { let statuses = doc.logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted } let allCompleted = !statuses.isEmpty && statuses.allSatisfy { $0 == .completed } let anyInProgress = statuses.contains { $0 == .inProgress } let allNotStarted = statuses.allSatisfy { $0 == .notStarted } if allCompleted { doc.status = WorkoutStatus.completed.rawValue doc.end = Date() } else if anyInProgress || !allNotStarted { doc.status = WorkoutStatus.inProgress.rawValue doc.end = nil } else { doc.status = WorkoutStatus.notStarted.rawValue doc.end = nil } } // MARK: - Formatting 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: - Phase Progress Dots /// Compact progress row for the work/rest cycle: purple dots for work, teal for rest, /// with the active phase drawn as a wider dash. Dots are deliberately a touch thick so /// the count of remaining sets reads at a glance. private struct PhaseProgressDots: View { let count: Int let currentIndex: Int var body: some View { HStack(spacing: 4) { ForEach(0.. Void var body: some View { VStack(spacing: 10) { Text("Ready?") .font(.system(size: 30, weight: .bold, design: .rounded)) if !summary.isEmpty { Text(summary) .font(.subheadline) .foregroundStyle(.secondary) } Button(action: onStart) { Label("Start", systemImage: "play.fill") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .tint(.workTint) .padding(.top, 4) } .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 /// 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 /// on a run-loop `Timer` keeps it advancing in the Always-On / wrist-down state, /// where that timer is throttled and stops firing. @State private var startDate = Date() var body: some View { VStack(spacing: 6) { Text("\(setNumber) of \(totalSets)") .font(.headline) .foregroundStyle(.secondary) Text(startDate, style: .timer) .font(.system(size: 48, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(Color.workTint) if !detail.isEmpty { Text(detail) .font(.subheadline) .foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { if isActive { restart() } } .onChange(of: isActive) { _, active in if active { restart() } } } private func restart() { startDate = Date() WKInterfaceDevice.current().play(.start) } } // MARK: - Rest Phase private struct RestPhaseView: View { let isActive: Bool /// Invoked once the countdown reaches zero (auto-advance to the next work phase). let onFinished: () -> Void @AppStorage("restSeconds") private var restSeconds: Int = 45 /// Wall-clock window for the countdown. SwiftUI renders the remaining time from this /// range (so the display keeps counting down in the Always-On / wrist-down state), /// and the haptics + auto-advance below are derived from `endDate` rather than a /// decremented counter — so they stay correct even after the run-loop `Timer` was /// throttled while the wrist was down. @State private var startDate = Date() @State private var endDate = Date() /// Lowest remaining-second we've already pinged, so a burst of catch-up ticks after a /// stall doesn't double-beep. @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 { VStack(spacing: 6) { Text("Rest") .font(.headline) .foregroundStyle(.secondary) Text(timerInterval: startDate...endDate, countsDown: true) .font(.system(size: 54, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(Color.restTint) } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { if isActive { start() } } .onChange(of: isActive) { _, active in if active { start() } } .onReceive(ticker) { _ in tick() } } private func start() { startDate = Date() endDate = startDate.addingTimeInterval(Double(max(1, restSeconds))) lastPingSecond = Int.max didFinish = false WKInterfaceDevice.current().play(.start) } 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 { // Time's up — final cue and slide to the next work phase. If the wrist was // down the timer may have stalled; this then fires on the first tick once the // app gets runtime again (e.g. the wrist comes up), never losing the rest. didFinish = true WKInterfaceDevice.current().play(.stop) onFinished() } else if remaining <= 3 && remaining < lastPingSecond { // Once-per-second countdown ping for the final three seconds. lastPingSecond = remaining WKInterfaceDevice.current().play(.notification) } } } // MARK: - Finish Phase /// Terminal page after the last set. **Done** completes the exercise and dismisses — /// 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 @AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5 /// Wall-clock deadline for the auto-Done (same Always-On rationale as the rest /// timer). `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: 8) { Text("Finished!") .font(.headline) .foregroundStyle(.secondary) Button(action: fire) { Label(remaining > 0 ? "Done · \(remaining)" : "Done", systemImage: "checkmark") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .tint(Color.workTint) Button(action: onOneMore) { Label("One More", systemImage: "plus") .frame(maxWidth: .infinity) } .buttonStyle(.bordered) } .padding(.horizontal) .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { if isActive { start() } } .onChange(of: isActive) { _, active in active ? start() : (didFire = true) } .onReceive(ticker) { _ in tick() } } private func start() { let total = max(1, doneCountdownSeconds) endDate = Date().addingTimeInterval(Double(total)) remaining = total 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 WKInterfaceDevice.current().play(.success) onDone() } }