// // ExerciseProgressView.swift // Workouts Watch App // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // import SwiftUI import WatchKit 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 /// taps to the read-after-write race. @Binding var doc: WorkoutDocument let logID: String let onChange: () -> Void @State private var currentPage: Int = 0 @State private var showingCancelConfirm = false private var log: WorkoutLogDocument? { doc.logs.first(where: { $0.id == logID }) } private var totalSets: Int { max(1, log?.sets ?? 1) } private var totalPages: Int { // Set 1, Rest 1, Set 2, Rest 2, ..., Set N, Done // = N sets + (N-1) rests + 1 done = 2N totalSets * 2 } private var firstUnfinishedSetPage: Int { // currentStateIndex is the number of completed sets let completedSets = log?.currentStateIndex ?? 0 if completedSets >= totalSets { // All done, go to done page return totalPages - 1 } // Each set is at page index * 2 (Set1=0, Set2=2, Set3=4...) return completedSets * 2 } var body: some View { TabView(selection: $currentPage) { ForEach(0.. some View { let lastPageIndex = totalPages - 1 if index == lastPageIndex { // Done page DonePageView { completeExercise() dismiss() } } else if index % 2 == 0 { // Set page (0, 2, 4, ...) let setNumber = (index / 2) + 1 SetPageView( setNumber: setNumber, totalSets: totalSets, reps: log?.reps ?? 0, isTimeBased: LoadType(rawValue: log?.loadType ?? LoadType.weight.rawValue) == .duration, durationMinutes: (log?.durationSeconds ?? 0) / 60, durationSeconds: (log?.durationSeconds ?? 0) % 60 ) } else { // Rest page (1, 3, 5, ...) let restNumber = (index / 2) + 1 RestPageView(restNumber: restNumber) } } private func updateProgress(for pageIndex: Int) { // Calculate which set we're on based on page index // Pages: Set1(0), Rest1(1), Set2(2), Rest2(3), Set3(4), Done(5) // After completing Set 1 and moving to Rest 1, progress should be 1 let setIndex = (pageIndex + 1) / 2 let clampedProgress = min(setIndex, totalSets) guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } guard clampedProgress != doc.logs[i].currentStateIndex else { return } doc.logs[i].currentStateIndex = clampedProgress if clampedProgress >= totalSets { doc.logs[i].status = WorkoutStatus.completed.rawValue doc.logs[i].completed = true } else if clampedProgress > 0 { 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 = totalSets 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: - Set Page View struct SetPageView: View { let setNumber: Int let totalSets: Int let reps: Int let isTimeBased: Bool let durationMinutes: Int let durationSeconds: Int var body: some View { VStack(spacing: 8) { Text("Set \(setNumber) of \(totalSets)") .font(.headline) .foregroundColor(.secondary) Text("\(setNumber)") .font(.system(size: 72, weight: .bold, design: .rounded)) .foregroundColor(.green) if isTimeBased { Text(formattedDuration) .font(.title3) .foregroundColor(.secondary) } else { Text("\(reps) reps") .font(.title3) .foregroundColor(.secondary) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { WKInterfaceDevice.current().play(.start) } } private var formattedDuration: String { if durationMinutes > 0 && durationSeconds > 0 { return "\(durationMinutes)m \(durationSeconds)s" } else if durationMinutes > 0 { return "\(durationMinutes) min" } else { return "\(durationSeconds) sec" } } } // MARK: - Rest Page View struct RestPageView: View { let restNumber: Int @State private var elapsedSeconds: Int = 0 private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() var body: some View { VStack(spacing: 8) { Text("Rest") .font(.headline) .foregroundColor(.secondary) Text(formattedTime) .font(.system(size: 56, weight: .bold, design: .monospaced)) .foregroundColor(.orange) Text("Swipe to continue") .font(.caption2) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { elapsedSeconds = 0 WKInterfaceDevice.current().play(.start) } .onReceive(ticker) { _ in elapsedSeconds += 1 checkHapticPing() } } private var formattedTime: String { let minutes = elapsedSeconds / 60 let seconds = elapsedSeconds % 60 return String(format: "%d:%02d", minutes, seconds) } private func checkHapticPing() { // Haptic ping every 10 seconds with pattern: // 10s: single, 20s: double, 30s: triple, 40s: single, 50s: double, etc. guard elapsedSeconds > 0 && elapsedSeconds % 10 == 0 else { return } let cyclePosition = (elapsedSeconds / 10) % 3 let pingCount: Int switch cyclePosition { case 1: pingCount = 1 // 10s, 40s, 70s... case 2: pingCount = 2 // 20s, 50s, 80s... case 0: pingCount = 3 // 30s, 60s, 90s... default: pingCount = 1 } playHapticPings(count: pingCount) } private func playHapticPings(count: Int) { for i in 0.. Void var body: some View { VStack(spacing: 16) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 60)) .foregroundColor(.green) Text("Done!") .font(.title2) .fontWeight(.bold) Text("Tap to finish") .font(.caption) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) .onTapGesture { WKInterfaceDevice.current().play(.success) onDone() } .onAppear { WKInterfaceDevice.current().play(.success) } } }