diff --git a/Workouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/ExerciseProgressView.swift index 5d7da27..f14e301 100644 --- a/Workouts Watch App/Views/ExerciseProgressView.swift +++ b/Workouts Watch App/Views/ExerciseProgressView.swift @@ -8,22 +8,20 @@ import SwiftUI import WatchKit -/// Runs a single exercise as a horizontally-paged flow: +/// Runs a single exercise as a horizontally-paged flow, mirroring the iPhone's: /// /// [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 **Ready?** page leads in *only* when the exercise hasn't been started yet (a resumed +/// exercise jumps straight to its first unfinished set). Rep-based **work** phases count +/// *up* (brand purple) and the user swipes on when done; **timed** work phases and +/// **rests** count *down* (rest in gray), ping once per second in the final three +/// seconds, then auto-advance. Sliding past the final set reaches a **Finish** page with +/// **One More** and an auto-firing **Done**. Swiping all the way back to Ready resets the +/// run from scratch. /// -/// A row of phase dots tracks progress: purple for work, teal for rest, with the -/// current phase drawn as a wider dash. +/// A dot row tracks progress with one marker per work set — the active set drawn as a +/// wider dash, with the gap widening at the rest you're currently in. struct ExerciseProgressView: View { @Environment(\.dismiss) private var dismiss @@ -35,16 +33,20 @@ struct ExerciseProgressView: View { let logID: String let onChange: () -> Void + /// Rest length between sets, shared with the phone via the same defaults key. + @AppStorage("restSeconds") private var restSeconds: Int = 45 + /// 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 + /// 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. Held in `@State` + /// so it stays fixed for the life of the screen (same reasoning as the iPhone view), + /// keeping the page-index math below stable. + @State private var 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. @@ -62,7 +64,7 @@ struct ExerciseProgressView: View { let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue let ready = debugInitialPage == nil && notStarted - self.showsReady = ready + _showsReady = State(initialValue: ready) let base = ready ? 1 : 0 // Resume on the first unfinished set's work page (clamped to the last set). @@ -84,9 +86,6 @@ struct ExerciseProgressView: View { /// 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) @@ -100,6 +99,19 @@ struct ExerciseProgressView: View { return (0..: View { + let header: String + let footer: String + let tint: Color + @ViewBuilder var timer: Content var body: some View { - HStack(spacing: 4) { - 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 full-width body. + func phaseButtonLabel() -> some View { + self + .font(.system(.headline, design: .rounded, weight: .heavy)) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) } } @@ -343,22 +501,23 @@ private struct ReadyPhaseView: View { let onStart: () -> Void var body: some View { - VStack(spacing: 10) { + VStack(spacing: 8) { Text("Ready?") .font(.system(size: 30, weight: .bold, design: .rounded)) if !summary.isEmpty { Text(summary) - .font(.subheadline) + .font(.caption) .foregroundStyle(.secondary) } Button(action: onStart) { - Label("Start", systemImage: "play.fill") - .frame(maxWidth: .infinity) + Text("Start") + .phaseButtonLabel() } .buttonStyle(.borderedProminent) - .tint(.workTint) + .tint(.workTimer) + .buttonBorderShape(.capsule) .padding(.top, 4) } .padding(.horizontal) @@ -381,68 +540,51 @@ private struct WorkPhaseView: View { @State private var startDate = Date() var body: some View { - VStack(spacing: 6) { - Text("\(setNumber) of \(totalSets)") - .font(.headline) - .foregroundStyle(.secondary) - + PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer) { 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) + WorkoutHaptic.start.play() } } -// MARK: - Rest Phase +// MARK: - Countdown Phase -private struct RestPhaseView: View { +/// 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"). +/// +/// The display is driven by a wall-clock window (so it keeps counting down in the +/// Always-On / wrist-down state), and the haptics + auto-advance 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. +private struct CountdownPhaseView: View { + let header: String + var footer: String = "" + let tint: Color + let seconds: Int let isActive: Bool - /// Invoked once the countdown reaches zero (auto-advance to the next work phase). + /// Invoked once the countdown reaches zero (auto-advance to the next page). 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. + /// 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 { - VStack(spacing: 6) { - Text("Rest") - .font(.headline) - .foregroundStyle(.secondary) - + PhaseTimerLayout(header: header, footer: footer, tint: tint) { 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() } @@ -450,10 +592,10 @@ private struct RestPhaseView: View { private func start() { startDate = Date() - endDate = startDate.addingTimeInterval(Double(max(1, restSeconds))) + endDate = startDate.addingTimeInterval(Double(max(1, seconds))) lastPingSecond = Int.max didFinish = false - WKInterfaceDevice.current().play(.start) + WorkoutHaptic.start.play() } private func tick() { @@ -462,25 +604,24 @@ private struct RestPhaseView: View { 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. + // Time's up — final cue and slide on. If the wrist was down the timer may have + // stalled; this then fires on the first tick once the app gets runtime again. didFinish = true - WKInterfaceDevice.current().play(.stop) + WorkoutHaptic.stop.play() onFinished() } else if remaining <= 3 && remaining < lastPingSecond { // Once-per-second countdown ping for the final three seconds. lastPingSecond = remaining - WKInterfaceDevice.current().play(.notification) + WorkoutHaptic.tick.play() } } } // 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. +/// 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 @@ -488,8 +629,8 @@ private struct FinishPhaseView: View { @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. + /// 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. @@ -498,22 +639,20 @@ private struct FinishPhaseView: View { 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) + .phaseButtonLabel() } .buttonStyle(.borderedProminent) - .tint(Color.workTint) + .tint(Color.workTimer) + .buttonBorderShape(.capsule) Button(action: onOneMore) { Label("One More", systemImage: "plus") - .frame(maxWidth: .infinity) + .phaseButtonLabel() } .buttonStyle(.bordered) + .buttonBorderShape(.capsule) } .padding(.horizontal) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -539,7 +678,7 @@ private struct FinishPhaseView: View { private func fire() { guard !didFire else { return } didFire = true - WKInterfaceDevice.current().play(.success) + WorkoutHaptic.success.play() onDone() } }