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
@@ -9,16 +9,25 @@ import SwiftUI
import WatchKit
struct ExerciseProgressView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(\.dismiss) private var dismiss
@ObservedObject var workoutLog: WorkoutLog
/// The shared working workout document owned by the parent. We mutate the
/// matching log in place and ask the parent to forward each change through the
/// bridge driving the UI from this doc (not the cache) avoids losing rapid
/// taps to the read-after-write race.
@Binding var doc: WorkoutDocument
let logID: String
let onChange: () -> Void
@State private var currentPage: Int = 0
@State private var showingCancelConfirm = false
private var log: WorkoutLogDocument? {
doc.logs.first(where: { $0.id == logID })
}
private var totalSets: Int {
max(1, Int(workoutLog.sets))
max(1, log?.sets ?? 1)
}
private var totalPages: Int {
@@ -29,7 +38,7 @@ struct ExerciseProgressView: View {
private var firstUnfinishedSetPage: Int {
// currentStateIndex is the number of completed sets
let completedSets = Int(workoutLog.currentStateIndex)
let completedSets = log?.currentStateIndex ?? 0
if completedSets >= totalSets {
// All done, go to done page
return totalPages - 1
@@ -86,10 +95,10 @@ struct ExerciseProgressView: View {
SetPageView(
setNumber: setNumber,
totalSets: totalSets,
reps: Int(workoutLog.reps),
isTimeBased: workoutLog.loadTypeEnum == .duration,
durationMinutes: workoutLog.durationMinutes,
durationSeconds: workoutLog.durationSeconds
reps: log?.reps ?? 0,
isTimeBased: LoadType(rawValue: log?.loadType ?? LoadType.weight.rawValue) == .duration,
durationMinutes: (log?.durationSeconds ?? 0) / 60,
durationSeconds: (log?.durationSeconds ?? 0) % 60
)
} else {
// Rest page (1, 3, 5, ...)
@@ -105,50 +114,50 @@ struct ExerciseProgressView: View {
let setIndex = (pageIndex + 1) / 2
let clampedProgress = min(setIndex, totalSets)
if clampedProgress != Int(workoutLog.currentStateIndex) {
workoutLog.currentStateIndex = Int32(clampedProgress)
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
guard clampedProgress != doc.logs[i].currentStateIndex else { return }
if clampedProgress >= totalSets {
workoutLog.status = .completed
workoutLog.completed = true
} else if clampedProgress > 0 {
workoutLog.status = .inProgress
workoutLog.completed = false
}
doc.logs[i].currentStateIndex = clampedProgress
updateWorkoutStatus()
try? viewContext.save()
// Sync to iOS
WatchConnectivityManager.shared.syncToiOS()
if clampedProgress >= totalSets {
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
} else if clampedProgress > 0 {
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
doc.logs[i].completed = false
}
recomputeWorkoutStatus()
doc.updatedAt = Date()
onChange()
}
private func completeExercise() {
workoutLog.currentStateIndex = Int32(totalSets)
workoutLog.status = .completed
workoutLog.completed = true
updateWorkoutStatus()
try? viewContext.save()
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].currentStateIndex = totalSets
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
// Sync to iOS
WatchConnectivityManager.shared.syncToiOS()
recomputeWorkoutStatus()
doc.updatedAt = Date()
onChange()
}
private func updateWorkoutStatus() {
guard let workout = workoutLog.workout else { return }
let logs = workout.logsArray
let allCompleted = logs.allSatisfy { $0.status == .completed }
let anyInProgress = logs.contains { $0.status == .inProgress }
let allNotStarted = logs.allSatisfy { $0.status == .notStarted }
private func recomputeWorkoutStatus() {
let statuses = doc.logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted }
let allCompleted = !statuses.isEmpty && statuses.allSatisfy { $0 == .completed }
let anyInProgress = statuses.contains { $0 == .inProgress }
let allNotStarted = statuses.allSatisfy { $0 == .notStarted }
if allCompleted {
workout.status = .completed
workout.end = Date()
doc.status = WorkoutStatus.completed.rawValue
doc.end = Date()
} else if anyInProgress || !allNotStarted {
workout.status = .inProgress
doc.status = WorkoutStatus.inProgress.rawValue
doc.end = nil
} else {
workout.status = .notStarted
doc.status = WorkoutStatus.notStarted.rawValue
doc.end = nil
}
}
}
@@ -206,7 +215,8 @@ struct RestPageView: View {
let restNumber: Int
@State private var elapsedSeconds: Int = 0
@State private var timer: Timer?
private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
var body: some View {
VStack(spacing: 8) {
@@ -224,11 +234,12 @@ struct RestPageView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
startTimer()
elapsedSeconds = 0
WKInterfaceDevice.current().play(.start)
}
.onDisappear {
stopTimer()
.onReceive(ticker) { _ in
elapsedSeconds += 1
checkHapticPing()
}
}
@@ -238,19 +249,6 @@ struct RestPageView: View {
return String(format: "%d:%02d", minutes, seconds)
}
private func startTimer() {
elapsedSeconds = 0
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
elapsedSeconds += 1
checkHapticPing()
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private func checkHapticPing() {
// Haptic ping every 10 seconds with pattern:
// 10s: single, 20s: double, 30s: triple, 40s: single, 50s: double, etc.