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,21 +9,31 @@
import SwiftUI
import Charts
import CoreData
import SwiftData
struct WeightProgressionChartView: View {
@Environment(\.managedObjectContext) private var viewContext
let exerciseName: String
@State private var weightData: [WeightDataPoint] = []
@State private var isLoading: Bool = true
@State private var motivationalMessage: String = ""
/// Completed logs for this exercise, oldest first.
@Query private var logs: [WorkoutLog]
init(exerciseName: String) {
self.exerciseName = exerciseName
let name = exerciseName
_logs = Query(
filter: #Predicate<WorkoutLog> { $0.exerciseName == name && $0.completed },
sort: \WorkoutLog.date,
order: .forward
)
}
private var weightData: [WeightDataPoint] {
logs.map { WeightDataPoint(date: $0.date, weight: $0.weight) }
}
var body: some View {
VStack(alignment: .leading) {
if isLoading {
ProgressView("Loading data...")
} else if weightData.isEmpty {
if weightData.isEmpty {
Text("No weight history available yet.")
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
@@ -70,51 +80,33 @@ struct WeightProgressionChartView: View {
}
}
.padding()
.onAppear {
loadWeightData()
}
}
private func loadWeightData() {
isLoading = true
let request: NSFetchRequest<WorkoutLog> = WorkoutLog.fetchRequest()
request.predicate = NSPredicate(format: "exerciseName == %@ AND completed == YES", exerciseName)
request.sortDescriptors = [NSSortDescriptor(keyPath: \WorkoutLog.date, ascending: true)]
if let logs = try? viewContext.fetch(request) {
weightData = logs.map { log in
WeightDataPoint(date: log.date, weight: Int(log.weight))
}
generateMotivationalMessage()
private var motivationalMessage: String {
let data = weightData
guard data.count >= 2 else {
return "Complete more workouts to track your progress!"
}
isLoading = false
}
private func generateMotivationalMessage() {
guard weightData.count >= 2 else {
motivationalMessage = "Complete more workouts to track your progress!"
return
}
let firstWeight = weightData.first?.weight ?? 0
let currentWeight = weightData.last?.weight ?? 0
let firstWeight = data.first?.weight ?? 0
let currentWeight = data.last?.weight ?? 0
let weightDifference = currentWeight - firstWeight
if weightDifference > 0 {
let percentIncrease = Int((Double(weightDifference) / Double(firstWeight)) * 100)
let percentIncrease = firstWeight > 0
? Int((Double(weightDifference) / Double(firstWeight)) * 100)
: 0
if percentIncrease >= 20 {
motivationalMessage = "Amazing progress! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
return "Amazing progress! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
} else if percentIncrease >= 10 {
motivationalMessage = "Great job! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
return "Great job! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
} else {
motivationalMessage = "You're making progress! Weight increased by \(weightDifference) lbs. Keep it up!"
return "You're making progress! Weight increased by \(weightDifference) lbs. Keep it up!"
}
} else if weightDifference == 0 {
motivationalMessage = "You're maintaining consistent weight. Focus on form and consider increasing when ready!"
return "You're maintaining consistent weight. Focus on form and consider increasing when ready!"
} else {
motivationalMessage = "Your current weight is lower than when you started. Adjust your training as needed and keep pushing!"
return "Your current weight is lower than when you started. Adjust your training as needed and keep pushing!"
}
}
}