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
456 lines
16 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|
|
}
|