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
+144
View File
@@ -0,0 +1,144 @@
import Foundation
/// On-disk JSON shape for each aggregate. Independent from the SwiftData cache
/// entities so the wire format can evolve without dragging the cache schema.
///
/// One document = one aggregate root:
/// `SplitDocument` embeds its `[ExerciseDocument]` `Splits/<ULID>.json`
/// `WorkoutDocument` embeds its `[WorkoutLogDocument]` `Workouts/YYYY/MM/<ULID>.json`
///
/// `schemaVersion` lets us migrate old files on read without forcing a rewrite
/// at sync time, and forward-gates files written by a newer app version.
/// These documents are also the wire format for the iPhoneWatch bridge.
// MARK: - Split
struct SplitDocument: Codable, Sendable, Equatable, Identifiable {
var schemaVersion: Int
var id: String // ULID
var name: String
var color: String
var systemImage: String
var order: Int
var createdAt: Date
var updatedAt: Date
var exercises: [ExerciseDocument]
static let currentSchema = 1
var relativePath: String { "Splits/\(id).json" }
}
struct ExerciseDocument: Codable, Sendable, Equatable, Identifiable {
var id: String // ULID
var name: String
var order: Int
var sets: Int
var reps: Int
var weight: Int
var loadType: Int
var durationSeconds: Int // total seconds (0 when not a timed exercise)
var weightLastUpdated: Date?
var weightReminderWeeks: Int
}
// MARK: - Workout
struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
var schemaVersion: Int
var id: String // ULID (chronological)
var splitID: String?
var splitName: String?
var start: Date
var end: Date?
var status: String // WorkoutStatus raw value
var createdAt: Date
var updatedAt: Date
var logs: [WorkoutLogDocument]
static let currentSchema = 1
var relativePath: String { Self.relativePath(id: id, start: start) }
static func relativePath(id: String, start: Date) -> String {
let cal = Calendar(identifier: .gregorian)
let comps = cal.dateComponents([.year, .month], from: start)
let year = comps.year ?? 1970
let month = comps.month ?? 1
return String(format: "Workouts/%04d/%02d/%@.json", year, month, id)
}
}
struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable {
var id: String // ULID
var exerciseName: String
var order: Int
var sets: Int
var reps: Int
var weight: Int
var loadType: Int
var durationSeconds: Int // total seconds (0 when not a timed exercise)
var currentStateIndex: Int
var completed: Bool
var status: String // WorkoutStatus raw value
var notes: String?
var date: Date
}
// MARK: - Forward-compatibility gate
/// A file whose `schemaVersion` exceeds the reader's `currentSchema` was written
/// by a newer app version; it must be quarantined, not partially decoded (Codable
/// silently drops unknown keys) and later rewritten which would downgrade it
/// and lose the newer fields.
protocol VersionedDocument {
var schemaVersion: Int { get }
static var currentSchema: Int { get }
}
extension SplitDocument: VersionedDocument {}
extension WorkoutDocument: VersionedDocument {}
extension VersionedDocument {
/// True if this build can safely read (and rewrite) the document.
var isReadable: Bool { schemaVersion <= Self.currentSchema }
}
// MARK: - Soft-delete tombstone
/// Lives at `Stubs/<id>.json` and tells every device "this aggregate has been
/// deleted" important when a remote device missed the `.removed` event for the
/// live file because it was offline. After `gracePeriod` any device can prune it.
struct Tombstone: Codable, Sendable, Equatable {
enum Kind: String, Codable, Sendable {
case split
case workout
}
var id: String // the aggregate's ULID
var kind: Kind
var deletedAt: Date
var relativePath: String { "Stubs/\(id).json" }
static let gracePeriod: TimeInterval = 30 * 24 * 60 * 60
}
// MARK: - JSON coding
/// Shared encoder/decoder. ISO-8601 dates and sorted keys for human-readable,
/// diff-friendly files when the container is browsed via the Files app.
enum DocumentCoder {
static let encoder: JSONEncoder = {
let e = JSONEncoder()
e.outputFormatting = [.prettyPrinted, .sortedKeys]
e.dateEncodingStrategy = .iso8601
return e
}()
static let decoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}()
}
+205
View File
@@ -0,0 +1,205 @@
import Foundation
import SwiftData
// SwiftData cache entities. These are a rebuildable read-through cache of the
// iCloud Drive JSON documents never the source of truth. App code reads them
// (via @Query); all writes go through `SyncEngine` to the document files, and the
// metadata observer mirrors the files back into these entities (see Mappers).
//
// `id` is the aggregate/child ULID (stable across cache rebuilds, unlike the
// SwiftData PersistentIdentifier). Computed helpers preserve the API the views
// used against the old Core Data classes.
// MARK: - Split
@Model
final class Split {
@Attribute(.unique) var id: String = ULID.make()
var name: String = ""
var color: String = "indigo"
var systemImage: String = "dumbbell.fill"
var order: Int = 0
var createdAt: Date = Date()
var updatedAt: Date = Date()
var jsonRelativePath: String = ""
@Relationship(deleteRule: .cascade, inverse: \Exercise.split)
var exercises: [Exercise] = []
init(id: String, name: String, color: String, systemImage: String, order: Int,
createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
self.id = id
self.name = name
self.color = color
self.systemImage = systemImage
self.order = order
self.createdAt = createdAt
self.updatedAt = updatedAt
self.jsonRelativePath = jsonRelativePath
}
static let unnamed = "Unnamed Split"
var exercisesArray: [Exercise] { exercises.sorted { $0.order < $1.order } }
}
// MARK: - Exercise
@Model
final class Exercise {
@Attribute(.unique) var id: String = ULID.make()
var name: String = ""
var order: Int = 0
var sets: Int = 0
var reps: Int = 0
var weight: Int = 0
var loadType: Int = LoadType.weight.rawValue
var durationTotalSeconds: Int = 0
var weightLastUpdated: Date?
var weightReminderTimeIntervalWeeks: Int = 2
var split: Split?
init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Int,
loadType: Int, durationTotalSeconds: Int, weightLastUpdated: Date?,
weightReminderTimeIntervalWeeks: Int) {
self.id = id
self.name = name
self.order = order
self.sets = sets
self.reps = reps
self.weight = weight
self.loadType = loadType
self.durationTotalSeconds = durationTotalSeconds
self.weightLastUpdated = weightLastUpdated
self.weightReminderTimeIntervalWeeks = weightReminderTimeIntervalWeeks
}
var loadTypeEnum: LoadType {
get { LoadType(rawValue: loadType) ?? .weight }
set { loadType = newValue.rawValue }
}
/// Minutes component of the total duration (for min:sec pickers).
var durationMinutes: Int {
get { durationTotalSeconds / 60 }
set { durationTotalSeconds = newValue * 60 + durationSeconds }
}
/// Seconds component (059) of the total duration.
var durationSeconds: Int {
get { durationTotalSeconds % 60 }
set { durationTotalSeconds = durationMinutes * 60 + newValue }
}
}
// MARK: - Workout
@Model
final class Workout {
@Attribute(.unique) var id: String = ULID.make()
var splitID: String?
var splitName: String?
var start: Date = Date()
var end: Date?
var statusRaw: String = WorkoutStatus.notStarted.rawValue
var createdAt: Date = Date()
var updatedAt: Date = Date()
var jsonRelativePath: String = ""
@Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout)
var logs: [WorkoutLog] = []
init(id: String, splitID: String?, splitName: String?, start: Date, end: Date?,
statusRaw: String, createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
self.id = id
self.splitID = splitID
self.splitName = splitName
self.start = start
self.end = end
self.statusRaw = statusRaw
self.createdAt = createdAt
self.updatedAt = updatedAt
self.jsonRelativePath = jsonRelativePath
}
var status: WorkoutStatus {
get { WorkoutStatus(rawValue: statusRaw) ?? .notStarted }
set { statusRaw = newValue.rawValue }
}
var statusName: String { status.displayName }
var logsArray: [WorkoutLog] { logs.sorted { $0.order < $1.order } }
var label: String {
if status == .completed, let endDate = end {
if start.isSameDay(as: endDate) {
return "\(start.formattedDate())\(endDate.formattedTime())"
} else {
return "\(start.formattedDate())\(endDate.formattedDate())"
}
} else {
return start.formattedDate()
}
}
}
// MARK: - WorkoutLog
@Model
final class WorkoutLog {
@Attribute(.unique) var id: String = ULID.make()
var exerciseName: String = ""
var order: Int = 0
var sets: Int = 0
var reps: Int = 0
var weight: Int = 0
var loadType: Int = LoadType.weight.rawValue
var durationTotalSeconds: Int = 0
var currentStateIndex: Int = 0
var completed: Bool = false
var statusRaw: String = WorkoutStatus.notStarted.rawValue
var notes: String?
var date: Date = Date()
var workout: Workout?
init(id: String, exerciseName: String, order: Int, sets: Int, reps: Int, weight: Int,
loadType: Int, durationTotalSeconds: Int, currentStateIndex: Int, completed: Bool,
statusRaw: String, notes: String?, date: Date) {
self.id = id
self.exerciseName = exerciseName
self.order = order
self.sets = sets
self.reps = reps
self.weight = weight
self.loadType = loadType
self.durationTotalSeconds = durationTotalSeconds
self.currentStateIndex = currentStateIndex
self.completed = completed
self.statusRaw = statusRaw
self.notes = notes
self.date = date
}
var status: WorkoutStatus {
get { WorkoutStatus(rawValue: statusRaw) ?? .notStarted }
set { statusRaw = newValue.rawValue }
}
var loadTypeEnum: LoadType {
get { LoadType(rawValue: loadType) ?? .weight }
set { loadType = newValue.rawValue }
}
var durationMinutes: Int {
get { durationTotalSeconds / 60 }
set { durationTotalSeconds = newValue * 60 + durationSeconds }
}
var durationSeconds: Int {
get { durationTotalSeconds % 60 }
set { durationTotalSeconds = durationMinutes * 60 + newValue }
}
}
+36
View File
@@ -0,0 +1,36 @@
import Foundation
/// Completion state of a workout or an individual exercise log. Persisted as its
/// raw string in both the JSON documents and the SwiftData cache.
enum WorkoutStatus: String, CaseIterable, Codable, Sendable {
case notStarted
case inProgress
case completed
case skipped
var displayName: String {
switch self {
case .notStarted: "Not Started"
case .inProgress: "In Progress"
case .completed: "Completed"
case .skipped: "Skipped"
}
}
var name: String { displayName }
}
/// How an exercise's effort is measured. Persisted as its raw `Int` value.
enum LoadType: Int, CaseIterable, Codable, Sendable {
case none = 0
case weight = 1
case duration = 2
var name: String {
switch self {
case .none: "None"
case .weight: "Weight"
case .duration: "Duration"
}
}
}
+183
View File
@@ -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 iPhoneWatch 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
}
}
+68
View File
@@ -0,0 +1,68 @@
import Foundation
/// Minimal ULID generator.
///
/// 26-character Crockford base32 string: 10 chars of millisecond timestamp +
/// 16 chars of randomness. Lexicographic order matches chronological order,
/// so workout documents sort newest-last by id and file listings stay ordered.
///
/// Spec: https://github.com/ulid/spec
enum ULID {
/// Crockford base32 alphabet no I, L, O, U to avoid visual confusion.
private static let alphabet: [Character] = Array("0123456789ABCDEFGHJKMNPQRSTVWXYZ")
/// Mints a fresh ULID for the current instant.
static func make() -> String {
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
var randomness = [UInt8](repeating: 0, count: 10)
_ = randomness.withUnsafeMutableBytes { buffer in
SecRandomCopyBytes(kSecRandomDefault, buffer.count, buffer.baseAddress!)
}
return encode(timestamp: timestamp, randomness: randomness)
}
/// True if `s` is a 26-char ULID using the Crockford alphabet.
static func isValid(_ s: String) -> Bool {
guard s.count == 26 else { return false }
let allowed = Set(alphabet)
return s.allSatisfy { allowed.contains($0) }
}
/// Decodes the timestamp portion of a ULID, if valid.
static func timestamp(of ulid: String) -> Date? {
guard ulid.count == 26 else { return nil }
let prefix = ulid.prefix(10)
var value: UInt64 = 0
let lookup = Dictionary(uniqueKeysWithValues: alphabet.enumerated().map { ($1, UInt64($0)) })
for char in prefix {
guard let digit = lookup[char] else { return nil }
value = (value << 5) | digit
}
return Date(timeIntervalSince1970: Double(value) / 1000)
}
// MARK: - Internal
private static func encode(timestamp: UInt64, randomness: [UInt8]) -> String {
precondition(randomness.count == 10, "ULID randomness must be 10 bytes (80 bits)")
// 48-bit timestamp 10 base32 chars (50 bits, top 2 unused).
var output = [Character](repeating: "0", count: 26)
var t = timestamp & ((1 << 48) - 1)
for i in stride(from: 9, through: 0, by: -1) {
output[i] = alphabet[Int(t & 0x1F)]
t >>= 5
}
// 80-bit randomness 16 base32 chars.
var bigEnd = randomness
for i in stride(from: 25, through: 10, by: -1) {
var carry: UInt16 = 0
for j in 0..<bigEnd.count {
let combined = UInt16(bigEnd[j]) | (carry << 8)
bigEnd[j] = UInt8(combined >> 5)
carry = combined & 0x1F
}
output[i] = alphabet[Int(UInt8(carry))]
}
return String(output)
}
}