Workouts 2.0: re-base persistence on iCloud Drive documents
Replace Core Data + NSPersistentCloudKitContainer + App-Group store + WatchConnectivity dictionary sync with the QuickRabbit iCloud-documents architecture: - iCloud Drive JSON documents are the sole source of truth (one file per aggregate: Splits/<ULID>.json, Workouts/YYYY/MM/<ULID>.json), with a rebuildable SwiftData cache populated only by an NSMetadataQuery observer and a connect-time reconcile. Soft-delete tombstones; hard iCloud gate. - Shared model layer (ULID, Codable *Documents + stateless mappers, @Model cache entities, SwiftData container) compiled into both targets. - New iPhone<->Watch bridge over WatchConnectivity keyed by ULIDs; the phone is the sole writer of iCloud Drive, the watch round-trips documents. - AppServices DI + iCloud-required root gate; Swift 6 strict concurrency. - Starter splits generated on demand from the bundled YAML catalogs. - Migrate to XcodeGen (project.yml), iOS 26 / watchOS 26; CloudDocuments entitlement (drop CloudKit/App Group/aps-environment). - Duration stored as Int seconds (was a Date epoch hack); fix workout end-on-create, undismissable delete dialog, toolbar-hiding nav stacks, and the Settings placeholder. - Add README/CHANGELOG/LICENSE, .gitignore, refreshed REQUIREMENTS, and the Scripts/ TestFlight pipeline (release.sh + ASC API scripts). MARKETING_VERSION 2.0.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftData
|
||||
import WatchConnectivity
|
||||
|
||||
/// Watch side of the iPhone↔Watch bridge. The watch never touches iCloud — it
|
||||
/// keeps a local SwiftData cache fed only by application-context pushes from the
|
||||
/// phone, updates it optimistically on local edits, and forwards changed workouts
|
||||
/// to the phone (which is the sole writer of iCloud Drive).
|
||||
@Observable
|
||||
@MainActor
|
||||
final class WatchConnectivityBridge: NSObject {
|
||||
private let container: ModelContainer
|
||||
private var session: WCSession?
|
||||
|
||||
/// Last time state was received from the phone (for a sync indicator).
|
||||
private(set) var lastSyncDate: Date?
|
||||
|
||||
private var context: ModelContext { container.mainContext }
|
||||
|
||||
init(container: ModelContainer) {
|
||||
self.container = container
|
||||
super.init()
|
||||
}
|
||||
|
||||
func activate() {
|
||||
guard WCSession.isSupported() else { return }
|
||||
let session = WCSession.default
|
||||
session.delegate = self
|
||||
session.activate()
|
||||
self.session = session
|
||||
// Apply whatever the phone last pushed, then ask for a fresh push.
|
||||
applyState(WCPayload.decodeSplits(session.receivedApplicationContext),
|
||||
workouts: WCPayload.decodeWorkouts(session.receivedApplicationContext))
|
||||
requestSync()
|
||||
}
|
||||
|
||||
func requestSync() {
|
||||
guard let session, session.activationState == .activated, session.isReachable else { return }
|
||||
session.sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: nil)
|
||||
}
|
||||
|
||||
/// Optimistically applies a workout edit to the local cache and forwards it to
|
||||
/// the phone for durable persistence in iCloud Drive.
|
||||
func update(workout doc: WorkoutDocument) {
|
||||
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
|
||||
try? context.save()
|
||||
sendToPhone(doc)
|
||||
}
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
private func sendToPhone(_ doc: WorkoutDocument) {
|
||||
guard let session, session.activationState == .activated else { return }
|
||||
let payload = WCPayload.encodeWorkoutUpdate(doc)
|
||||
if session.isReachable {
|
||||
session.sendMessage(payload, replyHandler: nil, errorHandler: { _ in
|
||||
session.transferUserInfo(payload) // fall back to guaranteed delivery
|
||||
})
|
||||
} else {
|
||||
session.transferUserInfo(payload)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyState(_ splits: [SplitDocument], workouts: [WorkoutDocument]) {
|
||||
guard !splits.isEmpty || !workouts.isEmpty else { return }
|
||||
var liveSplitIDs = Set<String>()
|
||||
for s in splits {
|
||||
CacheMapper.upsertSplit(s, relativePath: s.relativePath, into: context)
|
||||
liveSplitIDs.insert(s.id)
|
||||
}
|
||||
for w in workouts {
|
||||
CacheMapper.upsertWorkout(w, relativePath: w.relativePath, into: context)
|
||||
}
|
||||
// Splits are sent in full → prune any the phone no longer has. Workouts are
|
||||
// sent as a recent window, so they're upserted but never pruned (avoids a
|
||||
// race deleting a workout just created on the watch).
|
||||
if let allSplits = try? context.fetch(FetchDescriptor<Split>()) {
|
||||
for s in allSplits where !liveSplitIDs.contains(s.id) { context.delete(s) }
|
||||
}
|
||||
try? context.save()
|
||||
lastSyncDate = Date()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WCSessionDelegate
|
||||
|
||||
extension WatchConnectivityBridge: WCSessionDelegate {
|
||||
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||
Task { @MainActor in self.requestSync() }
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||
let splits = WCPayload.decodeSplits(applicationContext)
|
||||
let workouts = WCPayload.decodeWorkouts(applicationContext)
|
||||
Task { @MainActor in self.applyState(splits, workouts: workouts) }
|
||||
}
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user