From 8ef0e96b31fa1b4028b1c5417b984ba807b81815 Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 20 Jun 2026 19:54:31 -0400 Subject: [PATCH] Park the Watch run while iPhone edits an exercise or split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publish an exclusive-edit lock (editingWorkoutID / editingSplitID) in the phone→watch application context. While the phone has a workout's exercise (ExerciseView) or a split (SplitDetailView) open in an editor, the watch pops out of that run, blocks re-entry, and shows it as "Editing on iPhone" — so the two devices never drive the same run at once and the watch can't clobber the phone's edit with a stale optimistic write. The lock clears when the editor closes; absent keys in the latest-wins context mean "not editing". Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe --- CHANGELOG.md | 2 + Shared/Connectivity/WCPayload.swift | 14 +++- .../WatchConnectivityBridge.swift | 20 +++++- .../Views/ActiveWorkoutGateView.swift | 66 +++++++++++++++++-- .../PhoneConnectivityBridge.swift | 29 +++++++- Workouts/Views/Splits/SplitDetailView.swift | 6 ++ Workouts/Views/WorkoutLogs/ExerciseView.swift | 4 ++ 7 files changed, 129 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 484c384..89a14e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ **June 2026** +Editing an exercise or split on iPhone now steps the Apple Watch out of that workout, showing it as "Editing on iPhone" until you're done — so the watch never keeps running an exercise whose plan you're changing. + The app now wears its signature purple: it's the accent color throughout, and a completed exercise is marked with a purple check. In-progress exercises now read as a neutral gray. Tapping an exercise on iPhone now opens a paged workout run — the same Ready → work/rest → Finish flow as the Apple Watch, with rep sets counting up, timed sets and rests counting down and auto-advancing, plus haptics. diff --git a/Shared/Connectivity/WCPayload.swift b/Shared/Connectivity/WCPayload.swift index d2d52c7..155287a 100644 --- a/Shared/Connectivity/WCPayload.swift +++ b/Shared/Connectivity/WCPayload.swift @@ -11,18 +11,26 @@ enum WCPayload { static let workoutKey = "workout" static let restSecondsKey = "restSeconds" static let doneCountdownSecondsKey = "doneCountdownSeconds" + static let editingWorkoutIDKey = "editingWorkoutID" + static let editingSplitIDKey = "editingSplitID" 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, doneCountdownSeconds: Int) -> [String: Any] { + /// `editingWorkoutID` / `editingSplitID` are an exclusive-edit lock: while the phone + /// has a workout's exercise (or a split) open in an editor, the watch parks any + /// matching run and locks re-entry, so only one device owns the run at a time. They're + /// part of the same latest-wins context — absent keys mean "not editing" (lock clear). + static func encodeState(splits: [SplitDocument], workouts: [WorkoutDocument], restSeconds: Int, doneCountdownSeconds: Int, editingWorkoutID: String?, editingSplitID: String?) -> [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 + if let editingWorkoutID { dict[editingWorkoutIDKey] = editingWorkoutID } + if let editingSplitID { dict[editingSplitIDKey] = editingSplitID } return dict } @@ -40,6 +48,10 @@ enum WCPayload { static func decodeDoneCountdownSeconds(_ dict: [String: Any]) -> Int? { dict[doneCountdownSecondsKey] as? Int } + static func decodeEditingWorkoutID(_ dict: [String: Any]) -> String? { dict[editingWorkoutIDKey] as? String } + + static func decodeEditingSplitID(_ dict: [String: Any]) -> String? { dict[editingSplitIDKey] as? String } + // MARK: - Watch → Phone (a single updated workout) static func encodeWorkoutUpdate(_ workout: WorkoutDocument) -> [String: Any] { diff --git a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift index 79f43a3..aedbc24 100644 --- a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift +++ b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift @@ -16,6 +16,13 @@ final class WatchConnectivityBridge: NSObject { /// Last time state was received from the phone (for a sync indicator). private(set) var lastSyncDate: Date? + /// Exclusive-edit lock pushed by the phone. While set, the watch parks the matching + /// run (popping out of its progress view) and blocks re-entry, so the phone owns the + /// edit and the watch can't clobber it with a stale optimistic write. `editingWorkoutID` + /// matches a run by its workout id; `editingSplitID` matches any run by its `splitID`. + private(set) var editingWorkoutID: String? + private(set) var editingSplitID: String? + private var context: ModelContext { container.mainContext } init(container: ModelContainer) { @@ -30,9 +37,11 @@ final class WatchConnectivityBridge: NSObject { session.activate() self.session = session // Apply whatever the phone last pushed, then ask for a fresh push. - applyState(WCPayload.decodeSplits(session.receivedApplicationContext), - workouts: WCPayload.decodeWorkouts(session.receivedApplicationContext)) - applySettings(session.receivedApplicationContext) + let ctx = session.receivedApplicationContext + applyState(WCPayload.decodeSplits(ctx), workouts: WCPayload.decodeWorkouts(ctx)) + applySettings(ctx) + editingWorkoutID = WCPayload.decodeEditingWorkoutID(ctx) + editingSplitID = WCPayload.decodeEditingSplitID(ctx) requestSync() } @@ -105,10 +114,15 @@ extension WatchConnectivityBridge: WCSessionDelegate { let workouts = WCPayload.decodeWorkouts(applicationContext) let rest = WCPayload.decodeRestSeconds(applicationContext) let done = WCPayload.decodeDoneCountdownSeconds(applicationContext) + let editingWorkoutID = WCPayload.decodeEditingWorkoutID(applicationContext) + let editingSplitID = WCPayload.decodeEditingSplitID(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) } + // Absent keys mean "not editing" — set unconditionally so the lock clears. + self.editingWorkoutID = editingWorkoutID + self.editingSplitID = editingSplitID } } } diff --git a/Workouts Watch App/Views/ActiveWorkoutGateView.swift b/Workouts Watch App/Views/ActiveWorkoutGateView.swift index 96dea95..729d06b 100644 --- a/Workouts Watch App/Views/ActiveWorkoutGateView.swift +++ b/Workouts Watch App/Views/ActiveWorkoutGateView.swift @@ -23,6 +23,11 @@ struct ActiveWorkoutGateView: View { @Query(sort: \Workout.start, order: .reverse) private var workouts: [Workout] @Query private var splits: [Split] + /// Navigated workouts (depth 1). Held here — rather than relying on implicit + /// `NavigationLink` destinations — so we can pop back to the gate the moment the phone + /// takes over editing the run we're inside. + @State private var path: [ActiveWorkoutRoute] = [] + private var activeWorkouts: [Workout] { workouts.filter { $0.status == .inProgress || $0.status == .notStarted } } @@ -32,24 +37,36 @@ struct ActiveWorkoutGateView: View { return splits.first { $0.id == id } } + /// True while the phone has this workout's exercise — or the split it came from — open + /// in an editor. The watch parks such a run and blocks re-entry so the phone owns the + /// edit and the watch can't forward a stale optimistic write over it. + private func isLockedForEditing(_ workout: Workout) -> Bool { + if let id = bridge.editingWorkoutID, id == workout.id { return true } + if let splitID = bridge.editingSplitID, splitID == workout.splitID { return true } + return false + } + var body: some View { - NavigationStack { + NavigationStack(path: $path) { Group { if activeWorkouts.isEmpty { emptyState } else { List { ForEach(activeWorkouts) { workout in - NavigationLink { - WorkoutLogListView(workout: workout) - } label: { - ActiveWorkoutRow(workout: workout, split: split(for: workout)) - } + row(for: workout) } } .navigationTitle("In Progress") } } + .navigationDestination(for: ActiveWorkoutRoute.self) { route in + // Resolve from the full set (not just active) so a run that finishes while + // you're inside it still renders rather than blanking. + if let workout = workouts.first(where: { $0.id == route.workoutID }) { + WorkoutLogListView(workout: workout) + } + } } .task { // Nothing to run yet — pull fresh state in case the phone just started one. @@ -60,6 +77,28 @@ struct ActiveWorkoutGateView: View { // was keeping the launched app alive. if noActiveWorkouts { sessionManager.end() } } + // The phone just entered (or left) an editor — if we're inside the now-locked run, + // pop back to the gate so re-entry rebuilds a fresh working copy. + .onChange(of: bridge.editingWorkoutID) { _, _ in popIfNavigatedRunLocked() } + .onChange(of: bridge.editingSplitID) { _, _ in popIfNavigatedRunLocked() } + } + + @ViewBuilder + private func row(for workout: Workout) -> some View { + if isLockedForEditing(workout) { + ActiveWorkoutRow(workout: workout, split: split(for: workout), editingOnPhone: true) + } else { + NavigationLink(value: ActiveWorkoutRoute(workoutID: workout.id)) { + ActiveWorkoutRow(workout: workout, split: split(for: workout)) + } + } + } + + /// If the run we're currently navigated into has become locked, pop to the gate. + private func popIfNavigatedRunLocked() { + guard let route = path.last, + let workout = workouts.first(where: { $0.id == route.workoutID }) else { return } + if isLockedForEditing(workout) { path.removeAll() } } private var emptyState: some View { @@ -82,13 +121,15 @@ struct ActiveWorkoutGateView: View { private struct ActiveWorkoutRow: View { let workout: Workout let split: Split? + /// When true, the phone is editing this run — render it dimmed and non-tappable. + var editingOnPhone: Bool = false 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") + Image(systemName: editingOnPhone ? "pencil" : (split?.systemImage ?? "figure.strengthtraining.traditional")) .font(.title3) .foregroundStyle(split.map { Color.color(from: $0.color) } ?? .workTint) .frame(width: 26) @@ -103,11 +144,22 @@ private struct ActiveWorkoutRow: View { .foregroundStyle(.secondary) } } + .opacity(editingOnPhone ? 0.5 : 1) } private var subtitle: String { + if editingOnPhone { return "Editing on iPhone…" } guard !logs.isEmpty else { return workout.start.formattedDate() } if doneCount == 0 { return "Not started · \(logs.count) exercises" } return "\(doneCount) of \(logs.count) done" } } + +// MARK: - Navigation + +/// Stable, hashable handle for a navigated run. Keyed by id (not the `Workout` object) so +/// the path survives cache rebuilds, and a distinct type so it never collides with the +/// log-id destination inside `WorkoutLogListView`. +private struct ActiveWorkoutRoute: Hashable { + let workoutID: String +} diff --git a/Workouts/Connectivity/PhoneConnectivityBridge.swift b/Workouts/Connectivity/PhoneConnectivityBridge.swift index 8f6ef0b..b69903a 100644 --- a/Workouts/Connectivity/PhoneConnectivityBridge.swift +++ b/Workouts/Connectivity/PhoneConnectivityBridge.swift @@ -14,6 +14,14 @@ final class PhoneConnectivityBridge: NSObject { private let syncEngine: SyncEngine private var session: WCSession? + /// Exclusive-edit lock published to the watch. While the phone has a workout's + /// exercise (or a split) open in an editor, the watch parks any matching run and + /// blocks re-entry — so the two devices never drive the same run at once. Included in + /// every `pushAll` (the latest-wins context replaces wholesale, so a push that omitted + /// them would clear the lock prematurely). + private(set) var editingWorkoutID: String? + private(set) var editingSplitID: String? + private var context: ModelContext { container.mainContext } init(container: ModelContainer, syncEngine: SyncEngine) { @@ -48,11 +56,30 @@ final class PhoneConnectivityBridge: NSObject { splits: splits.map(SplitDocument.init(from:)), workouts: workouts.map(WorkoutDocument.init(from:)), restSeconds: restSeconds, - doneCountdownSeconds: doneCountdownSeconds + doneCountdownSeconds: doneCountdownSeconds, + editingWorkoutID: editingWorkoutID, + editingSplitID: editingSplitID ) try? session.updateApplicationContext(payload) } + /// Mark (or clear, with `nil`) the workout currently open in a phone exercise editor. + /// The watch parks that run and blocks re-entry until it clears. Pushes immediately so + /// the lock takes effect without waiting on a cache change. + func setEditingWorkout(_ id: String?) { + guard editingWorkoutID != id else { return } + editingWorkoutID = id + pushAll() + } + + /// Mark (or clear, with `nil`) the split currently open in a phone editor. The watch + /// parks any active run sourced from that split (matched by `splitID`). + func setEditingSplit(_ id: String?) { + guard editingSplitID != id else { return } + editingSplitID = id + 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]) { diff --git a/Workouts/Views/Splits/SplitDetailView.swift b/Workouts/Views/Splits/SplitDetailView.swift index 109564b..bfb63aa 100644 --- a/Workouts/Views/Splits/SplitDetailView.swift +++ b/Workouts/Views/Splits/SplitDetailView.swift @@ -12,6 +12,7 @@ import SwiftData struct SplitDetailView: View { @Environment(SyncEngine.self) private var sync + @Environment(AppServices.self) private var services @Environment(\.dismiss) private var dismiss var split: Split @@ -109,6 +110,11 @@ struct SplitDetailView: View { } message: { item in Text("Remove \"\(item.name)\" from this split?") } + // Editing this split (or any of its exercises, all reached from here) parks any + // active watch run sourced from it — matched by splitID — so the watch can't keep + // performing an exercise whose plan we're reconfiguring. + .onAppear { services.watchBridge.setEditingSplit(split.id) } + .onDisappear { services.watchBridge.setEditingSplit(nil) } } private func moveExercises(from source: IndexSet, to destination: Int) { diff --git a/Workouts/Views/WorkoutLogs/ExerciseView.swift b/Workouts/Views/WorkoutLogs/ExerciseView.swift index 15c32ea..d02ff2e 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseView.swift @@ -14,6 +14,7 @@ import UIKit struct ExerciseView: View { @Environment(SyncEngine.self) private var sync + @Environment(AppServices.self) private var services @Environment(\.dismiss) private var dismiss let workout: Workout @@ -69,9 +70,12 @@ struct ExerciseView: View { progress = log?.currentStateIndex ?? 0 // Keep the screen lit while logging sets — a mid-workout sleep is annoying. UIApplication.shared.isIdleTimerDisabled = true + // Take over this run: the watch parks and locks it while we're editing here. + services.watchBridge.setEditingWorkout(workout.id) } .onDisappear { UIApplication.shared.isIdleTimerDisabled = false + services.watchBridge.setEditingWorkout(nil) } // Reflect external changes (e.g. a set completed on the watch) live. Each edit // rewrites the whole workout file, so the cache always holds the latest — pulling