From 9a881e841b6b0a31d722d60f3a826a7dd15b36df Mon Sep 17 00:00:00 2001 From: rzen Date: Mon, 19 Jan 2026 19:15:38 -0500 Subject: [PATCH] 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 --- .../AppIcon.appiconset/Contents.json | 17 +- .../AppIcon.appiconset/icon-1024.png | Bin 0 -> 35148 bytes .../WatchConnectivityManager.swift | 455 ++++++++++++++++++ Workouts Watch App/ContentView.swift | 8 +- Workouts Watch App/Models/Workout.swift | 20 +- .../Persistence/PersistenceController.swift | 52 +- .../Views/ExerciseProgressView.swift | 309 ++++++++++++ .../Views/WorkoutLogListView.swift | 229 +++++++++ .../Views/WorkoutLogsView.swift | 99 ++++ .../Workouts Watch App.entitlements | 12 +- Workouts Watch App/WorkoutsApp.swift | 7 + Workouts.xcodeproj/project.pbxproj | 10 +- .../WatchConnectivityManager.swift | 345 +++++++++++++ .../Persistence/PersistenceController.swift | 52 +- Workouts/Views/WorkoutLogs/ExerciseView.swift | 9 + .../Views/WorkoutLogs/NotesEditView.swift | 1 + Workouts/Views/WorkoutLogs/PlanEditView.swift | 1 + .../WorkoutLogs/WorkoutLogListView.swift | 4 + .../Views/WorkoutLogs/WorkoutLogsView.swift | 7 + Workouts/Workouts.entitlements | 4 + Workouts/WorkoutsApp.swift | 7 + 21 files changed, 1581 insertions(+), 67 deletions(-) create mode 100644 Workouts Watch App/Assets.xcassets/AppIcon.appiconset/icon-1024.png create mode 100644 Workouts Watch App/Connectivity/WatchConnectivityManager.swift create mode 100644 Workouts Watch App/Views/ExerciseProgressView.swift create mode 100644 Workouts Watch App/Views/WorkoutLogListView.swift create mode 100644 Workouts Watch App/Views/WorkoutLogsView.swift create mode 100644 Workouts/Connectivity/WatchConnectivityManager.swift diff --git a/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json index 49c81cd..57b0f29 100644 --- a/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,13 +1,14 @@ { - "images" : [ + "images": [ { - "idiom" : "universal", - "platform" : "watchos", - "size" : "1024x1024" + "filename": "icon-1024.png", + "idiom": "universal", + "platform": "watchos", + "size": "1024x1024" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } -} +} \ No newline at end of file diff --git a/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..75412f5f63f9e21e774eee63709520d62e954fb2 GIT binary patch literal 35148 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 literal 0 HcmV?d00001 diff --git a/Workouts Watch App/Connectivity/WatchConnectivityManager.swift b/Workouts Watch App/Connectivity/WatchConnectivityManager.swift new file mode 100644 index 0000000..007d60f --- /dev/null +++ b/Workouts Watch App/Connectivity/WatchConnectivityManager.swift @@ -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 + } + } + } +} diff --git a/Workouts Watch App/ContentView.swift b/Workouts Watch App/ContentView.swift index ca9aee7..ff2205a 100644 --- a/Workouts Watch App/ContentView.swift +++ b/Workouts Watch App/ContentView.swift @@ -15,13 +15,7 @@ struct ContentView: View { @Environment(\.managedObjectContext) private var viewContext var body: some View { - VStack { - Image(systemName: "dumbbell.fill") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Workouts") - } - .padding() + WorkoutLogsView() } } diff --git a/Workouts Watch App/Models/Workout.swift b/Workouts Watch App/Models/Workout.swift index 61c7e8f..1a24da5 100644 --- a/Workouts Watch App/Models/Workout.swift +++ b/Workouts Watch App/Models/Workout.swift @@ -5,7 +5,6 @@ import CoreData public class Workout: NSManagedObject, Identifiable { @NSManaged public var start: Date @NSManaged public var end: Date? - @NSManaged private var statusRaw: String @NSManaged public var split: Split? @NSManaged public var logs: NSSet? @@ -13,13 +12,26 @@ public class Workout: NSManagedObject, Identifiable { public var id: NSManagedObjectID { objectID } var status: WorkoutStatus { - get { WorkoutStatus(rawValue: statusRaw) ?? .notStarted } - set { statusRaw = newValue.rawValue } + get { + willAccessValue(forKey: "status") + let raw = primitiveValue(forKey: "status") as? String ?? "notStarted" + didAccessValue(forKey: "status") + return WorkoutStatus(rawValue: raw) ?? .notStarted + } + set { + willChangeValue(forKey: "status") + setPrimitiveValue(newValue.rawValue, forKey: "status") + didChangeValue(forKey: "status") + } } var label: String { if status == .completed, let endDate = end { - return "\(start.formattedDate())—\(endDate.formattedDate())" + if start.isSameDay(as: endDate) { + return "\(start.formattedDate())—\(endDate.formattedTime())" + } else { + return "\(start.formattedDate())—\(endDate.formattedDate())" + } } else { return start.formattedDate() } diff --git a/Workouts Watch App/Persistence/PersistenceController.swift b/Workouts Watch App/Persistence/PersistenceController.swift index 47cf9cd..fdc972c 100644 --- a/Workouts Watch App/Persistence/PersistenceController.swift +++ b/Workouts Watch App/Persistence/PersistenceController.swift @@ -9,6 +9,9 @@ struct PersistenceController { // CloudKit container identifier - same as iOS app for sync static let cloudKitContainerIdentifier = "iCloud.dev.rzen.indie.Workouts" + // App Group identifier for shared storage between iOS and Watch + static let appGroupIdentifier = "group.dev.rzen.indie.Workouts" + var viewContext: NSManagedObjectContext { container.viewContext } @@ -57,28 +60,37 @@ struct PersistenceController { if inMemory { description.url = URL(fileURLWithPath: "/dev/null") description.cloudKitContainerOptions = nil - } else if cloudKitEnabled { - // Check if CloudKit is available before enabling - let cloudKitAvailable = FileManager.default.ubiquityIdentityToken != nil - - if cloudKitAvailable { - // Set CloudKit container options - let cloudKitOptions = NSPersistentCloudKitContainerOptions( - containerIdentifier: Self.cloudKitContainerIdentifier - ) - description.cloudKitContainerOptions = cloudKitOptions - } else { - // CloudKit not available (not signed in, etc.) - description.cloudKitContainerOptions = nil - print("CloudKit not available - using local storage only") + } else { + // Use App Group container for shared storage between iOS and Watch + if let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier) { + let storeURL = appGroupURL.appendingPathComponent("Workouts.sqlite") + description.url = storeURL + print("Using shared App Group store at: \(storeURL)") } - // Enable persistent history tracking (useful even without CloudKit) - description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) - description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) - } else { - // CloudKit explicitly disabled - description.cloudKitContainerOptions = nil + if cloudKitEnabled { + // Check if CloudKit is available before enabling + let cloudKitAvailable = FileManager.default.ubiquityIdentityToken != nil + + if cloudKitAvailable { + // Set CloudKit container options + let cloudKitOptions = NSPersistentCloudKitContainerOptions( + containerIdentifier: Self.cloudKitContainerIdentifier + ) + description.cloudKitContainerOptions = cloudKitOptions + } else { + // CloudKit not available (not signed in, etc.) + description.cloudKitContainerOptions = nil + print("CloudKit not available - using local storage only") + } + + // Enable persistent history tracking (useful even without CloudKit) + description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) + description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) + } else { + // CloudKit explicitly disabled + description.cloudKitContainerOptions = nil + } } container.loadPersistentStores { storeDescription, error in diff --git a/Workouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/ExerciseProgressView.swift new file mode 100644 index 0000000..a7f79c7 --- /dev/null +++ b/Workouts Watch App/Views/ExerciseProgressView.swift @@ -0,0 +1,309 @@ +// +// ExerciseProgressView.swift +// Workouts Watch App +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import WatchKit + +struct ExerciseProgressView: View { + @Environment(\.managedObjectContext) private var viewContext + @Environment(\.dismiss) private var dismiss + + @ObservedObject var workoutLog: WorkoutLog + + @State private var currentPage: Int = 0 + @State private var showingCancelConfirm = false + + private var totalSets: Int { + max(1, Int(workoutLog.sets)) + } + + private var totalPages: Int { + // Set 1, Rest 1, Set 2, Rest 2, ..., Set N, Done + // = N sets + (N-1) rests + 1 done = 2N + totalSets * 2 + } + + private var firstUnfinishedSetPage: Int { + // currentStateIndex is the number of completed sets + let completedSets = Int(workoutLog.currentStateIndex) + if completedSets >= totalSets { + // All done, go to done page + return totalPages - 1 + } + // Each set is at page index * 2 (Set1=0, Set2=2, Set3=4...) + return completedSets * 2 + } + + var body: some View { + TabView(selection: $currentPage) { + ForEach(0.. some View { + let lastPageIndex = totalPages - 1 + + if index == lastPageIndex { + // Done page + DonePageView { + completeExercise() + dismiss() + } + } else if index % 2 == 0 { + // Set page (0, 2, 4, ...) + let setNumber = (index / 2) + 1 + SetPageView( + setNumber: setNumber, + totalSets: totalSets, + reps: Int(workoutLog.reps), + isTimeBased: workoutLog.loadTypeEnum == .duration, + durationMinutes: workoutLog.durationMinutes, + durationSeconds: workoutLog.durationSeconds + ) + } else { + // Rest page (1, 3, 5, ...) + let restNumber = (index / 2) + 1 + RestPageView(restNumber: restNumber) + } + } + + private func updateProgress(for pageIndex: Int) { + // Calculate which set we're on based on page index + // Pages: Set1(0), Rest1(1), Set2(2), Rest2(3), Set3(4), Done(5) + // After completing Set 1 and moving to Rest 1, progress should be 1 + let setIndex = (pageIndex + 1) / 2 + let clampedProgress = min(setIndex, totalSets) + + if clampedProgress != Int(workoutLog.currentStateIndex) { + workoutLog.currentStateIndex = Int32(clampedProgress) + + if clampedProgress >= totalSets { + workoutLog.status = .completed + workoutLog.completed = true + } else if clampedProgress > 0 { + workoutLog.status = .inProgress + workoutLog.completed = false + } + + updateWorkoutStatus() + try? viewContext.save() + + // Sync to iOS + WatchConnectivityManager.shared.syncToiOS() + } + } + + private func completeExercise() { + workoutLog.currentStateIndex = Int32(totalSets) + workoutLog.status = .completed + workoutLog.completed = true + updateWorkoutStatus() + try? viewContext.save() + + // Sync to iOS + WatchConnectivityManager.shared.syncToiOS() + } + + private func updateWorkoutStatus() { + guard let workout = workoutLog.workout else { return } + let logs = workout.logsArray + let allCompleted = logs.allSatisfy { $0.status == .completed } + let anyInProgress = logs.contains { $0.status == .inProgress } + let allNotStarted = logs.allSatisfy { $0.status == .notStarted } + + if allCompleted { + workout.status = .completed + workout.end = Date() + } else if anyInProgress || !allNotStarted { + workout.status = .inProgress + } else { + workout.status = .notStarted + } + } +} + +// MARK: - Set Page View + +struct SetPageView: View { + let setNumber: Int + let totalSets: Int + let reps: Int + let isTimeBased: Bool + let durationMinutes: Int + let durationSeconds: Int + + var body: some View { + VStack(spacing: 8) { + Text("Set \(setNumber) of \(totalSets)") + .font(.headline) + .foregroundColor(.secondary) + + Text("\(setNumber)") + .font(.system(size: 72, weight: .bold, design: .rounded)) + .foregroundColor(.green) + + if isTimeBased { + Text(formattedDuration) + .font(.title3) + .foregroundColor(.secondary) + } else { + Text("\(reps) reps") + .font(.title3) + .foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + WKInterfaceDevice.current().play(.start) + } + } + + private var formattedDuration: String { + if durationMinutes > 0 && durationSeconds > 0 { + return "\(durationMinutes)m \(durationSeconds)s" + } else if durationMinutes > 0 { + return "\(durationMinutes) min" + } else { + return "\(durationSeconds) sec" + } + } +} + +// MARK: - Rest Page View + +struct RestPageView: View { + let restNumber: Int + + @State private var elapsedSeconds: Int = 0 + @State private var timer: Timer? + + var body: some View { + VStack(spacing: 8) { + Text("Rest") + .font(.headline) + .foregroundColor(.secondary) + + Text(formattedTime) + .font(.system(size: 56, weight: .bold, design: .monospaced)) + .foregroundColor(.orange) + + Text("Swipe to continue") + .font(.caption2) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + startTimer() + WKInterfaceDevice.current().play(.start) + } + .onDisappear { + stopTimer() + } + } + + private var formattedTime: String { + let minutes = elapsedSeconds / 60 + let seconds = elapsedSeconds % 60 + return String(format: "%d:%02d", minutes, seconds) + } + + private func startTimer() { + elapsedSeconds = 0 + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + elapsedSeconds += 1 + checkHapticPing() + } + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + } + + private func checkHapticPing() { + // Haptic ping every 10 seconds with pattern: + // 10s: single, 20s: double, 30s: triple, 40s: single, 50s: double, etc. + guard elapsedSeconds > 0 && elapsedSeconds % 10 == 0 else { return } + + let cyclePosition = (elapsedSeconds / 10) % 3 + let pingCount: Int + switch cyclePosition { + case 1: pingCount = 1 // 10s, 40s, 70s... + case 2: pingCount = 2 // 20s, 50s, 80s... + case 0: pingCount = 3 // 30s, 60s, 90s... + default: pingCount = 1 + } + + playHapticPings(count: pingCount) + } + + private func playHapticPings(count: Int) { + for i in 0.. Void + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.green) + + Text("Done!") + .font(.title2) + .fontWeight(.bold) + + Text("Tap to finish") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + .onTapGesture { + WKInterfaceDevice.current().play(.success) + onDone() + } + .onAppear { + WKInterfaceDevice.current().play(.success) + } + } +} diff --git a/Workouts Watch App/Views/WorkoutLogListView.swift b/Workouts Watch App/Views/WorkoutLogListView.swift new file mode 100644 index 0000000..f4628ae --- /dev/null +++ b/Workouts Watch App/Views/WorkoutLogListView.swift @@ -0,0 +1,229 @@ +// +// WorkoutLogListView.swift +// Workouts Watch App +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import CoreData + +struct WorkoutLogListView: View { + @Environment(\.managedObjectContext) private var viewContext + + @ObservedObject var workout: Workout + + @State private var showingExercisePicker = false + @State private var selectedLog: WorkoutLog? + + var sortedWorkoutLogs: [WorkoutLog] { + workout.logsArray + } + + var body: some View { + List { + Section(header: Text(workout.label)) { + ForEach(sortedWorkoutLogs, id: \.objectID) { log in + Button { + selectedLog = log + } label: { + WorkoutLogRowLabel(log: log) + } + .buttonStyle(.plain) + } + } + + Section { + Button { + showingExercisePicker = true + } label: { + HStack { + Image(systemName: "plus.circle.fill") + .foregroundColor(.green) + Text("Add Exercise") + } + } + } + } + .overlay { + if sortedWorkoutLogs.isEmpty { + ContentUnavailableView( + "No Exercises", + systemImage: "figure.strengthtraining.traditional", + description: Text("Tap + to add exercises.") + ) + } + } + .navigationTitle(workout.split?.name ?? Split.unnamed) + .navigationDestination(item: $selectedLog) { log in + ExerciseProgressView(workoutLog: log) + } + .sheet(isPresented: $showingExercisePicker) { + ExercisePickerView(workout: workout) + } + } + +} + +// MARK: - Workout Log Row Label + +struct WorkoutLogRowLabel: View { + @ObservedObject var log: WorkoutLog + + var body: some View { + HStack { + statusIcon + .foregroundColor(statusColor) + + VStack(alignment: .leading, spacing: 2) { + Text(log.exerciseName) + .font(.headline) + .lineLimit(1) + + Text(subtitle) + .font(.caption2) + .foregroundColor(.secondary) + } + + Spacer() + } + } + + private var statusIcon: Image { + switch log.status { + case .completed: + Image(systemName: "checkmark.circle.fill") + case .inProgress: + Image(systemName: "circle.dotted") + case .notStarted: + Image(systemName: "circle") + case .skipped: + Image(systemName: "xmark.circle") + } + } + + private var statusColor: Color { + switch log.status { + case .completed: + .green + case .inProgress: + .orange + case .notStarted: + .secondary + case .skipped: + .secondary + } + } + + private var subtitle: String { + if log.loadTypeEnum == .duration { + let mins = log.durationMinutes + let secs = log.durationSeconds + if mins > 0 && secs > 0 { + return "\(log.sets) × \(mins)m \(secs)s" + } else if mins > 0 { + return "\(log.sets) × \(mins) min" + } else { + return "\(log.sets) × \(secs) sec" + } + } else { + return "\(log.sets) × \(log.reps) × \(log.weight) lbs" + } + } +} + +// MARK: - Exercise Picker View + +struct ExercisePickerView: View { + @Environment(\.managedObjectContext) private var viewContext + @Environment(\.dismiss) private var dismiss + + @ObservedObject var workout: Workout + + private var availableExercises: [Exercise] { + guard let split = workout.split else { return [] } + let existingNames = Set(workout.logsArray.map { $0.exerciseName }) + return split.exercisesArray.filter { !existingNames.contains($0.name) } + } + + var body: some View { + NavigationStack { + List { + if availableExercises.isEmpty { + Text("All exercises added") + .foregroundColor(.secondary) + } else { + ForEach(availableExercises, id: \.objectID) { exercise in + Button { + addExercise(exercise) + } label: { + VStack(alignment: .leading) { + Text(exercise.name) + .font(.headline) + Text(exerciseSubtitle(exercise)) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + } + } + .navigationTitle("Add Exercise") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + } + } + + private func addExercise(_ exercise: Exercise) { + let log = WorkoutLog(context: viewContext) + log.exerciseName = exercise.name + log.date = Date() + log.order = Int32(workout.logsArray.count) + log.sets = exercise.sets + log.reps = exercise.reps + log.weight = exercise.weight + log.loadType = exercise.loadType + log.duration = exercise.duration + log.status = .notStarted + log.workout = workout + + // Update workout start if first exercise + if workout.logsArray.count == 1 { + workout.start = Date() + } + + try? viewContext.save() + + // Sync to iOS + WatchConnectivityManager.shared.syncToiOS() + + dismiss() + } + + private func exerciseSubtitle(_ exercise: Exercise) -> String { + let loadType = LoadType(rawValue: Int(exercise.loadType)) ?? .weight + if loadType == .duration { + let mins = exercise.durationMinutes + let secs = exercise.durationSeconds + if mins > 0 && secs > 0 { + return "\(exercise.sets) × \(mins)m \(secs)s" + } else if mins > 0 { + return "\(exercise.sets) × \(mins) min" + } else { + return "\(exercise.sets) × \(secs) sec" + } + } else { + return "\(exercise.sets) × \(exercise.reps) × \(exercise.weight) lbs" + } + } +} + +#Preview { + WorkoutLogListView(workout: Workout()) + .environment(\.managedObjectContext, PersistenceController.preview.viewContext) +} diff --git a/Workouts Watch App/Views/WorkoutLogsView.swift b/Workouts Watch App/Views/WorkoutLogsView.swift new file mode 100644 index 0000000..3975323 --- /dev/null +++ b/Workouts Watch App/Views/WorkoutLogsView.swift @@ -0,0 +1,99 @@ +// +// WorkoutLogsView.swift +// Workouts Watch App +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import CoreData + +struct WorkoutLogsView: View { + @Environment(\.managedObjectContext) private var viewContext + @EnvironmentObject var connectivityManager: WatchConnectivityManager + + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \Workout.start, ascending: false)], + animation: .default + ) + private var workouts: FetchedResults + + var body: some View { + NavigationStack { + List { + ForEach(workouts, id: \.objectID) { workout in + NavigationLink(destination: WorkoutLogListView(workout: workout)) { + WorkoutRow(workout: workout) + } + } + } + .overlay { + if workouts.isEmpty { + ContentUnavailableView( + "No Workouts", + systemImage: "list.bullet.clipboard", + description: Text("Tap sync or start a workout from iPhone.") + ) + } + } + .navigationTitle("Workouts") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + connectivityManager.requestSync() + } label: { + Image(systemName: "arrow.triangle.2.circlepath") + } + } + } + } + } +} + +// MARK: - Workout Row + +struct WorkoutRow: View { + @ObservedObject var workout: Workout + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(workout.split?.name ?? Split.unnamed) + .font(.headline) + .lineLimit(1) + + HStack { + Text(workout.start.formatDate()) + .font(.caption2) + .foregroundColor(.secondary) + + Spacer() + + statusIndicator + } + } + .padding(.vertical, 4) + } + + @ViewBuilder + private var statusIndicator: some View { + switch workout.status { + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + case .inProgress: + Image(systemName: "circle.dotted") + .foregroundColor(.orange) + case .notStarted: + Image(systemName: "circle") + .foregroundColor(.secondary) + case .skipped: + Image(systemName: "xmark.circle") + .foregroundColor(.secondary) + } + } +} + +#Preview { + WorkoutLogsView() + .environment(\.managedObjectContext, PersistenceController.preview.viewContext) +} diff --git a/Workouts Watch App/Workouts Watch App.entitlements b/Workouts Watch App/Workouts Watch App.entitlements index 0cf54c2..226f503 100644 --- a/Workouts Watch App/Workouts Watch App.entitlements +++ b/Workouts Watch App/Workouts Watch App.entitlements @@ -5,12 +5,16 @@ aps-environment development com.apple.developer.icloud-container-identifiers - - iCloud.dev.rzen.indie.Workouts - + com.apple.developer.icloud-services CloudKit + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)$(CFBundleIdentifier) + com.apple.security.application-groups + + group.dev.rzen.indie.Workouts + - \ No newline at end of file + diff --git a/Workouts Watch App/WorkoutsApp.swift b/Workouts Watch App/WorkoutsApp.swift index 0e0f64f..c17d592 100644 --- a/Workouts Watch App/WorkoutsApp.swift +++ b/Workouts Watch App/WorkoutsApp.swift @@ -14,11 +14,18 @@ import CoreData @main struct WorkoutsWatchApp: App { let persistenceController = PersistenceController.shared + let connectivityManager = WatchConnectivityManager.shared + + init() { + // Set up iPhone connectivity with Core Data context + connectivityManager.setViewContext(persistenceController.viewContext) + } var body: some Scene { WindowGroup { ContentView() .environment(\.managedObjectContext, persistenceController.viewContext) + .environmentObject(connectivityManager) } } } diff --git a/Workouts.xcodeproj/project.pbxproj b/Workouts.xcodeproj/project.pbxproj index 72f721a..9fa4b35 100644 --- a/Workouts.xcodeproj/project.pbxproj +++ b/Workouts.xcodeproj/project.pbxproj @@ -376,6 +376,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "Workouts Watch App/Workouts Watch App.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\""; @@ -396,7 +397,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 11.2; + WATCHOS_DEPLOYMENT_TARGET = 26.0; }; name = Debug; }; @@ -405,6 +406,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "Workouts Watch App/Workouts Watch App.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\""; @@ -426,7 +428,7 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; VALIDATE_PRODUCT = YES; - WATCHOS_DEPLOYMENT_TARGET = 11.2; + WATCHOS_DEPLOYMENT_TARGET = 26.0; }; name = Release; }; @@ -449,7 +451,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -483,7 +485,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Workouts/Connectivity/WatchConnectivityManager.swift b/Workouts/Connectivity/WatchConnectivityManager.swift new file mode 100644 index 0000000..c84c2ba --- /dev/null +++ b/Workouts/Connectivity/WatchConnectivityManager.swift @@ -0,0 +1,345 @@ +// +// 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 + } + } +} diff --git a/Workouts/Persistence/PersistenceController.swift b/Workouts/Persistence/PersistenceController.swift index 4472254..106cfd1 100644 --- a/Workouts/Persistence/PersistenceController.swift +++ b/Workouts/Persistence/PersistenceController.swift @@ -9,6 +9,9 @@ struct PersistenceController { // CloudKit container identifier static let cloudKitContainerIdentifier = "iCloud.dev.rzen.indie.Workouts" + // App Group identifier for shared storage between iOS and Watch + static let appGroupIdentifier = "group.dev.rzen.indie.Workouts" + var viewContext: NSManagedObjectContext { container.viewContext } @@ -57,28 +60,37 @@ struct PersistenceController { if inMemory { description.url = URL(fileURLWithPath: "/dev/null") description.cloudKitContainerOptions = nil - } else if cloudKitEnabled { - // Check if CloudKit is available before enabling - let cloudKitAvailable = FileManager.default.ubiquityIdentityToken != nil - - if cloudKitAvailable { - // Set CloudKit container options - let cloudKitOptions = NSPersistentCloudKitContainerOptions( - containerIdentifier: Self.cloudKitContainerIdentifier - ) - description.cloudKitContainerOptions = cloudKitOptions - } else { - // CloudKit not available (not signed in, etc.) - description.cloudKitContainerOptions = nil - print("CloudKit not available - using local storage only") + } else { + // Use App Group container for shared storage between iOS and Watch + if let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier) { + let storeURL = appGroupURL.appendingPathComponent("Workouts.sqlite") + description.url = storeURL + print("Using shared App Group store at: \(storeURL)") } - // Enable persistent history tracking (useful even without CloudKit) - description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) - description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) - } else { - // CloudKit explicitly disabled - description.cloudKitContainerOptions = nil + if cloudKitEnabled { + // Check if CloudKit is available before enabling + let cloudKitAvailable = FileManager.default.ubiquityIdentityToken != nil + + if cloudKitAvailable { + // Set CloudKit container options + let cloudKitOptions = NSPersistentCloudKitContainerOptions( + containerIdentifier: Self.cloudKitContainerIdentifier + ) + description.cloudKitContainerOptions = cloudKitOptions + } else { + // CloudKit not available (not signed in, etc.) + description.cloudKitContainerOptions = nil + print("CloudKit not available - using local storage only") + } + + // Enable persistent history tracking (useful even without CloudKit) + description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) + description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) + } else { + // CloudKit explicitly disabled + description.cloudKitContainerOptions = nil + } } container.loadPersistentStores { storeDescription, error in diff --git a/Workouts/Views/WorkoutLogs/ExerciseView.swift b/Workouts/Views/WorkoutLogs/ExerciseView.swift index 78c92ce..5c4b719 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseView.swift @@ -125,6 +125,14 @@ struct ExerciseView: View { .onAppear { progress = Int(workoutLog.currentStateIndex) } + .onChange(of: workoutLog.currentStateIndex) { _, newValue in + // Update local state when CoreData changes (e.g., from Watch sync) + if progress != Int(newValue) { + withAnimation(.easeInOut(duration: 0.2)) { + progress = Int(newValue) + } + } + } } private func updateLogStatus() { @@ -162,6 +170,7 @@ struct ExerciseView: View { private func saveChanges() { try? viewContext.save() + WatchConnectivityManager.shared.syncAllData() } } diff --git a/Workouts/Views/WorkoutLogs/NotesEditView.swift b/Workouts/Views/WorkoutLogs/NotesEditView.swift index 8582a16..5a38713 100644 --- a/Workouts/Views/WorkoutLogs/NotesEditView.swift +++ b/Workouts/Views/WorkoutLogs/NotesEditView.swift @@ -48,5 +48,6 @@ struct NotesEditView: View { private func saveChanges() { workoutLog.notes = notesText try? viewContext.save() + WatchConnectivityManager.shared.syncAllData() } } diff --git a/Workouts/Views/WorkoutLogs/PlanEditView.swift b/Workouts/Views/WorkoutLogs/PlanEditView.swift index 18eeb7f..a81e89f 100644 --- a/Workouts/Views/WorkoutLogs/PlanEditView.swift +++ b/Workouts/Views/WorkoutLogs/PlanEditView.swift @@ -163,5 +163,6 @@ struct PlanEditView: View { } try? viewContext.save() + WatchConnectivityManager.shared.syncAllData() } } diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift index cd77383..189be41 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift @@ -133,12 +133,14 @@ struct WorkoutLogListView: View { } updateWorkoutStatus() try? viewContext.save() + WatchConnectivityManager.shared.syncAllData() } private func completeLog(_ log: WorkoutLog) { log.status = .completed updateWorkoutStatus() try? viewContext.save() + WatchConnectivityManager.shared.syncAllData() } private func updateWorkoutStatus() { @@ -164,6 +166,7 @@ struct WorkoutLogListView: View { log.order = Int32(index) } try? viewContext.save() + WatchConnectivityManager.shared.syncAllData() } private func addExerciseFromSplit(_ exercise: Exercise) { @@ -188,6 +191,7 @@ struct WorkoutLogListView: View { log.workout = workout try? viewContext.save() + WatchConnectivityManager.shared.syncAllData() // Navigate to the new exercise view newlyAddedLog = log diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift index b53f7df..cec0c14 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift @@ -88,6 +88,7 @@ struct WorkoutLogsView: View { withAnimation { viewContext.delete(item) try? viewContext.save() + WatchConnectivityManager.shared.syncAllData() itemToDelete = nil } } @@ -167,11 +168,17 @@ struct SplitPickerSheet: View { workoutLog.sets = exercise.sets workoutLog.reps = exercise.reps workoutLog.weight = exercise.weight + workoutLog.loadType = exercise.loadType + workoutLog.duration = exercise.duration workoutLog.status = .notStarted workoutLog.workout = workout } try? viewContext.save() + + // Sync to Watch + WatchConnectivityManager.shared.syncAllData() + dismiss() } } diff --git a/Workouts/Workouts.entitlements b/Workouts/Workouts.entitlements index 915ede2..1508a57 100644 --- a/Workouts/Workouts.entitlements +++ b/Workouts/Workouts.entitlements @@ -14,5 +14,9 @@ com.apple.developer.ubiquity-kvstore-identifier $(TeamIdentifierPrefix)$(CFBundleIdentifier) + com.apple.security.application-groups + + group.dev.rzen.indie.Workouts + diff --git a/Workouts/WorkoutsApp.swift b/Workouts/WorkoutsApp.swift index 96fa24a..eee0172 100644 --- a/Workouts/WorkoutsApp.swift +++ b/Workouts/WorkoutsApp.swift @@ -14,11 +14,18 @@ import CoreData @main struct WorkoutsApp: App { let persistenceController = PersistenceController.shared + let connectivityManager = WatchConnectivityManager.shared + + init() { + // Set up Watch connectivity with Core Data context + connectivityManager.setViewContext(persistenceController.viewContext) + } var body: some Scene { WindowGroup { ContentView() .environment(\.managedObjectContext, persistenceController.viewContext) + .environmentObject(connectivityManager) } } }