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 0000000..75412f5 Binary files /dev/null and b/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ 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) } } }