// // ExerciseProgressView.swift // Workouts Watch App // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // import SwiftUI import WatchKit /// Runs a single exercise as a horizontally-paged HIIT cycle: /// /// Work₁ → Rest₁ → Work₂ → Rest₂ → … → Rest₍ₙ₋₁₎ → Workₙ /// /// The **work** phase counts *up* (a stopwatch for the current set); the user swipes /// left when they're done. The **rest** phase counts *down* from the configurable rest /// time, beeps once per second in the final three seconds, and then auto-advances to /// the next work phase. The final work phase has no rest after it — instead it offers /// **One More** (append a bonus set and keep going) and **Done** (mark the exercise /// complete and return to the list). 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 init(doc: Binding, logID: String, onChange: @escaping () -> Void) { self._doc = doc self.logID = logID self.onChange = onChange let log = doc.wrappedValue.logs.first { $0.id == logID } let sets = max(1, log?.sets ?? 1) _setCount = State(initialValue: sets) // Resume on the first unfinished set's work page (clamped to the last set). let completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1) _currentPage = State(initialValue: completed * 2) } private var log: WorkoutLogDocument? { doc.logs.first { $0.id == logID } } /// Work₁, Rest₁, …, Workₙ ⇒ N sets + (N−1) rests = 2N − 1 pages. private var totalPages: Int { setCount * 2 - 1 } /// The first unfinished set's work page (clamped to the last set). Resuming an /// exercise opens here, skipping any completed work/rest pairs. private var resumePage: Int { let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1) return completed * 2 } private var detail: String { guard let log else { return "" } if LoadType(rawValue: log.loadType) == .duration { return Self.durationLabel(log.durationSeconds) } return "\(log.reps) reps" } var body: some View { TabView(selection: $currentPage) { ForEach(0.. some View { let isActive = index == currentPage if index.isMultiple(of: 2) { // Work phase. The last page (set N) finishes the exercise. WorkPhaseView( setNumber: index / 2 + 1, totalSets: setCount, detail: detail, isLast: index == totalPages - 1, isActive: isActive, onOneMore: addSet, onDone: { completeExercise(); dismiss() } ) } else { // Rest phase. Auto-advances to the next work page when the timer hits zero. RestPhaseView(isActive: isActive) { withAnimation { advance(from: index) } } } } // MARK: - Mutations /// 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 } /// Append a bonus set: grow the plan, record the just-finished set as done, and /// slide into the rest period that now follows the previously-final work page. private func addSet() { let restPage = currentPage + 1 // the rest page that becomes valid after the bump setCount += 1 if let i = doc.logs.firstIndex(where: { $0.id == logID }) { doc.logs[i].sets = setCount doc.logs[i].currentStateIndex = setCount - 1 // the old final set is complete doc.logs[i].status = WorkoutStatus.inProgress.rawValue doc.logs[i].completed = false recomputeWorkoutStatus() doc.updatedAt = Date() onChange() } withAnimation { currentPage = restPage } } /// Page index → completed-set count: Work₁(0)→0, Rest₁(1)→1, Work₂(2)→1, … — /// i.e. `(pageIndex + 1) / 2`. Reaching set N as *completed* only happens via Done. /// /// 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 finished set. private func recordProgress(for pageIndex: Int) { let reached = min((pageIndex + 1) / 2, setCount) 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 if reached >= setCount { doc.logs[i].status = WorkoutStatus.completed.rawValue doc.logs[i].completed = true } else { 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: - Work Phase private struct WorkPhaseView: View { let setNumber: Int let totalSets: Int let detail: String let isLast: Bool let isActive: Bool let onOneMore: () -> Void let onDone: () -> Void @State private var elapsed = 0 private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() var body: some View { VStack(spacing: 6) { Text("Set \(setNumber) of \(totalSets)") .font(.headline) .foregroundStyle(.secondary) Text(clockString(elapsed)) .font(.system(size: 48, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.green) Text(detail) .font(.subheadline) .foregroundStyle(.secondary) if isLast { VStack(spacing: 6) { Button(action: onOneMore) { Label("One More", systemImage: "plus") .frame(maxWidth: .infinity) } .tint(.blue) Button(action: onDone) { Label("Done", systemImage: "checkmark") .frame(maxWidth: .infinity) } .tint(.green) } .buttonStyle(.borderedProminent) .padding(.top, 4) } else { Label("Swipe to rest", systemImage: "chevron.right") .font(.caption2) .foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { if isActive { restart() } } .onChange(of: isActive) { _, active in if active { restart() } } .onReceive(ticker) { _ in if isActive { elapsed += 1 } } } private func restart() { elapsed = 0 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 @State private var remaining = 0 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(clockString(max(0, remaining))) .font(.system(size: 54, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.orange) Label("Swipe to skip", systemImage: "chevron.right") .font(.caption2) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { if isActive { start() } } .onChange(of: isActive) { _, active in if active { start() } } .onReceive(ticker) { _ in tick() } } private func start() { remaining = max(1, restSeconds) WKInterfaceDevice.current().play(.start) } private func tick() { guard isActive, remaining > 0 else { return } remaining -= 1 if remaining == 0 { // Time's up — final cue and slide to the next work phase. WKInterfaceDevice.current().play(.stop) onFinished() } else if remaining <= 3 { // Once-per-second countdown ping for the final three seconds. WKInterfaceDevice.current().play(.notification) } } } // MARK: - Shared private func clockString(_ seconds: Int) -> String { String(format: "%d:%02d", seconds / 60, seconds % 60) }