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
This commit is contained in:
455
Workouts Watch App/Connectivity/WatchConnectivityManager.swift
Normal file
455
Workouts Watch App/Connectivity/WatchConnectivityManager.swift
Normal file
@@ -0,0 +1,455 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user