Files
workouts/Workouts Watch App/Connectivity/WatchConnectivityManager.swift
rzen 9a881e841b Add WatchConnectivity for bidirectional iOS-Watch sync
Implement real-time sync between iOS and Apple Watch apps using
WatchConnectivity framework. This replaces reliance on CloudKit
which doesn't work reliably in simulators.

- Add WatchConnectivityManager to both iOS and Watch targets
- Sync workouts, splits, exercises, and logs between devices
- Update iOS views to trigger sync on data changes
- Add onChange observer to ExerciseView for live progress updates
- Configure App Groups for shared container storage
- Add Watch app views: WorkoutLogsView, WorkoutLogListView, ExerciseProgressView
2026-01-19 19:15:38 -05:00

456 lines
16 KiB
Swift

//
// 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
}
}
}
}