From 2ae8dc6dc9b034863e3b47dc50f7440e92439580 Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 20 Jun 2026 15:36:10 -0400 Subject: [PATCH] Match the Apple Watch progress run to the iPhone redesign Times work sets count down for timed exercises (rep sets still count up), rest tint is now gray, and the dot row shows one marker per work set (dash on the active set, gap widening at the current rest). Start/Done/One More are capsule buttons with a heavier rounded label and Start is text-only; the Finished! label is gone; swiping back to Ready resets the run. Shares a timer-layout skeleton so work/rest timers match; keeps WatchKit haptics, the cancel-X toolbar, the screenshot hook, and watch-sized fonts. --- .../Views/ExerciseProgressView.swift | 367 ++++++++++++------ 1 file changed, 253 insertions(+), 114 deletions(-) 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() } }