From 373f812968dade49b6fa4ea456f56f6418fcb3a6 Mon Sep 17 00:00:00 2001 From: rzen Date: Thu, 16 Jul 2026 20:18:47 -0400 Subject: [PATCH] Adjust rest time from the watch with the same 1-second stepper A gear on the watch root opens a small Settings sheet whose stepper edits the shared restSeconds default (10-180s, 1s steps). Edits ride a new settingsUpdate message to the phone - debounced per stepper burst, falling back to transferUserInfo when unreachable - which clamps the value, writes the shared default, and re-echoes it to every device through the application context. While an edit is pending on the watch, an in-flight context's stale rest value is skipped so it can't yank the stepper back mid-edit. --- CHANGELOG.md | 2 + README.md | 5 ++- Shared/Connectivity/WCPayload.swift | 11 +++++ .../WatchConnectivityBridge.swift | 41 ++++++++++++++++++- .../Views/ActiveWorkoutGateView.swift | 15 +++++++ .../Views/WatchSettingsView.swift | 39 ++++++++++++++++++ Workouts Watch App/WatchScreenshotRoot.swift | 2 + .../PhoneConnectivityBridge.swift | 13 ++++++ 8 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 Workouts Watch App/Views/WatchSettingsView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 735606d..5b4d527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Workouts is now on the Mac: a native app for managing routines and schedules and Rest time now adjusts in one-second steps — in Settings and in a routine's custom rest — instead of five-second jumps. +Rest time can now be changed on the watch too — a new Settings sheet with the same one-second stepper, synced back to your iPhone. + A live status panel between the timer and the figure now shows heart rate (with the target color cues, in much larger type), HR zone, calories, and workout time. A new Library tab puts your routines and the exercise library one tap away instead of buried in Settings. diff --git a/README.md b/README.md index e641e8a..abde1a7 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,9 @@ your own iCloud Drive. exercise, and run it as a paged flow: a **Ready?** lead-in, count-up work phases, count-down rests with final-three-second haptics and auto-advance, and a **Finish** page with **One More** and a **Done** that auto-completes after a countdown. A - phase-dot row (purple work, teal rest) tracks progress. Rest time and the - auto-finish countdown are configurable; changes sync back to the phone. + phase-dot row (purple work, teal rest) tracks progress. A Settings sheet on the + watch adjusts the rest time in 1-second steps; the change syncs back to the + phone, which echoes it to every device. - **Two-way live run** — prop your iPhone up during an Apple Watch workout and it runs the same Ready → work/rest → Finish flow with live timers, in step with the watch. It's bidirectional: drive from either device — swipe ahead, finish a set, add one — and the diff --git a/Shared/Connectivity/WCPayload.swift b/Shared/Connectivity/WCPayload.swift index f127eee..28aefbe 100644 --- a/Shared/Connectivity/WCPayload.swift +++ b/Shared/Connectivity/WCPayload.swift @@ -21,6 +21,7 @@ enum WCPayload { static let workoutUpdateType = "workoutUpdate" // watch → phone (one workout) static let requestSyncType = "requestSync" // watch → phone (please push state) + static let settingsUpdateType = "settingsUpdate" // watch → phone (a setting changed on the watch) static let liveProgressType = "liveProgress" // watch → phone (ephemeral mirror frame) static let liveEndedType = "liveEnded" // watch → phone (stop mirroring a run) static let liveHeartRateType = "liveHeartRate" // watch → phone (ephemeral HR sample) @@ -82,6 +83,16 @@ enum WCPayload { static func requestSyncMessage() -> [String: Any] { [typeKey: requestSyncType] } + // MARK: - Watch → Phone (a setting changed on the watch) + + /// The watch's settings edits ride to the phone, which owns the durable value and + /// echoes it back to every device through the application context. Carries only the + /// settings the watch can edit (just rest time today); reuses the context's key, so + /// `decodeRestSeconds` reads both. + static func encodeSettingsUpdate(restSeconds: Int) -> [String: Any] { + [typeKey: settingsUpdateType, restSecondsKey: restSeconds] + } + // MARK: - Watch → Phone (ephemeral live-run mirror) /// Anchor `Date`s ride as native plist values (not JSON), so they keep sub-second diff --git a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift index 107dadf..9041ae9 100644 --- a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift +++ b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift @@ -288,8 +288,47 @@ final class WatchConnectivityBridge: NSObject { } } + // MARK: - Settings edits (watch → phone) + + /// Debounces the stepper's tick-per-second bursts into one settings message. + private var settingsSendTask: Task? + + /// Forward a rest-time change to the phone, which owns the durable value and echoes + /// it back through the application context. The local default was already written by + /// the setting's `@AppStorage`; debounced so holding the stepper sends once, and the + /// send falls back to `transferUserInfo` so the change survives the phone being + /// unreachable right now. + func sendRestSeconds(_ seconds: Int) { + settingsSendTask?.cancel() + settingsSendTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(0.6)) + guard let self, !Task.isCancelled else { return } + self.settingsSendTask = nil + self.pushSettingsUpdate(restSeconds: seconds) + } + } + + private func pushSettingsUpdate(restSeconds: Int) { + guard let session, session.activationState == .activated else { return } + let payload = WCPayload.encodeSettingsUpdate(restSeconds: restSeconds) + if session.isReachable { + // Re-encode in the fallback rather than capturing the non-Sendable payload + // (the error handler runs on WatchConnectivity's background queue). + session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable [weak self] _ in + Task { @MainActor in + _ = self?.session?.transferUserInfo(WCPayload.encodeSettingsUpdate(restSeconds: restSeconds)) + } + }) + } else { + session.transferUserInfo(payload) + } + } + private func applySettings(_ dict: [String: Any]) { - if let rest = WCPayload.decodeRestSeconds(dict) { + // Skip the context's rest value while a local edit is still waiting to be sent — + // a concurrent push (triggered by anything) would carry the pre-edit value and + // yank the stepper back mid-edit. The phone echoes our value right after it lands. + if let rest = WCPayload.decodeRestSeconds(dict), settingsSendTask == nil { UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey) } if let done = WCPayload.decodeDoneCountdownSeconds(dict) { diff --git a/Workouts Watch App/Views/ActiveWorkoutGateView.swift b/Workouts Watch App/Views/ActiveWorkoutGateView.swift index b62e8a4..09abfcd 100644 --- a/Workouts Watch App/Views/ActiveWorkoutGateView.swift +++ b/Workouts Watch App/Views/ActiveWorkoutGateView.swift @@ -27,6 +27,8 @@ struct ActiveWorkoutGateView: View { /// takes over editing the run we're inside. @State private var path: [ActiveWorkoutRoute] = [] + @State private var showingSettings = false + private var activeWorkouts: [Workout] { workouts.filter { $0.status == .inProgress || $0.status == .notStarted } } @@ -66,6 +68,19 @@ struct ActiveWorkoutGateView: View { WorkoutLogListView(workout: workout) } } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingSettings = true + } label: { + Image(systemName: "gearshape") + } + .accessibilityLabel("Settings") + } + } + .sheet(isPresented: $showingSettings) { + NavigationStack { WatchSettingsView() } + } // The phone's pushes aren't decoding (its app updated first; this one hasn't // yet) — everything shown is frozen at the last good sync. Say so rather than // silently presenting stale workouts until the watch app catches up. diff --git a/Workouts Watch App/Views/WatchSettingsView.swift b/Workouts Watch App/Views/WatchSettingsView.swift new file mode 100644 index 0000000..48b1982 --- /dev/null +++ b/Workouts Watch App/Views/WatchSettingsView.swift @@ -0,0 +1,39 @@ +// +// WatchSettingsView.swift +// Workouts Watch App +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI + +/// The watch's small settings sheet — just the rest time today, adjusted with the +/// same 1-second stepper as the iPhone. The value lives in the shared defaults key +/// the phone pushes; edits are forwarded back so the phone (the durable owner) +/// re-echoes them to every device. +struct WatchSettingsView: View { + @Environment(WatchConnectivityBridge.self) private var bridge + + /// Shared with the phone via the same defaults key (see `WCPayload.restSecondsKey`). + @AppStorage("restSeconds") private var restSeconds: Int = 45 + + var body: some View { + Form { + Section { + Stepper(value: $restSeconds, in: 10...180, step: 1) { + Text("\(restSeconds)s") + .font(.system(.title3, design: .rounded, weight: .semibold)) + .monospacedDigit() + } + } header: { + Text("Rest Between Sets") + } footer: { + Text("Used between sets and, when auto-advancing, between exercises. A routine's custom rest time overrides it.") + } + } + .navigationTitle("Settings") + .onChange(of: restSeconds) { _, seconds in + bridge.sendRestSeconds(seconds) + } + } +} diff --git a/Workouts Watch App/WatchScreenshotRoot.swift b/Workouts Watch App/WatchScreenshotRoot.swift index b219cef..425c6fa 100644 --- a/Workouts Watch App/WatchScreenshotRoot.swift +++ b/Workouts Watch App/WatchScreenshotRoot.swift @@ -30,6 +30,8 @@ struct WatchScreenshotRoot: View { ProgressHost(workout: workout, exerciseName: "Lat Pulldown", page: 0) case "rest": ProgressHost(workout: workout, exerciseName: "Lat Pulldown", page: 1) + case "settings": + NavigationStack { WatchSettingsView() } default: NavigationStack { WorkoutLogListView(workout: workout) } } diff --git a/Workouts/Connectivity/PhoneConnectivityBridge.swift b/Workouts/Connectivity/PhoneConnectivityBridge.swift index 2a19324..fa3a960 100644 --- a/Workouts/Connectivity/PhoneConnectivityBridge.swift +++ b/Workouts/Connectivity/PhoneConnectivityBridge.swift @@ -249,6 +249,15 @@ final class PhoneConnectivityBridge: NSObject { liveRunState.apply(frame) } + /// A setting edited on the watch. The phone owns the durable value: clamp it, write + /// the shared default (the same store Settings' `@AppStorage` reads), and re-push the + /// application context so every device converges on it. + private func applySettingsUpdate(restSeconds: Int) { + let clamped = min(max(restSeconds, 10), 180) + UserDefaults.standard.set(clamped, forKey: WCPayload.restSecondsKey) + pushAll() + } + /// Parse the (non-Sendable) WC dictionary in the nonisolated delegate context, /// then hop to the MainActor with only Sendable values. nonisolated private func route(_ dict: [String: Any]) { @@ -259,6 +268,10 @@ final class PhoneConnectivityBridge: NSObject { if let doc = WCPayload.decodeWorkoutUpdate(dict) { Task { @MainActor in await self.syncEngine.ingestFromWatch(doc) } } + case WCPayload.settingsUpdateType: + if let rest = WCPayload.decodeRestSeconds(dict) { + Task { @MainActor in self.applySettingsUpdate(restSeconds: rest) } + } case WCPayload.liveProgressType: if let frame = WCPayload.decodeLiveProgress(dict) { Task { @MainActor in self.applyIncomingLive(frame) }