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,183 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
// Stateless translation between the on-disk `*Document` wire format and the
|
||||
// SwiftData cache entities. The only code that knows both shapes.
|
||||
//
|
||||
// • `init(from: entity)` — cache → document. Used by the write path (build a
|
||||
// document to save) and the iPhone↔Watch bridge.
|
||||
// • `CacheMapper.upsert*` — document → cache. Used ONLY by the metadata
|
||||
// observer (the sole cache mutator) and cache rebuilds. Embedded children
|
||||
// are reconciled by id (update / insert / delete) so frequent in-workout
|
||||
// edits don't churn unrelated rows.
|
||||
|
||||
// MARK: - Cache → Document
|
||||
|
||||
extension ExerciseDocument {
|
||||
init(from e: Exercise) {
|
||||
self.init(id: e.id, name: e.name, order: e.order, sets: e.sets, reps: e.reps,
|
||||
weight: e.weight, loadType: e.loadType, durationSeconds: e.durationTotalSeconds,
|
||||
weightLastUpdated: e.weightLastUpdated,
|
||||
weightReminderWeeks: e.weightReminderTimeIntervalWeeks)
|
||||
}
|
||||
}
|
||||
|
||||
extension SplitDocument {
|
||||
init(from split: Split) {
|
||||
self.init(schemaVersion: Self.currentSchema, id: split.id, name: split.name,
|
||||
color: split.color, systemImage: split.systemImage, order: split.order,
|
||||
createdAt: split.createdAt, updatedAt: split.updatedAt,
|
||||
exercises: split.exercisesArray.map(ExerciseDocument.init(from:)))
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkoutLogDocument {
|
||||
init(from log: WorkoutLog) {
|
||||
self.init(id: log.id, exerciseName: log.exerciseName, order: log.order, sets: log.sets,
|
||||
reps: log.reps, weight: log.weight, loadType: log.loadType,
|
||||
durationSeconds: log.durationTotalSeconds, currentStateIndex: log.currentStateIndex,
|
||||
completed: log.completed, status: log.statusRaw, notes: log.notes, date: log.date)
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkoutDocument {
|
||||
init(from workout: Workout) {
|
||||
self.init(schemaVersion: Self.currentSchema, id: workout.id, splitID: workout.splitID,
|
||||
splitName: workout.splitName, start: workout.start, end: workout.end,
|
||||
status: workout.statusRaw, createdAt: workout.createdAt, updatedAt: workout.updatedAt,
|
||||
logs: workout.logsArray.map(WorkoutLogDocument.init(from:)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Document → Cache (upsert)
|
||||
|
||||
enum CacheMapper {
|
||||
static func fetchSplit(id: String, in context: ModelContext) -> Split? {
|
||||
var d = FetchDescriptor<Split>(predicate: #Predicate { $0.id == id })
|
||||
d.fetchLimit = 1
|
||||
return try? context.fetch(d).first
|
||||
}
|
||||
|
||||
static func fetchWorkout(id: String, in context: ModelContext) -> Workout? {
|
||||
var d = FetchDescriptor<Workout>(predicate: #Predicate { $0.id == id })
|
||||
d.fetchLimit = 1
|
||||
return try? context.fetch(d).first
|
||||
}
|
||||
|
||||
// MARK: Split
|
||||
|
||||
static func upsertSplit(_ doc: SplitDocument, relativePath: String, into context: ModelContext) {
|
||||
let split: Split
|
||||
if let existing = fetchSplit(id: doc.id, in: context) {
|
||||
split = existing
|
||||
} else {
|
||||
split = Split(id: doc.id, name: doc.name, color: doc.color, systemImage: doc.systemImage,
|
||||
order: doc.order, createdAt: doc.createdAt, updatedAt: doc.updatedAt,
|
||||
jsonRelativePath: relativePath)
|
||||
context.insert(split)
|
||||
}
|
||||
split.name = doc.name
|
||||
split.color = doc.color
|
||||
split.systemImage = doc.systemImage
|
||||
split.order = doc.order
|
||||
split.createdAt = doc.createdAt
|
||||
split.updatedAt = doc.updatedAt
|
||||
split.jsonRelativePath = relativePath
|
||||
|
||||
let existing = Dictionary(split.exercises.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
var keep = Set<String>()
|
||||
for ed in doc.exercises {
|
||||
keep.insert(ed.id)
|
||||
if let e = existing[ed.id] {
|
||||
apply(ed, to: e)
|
||||
} else {
|
||||
let e = makeExercise(ed)
|
||||
e.split = split
|
||||
context.insert(e)
|
||||
}
|
||||
}
|
||||
for e in Array(split.exercises) where !keep.contains(e.id) {
|
||||
context.delete(e)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeExercise(_ d: ExerciseDocument) -> Exercise {
|
||||
Exercise(id: d.id, name: d.name, order: d.order, sets: d.sets, reps: d.reps, weight: d.weight,
|
||||
loadType: d.loadType, durationTotalSeconds: d.durationSeconds,
|
||||
weightLastUpdated: d.weightLastUpdated,
|
||||
weightReminderTimeIntervalWeeks: d.weightReminderWeeks)
|
||||
}
|
||||
|
||||
private static func apply(_ d: ExerciseDocument, to e: Exercise) {
|
||||
e.name = d.name
|
||||
e.order = d.order
|
||||
e.sets = d.sets
|
||||
e.reps = d.reps
|
||||
e.weight = d.weight
|
||||
e.loadType = d.loadType
|
||||
e.durationTotalSeconds = d.durationSeconds
|
||||
e.weightLastUpdated = d.weightLastUpdated
|
||||
e.weightReminderTimeIntervalWeeks = d.weightReminderWeeks
|
||||
}
|
||||
|
||||
// MARK: Workout
|
||||
|
||||
static func upsertWorkout(_ doc: WorkoutDocument, relativePath: String, into context: ModelContext) {
|
||||
let workout: Workout
|
||||
if let existing = fetchWorkout(id: doc.id, in: context) {
|
||||
workout = existing
|
||||
} else {
|
||||
workout = Workout(id: doc.id, splitID: doc.splitID, splitName: doc.splitName,
|
||||
start: doc.start, end: doc.end, statusRaw: doc.status,
|
||||
createdAt: doc.createdAt, updatedAt: doc.updatedAt,
|
||||
jsonRelativePath: relativePath)
|
||||
context.insert(workout)
|
||||
}
|
||||
workout.splitID = doc.splitID
|
||||
workout.splitName = doc.splitName
|
||||
workout.start = doc.start
|
||||
workout.end = doc.end
|
||||
workout.statusRaw = doc.status
|
||||
workout.createdAt = doc.createdAt
|
||||
workout.updatedAt = doc.updatedAt
|
||||
workout.jsonRelativePath = relativePath
|
||||
|
||||
let existing = Dictionary(workout.logs.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
var keep = Set<String>()
|
||||
for ld in doc.logs {
|
||||
keep.insert(ld.id)
|
||||
if let l = existing[ld.id] {
|
||||
apply(ld, to: l)
|
||||
} else {
|
||||
let l = makeLog(ld)
|
||||
l.workout = workout
|
||||
context.insert(l)
|
||||
}
|
||||
}
|
||||
for l in Array(workout.logs) where !keep.contains(l.id) {
|
||||
context.delete(l)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeLog(_ d: WorkoutLogDocument) -> WorkoutLog {
|
||||
WorkoutLog(id: d.id, exerciseName: d.exerciseName, order: d.order, sets: d.sets, reps: d.reps,
|
||||
weight: d.weight, loadType: d.loadType, durationTotalSeconds: d.durationSeconds,
|
||||
currentStateIndex: d.currentStateIndex, completed: d.completed, statusRaw: d.status,
|
||||
notes: d.notes, date: d.date)
|
||||
}
|
||||
|
||||
private static func apply(_ d: WorkoutLogDocument, to l: WorkoutLog) {
|
||||
l.exerciseName = d.exerciseName
|
||||
l.order = d.order
|
||||
l.sets = d.sets
|
||||
l.reps = d.reps
|
||||
l.weight = d.weight
|
||||
l.loadType = d.loadType
|
||||
l.durationTotalSeconds = d.durationSeconds
|
||||
l.currentStateIndex = d.currentStateIndex
|
||||
l.completed = d.completed
|
||||
l.statusRaw = d.status
|
||||
l.notes = d.notes
|
||||
l.date = d.date
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user