import Foundation import Observation import SwiftData import WatchConnectivity /// Watch side of the iPhone↔Watch 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? /// Monotonic sequence stamped on each live-run frame we send. Bumped to stay ahead of any /// frame we *receive*, so the two devices share one increasing per-run sequence and either /// side can drop a stale / out-of-order delivery (see `LiveProgress.version`). private var liveVersion = 0 /// The latest live-run frame the *phone* sent, for the run we currently have open to apply /// (ephemeral; nil when the phone isn't driving). The watch's `ExerciseProgressView` reads /// this to follow a phone-driven transition; it's never persisted. private(set) var liveIncoming: LiveProgress? /// The run currently open in the watch's navigated driver. When the incoming frame is for /// it, the watch follows inline there and suppresses the follower cover (so it never stacks /// on top of a run the user already has open). var navigatedRunID: String? /// A run the user dismissed the follower cover for; suppressed until that run ends. private var mutedLogID: String? /// The frame to present as a follower cover when the phone drives a run the watch isn't /// already showing: the latest, unless the user dismissed it or has that run open inline. var presentable: LiveProgress? { guard let f = liveIncoming, f.logID != mutedLogID, f.logID != navigatedRunID else { return nil } return f } /// The user dismissed the follower cover; don't re-present this run until it ends. func muteLive() { mutedLogID = liveIncoming?.logID } 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: - Live run mirror (ephemeral; reachable-only) /// Broadcast where the run flow currently is, so a propped-up iPhone can mirror it. Sent /// over `sendMessage` only when the phone is reachable — this is throwaway presence, so /// there's no guaranteed-delivery fallback (a queued frame would be stale on arrival). func sendLiveProgress(_ frame: LiveProgress) { guard let session, session.activationState == .activated, session.isReachable else { return } liveVersion += 1 var stamped = frame stamped.version = liveVersion session.sendMessage(WCPayload.encodeLiveProgress(stamped), replyHandler: nil, errorHandler: { _ in }) } /// Tell the phone to stop mirroring this run (the user left the progress flow). func sendLiveEnded(workoutID: String, logID: String) { guard let session, session.activationState == .activated, session.isReachable else { return } session.sendMessage(WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID), replyHandler: nil, errorHandler: { _ in }) } /// Apply a live-run frame the phone sent. Drops a stale one for the same run, and catches /// our send counter up so the next frame we send outranks it (shared per-run sequence). private func applyIncomingLive(_ frame: LiveProgress) { liveVersion = max(liveVersion, frame.version) if let current = liveIncoming, current.logID == frame.logID, frame.version < current.version { return } liveIncoming = frame } /// The phone left the run — stop following it (and clear any dismiss for it). private func endIncomingLive(logID: String) { if liveIncoming?.logID == logID { liveIncoming = nil } if mutedLogID == logID { mutedLogID = nil } } // 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() 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()) { 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() } } /// Live-run frames arrive as messages (reachable-only), distinct from the latest-wins /// application context that carries durable state. nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { switch message[WCPayload.typeKey] as? String { case WCPayload.liveProgressType: if let frame = WCPayload.decodeLiveProgress(message) { Task { @MainActor in self.applyIncomingLive(frame) } } case WCPayload.liveEndedType: if let logID = message[WCPayload.lpLogIDKey] as? String { Task { @MainActor in self.endIncomingLive(logID: logID) } } default: break } } 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 } } }