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:
2026-07-16 20:18:47 -04:00
parent 47a49cc356
commit 373f812968
8 changed files with 125 additions and 3 deletions
@@ -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]) {
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) {
@@ -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.
@@ -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)
case "rest":
ProgressHost(workout: workout, exerciseName: "Lat Pulldown", page: 1)
case "settings":
NavigationStack { WatchSettingsView() }
default:
NavigationStack { WorkoutLogListView(workout: workout) }
}