Files
workouts/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift
T
rzen 8ef0e96b31 Park the Watch run while iPhone edits an exercise or split
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
2026-06-20 19:54:31 -04:00

129 lines
5.5 KiB
Swift

import Foundation
import Observation
import SwiftData
import WatchConnectivity
/// Watch side of the iPhoneWatch bridge. The watch never touches iCloud it
/// keeps a local SwiftData cache fed only by application-context pushes from the
/// phone, updates it optimistically on local edits, and forwards changed workouts
/// to the phone (which is the sole writer of iCloud Drive).
@Observable
@MainActor
final class WatchConnectivityBridge: NSObject {
private let container: ModelContainer
private var session: WCSession?
/// 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) {
self.container = container
super.init()
}
func activate() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.delegate = self
session.activate()
self.session = session
// Apply whatever the phone last pushed, then ask for a fresh push.
let ctx = session.receivedApplicationContext
applyState(WCPayload.decodeSplits(ctx), workouts: WCPayload.decodeWorkouts(ctx))
applySettings(ctx)
editingWorkoutID = WCPayload.decodeEditingWorkoutID(ctx)
editingSplitID = WCPayload.decodeEditingSplitID(ctx)
requestSync()
}
func requestSync() {
guard let session, session.activationState == .activated, session.isReachable else { return }
session.sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: nil)
}
/// Optimistically applies a workout edit to the local cache and forwards it to
/// the phone for durable persistence in iCloud Drive.
func update(workout doc: WorkoutDocument) {
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
try? context.save()
sendToPhone(doc)
}
// MARK: - Internal
private func sendToPhone(_ doc: WorkoutDocument) {
guard let session, session.activationState == .activated else { return }
let payload = WCPayload.encodeWorkoutUpdate(doc)
if session.isReachable {
session.sendMessage(payload, replyHandler: nil, errorHandler: { _ in
session.transferUserInfo(payload) // fall back to guaranteed delivery
})
} else {
session.transferUserInfo(payload)
}
}
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]) {
guard !splits.isEmpty || !workouts.isEmpty else { return }
var liveSplitIDs = Set<String>()
for s in splits {
CacheMapper.upsertSplit(s, relativePath: s.relativePath, into: context)
liveSplitIDs.insert(s.id)
}
for w in workouts {
CacheMapper.upsertWorkout(w, relativePath: w.relativePath, into: context)
}
// Splits are sent in full prune any the phone no longer has. Workouts are
// sent as a recent window, so they're upserted but never pruned (avoids a
// race deleting a workout just created on the watch).
if let allSplits = try? context.fetch(FetchDescriptor<Split>()) {
for s in allSplits where !liveSplitIDs.contains(s.id) { context.delete(s) }
}
try? context.save()
lastSyncDate = Date()
}
}
// MARK: - WCSessionDelegate
extension WatchConnectivityBridge: WCSessionDelegate {
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
Task { @MainActor in self.requestSync() }
}
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
let splits = WCPayload.decodeSplits(applicationContext)
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
}
}
}