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:
2026-06-19 14:25:27 -04:00
parent 9a881e841b
commit 85d0eaddbb
86 changed files with 3873 additions and 3755 deletions
@@ -0,0 +1,98 @@
import Foundation
import Observation
import SwiftData
import WatchConnectivity
/// Watch side of the iPhoneWatch 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
}
}
}
}