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? 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. applyState(WCPayload.decodeSplits(session.receivedApplicationContext), workouts: WCPayload.decodeWorkouts(session.receivedApplicationContext)) 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 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() } } nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { let splits = WCPayload.decodeSplits(applicationContext) let workouts = WCPayload.decodeWorkouts(applicationContext) Task { @MainActor in self.applyState(splits, workouts: workouts) } } }