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,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 iPhone↔Watch 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
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user