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:
2026-01-19 19:15:38 -05:00
parent 8b6250e4d6
commit 9a881e841b
21 changed files with 1581 additions and 67 deletions

View File

@@ -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
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View 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
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}

View File

@@ -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

View 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)
}
}
}

View 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)
}

View 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)
}

View File

@@ -5,12 +5,16 @@
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.dev.rzen.indie.Workouts</string>
</array>
<array/>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</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>
</plist>
</plist>

View File

@@ -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)
}
}
}

View File

@@ -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",

View 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
}
}
}

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -48,5 +48,6 @@ struct NotesEditView: View {
private func saveChanges() {
workoutLog.notes = notesText
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
}
}

View File

@@ -163,5 +163,6 @@ struct PlanEditView: View {
}
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
}
}

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -14,5 +14,9 @@
</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>
</plist>

View File

@@ -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)
}
}
}