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
+59
View File
@@ -0,0 +1,59 @@
import Foundation
import SwiftData
/// Builds starter Splits from the bundled YAML exercise catalog, grouped by the
/// catalog's `split` label (Upper Body / Core / Lower Body). Written on demand
/// (never auto-seeded) through the SyncEngine an empty cache at launch can't be
/// told apart from an iCloud library that simply hasn't downloaded yet.
enum SplitSeeder {
/// Visual theme per starter split group, in display order.
private static let groups: [(name: String, color: String, icon: String)] = [
("Upper Body", "blue", "figure.strengthtraining.traditional"),
("Core", "orange", "figure.core.training"),
("Lower Body", "green", "figure.run"),
]
/// Default split source catalog (most universally applicable).
private static let sourceFile = "bodyweight-starter.exercises.yaml"
/// Builds the default split documents (fresh ULIDs each call).
static func defaultSplitDocuments() -> [SplitDocument] {
let lists = ExerciseListLoader.loadExerciseLists()
guard let catalog = lists[sourceFile] else { return [] }
var docs: [SplitDocument] = []
for (order, group) in groups.enumerated() {
let items = catalog.exercises.filter { $0.split == group.name }
guard !items.isEmpty else { continue }
let exercises = items.enumerated().map { idx, item in
ExerciseDocument(
id: ULID.make(), name: item.name, order: idx,
sets: 3, reps: 10, weight: 0, loadType: LoadType.weight.rawValue,
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2
)
}
docs.append(SplitDocument(
schemaVersion: SplitDocument.currentSchema, id: ULID.make(),
name: group.name, color: group.color, systemImage: group.icon, order: order,
createdAt: Date(), updatedAt: Date(), exercises: exercises
))
}
return docs
}
/// Writes any starter splits whose name doesn't already exist, appended after
/// existing splits. Idempotent against double-taps / partial prior seeds.
@MainActor
static func seedDefaults(into context: ModelContext, using sync: SyncEngine) async {
let existing = (try? context.fetch(FetchDescriptor<Split>())) ?? []
let existingNames = Set(existing.map(\.name))
let base = existing.count
let fresh = defaultSplitDocuments().filter { !existingNames.contains($0.name) }
for (offset, var doc) in fresh.enumerated() {
doc.order = base + offset
await sync.save(split: doc)
}
}
}