Add WatchConnectivity for bidirectional iOS-Watch sync
Implement real-time sync between iOS and Apple Watch apps using WatchConnectivity framework. This replaces reliance on CloudKit which doesn't work reliably in simulators. - Add WatchConnectivityManager to both iOS and Watch targets - Sync workouts, splits, exercises, and logs between devices - Update iOS views to trigger sync on data changes - Add onChange observer to ExerciseView for live progress updates - Configure App Groups for shared container storage - Add Watch app views: WorkoutLogsView, WorkoutLogListView, ExerciseProgressView
This commit is contained in:
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images": [
|
||||||
{
|
{
|
||||||
"idiom" : "universal",
|
"filename": "icon-1024.png",
|
||||||
"platform" : "watchos",
|
"idiom": "universal",
|
||||||
"size" : "1024x1024"
|
"platform": "watchos",
|
||||||
|
"size": "1024x1024"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info": {
|
||||||
"author" : "xcode",
|
"author": "xcode",
|
||||||
"version" : 1
|
"version": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
455
Workouts Watch App/Connectivity/WatchConnectivityManager.swift
Normal file
455
Workouts Watch App/Connectivity/WatchConnectivityManager.swift
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
//
|
||||||
|
// WatchConnectivityManager.swift
|
||||||
|
// Workouts Watch App
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import WatchConnectivity
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
class WatchConnectivityManager: NSObject, ObservableObject {
|
||||||
|
static let shared = WatchConnectivityManager()
|
||||||
|
|
||||||
|
private var session: WCSession?
|
||||||
|
private var viewContext: NSManagedObjectContext?
|
||||||
|
|
||||||
|
@Published var lastSyncDate: Date?
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
if WCSession.isSupported() {
|
||||||
|
session = WCSession.default
|
||||||
|
session?.delegate = self
|
||||||
|
session?.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setViewContext(_ context: NSManagedObjectContext) {
|
||||||
|
self.viewContext = context
|
||||||
|
|
||||||
|
// Process any pending application context
|
||||||
|
if let session = session, !session.receivedApplicationContext.isEmpty {
|
||||||
|
processApplicationContext(session.receivedApplicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Send Data to iOS
|
||||||
|
|
||||||
|
func syncToiOS() {
|
||||||
|
guard let session = session else {
|
||||||
|
print("[WC-Watch] No WCSession")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[WC-Watch] Syncing to iOS - state: \(session.activationState.rawValue), reachable: \(session.isReachable)")
|
||||||
|
|
||||||
|
guard session.activationState == .activated else {
|
||||||
|
print("[WC-Watch] Session not activated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let context = viewContext else {
|
||||||
|
print("[WC-Watch] No view context")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.perform {
|
||||||
|
do {
|
||||||
|
let workoutsData = try self.encodeAllWorkouts(context: context)
|
||||||
|
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"type": "syncFromWatch",
|
||||||
|
"workouts": workoutsData,
|
||||||
|
"timestamp": Date().timeIntervalSince1970
|
||||||
|
]
|
||||||
|
|
||||||
|
if session.isReachable {
|
||||||
|
session.sendMessage(payload, replyHandler: nil) { error in
|
||||||
|
print("[WC-Watch] Failed to send sync: \(error)")
|
||||||
|
}
|
||||||
|
print("[WC-Watch] Sent \(workoutsData.count) workouts to iOS via message")
|
||||||
|
} else {
|
||||||
|
// Use transferUserInfo for background delivery
|
||||||
|
session.transferUserInfo(payload)
|
||||||
|
print("[WC-Watch] Queued \(workoutsData.count) workouts to iOS via transferUserInfo")
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print("[WC-Watch] Failed to encode data: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodeAllWorkouts(context: NSManagedObjectContext) throws -> [[String: Any]] {
|
||||||
|
let request = Workout.fetchRequest()
|
||||||
|
request.sortDescriptors = [NSSortDescriptor(keyPath: \Workout.start, ascending: false)]
|
||||||
|
let workouts = try context.fetch(request)
|
||||||
|
return workouts.map { encodeWorkout($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodeWorkout(_ workout: Workout) -> [String: Any] {
|
||||||
|
var data: [String: Any] = [
|
||||||
|
"start": workout.start.timeIntervalSince1970,
|
||||||
|
"status": workout.status.rawValue
|
||||||
|
]
|
||||||
|
|
||||||
|
if let end = workout.end {
|
||||||
|
data["end"] = end.timeIntervalSince1970
|
||||||
|
}
|
||||||
|
|
||||||
|
if let split = workout.split {
|
||||||
|
data["splitName"] = split.name
|
||||||
|
}
|
||||||
|
|
||||||
|
data["logs"] = workout.logsArray.map { encodeWorkoutLog($0) }
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodeWorkoutLog(_ log: WorkoutLog) -> [String: Any] {
|
||||||
|
var data: [String: Any] = [
|
||||||
|
"exerciseName": log.exerciseName,
|
||||||
|
"order": log.order,
|
||||||
|
"sets": log.sets,
|
||||||
|
"reps": log.reps,
|
||||||
|
"weight": log.weight,
|
||||||
|
"status": log.status.rawValue,
|
||||||
|
"currentStateIndex": log.currentStateIndex,
|
||||||
|
"completed": log.completed,
|
||||||
|
"loadType": log.loadType
|
||||||
|
]
|
||||||
|
|
||||||
|
if let duration = log.duration {
|
||||||
|
data["duration"] = duration.timeIntervalSince1970
|
||||||
|
}
|
||||||
|
|
||||||
|
if let notes = log.notes {
|
||||||
|
data["notes"] = notes
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Request Sync from iOS
|
||||||
|
|
||||||
|
func requestSync() {
|
||||||
|
guard let session = session else {
|
||||||
|
print("[WC-Watch] No WCSession")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[WC-Watch] Session state: \(session.activationState.rawValue), reachable: \(session.isReachable)")
|
||||||
|
|
||||||
|
guard session.isReachable else {
|
||||||
|
print("[WC-Watch] iPhone not reachable, checking pending context...")
|
||||||
|
// Try to process any pending application context
|
||||||
|
if !session.receivedApplicationContext.isEmpty {
|
||||||
|
print("[WC-Watch] Found pending context, processing...")
|
||||||
|
processApplicationContext(session.receivedApplicationContext)
|
||||||
|
} else {
|
||||||
|
print("[WC-Watch] No pending context")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.sendMessage(["type": "requestSync"], replyHandler: nil) { error in
|
||||||
|
print("[WC-Watch] Failed to request sync: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Process Incoming Data
|
||||||
|
|
||||||
|
private func processApplicationContext(_ context: [String: Any]) {
|
||||||
|
guard let viewContext = viewContext else {
|
||||||
|
print("View context not set")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewContext.perform {
|
||||||
|
do {
|
||||||
|
// Process splits first (workouts reference them)
|
||||||
|
if let splitsData = context["splits"] as? [[String: Any]] {
|
||||||
|
// Get all split names from iOS
|
||||||
|
let iosSplitNames = Set(splitsData.compactMap { $0["name"] as? String })
|
||||||
|
|
||||||
|
// Delete splits not on iOS
|
||||||
|
let existingSplits = (try? viewContext.fetch(Split.fetchRequest())) ?? []
|
||||||
|
for split in existingSplits {
|
||||||
|
if !iosSplitNames.contains(split.name) {
|
||||||
|
viewContext.delete(split)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for splitData in splitsData {
|
||||||
|
self.importSplit(splitData, context: viewContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process workouts
|
||||||
|
if let workoutsData = context["workouts"] as? [[String: Any]] {
|
||||||
|
// Get all workout start dates from iOS
|
||||||
|
let iosStartDates = Set(workoutsData.compactMap { $0["start"] as? TimeInterval })
|
||||||
|
|
||||||
|
// Delete workouts not on iOS
|
||||||
|
let existingWorkouts = (try? viewContext.fetch(Workout.fetchRequest())) ?? []
|
||||||
|
for workout in existingWorkouts {
|
||||||
|
let startInterval = workout.start.timeIntervalSince1970
|
||||||
|
// Check if this workout exists on iOS (within 1 second tolerance)
|
||||||
|
let existsOnIOS = iosStartDates.contains { abs($0 - startInterval) < 1 }
|
||||||
|
if !existsOnIOS {
|
||||||
|
viewContext.delete(workout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for workoutData in workoutsData {
|
||||||
|
self.importWorkout(workoutData, context: viewContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try viewContext.save()
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.lastSyncDate = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Successfully imported data from iPhone")
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print("Failed to import data: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Import Methods
|
||||||
|
|
||||||
|
private func importSplit(_ data: [String: Any], context: NSManagedObjectContext) {
|
||||||
|
guard let idString = data["id"] as? String,
|
||||||
|
let name = data["name"] as? String else { return }
|
||||||
|
|
||||||
|
// Find existing or create new
|
||||||
|
let split = findOrCreateSplit(idString: idString, name: name, context: context)
|
||||||
|
|
||||||
|
split.name = name
|
||||||
|
split.color = data["color"] as? String ?? "blue"
|
||||||
|
split.systemImage = data["systemImage"] as? String ?? "dumbbell.fill"
|
||||||
|
split.order = Int32(data["order"] as? Int ?? 0)
|
||||||
|
|
||||||
|
// Import exercises
|
||||||
|
if let exercisesData = data["exercises"] as? [[String: Any]] {
|
||||||
|
// Get all exercise names from iOS
|
||||||
|
let iosExerciseNames = Set(exercisesData.compactMap { $0["name"] as? String })
|
||||||
|
|
||||||
|
// Delete exercises not on iOS
|
||||||
|
for exercise in split.exercisesArray {
|
||||||
|
if !iosExerciseNames.contains(exercise.name) {
|
||||||
|
context.delete(exercise)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import/update exercises from iOS
|
||||||
|
for exerciseData in exercisesData {
|
||||||
|
importExercise(exerciseData, split: split, context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func importExercise(_ data: [String: Any], split: Split, context: NSManagedObjectContext) {
|
||||||
|
guard let idString = data["id"] as? String,
|
||||||
|
let name = data["name"] as? String else { return }
|
||||||
|
|
||||||
|
let exercise = findOrCreateExercise(idString: idString, name: name, split: split, context: context)
|
||||||
|
|
||||||
|
exercise.name = name
|
||||||
|
exercise.order = Int32(data["order"] as? Int ?? 0)
|
||||||
|
exercise.sets = Int32(data["sets"] as? Int ?? 3)
|
||||||
|
exercise.reps = Int32(data["reps"] as? Int ?? 10)
|
||||||
|
exercise.weight = Int32(data["weight"] as? Int ?? 0)
|
||||||
|
exercise.loadType = Int32(data["loadType"] as? Int ?? 1)
|
||||||
|
|
||||||
|
if let durationInterval = data["duration"] as? TimeInterval {
|
||||||
|
exercise.duration = Date(timeIntervalSince1970: durationInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
exercise.split = split
|
||||||
|
}
|
||||||
|
|
||||||
|
private func importWorkout(_ data: [String: Any], context: NSManagedObjectContext) {
|
||||||
|
guard let idString = data["id"] as? String,
|
||||||
|
let startInterval = data["start"] as? TimeInterval else { return }
|
||||||
|
|
||||||
|
let startDate = Date(timeIntervalSince1970: startInterval)
|
||||||
|
let workout = findOrCreateWorkout(idString: idString, startDate: startDate, context: context)
|
||||||
|
|
||||||
|
workout.start = startDate
|
||||||
|
|
||||||
|
if let endInterval = data["end"] as? TimeInterval {
|
||||||
|
workout.end = Date(timeIntervalSince1970: endInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let statusRaw = data["status"] as? String,
|
||||||
|
let status = WorkoutStatus(rawValue: statusRaw) {
|
||||||
|
workout.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link to split
|
||||||
|
if let splitName = data["splitName"] as? String {
|
||||||
|
workout.split = findSplitByName(splitName, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import logs
|
||||||
|
if let logsData = data["logs"] as? [[String: Any]] {
|
||||||
|
// Get all exercise names from iOS
|
||||||
|
let iosExerciseNames = Set(logsData.compactMap { $0["exerciseName"] as? String })
|
||||||
|
|
||||||
|
// Delete logs not on iOS
|
||||||
|
for log in workout.logsArray {
|
||||||
|
if !iosExerciseNames.contains(log.exerciseName) {
|
||||||
|
context.delete(log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import/update logs from iOS
|
||||||
|
for logData in logsData {
|
||||||
|
importWorkoutLog(logData, workout: workout, context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func importWorkoutLog(_ data: [String: Any], workout: Workout, context: NSManagedObjectContext) {
|
||||||
|
guard let idString = data["id"] as? String,
|
||||||
|
let exerciseName = data["exerciseName"] as? String else { return }
|
||||||
|
|
||||||
|
let log = findOrCreateWorkoutLog(idString: idString, exerciseName: exerciseName, workout: workout, context: context)
|
||||||
|
|
||||||
|
log.exerciseName = exerciseName
|
||||||
|
log.date = Date(timeIntervalSince1970: data["date"] as? TimeInterval ?? Date().timeIntervalSince1970)
|
||||||
|
log.order = Int32(data["order"] as? Int ?? 0)
|
||||||
|
log.sets = Int32(data["sets"] as? Int ?? 3)
|
||||||
|
log.reps = Int32(data["reps"] as? Int ?? 10)
|
||||||
|
log.weight = Int32(data["weight"] as? Int ?? 0)
|
||||||
|
log.currentStateIndex = Int32(data["currentStateIndex"] as? Int ?? 0)
|
||||||
|
log.completed = data["completed"] as? Bool ?? false
|
||||||
|
log.loadType = Int32(data["loadType"] as? Int ?? 1)
|
||||||
|
|
||||||
|
if let statusRaw = data["status"] as? String,
|
||||||
|
let status = WorkoutStatus(rawValue: statusRaw) {
|
||||||
|
log.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
if let durationInterval = data["duration"] as? TimeInterval {
|
||||||
|
log.duration = Date(timeIntervalSince1970: durationInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.notes = data["notes"] as? String
|
||||||
|
log.workout = workout
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Find or Create Helpers
|
||||||
|
|
||||||
|
private func findOrCreateSplit(idString: String, name: String, context: NSManagedObjectContext) -> Split {
|
||||||
|
// Try to find by name first (more reliable than object ID across devices)
|
||||||
|
let request = Split.fetchRequest()
|
||||||
|
request.predicate = NSPredicate(format: "name == %@", name)
|
||||||
|
request.fetchLimit = 1
|
||||||
|
|
||||||
|
if let existing = try? context.fetch(request).first {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
return Split(context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findOrCreateExercise(idString: String, name: String, split: Split, context: NSManagedObjectContext) -> Exercise {
|
||||||
|
// Find by name within split
|
||||||
|
if let existing = split.exercisesArray.first(where: { $0.name == name }) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
return Exercise(context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findOrCreateWorkout(idString: String, startDate: Date, context: NSManagedObjectContext) -> Workout {
|
||||||
|
// Find by start date (should be unique per workout)
|
||||||
|
let request = Workout.fetchRequest()
|
||||||
|
// Match within 1 second to account for any floating point differences
|
||||||
|
let startInterval = startDate.timeIntervalSince1970
|
||||||
|
request.predicate = NSPredicate(
|
||||||
|
format: "start >= %@ AND start <= %@",
|
||||||
|
Date(timeIntervalSince1970: startInterval - 1) as NSDate,
|
||||||
|
Date(timeIntervalSince1970: startInterval + 1) as NSDate
|
||||||
|
)
|
||||||
|
request.fetchLimit = 1
|
||||||
|
|
||||||
|
if let existing = try? context.fetch(request).first {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
return Workout(context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findOrCreateWorkoutLog(idString: String, exerciseName: String, workout: Workout, context: NSManagedObjectContext) -> WorkoutLog {
|
||||||
|
// Find existing log in this workout with same exercise name
|
||||||
|
if let existing = workout.logsArray.first(where: { $0.exerciseName == exerciseName }) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
return WorkoutLog(context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findSplitByName(_ name: String, context: NSManagedObjectContext) -> Split? {
|
||||||
|
let request = Split.fetchRequest()
|
||||||
|
request.predicate = NSPredicate(format: "name == %@", name)
|
||||||
|
request.fetchLimit = 1
|
||||||
|
return try? context.fetch(request).first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WCSessionDelegate
|
||||||
|
|
||||||
|
extension WatchConnectivityManager: WCSessionDelegate {
|
||||||
|
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||||
|
if let error = error {
|
||||||
|
print("[WC-Watch] Activation failed: \(error)")
|
||||||
|
} else {
|
||||||
|
print("[WC-Watch] Activated with state: \(activationState.rawValue)")
|
||||||
|
|
||||||
|
// Check for any pending context
|
||||||
|
let context = session.receivedApplicationContext
|
||||||
|
print("[WC-Watch] Pending context keys: \(context.keys)")
|
||||||
|
if !context.isEmpty {
|
||||||
|
print("[WC-Watch] Processing pending context...")
|
||||||
|
processApplicationContext(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive application context updates
|
||||||
|
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||||
|
print("[WC-Watch] Received application context with keys: \(applicationContext.keys)")
|
||||||
|
if let workouts = applicationContext["workouts"] as? [[String: Any]] {
|
||||||
|
print("[WC-Watch] Contains \(workouts.count) workouts")
|
||||||
|
}
|
||||||
|
processApplicationContext(applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive immediate messages
|
||||||
|
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||||
|
if let type = message["type"] as? String {
|
||||||
|
switch type {
|
||||||
|
case "workoutUpdate":
|
||||||
|
if let workoutData = message["workout"] as? [String: Any],
|
||||||
|
let context = viewContext {
|
||||||
|
context.perform {
|
||||||
|
self.importWorkout(workoutData, context: context)
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,13 +15,7 @@ struct ContentView: View {
|
|||||||
@Environment(\.managedObjectContext) private var viewContext
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
WorkoutLogsView()
|
||||||
Image(systemName: "dumbbell.fill")
|
|
||||||
.imageScale(.large)
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
Text("Workouts")
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import CoreData
|
|||||||
public class Workout: NSManagedObject, Identifiable {
|
public class Workout: NSManagedObject, Identifiable {
|
||||||
@NSManaged public var start: Date
|
@NSManaged public var start: Date
|
||||||
@NSManaged public var end: Date?
|
@NSManaged public var end: Date?
|
||||||
@NSManaged private var statusRaw: String
|
|
||||||
|
|
||||||
@NSManaged public var split: Split?
|
@NSManaged public var split: Split?
|
||||||
@NSManaged public var logs: NSSet?
|
@NSManaged public var logs: NSSet?
|
||||||
@@ -13,13 +12,26 @@ public class Workout: NSManagedObject, Identifiable {
|
|||||||
public var id: NSManagedObjectID { objectID }
|
public var id: NSManagedObjectID { objectID }
|
||||||
|
|
||||||
var status: WorkoutStatus {
|
var status: WorkoutStatus {
|
||||||
get { WorkoutStatus(rawValue: statusRaw) ?? .notStarted }
|
get {
|
||||||
set { statusRaw = newValue.rawValue }
|
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 {
|
var label: String {
|
||||||
if status == .completed, let endDate = end {
|
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 {
|
} else {
|
||||||
return start.formattedDate()
|
return start.formattedDate()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ struct PersistenceController {
|
|||||||
// CloudKit container identifier - same as iOS app for sync
|
// CloudKit container identifier - same as iOS app for sync
|
||||||
static let cloudKitContainerIdentifier = "iCloud.dev.rzen.indie.Workouts"
|
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 {
|
var viewContext: NSManagedObjectContext {
|
||||||
container.viewContext
|
container.viewContext
|
||||||
}
|
}
|
||||||
@@ -57,28 +60,37 @@ struct PersistenceController {
|
|||||||
if inMemory {
|
if inMemory {
|
||||||
description.url = URL(fileURLWithPath: "/dev/null")
|
description.url = URL(fileURLWithPath: "/dev/null")
|
||||||
description.cloudKitContainerOptions = nil
|
description.cloudKitContainerOptions = nil
|
||||||
} else if cloudKitEnabled {
|
} else {
|
||||||
// Check if CloudKit is available before enabling
|
// Use App Group container for shared storage between iOS and Watch
|
||||||
let cloudKitAvailable = FileManager.default.ubiquityIdentityToken != nil
|
if let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier) {
|
||||||
|
let storeURL = appGroupURL.appendingPathComponent("Workouts.sqlite")
|
||||||
if cloudKitAvailable {
|
description.url = storeURL
|
||||||
// Set CloudKit container options
|
print("Using shared App Group store at: \(storeURL)")
|
||||||
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)
|
if cloudKitEnabled {
|
||||||
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
// Check if CloudKit is available before enabling
|
||||||
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
let cloudKitAvailable = FileManager.default.ubiquityIdentityToken != nil
|
||||||
} else {
|
|
||||||
// CloudKit explicitly disabled
|
if cloudKitAvailable {
|
||||||
description.cloudKitContainerOptions = nil
|
// 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
|
container.loadPersistentStores { storeDescription, error in
|
||||||
|
|||||||
309
Workouts Watch App/Views/ExerciseProgressView.swift
Normal file
309
Workouts Watch App/Views/ExerciseProgressView.swift
Normal file
@@ -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..<totalPages, id: \.self) { index in
|
||||||
|
pageView(for: index)
|
||||||
|
.tag(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewStyle(.page)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button {
|
||||||
|
showingCancelConfirm = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog("Cancel Exercise?", isPresented: $showingCancelConfirm, titleVisibility: .visible) {
|
||||||
|
Button("Cancel Exercise", role: .destructive) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
Button("Continue", role: .cancel) { }
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Skip to first unfinished set
|
||||||
|
currentPage = firstUnfinishedSetPage
|
||||||
|
}
|
||||||
|
.onChange(of: currentPage) { _, newPage in
|
||||||
|
updateProgress(for: newPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func pageView(for index: Int) -> 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..<count {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.2) {
|
||||||
|
WKInterfaceDevice.current().play(.click)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Done Page View
|
||||||
|
|
||||||
|
struct DonePageView: View {
|
||||||
|
let onDone: () -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
229
Workouts Watch App/Views/WorkoutLogListView.swift
Normal file
229
Workouts Watch App/Views/WorkoutLogListView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
99
Workouts Watch App/Views/WorkoutLogsView.swift
Normal file
99
Workouts Watch App/Views/WorkoutLogsView.swift
Normal file
@@ -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<Workout>
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -5,12 +5,16 @@
|
|||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
<array>
|
<array/>
|
||||||
<string>iCloud.dev.rzen.indie.Workouts</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.icloud-services</key>
|
<key>com.apple.developer.icloud-services</key>
|
||||||
<array>
|
<array>
|
||||||
<string>CloudKit</string>
|
<string>CloudKit</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
|
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.dev.rzen.indie.Workouts</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -14,11 +14,18 @@ import CoreData
|
|||||||
@main
|
@main
|
||||||
struct WorkoutsWatchApp: App {
|
struct WorkoutsWatchApp: App {
|
||||||
let persistenceController = PersistenceController.shared
|
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 {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environment(\.managedObjectContext, persistenceController.viewContext)
|
.environment(\.managedObjectContext, persistenceController.viewContext)
|
||||||
|
.environmentObject(connectivityManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,6 +376,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "Workouts Watch App/Workouts Watch App.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\"";
|
||||||
@@ -396,7 +397,7 @@
|
|||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 4;
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
WATCHOS_DEPLOYMENT_TARGET = 11.2;
|
WATCHOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -405,6 +406,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "Workouts Watch App/Workouts Watch App.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\"";
|
||||||
@@ -426,7 +428,7 @@
|
|||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 4;
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
WATCHOS_DEPLOYMENT_TARGET = 11.2;
|
WATCHOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@@ -449,7 +451,7 @@
|
|||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -483,7 +485,7 @@
|
|||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
345
Workouts/Connectivity/WatchConnectivityManager.swift
Normal file
345
Workouts/Connectivity/WatchConnectivityManager.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ struct PersistenceController {
|
|||||||
// CloudKit container identifier
|
// CloudKit container identifier
|
||||||
static let cloudKitContainerIdentifier = "iCloud.dev.rzen.indie.Workouts"
|
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 {
|
var viewContext: NSManagedObjectContext {
|
||||||
container.viewContext
|
container.viewContext
|
||||||
}
|
}
|
||||||
@@ -57,28 +60,37 @@ struct PersistenceController {
|
|||||||
if inMemory {
|
if inMemory {
|
||||||
description.url = URL(fileURLWithPath: "/dev/null")
|
description.url = URL(fileURLWithPath: "/dev/null")
|
||||||
description.cloudKitContainerOptions = nil
|
description.cloudKitContainerOptions = nil
|
||||||
} else if cloudKitEnabled {
|
} else {
|
||||||
// Check if CloudKit is available before enabling
|
// Use App Group container for shared storage between iOS and Watch
|
||||||
let cloudKitAvailable = FileManager.default.ubiquityIdentityToken != nil
|
if let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier) {
|
||||||
|
let storeURL = appGroupURL.appendingPathComponent("Workouts.sqlite")
|
||||||
if cloudKitAvailable {
|
description.url = storeURL
|
||||||
// Set CloudKit container options
|
print("Using shared App Group store at: \(storeURL)")
|
||||||
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)
|
if cloudKitEnabled {
|
||||||
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
// Check if CloudKit is available before enabling
|
||||||
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
let cloudKitAvailable = FileManager.default.ubiquityIdentityToken != nil
|
||||||
} else {
|
|
||||||
// CloudKit explicitly disabled
|
if cloudKitAvailable {
|
||||||
description.cloudKitContainerOptions = nil
|
// 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
|
container.loadPersistentStores { storeDescription, error in
|
||||||
|
|||||||
@@ -125,6 +125,14 @@ struct ExerciseView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
progress = Int(workoutLog.currentStateIndex)
|
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() {
|
private func updateLogStatus() {
|
||||||
@@ -162,6 +170,7 @@ struct ExerciseView: View {
|
|||||||
|
|
||||||
private func saveChanges() {
|
private func saveChanges() {
|
||||||
try? viewContext.save()
|
try? viewContext.save()
|
||||||
|
WatchConnectivityManager.shared.syncAllData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,5 +48,6 @@ struct NotesEditView: View {
|
|||||||
private func saveChanges() {
|
private func saveChanges() {
|
||||||
workoutLog.notes = notesText
|
workoutLog.notes = notesText
|
||||||
try? viewContext.save()
|
try? viewContext.save()
|
||||||
|
WatchConnectivityManager.shared.syncAllData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,5 +163,6 @@ struct PlanEditView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try? viewContext.save()
|
try? viewContext.save()
|
||||||
|
WatchConnectivityManager.shared.syncAllData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,12 +133,14 @@ struct WorkoutLogListView: View {
|
|||||||
}
|
}
|
||||||
updateWorkoutStatus()
|
updateWorkoutStatus()
|
||||||
try? viewContext.save()
|
try? viewContext.save()
|
||||||
|
WatchConnectivityManager.shared.syncAllData()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func completeLog(_ log: WorkoutLog) {
|
private func completeLog(_ log: WorkoutLog) {
|
||||||
log.status = .completed
|
log.status = .completed
|
||||||
updateWorkoutStatus()
|
updateWorkoutStatus()
|
||||||
try? viewContext.save()
|
try? viewContext.save()
|
||||||
|
WatchConnectivityManager.shared.syncAllData()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateWorkoutStatus() {
|
private func updateWorkoutStatus() {
|
||||||
@@ -164,6 +166,7 @@ struct WorkoutLogListView: View {
|
|||||||
log.order = Int32(index)
|
log.order = Int32(index)
|
||||||
}
|
}
|
||||||
try? viewContext.save()
|
try? viewContext.save()
|
||||||
|
WatchConnectivityManager.shared.syncAllData()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addExerciseFromSplit(_ exercise: Exercise) {
|
private func addExerciseFromSplit(_ exercise: Exercise) {
|
||||||
@@ -188,6 +191,7 @@ struct WorkoutLogListView: View {
|
|||||||
log.workout = workout
|
log.workout = workout
|
||||||
|
|
||||||
try? viewContext.save()
|
try? viewContext.save()
|
||||||
|
WatchConnectivityManager.shared.syncAllData()
|
||||||
|
|
||||||
// Navigate to the new exercise view
|
// Navigate to the new exercise view
|
||||||
newlyAddedLog = log
|
newlyAddedLog = log
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ struct WorkoutLogsView: View {
|
|||||||
withAnimation {
|
withAnimation {
|
||||||
viewContext.delete(item)
|
viewContext.delete(item)
|
||||||
try? viewContext.save()
|
try? viewContext.save()
|
||||||
|
WatchConnectivityManager.shared.syncAllData()
|
||||||
itemToDelete = nil
|
itemToDelete = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,11 +168,17 @@ struct SplitPickerSheet: View {
|
|||||||
workoutLog.sets = exercise.sets
|
workoutLog.sets = exercise.sets
|
||||||
workoutLog.reps = exercise.reps
|
workoutLog.reps = exercise.reps
|
||||||
workoutLog.weight = exercise.weight
|
workoutLog.weight = exercise.weight
|
||||||
|
workoutLog.loadType = exercise.loadType
|
||||||
|
workoutLog.duration = exercise.duration
|
||||||
workoutLog.status = .notStarted
|
workoutLog.status = .notStarted
|
||||||
workoutLog.workout = workout
|
workoutLog.workout = workout
|
||||||
}
|
}
|
||||||
|
|
||||||
try? viewContext.save()
|
try? viewContext.save()
|
||||||
|
|
||||||
|
// Sync to Watch
|
||||||
|
WatchConnectivityManager.shared.syncAllData()
|
||||||
|
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,9 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.dev.rzen.indie.Workouts</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -14,11 +14,18 @@ import CoreData
|
|||||||
@main
|
@main
|
||||||
struct WorkoutsApp: App {
|
struct WorkoutsApp: App {
|
||||||
let persistenceController = PersistenceController.shared
|
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 {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environment(\.managedObjectContext, persistenceController.viewContext)
|
.environment(\.managedObjectContext, persistenceController.viewContext)
|
||||||
|
.environmentObject(connectivityManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user