// // WatchConnectivityManager.swift // Workouts // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // import Foundation import WatchConnectivity import CoreData class WatchConnectivityManager: NSObject, ObservableObject { static let shared = WatchConnectivityManager() private var session: WCSession? private var viewContext: NSManagedObjectContext? override init() { super.init() if WCSession.isSupported() { session = WCSession.default session?.delegate = self session?.activate() } } func setViewContext(_ context: NSManagedObjectContext) { self.viewContext = context } // MARK: - Send Data to Watch func syncAllData() { guard let session = session else { print("[WC-iOS] No WCSession") return } print("[WC-iOS] Session state: \(session.activationState.rawValue), reachable: \(session.isReachable), paired: \(session.isPaired), installed: \(session.isWatchAppInstalled)") guard session.activationState == .activated else { print("[WC-iOS] Session not activated") return } guard let context = viewContext else { print("[WC-iOS] No view context") return } context.perform { do { let workoutsData = try self.encodeAllWorkouts(context: context) let splitsData = try self.encodeAllSplits(context: context) let payload: [String: Any] = [ "workouts": workoutsData, "splits": splitsData, "timestamp": Date().timeIntervalSince1970 ] // Use updateApplicationContext for persistent state try session.updateApplicationContext(payload) print("[WC-iOS] Synced \(workoutsData.count) workouts, \(splitsData.count) splits to Watch") } catch { print("Failed to sync data: \(error)") } } } func sendWorkoutUpdate(_ workout: Workout) { guard let session = session, session.activationState == .activated else { return } do { let workoutData = try encodeWorkout(workout) let message: [String: Any] = [ "type": "workoutUpdate", "workout": workoutData ] if session.isReachable { session.sendMessage(message, replyHandler: nil) { error in print("Failed to send workout update: \(error)") } } else { // Queue for later via application context syncAllData() } } catch { print("Failed to encode workout: \(error)") } } // MARK: - Encoding private func encodeAllWorkouts(context: NSManagedObjectContext) throws -> [[String: Any]] { let request = Workout.fetchRequest() request.sortDescriptors = [NSSortDescriptor(keyPath: \Workout.start, ascending: false)] let workouts = try context.fetch(request) return try workouts.map { try encodeWorkout($0) } } private func encodeAllSplits(context: NSManagedObjectContext) throws -> [[String: Any]] { let request = Split.fetchRequest() request.sortDescriptors = [NSSortDescriptor(keyPath: \Split.order, ascending: true)] let splits = try context.fetch(request) return try splits.map { try encodeSplit($0) } } private func encodeWorkout(_ workout: Workout) throws -> [String: Any] { var data: [String: Any] = [ "id": workout.objectID.uriRepresentation().absoluteString, "start": workout.start.timeIntervalSince1970, "status": workout.status.rawValue ] if let end = workout.end { data["end"] = end.timeIntervalSince1970 } if let split = workout.split { data["splitId"] = split.objectID.uriRepresentation().absoluteString data["splitName"] = split.name } data["logs"] = workout.logsArray.map { encodeWorkoutLog($0) } return data } private func encodeWorkoutLog(_ log: WorkoutLog) -> [String: Any] { var data: [String: Any] = [ "id": log.objectID.uriRepresentation().absoluteString, "exerciseName": log.exerciseName, "date": log.date.timeIntervalSince1970, "order": log.order, "sets": log.sets, "reps": log.reps, "weight": log.weight, "status": log.status.rawValue, "currentStateIndex": log.currentStateIndex, "completed": log.completed, "loadType": log.loadType ] if let duration = log.duration { data["duration"] = duration.timeIntervalSince1970 } if let notes = log.notes { data["notes"] = notes } return data } private func encodeSplit(_ split: Split) throws -> [String: Any] { var data: [String: Any] = [ "id": split.objectID.uriRepresentation().absoluteString, "name": split.name, "color": split.color, "systemImage": split.systemImage, "order": split.order ] data["exercises"] = split.exercisesArray.map { encodeExercise($0) } return data } private func encodeExercise(_ exercise: Exercise) -> [String: Any] { var data: [String: Any] = [ "id": exercise.objectID.uriRepresentation().absoluteString, "name": exercise.name, "order": exercise.order, "sets": exercise.sets, "reps": exercise.reps, "weight": exercise.weight, "loadType": exercise.loadType ] if let duration = exercise.duration { data["duration"] = duration.timeIntervalSince1970 } return data } } // MARK: - WCSessionDelegate extension WatchConnectivityManager: WCSessionDelegate { func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { if let error = error { print("[WC-iOS] Activation failed: \(error)") } else { print("[WC-iOS] Activated with state: \(activationState.rawValue), paired: \(session.isPaired), installed: \(session.isWatchAppInstalled)") // Sync data when session activates DispatchQueue.main.async { self.syncAllData() } } } func sessionDidBecomeInactive(_ session: WCSession) { print("[WC-iOS] Session became inactive") } func sessionDidDeactivate(_ session: WCSession) { print("[WC-iOS] Session deactivated") // Reactivate for switching watches session.activate() } func sessionReachabilityDidChange(_ session: WCSession) { print("[WC-iOS] Reachability changed: \(session.isReachable)") if session.isReachable { syncAllData() } } // Receive messages from Watch (for bidirectional sync) func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { print("[WC-iOS] Received message with keys: \(message.keys)") if let type = message["type"] as? String { switch type { case "requestSync": syncAllData() case "syncFromWatch": processWatchSync(message) default: break } } } // Receive user info transfers from Watch (background delivery) func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { print("[WC-iOS] Received userInfo with keys: \(userInfo.keys)") if let type = userInfo["type"] as? String, type == "syncFromWatch" { processWatchSync(userInfo) } } // MARK: - Process Watch Sync private func processWatchSync(_ data: [String: Any]) { guard let viewContext = viewContext else { print("[WC-iOS] No view context for Watch sync") return } guard let workoutsData = data["workouts"] as? [[String: Any]] else { print("[WC-iOS] No workouts in Watch sync data") return } print("[WC-iOS] Processing \(workoutsData.count) workouts from Watch") DispatchQueue.main.async { viewContext.perform { for workoutData in workoutsData { self.updateWorkoutFromWatch(workoutData, context: viewContext) } do { try viewContext.save() print("[WC-iOS] Successfully saved Watch sync data") // Refresh all objects to ensure SwiftUI observes changes viewContext.refreshAllObjects() } catch { print("[WC-iOS] Failed to save Watch sync: \(error)") } } } } private func updateWorkoutFromWatch(_ data: [String: Any], context: NSManagedObjectContext) { guard let startInterval = data["start"] as? TimeInterval else { return } // Find workout by start date let request = Workout.fetchRequest() let startDate = Date(timeIntervalSince1970: startInterval) request.predicate = NSPredicate( format: "start >= %@ AND start <= %@", Date(timeIntervalSince1970: startInterval - 1) as NSDate, Date(timeIntervalSince1970: startInterval + 1) as NSDate ) request.fetchLimit = 1 guard let workout = try? context.fetch(request).first else { print("[WC-iOS] Workout not found for start date: \(startDate)") return } // Update workout status if let statusRaw = data["status"] as? String, let status = WorkoutStatus(rawValue: statusRaw) { workout.status = status } if let endInterval = data["end"] as? TimeInterval { workout.end = Date(timeIntervalSince1970: endInterval) } // Update logs if let logsData = data["logs"] as? [[String: Any]] { for logData in logsData { updateWorkoutLogFromWatch(logData, workout: workout, context: context) } } } private func updateWorkoutLogFromWatch(_ data: [String: Any], workout: Workout, context: NSManagedObjectContext) { guard let exerciseName = data["exerciseName"] as? String else { return } // Find log by exercise name in this workout guard let log = workout.logsArray.first(where: { $0.exerciseName == exerciseName }) else { print("[WC-iOS] Log not found for exercise: \(exerciseName)") return } // Update status and progress if let statusRaw = data["status"] as? String, let status = WorkoutStatus(rawValue: statusRaw) { log.status = status } if let currentStateIndex = data["currentStateIndex"] as? Int { log.currentStateIndex = Int32(currentStateIndex) } if let completed = data["completed"] as? Bool { log.completed = completed } // Update other fields that might have changed if let notes = data["notes"] as? String { log.notes = notes } } }