// // WatchConnectivityManager.swift // Workouts Watch App // // 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? @Published var lastSyncDate: Date? override init() { super.init() if WCSession.isSupported() { session = WCSession.default session?.delegate = self session?.activate() } } func setViewContext(_ context: NSManagedObjectContext) { self.viewContext = context // Process any pending application context if let session = session, !session.receivedApplicationContext.isEmpty { processApplicationContext(session.receivedApplicationContext) } } // MARK: - Send Data to iOS func syncToiOS() { guard let session = session else { print("[WC-Watch] No WCSession") return } print("[WC-Watch] Syncing to iOS - state: \(session.activationState.rawValue), reachable: \(session.isReachable)") guard session.activationState == .activated else { print("[WC-Watch] Session not activated") return } guard let context = viewContext else { print("[WC-Watch] No view context") return } context.perform { do { let workoutsData = try self.encodeAllWorkouts(context: context) let payload: [String: Any] = [ "type": "syncFromWatch", "workouts": workoutsData, "timestamp": Date().timeIntervalSince1970 ] if session.isReachable { session.sendMessage(payload, replyHandler: nil) { error in print("[WC-Watch] Failed to send sync: \(error)") } print("[WC-Watch] Sent \(workoutsData.count) workouts to iOS via message") } else { // Use transferUserInfo for background delivery session.transferUserInfo(payload) print("[WC-Watch] Queued \(workoutsData.count) workouts to iOS via transferUserInfo") } } catch { print("[WC-Watch] Failed to encode data: \(error)") } } } 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 workouts.map { encodeWorkout($0) } } private func encodeWorkout(_ workout: Workout) -> [String: Any] { var data: [String: Any] = [ "start": workout.start.timeIntervalSince1970, "status": workout.status.rawValue ] if let end = workout.end { data["end"] = end.timeIntervalSince1970 } if let split = workout.split { data["splitName"] = split.name } data["logs"] = workout.logsArray.map { encodeWorkoutLog($0) } return data } private func encodeWorkoutLog(_ log: WorkoutLog) -> [String: Any] { var data: [String: Any] = [ "exerciseName": log.exerciseName, "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 } // MARK: - Request Sync from iOS func requestSync() { guard let session = session else { print("[WC-Watch] No WCSession") return } print("[WC-Watch] Session state: \(session.activationState.rawValue), reachable: \(session.isReachable)") guard session.isReachable else { print("[WC-Watch] iPhone not reachable, checking pending context...") // Try to process any pending application context if !session.receivedApplicationContext.isEmpty { print("[WC-Watch] Found pending context, processing...") processApplicationContext(session.receivedApplicationContext) } else { print("[WC-Watch] No pending context") } return } session.sendMessage(["type": "requestSync"], replyHandler: nil) { error in print("[WC-Watch] Failed to request sync: \(error)") } } // MARK: - Process Incoming Data private func processApplicationContext(_ context: [String: Any]) { guard let viewContext = viewContext else { print("View context not set") return } viewContext.perform { do { // Process splits first (workouts reference them) if let splitsData = context["splits"] as? [[String: Any]] { // Get all split names from iOS let iosSplitNames = Set(splitsData.compactMap { $0["name"] as? String }) // Delete splits not on iOS let existingSplits = (try? viewContext.fetch(Split.fetchRequest())) ?? [] for split in existingSplits { if !iosSplitNames.contains(split.name) { viewContext.delete(split) } } for splitData in splitsData { self.importSplit(splitData, context: viewContext) } } // Process workouts if let workoutsData = context["workouts"] as? [[String: Any]] { // Get all workout start dates from iOS let iosStartDates = Set(workoutsData.compactMap { $0["start"] as? TimeInterval }) // Delete workouts not on iOS let existingWorkouts = (try? viewContext.fetch(Workout.fetchRequest())) ?? [] for workout in existingWorkouts { let startInterval = workout.start.timeIntervalSince1970 // Check if this workout exists on iOS (within 1 second tolerance) let existsOnIOS = iosStartDates.contains { abs($0 - startInterval) < 1 } if !existsOnIOS { viewContext.delete(workout) } } for workoutData in workoutsData { self.importWorkout(workoutData, context: viewContext) } } try viewContext.save() DispatchQueue.main.async { self.lastSyncDate = Date() } print("Successfully imported data from iPhone") } catch { print("Failed to import data: \(error)") } } } // MARK: - Import Methods private func importSplit(_ data: [String: Any], context: NSManagedObjectContext) { guard let idString = data["id"] as? String, let name = data["name"] as? String else { return } // Find existing or create new let split = findOrCreateSplit(idString: idString, name: name, context: context) split.name = name split.color = data["color"] as? String ?? "blue" split.systemImage = data["systemImage"] as? String ?? "dumbbell.fill" split.order = Int32(data["order"] as? Int ?? 0) // Import exercises if let exercisesData = data["exercises"] as? [[String: Any]] { // Get all exercise names from iOS let iosExerciseNames = Set(exercisesData.compactMap { $0["name"] as? String }) // Delete exercises not on iOS for exercise in split.exercisesArray { if !iosExerciseNames.contains(exercise.name) { context.delete(exercise) } } // Import/update exercises from iOS for exerciseData in exercisesData { importExercise(exerciseData, split: split, context: context) } } } private func importExercise(_ data: [String: Any], split: Split, context: NSManagedObjectContext) { guard let idString = data["id"] as? String, let name = data["name"] as? String else { return } let exercise = findOrCreateExercise(idString: idString, name: name, split: split, context: context) exercise.name = name exercise.order = Int32(data["order"] as? Int ?? 0) exercise.sets = Int32(data["sets"] as? Int ?? 3) exercise.reps = Int32(data["reps"] as? Int ?? 10) exercise.weight = Int32(data["weight"] as? Int ?? 0) exercise.loadType = Int32(data["loadType"] as? Int ?? 1) if let durationInterval = data["duration"] as? TimeInterval { exercise.duration = Date(timeIntervalSince1970: durationInterval) } exercise.split = split } private func importWorkout(_ data: [String: Any], context: NSManagedObjectContext) { guard let idString = data["id"] as? String, let startInterval = data["start"] as? TimeInterval else { return } let startDate = Date(timeIntervalSince1970: startInterval) let workout = findOrCreateWorkout(idString: idString, startDate: startDate, context: context) workout.start = startDate if let endInterval = data["end"] as? TimeInterval { workout.end = Date(timeIntervalSince1970: endInterval) } if let statusRaw = data["status"] as? String, let status = WorkoutStatus(rawValue: statusRaw) { workout.status = status } // Link to split if let splitName = data["splitName"] as? String { workout.split = findSplitByName(splitName, context: context) } // Import logs if let logsData = data["logs"] as? [[String: Any]] { // Get all exercise names from iOS let iosExerciseNames = Set(logsData.compactMap { $0["exerciseName"] as? String }) // Delete logs not on iOS for log in workout.logsArray { if !iosExerciseNames.contains(log.exerciseName) { context.delete(log) } } // Import/update logs from iOS for logData in logsData { importWorkoutLog(logData, workout: workout, context: context) } } } private func importWorkoutLog(_ data: [String: Any], workout: Workout, context: NSManagedObjectContext) { guard let idString = data["id"] as? String, let exerciseName = data["exerciseName"] as? String else { return } let log = findOrCreateWorkoutLog(idString: idString, exerciseName: exerciseName, workout: workout, context: context) log.exerciseName = exerciseName log.date = Date(timeIntervalSince1970: data["date"] as? TimeInterval ?? Date().timeIntervalSince1970) log.order = Int32(data["order"] as? Int ?? 0) log.sets = Int32(data["sets"] as? Int ?? 3) log.reps = Int32(data["reps"] as? Int ?? 10) log.weight = Int32(data["weight"] as? Int ?? 0) log.currentStateIndex = Int32(data["currentStateIndex"] as? Int ?? 0) log.completed = data["completed"] as? Bool ?? false log.loadType = Int32(data["loadType"] as? Int ?? 1) if let statusRaw = data["status"] as? String, let status = WorkoutStatus(rawValue: statusRaw) { log.status = status } if let durationInterval = data["duration"] as? TimeInterval { log.duration = Date(timeIntervalSince1970: durationInterval) } log.notes = data["notes"] as? String log.workout = workout } // MARK: - Find or Create Helpers private func findOrCreateSplit(idString: String, name: String, context: NSManagedObjectContext) -> Split { // Try to find by name first (more reliable than object ID across devices) let request = Split.fetchRequest() request.predicate = NSPredicate(format: "name == %@", name) request.fetchLimit = 1 if let existing = try? context.fetch(request).first { return existing } return Split(context: context) } private func findOrCreateExercise(idString: String, name: String, split: Split, context: NSManagedObjectContext) -> Exercise { // Find by name within split if let existing = split.exercisesArray.first(where: { $0.name == name }) { return existing } return Exercise(context: context) } private func findOrCreateWorkout(idString: String, startDate: Date, context: NSManagedObjectContext) -> Workout { // Find by start date (should be unique per workout) let request = Workout.fetchRequest() // Match within 1 second to account for any floating point differences let startInterval = startDate.timeIntervalSince1970 request.predicate = NSPredicate( format: "start >= %@ AND start <= %@", Date(timeIntervalSince1970: startInterval - 1) as NSDate, Date(timeIntervalSince1970: startInterval + 1) as NSDate ) request.fetchLimit = 1 if let existing = try? context.fetch(request).first { return existing } return Workout(context: context) } private func findOrCreateWorkoutLog(idString: String, exerciseName: String, workout: Workout, context: NSManagedObjectContext) -> WorkoutLog { // Find existing log in this workout with same exercise name if let existing = workout.logsArray.first(where: { $0.exerciseName == exerciseName }) { return existing } return WorkoutLog(context: context) } private func findSplitByName(_ name: String, context: NSManagedObjectContext) -> Split? { let request = Split.fetchRequest() request.predicate = NSPredicate(format: "name == %@", name) request.fetchLimit = 1 return try? context.fetch(request).first } } // MARK: - WCSessionDelegate extension WatchConnectivityManager: WCSessionDelegate { func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { if let error = error { print("[WC-Watch] Activation failed: \(error)") } else { print("[WC-Watch] Activated with state: \(activationState.rawValue)") // Check for any pending context let context = session.receivedApplicationContext print("[WC-Watch] Pending context keys: \(context.keys)") if !context.isEmpty { print("[WC-Watch] Processing pending context...") processApplicationContext(context) } } } // Receive application context updates func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { print("[WC-Watch] Received application context with keys: \(applicationContext.keys)") if let workouts = applicationContext["workouts"] as? [[String: Any]] { print("[WC-Watch] Contains \(workouts.count) workouts") } processApplicationContext(applicationContext) } // Receive immediate messages func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { if let type = message["type"] as? String { switch type { case "workoutUpdate": if let workoutData = message["workout"] as? [String: Any], let context = viewContext { context.perform { self.importWorkout(workoutData, context: context) try? context.save() } } default: break } } } }