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
@@ -8,29 +8,29 @@
//
import SwiftUI
import CoreData
import SwiftData
struct WorkoutLogsView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Workout.start, ascending: false)],
animation: .default
)
private var workouts: FetchedResults<Workout>
@Query(sort: \Workout.start, order: .reverse)
private var workouts: [Workout]
@State private var showingSplitPicker = false
@State private var showingSettings = false
@State private var itemToDelete: Workout? = nil
@State private var itemToDelete: Workout?
// WorkoutLogsView is the app's root screen, so it owns its NavigationStack.
var body: some View {
NavigationStack {
List {
ForEach(workouts, id: \.objectID) { workout in
NavigationLink(destination: WorkoutLogListView(workout: workout)) {
ForEach(workouts) { workout in
NavigationLink {
WorkoutLogListView(workout: workout)
} label: {
CalendarListItem(
date: workout.start,
title: workout.split?.name ?? Split.unnamed,
title: workout.splitName ?? Split.unnamed,
subtitle: getSubtitle(for: workout),
subtitle2: workout.statusName
)
@@ -77,21 +77,16 @@ struct WorkoutLogsView: View {
}
.confirmationDialog(
"Delete Workout?",
isPresented: Binding<Bool>(
isPresented: Binding(
get: { itemToDelete != nil },
set: { if !$0 { itemToDelete = nil } }
),
titleVisibility: .visible
) {
titleVisibility: .visible,
presenting: itemToDelete
) { workout in
Button("Delete", role: .destructive) {
if let item = itemToDelete {
withAnimation {
viewContext.delete(item)
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
itemToDelete = nil
}
}
Task { await sync.delete(workout: workout) }
itemToDelete = nil
}
Button("Cancel", role: .cancel) {
itemToDelete = nil
@@ -112,22 +107,16 @@ struct WorkoutLogsView: View {
// MARK: - Split Picker Sheet
struct SplitPickerSheet: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.dismiss) private var dismiss
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \Split.order, ascending: true),
NSSortDescriptor(keyPath: \Split.name, ascending: true)
],
animation: .default
)
private var splits: FetchedResults<Split>
@Query(sort: [SortDescriptor(\Split.order), SortDescriptor(\Split.name)])
private var splits: [Split]
var body: some View {
NavigationStack {
List {
ForEach(splits, id: \.objectID) { split in
ForEach(splits) { split in
Button {
startWorkout(with: split)
} label: {
@@ -155,35 +144,40 @@ struct SplitPickerSheet: View {
}
private func startWorkout(with split: Split) {
let workout = Workout(context: viewContext)
workout.start = Date()
workout.status = .notStarted
workout.split = split
for exercise in split.exercisesArray {
let workoutLog = WorkoutLog(context: viewContext)
workoutLog.exerciseName = exercise.name
workoutLog.date = Date()
workoutLog.order = exercise.order
workoutLog.sets = exercise.sets
workoutLog.reps = exercise.reps
workoutLog.weight = exercise.weight
workoutLog.loadType = exercise.loadType
workoutLog.duration = exercise.duration
workoutLog.status = .notStarted
workoutLog.workout = workout
let start = Date()
let logs = split.exercisesArray.enumerated().map { index, exercise in
WorkoutLogDocument(
id: ULID.make(),
exerciseName: exercise.name,
order: index,
sets: exercise.sets,
reps: exercise.reps,
weight: exercise.weight,
loadType: exercise.loadType,
durationSeconds: exercise.durationTotalSeconds,
currentStateIndex: 0,
completed: false,
status: WorkoutStatus.notStarted.rawValue,
notes: nil,
date: start
)
}
try? viewContext.save()
// Sync to Watch
WatchConnectivityManager.shared.syncAllData()
// A freshly started workout has no `end` only completion stamps it.
let doc = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchema,
id: ULID.make(),
splitID: split.id,
splitName: split.name,
start: start,
end: nil,
status: WorkoutStatus.notStarted.rawValue,
createdAt: start,
updatedAt: start,
logs: logs
)
Task { await sync.save(workout: doc) }
dismiss()
}
}
#Preview {
WorkoutLogsView()
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
}