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.
This commit is contained in:
@@ -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 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 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.
|
A new Library tab puts your routines and the exercise library one tap away instead of buried in Settings.
|
||||||
|
|||||||
@@ -86,8 +86,9 @@ your own iCloud Drive.
|
|||||||
exercise, and run it as a paged flow: a **Ready?** lead-in, count-up work phases,
|
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**
|
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
|
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
|
phase-dot row (purple work, teal rest) tracks progress. A Settings sheet on the
|
||||||
auto-finish countdown are configurable; changes sync back to the phone.
|
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
|
- **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
|
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
|
bidirectional: drive from either device — swipe ahead, finish a set, add one — and the
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ enum WCPayload {
|
|||||||
|
|
||||||
static let workoutUpdateType = "workoutUpdate" // watch → phone (one workout)
|
static let workoutUpdateType = "workoutUpdate" // watch → phone (one workout)
|
||||||
static let requestSyncType = "requestSync" // watch → phone (please push state)
|
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 liveProgressType = "liveProgress" // watch → phone (ephemeral mirror frame)
|
||||||
static let liveEndedType = "liveEnded" // watch → phone (stop mirroring a run)
|
static let liveEndedType = "liveEnded" // watch → phone (stop mirroring a run)
|
||||||
static let liveHeartRateType = "liveHeartRate" // watch → phone (ephemeral HR sample)
|
static let liveHeartRateType = "liveHeartRate" // watch → phone (ephemeral HR sample)
|
||||||
@@ -82,6 +83,16 @@ enum WCPayload {
|
|||||||
|
|
||||||
static func requestSyncMessage() -> [String: Any] { [typeKey: requestSyncType] }
|
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)
|
// MARK: - Watch → Phone (ephemeral live-run mirror)
|
||||||
|
|
||||||
/// Anchor `Date`s ride as native plist values (not JSON), so they keep sub-second
|
/// Anchor `Date`s ride as native plist values (not JSON), so they keep sub-second
|
||||||
|
|||||||
@@ -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<Void, Never>?
|
||||||
|
|
||||||
|
/// 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]) {
|
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)
|
UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey)
|
||||||
}
|
}
|
||||||
if let done = WCPayload.decodeDoneCountdownSeconds(dict) {
|
if let done = WCPayload.decodeDoneCountdownSeconds(dict) {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ struct ActiveWorkoutGateView: View {
|
|||||||
/// takes over editing the run we're inside.
|
/// takes over editing the run we're inside.
|
||||||
@State private var path: [ActiveWorkoutRoute] = []
|
@State private var path: [ActiveWorkoutRoute] = []
|
||||||
|
|
||||||
|
@State private var showingSettings = false
|
||||||
|
|
||||||
private var activeWorkouts: [Workout] {
|
private var activeWorkouts: [Workout] {
|
||||||
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
|
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
|
||||||
}
|
}
|
||||||
@@ -66,6 +68,19 @@ struct ActiveWorkoutGateView: View {
|
|||||||
WorkoutLogListView(workout: workout)
|
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
|
// 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
|
// yet) — everything shown is frozen at the last good sync. Say so rather than
|
||||||
// silently presenting stale workouts until the watch app catches up.
|
// silently presenting stale workouts until the watch app catches up.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,8 @@ struct WatchScreenshotRoot: View {
|
|||||||
ProgressHost(workout: workout, exerciseName: "Lat Pulldown", page: 0)
|
ProgressHost(workout: workout, exerciseName: "Lat Pulldown", page: 0)
|
||||||
case "rest":
|
case "rest":
|
||||||
ProgressHost(workout: workout, exerciseName: "Lat Pulldown", page: 1)
|
ProgressHost(workout: workout, exerciseName: "Lat Pulldown", page: 1)
|
||||||
|
case "settings":
|
||||||
|
NavigationStack { WatchSettingsView() }
|
||||||
default:
|
default:
|
||||||
NavigationStack { WorkoutLogListView(workout: workout) }
|
NavigationStack { WorkoutLogListView(workout: workout) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,6 +249,15 @@ final class PhoneConnectivityBridge: NSObject {
|
|||||||
liveRunState.apply(frame)
|
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,
|
/// Parse the (non-Sendable) WC dictionary in the nonisolated delegate context,
|
||||||
/// then hop to the MainActor with only Sendable values.
|
/// then hop to the MainActor with only Sendable values.
|
||||||
nonisolated private func route(_ dict: [String: Any]) {
|
nonisolated private func route(_ dict: [String: Any]) {
|
||||||
@@ -259,6 +268,10 @@ final class PhoneConnectivityBridge: NSObject {
|
|||||||
if let doc = WCPayload.decodeWorkoutUpdate(dict) {
|
if let doc = WCPayload.decodeWorkoutUpdate(dict) {
|
||||||
Task { @MainActor in await self.syncEngine.ingestFromWatch(doc) }
|
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:
|
case WCPayload.liveProgressType:
|
||||||
if let frame = WCPayload.decodeLiveProgress(dict) {
|
if let frame = WCPayload.decodeLiveProgress(dict) {
|
||||||
Task { @MainActor in self.applyIncomingLive(frame) }
|
Task { @MainActor in self.applyIncomingLive(frame) }
|
||||||
|
|||||||
Reference in New Issue
Block a user