From f06c4e996e3effbb24a53c88c1069fbc7a8f3b4c Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 20 Jun 2026 14:15:31 -0400 Subject: [PATCH] Rework the Apple Watch progress flow Watch root lists every in-progress workout; picking an exercise runs a paged Ready -> work/rest -> Finish flow (One More + auto-firing Done), with a phase-dot row and brand-tinted count-up/down timers. Includes the configurable rest and auto-finish settings synced over WatchConnectivity and the wrist-down timer fix. --- Shared/Connectivity/WCPayload.swift | 6 +- Shared/Utils/Color+Extensions.swift | 8 + .../WatchConnectivityBridge.swift | 9 +- .../Views/ActiveWorkoutGateView.swift | 83 ++++- .../Views/ExerciseProgressView.swift | 343 +++++++++++++----- Workouts Watch App/WatchAppDelegate.swift | 5 + Workouts Watch App/WatchScreenshotRoot.swift | 7 +- .../PhoneConnectivityBridge.swift | 4 +- Workouts/Views/Settings/SettingsView.swift | 19 +- 9 files changed, 373 insertions(+), 111 deletions(-) diff --git a/Shared/Connectivity/WCPayload.swift b/Shared/Connectivity/WCPayload.swift index 2bbf675..d2d52c7 100644 --- a/Shared/Connectivity/WCPayload.swift +++ b/Shared/Connectivity/WCPayload.swift @@ -10,17 +10,19 @@ enum WCPayload { static let workoutsKey = "workouts" static let workoutKey = "workout" static let restSecondsKey = "restSeconds" + static let doneCountdownSecondsKey = "doneCountdownSeconds" static let workoutUpdateType = "workoutUpdate" // watch → phone (one workout) static let requestSyncType = "requestSync" // watch → phone (please push state) // MARK: - Phone → Watch (application context: latest-state-wins) - static func encodeState(splits: [SplitDocument], workouts: [WorkoutDocument], restSeconds: Int) -> [String: Any] { + static func encodeState(splits: [SplitDocument], workouts: [WorkoutDocument], restSeconds: Int, doneCountdownSeconds: Int) -> [String: Any] { var dict: [String: Any] = [:] if let s = try? DocumentCoder.encoder.encode(splits) { dict[splitsKey] = s } if let w = try? DocumentCoder.encoder.encode(workouts) { dict[workoutsKey] = w } dict[restSecondsKey] = restSeconds + dict[doneCountdownSecondsKey] = doneCountdownSeconds return dict } @@ -36,6 +38,8 @@ enum WCPayload { static func decodeRestSeconds(_ dict: [String: Any]) -> Int? { dict[restSecondsKey] as? Int } + static func decodeDoneCountdownSeconds(_ dict: [String: Any]) -> Int? { dict[doneCountdownSecondsKey] as? Int } + // MARK: - Watch → Phone (a single updated workout) static func encodeWorkoutUpdate(_ workout: WorkoutDocument) -> [String: Any] { diff --git a/Shared/Utils/Color+Extensions.swift b/Shared/Utils/Color+Extensions.swift index d968b4c..a9d593d 100644 --- a/Shared/Utils/Color+Extensions.swift +++ b/Shared/Utils/Color+Extensions.swift @@ -22,6 +22,14 @@ extension Color { } } + /// Brand purple from the app logo (a touch brighter than the icon's `#7023BC` + /// so it stays legible on the watch's black background). Used for the work phase — + /// its count-up timer and progress dots. + static let workTint = Color(red: 0.51, green: 0.22, blue: 0.84) + + /// Light teal counterpart for the rest phase — its count-down timer and dots. + static let restTint = Color(red: 0.44, green: 0.85, blue: 0.84) + /// Returns a darker shade by reducing HSB brightness (not opacity). func darker(by percentage: CGFloat = 0.2) -> Color { #if canImport(UIKit) diff --git a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift index 09521a5..79f43a3 100644 --- a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift +++ b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift @@ -32,7 +32,7 @@ final class WatchConnectivityBridge: NSObject { // Apply whatever the phone last pushed, then ask for a fresh push. applyState(WCPayload.decodeSplits(session.receivedApplicationContext), workouts: WCPayload.decodeWorkouts(session.receivedApplicationContext)) - applyRestSeconds(session.receivedApplicationContext) + applySettings(session.receivedApplicationContext) requestSync() } @@ -63,10 +63,13 @@ final class WatchConnectivityBridge: NSObject { } } - private func applyRestSeconds(_ dict: [String: Any]) { + private func applySettings(_ dict: [String: Any]) { if let rest = WCPayload.decodeRestSeconds(dict) { UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey) } + if let done = WCPayload.decodeDoneCountdownSeconds(dict) { + UserDefaults.standard.set(done, forKey: WCPayload.doneCountdownSecondsKey) + } } private func applyState(_ splits: [SplitDocument], workouts: [WorkoutDocument]) { @@ -101,9 +104,11 @@ extension WatchConnectivityBridge: WCSessionDelegate { let splits = WCPayload.decodeSplits(applicationContext) let workouts = WCPayload.decodeWorkouts(applicationContext) let rest = WCPayload.decodeRestSeconds(applicationContext) + let done = WCPayload.decodeDoneCountdownSeconds(applicationContext) Task { @MainActor in self.applyState(splits, workouts: workouts) if let rest { UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey) } + if let done { UserDefaults.standard.set(done, forKey: WCPayload.doneCountdownSecondsKey) } } } } diff --git a/Workouts Watch App/Views/ActiveWorkoutGateView.swift b/Workouts Watch App/Views/ActiveWorkoutGateView.swift index dbc214e..96dea95 100644 --- a/Workouts Watch App/Views/ActiveWorkoutGateView.swift +++ b/Workouts Watch App/Views/ActiveWorkoutGateView.swift @@ -8,37 +8,57 @@ import SwiftUI import SwiftData -/// Root of the watch app. The watch only runs the workout that's currently active -/// on the phone — there's no history browsing here. The "active" workout is the most -/// recent cached one that isn't finished (`notStarted` or `inProgress`); a workout is +/// Root of the watch app. The watch only runs the workouts that are currently +/// active on the phone — there's no history browsing here. An "active" workout is a +/// cached one that isn't finished (`notStarted` or `inProgress`); a workout is /// created on the phone as `notStarted` the moment a split is picked, and flips to -/// `completed` once every exercise is done, at which point we fall back to the gate. +/// `completed` once every exercise is done, at which point it drops off this list. +/// +/// The root shows every active workout (most-recent first); picking one opens its +/// exercise list. struct ActiveWorkoutGateView: View { @Environment(WatchConnectivityBridge.self) private var bridge @Environment(WorkoutSessionManager.self) private var sessionManager @Query(sort: \Workout.start, order: .reverse) private var workouts: [Workout] + @Query private var splits: [Split] - private var activeWorkout: Workout? { - workouts.first { $0.status == .inProgress || $0.status == .notStarted } + private var activeWorkouts: [Workout] { + workouts.filter { $0.status == .inProgress || $0.status == .notStarted } + } + + private func split(for workout: Workout) -> Split? { + guard let id = workout.splitID else { return nil } + return splits.first { $0.id == id } } var body: some View { NavigationStack { - if let workout = activeWorkout { - WorkoutLogListView(workout: workout) - } else { - emptyState + Group { + if activeWorkouts.isEmpty { + emptyState + } else { + List { + ForEach(activeWorkouts) { workout in + NavigationLink { + WorkoutLogListView(workout: workout) + } label: { + ActiveWorkoutRow(workout: workout, split: split(for: workout)) + } + } + } + .navigationTitle("In Progress") + } } } .task { // Nothing to run yet — pull fresh state in case the phone just started one. - if activeWorkout == nil { bridge.requestSync() } + if activeWorkouts.isEmpty { bridge.requestSync() } } - .onChange(of: activeWorkout == nil) { _, noActiveWorkout in - // The workout finished (or was cleared) — release the HealthKit session that + .onChange(of: activeWorkouts.isEmpty) { _, noActiveWorkouts in + // Everything finished (or was cleared) — release the HealthKit session that // was keeping the launched app alive. - if noActiveWorkout { sessionManager.end() } + if noActiveWorkouts { sessionManager.end() } } } @@ -56,3 +76,38 @@ struct ActiveWorkoutGateView: View { } } } + +// MARK: - Active Workout Row + +private struct ActiveWorkoutRow: View { + let workout: Workout + let split: Split? + + private var logs: [WorkoutLog] { workout.logsArray } + private var doneCount: Int { logs.filter { $0.status == .completed }.count } + + var body: some View { + HStack(spacing: 10) { + Image(systemName: split?.systemImage ?? "figure.strengthtraining.traditional") + .font(.title3) + .foregroundStyle(split.map { Color.color(from: $0.color) } ?? .workTint) + .frame(width: 26) + + VStack(alignment: .leading, spacing: 2) { + Text(workout.splitName ?? Split.unnamed) + .font(.headline) + .lineLimit(1) + + Text(subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + + private var subtitle: String { + guard !logs.isEmpty else { return workout.start.formattedDate() } + if doneCount == 0 { return "Not started · \(logs.count) exercises" } + return "\(doneCount) of \(logs.count) done" + } +} diff --git a/Workouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/ExerciseProgressView.swift index 897a33b..5d7da27 100644 --- a/Workouts Watch App/Views/ExerciseProgressView.swift +++ b/Workouts Watch App/Views/ExerciseProgressView.swift @@ -8,16 +8,22 @@ import SwiftUI import WatchKit -/// Runs a single exercise as a horizontally-paged HIIT cycle: +/// Runs a single exercise as a horizontally-paged flow: /// -/// Work₁ → Rest₁ → Work₂ → Rest₂ → … → Rest₍ₙ₋₁₎ → Workₙ +/// [Ready] → Work₁ → Rest₁ → Work₂ → … → Workₙ → Finish /// -/// 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). +/// 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 @@ -35,8 +41,13 @@ struct ExerciseProgressView: View { @State private var showingCancelConfirm = false @State private var didRestorePage = false - /// Forces the starting page (used only by the DEBUG screenshot host to land on a - /// rest page). Always nil in normal use. + /// 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) { @@ -48,23 +59,45 @@ struct ExerciseProgressView: View { 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) - _currentPage = State(initialValue: debugInitialPage ?? (completed * 2)) + let resume = ready ? 0 : base + completed * 2 + _currentPage = State(initialValue: debugInitialPage ?? resume) } 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 } + /// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`. + private var base: Int { showsReady ? 1 : 0 } - /// The first unfinished set's work page (clamped to the last set). Resuming an - /// exercise opens here, skipping any completed work/rest pairs. + /// 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 completed * 2 + 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 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() } - ) + if showsReady && index == 0 { + ReadyPhaseView(summary: readySummary, onStart: start) } else { - // Rest phase. Auto-advances to the next work page when the timer hits zero. - RestPhaseView(isActive: isActive) { - withAnimation { advance(from: index) } + 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) { @@ -153,15 +213,30 @@ struct ExerciseProgressView: View { 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. + /// 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 restPage = currentPage + 1 // the rest page that becomes valid after the bump - setCount += 1 + let newCount = 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].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() @@ -169,29 +244,29 @@ struct ExerciseProgressView: View { onChange() } - withAnimation { currentPage = restPage } + withAnimation { + setCount = newCount + currentPage += 1 + } } - /// 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. + /// Map a page to completed-set count and record forward progress. /// - /// 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. + /// 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) { - let reached = min((pageIndex + 1) / 2, setCount) + 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 - 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 - } + doc.logs[i].status = WorkoutStatus.inProgress.rawValue + doc.logs[i].completed = false recomputeWorkoutStatus() doc.updatedAt = Date() @@ -238,16 +313,66 @@ struct ExerciseProgressView: View { } } +// 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 isLast: Bool let isActive: Bool - let onOneMore: () -> Void - let onDone: () -> Void /// 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 @@ -257,38 +382,18 @@ private struct WorkPhaseView: View { var body: some View { VStack(spacing: 6) { - Text("Set \(setNumber) of \(totalSets)") + Text("\(setNumber) of \(totalSets)") .font(.headline) .foregroundStyle(.secondary) Text(startDate, style: .timer) .font(.system(size: 48, weight: .bold, design: .rounded)) .monospacedDigit() - .foregroundStyle(.green) + .foregroundStyle(Color.workTint) - 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) + if !detail.isEmpty { + Text(detail) + .font(.subheadline) .foregroundStyle(.secondary) } } @@ -335,11 +440,7 @@ private struct RestPhaseView: View { Text(timerInterval: startDate...endDate, countsDown: true) .font(.system(size: 54, weight: .bold, design: .rounded)) .monospacedDigit() - .foregroundStyle(.orange) - - Label("Swipe to skip", systemImage: "chevron.right") - .font(.caption2) - .foregroundStyle(.secondary) + .foregroundStyle(Color.restTint) } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { if isActive { start() } } @@ -374,3 +475,71 @@ private struct RestPhaseView: View { } } } + +// 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() + } +} diff --git a/Workouts Watch App/WatchAppDelegate.swift b/Workouts Watch App/WatchAppDelegate.swift index ebf8491..a888479 100644 --- a/Workouts Watch App/WatchAppDelegate.swift +++ b/Workouts Watch App/WatchAppDelegate.swift @@ -19,6 +19,11 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate { let sessionManager = WorkoutSessionManager() func applicationDidFinishLaunching() { + #if DEBUG + // The screenshot harness renders fixed screens — don't pop the Health auth + // dialog over them (it would also leak into App Store captures). + if ScreenshotSeed.isActive { return } + #endif sessionManager.requestAuthorization() } diff --git a/Workouts Watch App/WatchScreenshotRoot.swift b/Workouts Watch App/WatchScreenshotRoot.swift index 8b35472..b219cef 100644 --- a/Workouts Watch App/WatchScreenshotRoot.swift +++ b/Workouts Watch App/WatchScreenshotRoot.swift @@ -27,7 +27,7 @@ struct WatchScreenshotRoot: View { if let workout = activeWorkout { switch ScreenshotSeed.screen(default: "list") { case "work": - ProgressHost(workout: workout, exerciseName: "Lat Pulldown", page: nil) + ProgressHost(workout: workout, exerciseName: "Lat Pulldown", page: 0) case "rest": ProgressHost(workout: workout, exerciseName: "Lat Pulldown", page: 1) default: @@ -39,8 +39,9 @@ struct WatchScreenshotRoot: View { } } -/// Hosts the progress view with a working-copy binding (and an optional pinned page so -/// we can capture a rest phase, which the normal resume logic never lands on). +/// Hosts the progress view with a working-copy binding and a pinned page (which also +/// suppresses the Ready lead-in), so we can capture a specific work or rest phase that +/// the normal resume logic wouldn't land on. private struct ProgressHost: View { @State private var doc: WorkoutDocument private let logID: String diff --git a/Workouts/Connectivity/PhoneConnectivityBridge.swift b/Workouts/Connectivity/PhoneConnectivityBridge.swift index efdaac1..8f6ef0b 100644 --- a/Workouts/Connectivity/PhoneConnectivityBridge.swift +++ b/Workouts/Connectivity/PhoneConnectivityBridge.swift @@ -43,10 +43,12 @@ final class PhoneConnectivityBridge: NSObject { let workouts = (try? context.fetch(wDesc)) ?? [] let restSeconds = UserDefaults.standard.object(forKey: WCPayload.restSecondsKey) as? Int ?? 45 + let doneCountdownSeconds = UserDefaults.standard.object(forKey: WCPayload.doneCountdownSecondsKey) as? Int ?? 5 let payload = WCPayload.encodeState( splits: splits.map(SplitDocument.init(from:)), workouts: workouts.map(WorkoutDocument.init(from:)), - restSeconds: restSeconds + restSeconds: restSeconds, + doneCountdownSeconds: doneCountdownSeconds ) try? session.updateApplicationContext(payload) } diff --git a/Workouts/Views/Settings/SettingsView.swift b/Workouts/Views/Settings/SettingsView.swift index d1d4be4..53577db 100644 --- a/Workouts/Views/Settings/SettingsView.swift +++ b/Workouts/Views/Settings/SettingsView.swift @@ -17,13 +17,14 @@ struct SettingsView: View { @Query(sort: \Split.order) private var splits: [Split] @AppStorage("restSeconds") private var restSeconds: Int = 45 + @AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5 @State private var showingAddSplitSheet = false var body: some View { NavigationStack { Form { // MARK: - Workout Section - Section(header: Text("Workout")) { + Section { Stepper(value: $restSeconds, in: 10...180, step: 5) { HStack { Text("Rest Between Sets") @@ -31,6 +32,18 @@ struct SettingsView: View { Text("\(restSeconds)s").foregroundColor(.secondary) } } + + Stepper(value: $doneCountdownSeconds, in: 3...20, step: 1) { + HStack { + Text("Auto-Finish Countdown") + Spacer() + Text("\(doneCountdownSeconds)s").foregroundColor(.secondary) + } + } + } header: { + Text("Workout") + } footer: { + Text("How long the watch waits on the finish screen before completing an exercise automatically.") } // MARK: - Splits Section @@ -97,8 +110,7 @@ struct SettingsView: View { IndieAbout(configuration: AppInfoConfiguration( documents: [ .custom(title: "Changelog", filename: "CHANGELOG", extension: "md"), - .license(), - .acknowledgements() + .license(extension: "md") ] )) } @@ -108,6 +120,7 @@ struct SettingsView: View { SplitAddEditView(split: nil) } .onChange(of: restSeconds) { _, _ in services.watchBridge.pushAll() } + .onChange(of: doneCountdownSeconds) { _, _ in services.watchBridge.pushAll() } } } }