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:
@@ -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.
|
||||
|
||||
@@ -6,26 +6,51 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import SwiftData
|
||||
|
||||
struct WorkoutLogListView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(WatchConnectivityBridge.self) private var bridge
|
||||
|
||||
@ObservedObject var workout: Workout
|
||||
/// The split this workout came from (read-only on the watch), used to offer
|
||||
/// additional exercises that aren't logged yet.
|
||||
@Query private var matchingSplits: [Split]
|
||||
|
||||
/// Working copy of the workout. We drive the UI from this and mutate it on
|
||||
/// every edit (then forward through the bridge) to avoid the read-after-write
|
||||
/// race against the cache, which lags local writes by a beat.
|
||||
@State private var doc: WorkoutDocument
|
||||
|
||||
@State private var showingExercisePicker = false
|
||||
@State private var selectedLog: WorkoutLog?
|
||||
@State private var selectedLogID: String?
|
||||
|
||||
var sortedWorkoutLogs: [WorkoutLog] {
|
||||
workout.logsArray
|
||||
init(workout: Workout) {
|
||||
_doc = State(initialValue: WorkoutDocument(from: workout))
|
||||
if let splitID = workout.splitID {
|
||||
_matchingSplits = Query(filter: #Predicate<Split> { $0.id == splitID })
|
||||
} else {
|
||||
// No source split: never match anything.
|
||||
_matchingSplits = Query(filter: #Predicate<Split> { _ in false })
|
||||
}
|
||||
}
|
||||
|
||||
private var split: Split? { matchingSplits.first }
|
||||
|
||||
private var sortedLogs: [WorkoutLogDocument] {
|
||||
doc.logs.sorted { $0.order < $1.order }
|
||||
}
|
||||
|
||||
private var availableExercises: [Exercise] {
|
||||
guard let split else { return [] }
|
||||
let existingNames = Set(doc.logs.map { $0.exerciseName })
|
||||
return split.exercisesArray.filter { !existingNames.contains($0.name) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section(header: Text(workout.label)) {
|
||||
ForEach(sortedWorkoutLogs, id: \.objectID) { log in
|
||||
Section(header: Text(label)) {
|
||||
ForEach(sortedLogs) { log in
|
||||
Button {
|
||||
selectedLog = log
|
||||
selectedLogID = log.id
|
||||
} label: {
|
||||
WorkoutLogRowLabel(log: log)
|
||||
}
|
||||
@@ -33,42 +58,81 @@ struct WorkoutLogListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
showingExercisePicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Add Exercise")
|
||||
if !availableExercises.isEmpty {
|
||||
Section {
|
||||
Button {
|
||||
showingExercisePicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Add Exercise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if sortedWorkoutLogs.isEmpty {
|
||||
if sortedLogs.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Exercises",
|
||||
systemImage: "figure.strengthtraining.traditional",
|
||||
description: Text("Tap + to add exercises.")
|
||||
description: Text(availableExercises.isEmpty
|
||||
? "No exercises in this workout."
|
||||
: "Tap + to add exercises.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle(workout.split?.name ?? Split.unnamed)
|
||||
.navigationDestination(item: $selectedLog) { log in
|
||||
ExerciseProgressView(workoutLog: log)
|
||||
.navigationTitle(doc.splitName ?? Split.unnamed)
|
||||
.navigationDestination(item: $selectedLogID) { logID in
|
||||
ExerciseProgressView(doc: $doc, logID: logID, onChange: { bridge.update(workout: doc) })
|
||||
}
|
||||
.sheet(isPresented: $showingExercisePicker) {
|
||||
ExercisePickerView(workout: workout)
|
||||
ExercisePickerView(exercises: availableExercises) { exercise in
|
||||
addExercise(exercise)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var label: String {
|
||||
let start = doc.start
|
||||
if doc.status == WorkoutStatus.completed.rawValue, let end = doc.end {
|
||||
if start.isSameDay(as: end) {
|
||||
return "\(start.formattedDate())—\(end.formattedTime())"
|
||||
} else {
|
||||
return "\(start.formattedDate())—\(end.formattedDate())"
|
||||
}
|
||||
}
|
||||
return start.formattedDate()
|
||||
}
|
||||
|
||||
private func addExercise(_ exercise: Exercise) {
|
||||
let newLog = WorkoutLogDocument(
|
||||
id: ULID.make(),
|
||||
exerciseName: exercise.name,
|
||||
order: doc.logs.count,
|
||||
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: doc.start
|
||||
)
|
||||
doc.logs.append(newLog)
|
||||
doc.updatedAt = Date()
|
||||
bridge.update(workout: doc)
|
||||
showingExercisePicker = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout Log Row Label
|
||||
|
||||
struct WorkoutLogRowLabel: View {
|
||||
@ObservedObject var log: WorkoutLog
|
||||
let log: WorkoutLogDocument
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
@@ -89,8 +153,12 @@ struct WorkoutLogRowLabel: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var status: WorkoutStatus {
|
||||
WorkoutStatus(rawValue: log.status) ?? .notStarted
|
||||
}
|
||||
|
||||
private var statusIcon: Image {
|
||||
switch log.status {
|
||||
switch status {
|
||||
case .completed:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
case .inProgress:
|
||||
@@ -103,7 +171,7 @@ struct WorkoutLogRowLabel: View {
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch log.status {
|
||||
switch status {
|
||||
case .completed:
|
||||
.green
|
||||
case .inProgress:
|
||||
@@ -116,9 +184,9 @@ struct WorkoutLogRowLabel: View {
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if log.loadTypeEnum == .duration {
|
||||
let mins = log.durationMinutes
|
||||
let secs = log.durationSeconds
|
||||
if LoadType(rawValue: log.loadType) == .duration {
|
||||
let mins = log.durationSeconds / 60
|
||||
let secs = log.durationSeconds % 60
|
||||
if mins > 0 && secs > 0 {
|
||||
return "\(log.sets) × \(mins)m \(secs)s"
|
||||
} else if mins > 0 {
|
||||
@@ -135,27 +203,22 @@ struct WorkoutLogRowLabel: View {
|
||||
// MARK: - Exercise Picker View
|
||||
|
||||
struct ExercisePickerView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@ObservedObject var workout: Workout
|
||||
|
||||
private var availableExercises: [Exercise] {
|
||||
guard let split = workout.split else { return [] }
|
||||
let existingNames = Set(workout.logsArray.map { $0.exerciseName })
|
||||
return split.exercisesArray.filter { !existingNames.contains($0.name) }
|
||||
}
|
||||
let exercises: [Exercise]
|
||||
let onSelect: (Exercise) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if availableExercises.isEmpty {
|
||||
if exercises.isEmpty {
|
||||
Text("All exercises added")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(availableExercises, id: \.objectID) { exercise in
|
||||
ForEach(exercises) { exercise in
|
||||
Button {
|
||||
addExercise(exercise)
|
||||
onSelect(exercise)
|
||||
dismiss()
|
||||
} label: {
|
||||
VStack(alignment: .leading) {
|
||||
Text(exercise.name)
|
||||
@@ -179,35 +242,8 @@ struct ExercisePickerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func addExercise(_ exercise: Exercise) {
|
||||
let log = WorkoutLog(context: viewContext)
|
||||
log.exerciseName = exercise.name
|
||||
log.date = Date()
|
||||
log.order = Int32(workout.logsArray.count)
|
||||
log.sets = exercise.sets
|
||||
log.reps = exercise.reps
|
||||
log.weight = exercise.weight
|
||||
log.loadType = exercise.loadType
|
||||
log.duration = exercise.duration
|
||||
log.status = .notStarted
|
||||
log.workout = workout
|
||||
|
||||
// Update workout start if first exercise
|
||||
if workout.logsArray.count == 1 {
|
||||
workout.start = Date()
|
||||
}
|
||||
|
||||
try? viewContext.save()
|
||||
|
||||
// Sync to iOS
|
||||
WatchConnectivityManager.shared.syncToiOS()
|
||||
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func exerciseSubtitle(_ exercise: Exercise) -> String {
|
||||
let loadType = LoadType(rawValue: Int(exercise.loadType)) ?? .weight
|
||||
if loadType == .duration {
|
||||
if exercise.loadTypeEnum == .duration {
|
||||
let mins = exercise.durationMinutes
|
||||
let secs = exercise.durationSeconds
|
||||
if mins > 0 && secs > 0 {
|
||||
@@ -222,8 +258,3 @@ struct ExercisePickerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
WorkoutLogListView(workout: Workout())
|
||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
||||
}
|
||||
|
||||
@@ -6,22 +6,17 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import SwiftData
|
||||
|
||||
struct WorkoutLogsView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@EnvironmentObject var connectivityManager: WatchConnectivityManager
|
||||
@Environment(WatchConnectivityBridge.self) private var bridge
|
||||
|
||||
@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]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(workouts, id: \.objectID) { workout in
|
||||
ForEach(workouts) { workout in
|
||||
NavigationLink(destination: WorkoutLogListView(workout: workout)) {
|
||||
WorkoutRow(workout: workout)
|
||||
}
|
||||
@@ -40,12 +35,17 @@ struct WorkoutLogsView: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
connectivityManager.requestSync()
|
||||
bridge.requestSync()
|
||||
} label: {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if workouts.isEmpty {
|
||||
bridge.requestSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,11 +53,11 @@ struct WorkoutLogsView: View {
|
||||
// MARK: - Workout Row
|
||||
|
||||
struct WorkoutRow: View {
|
||||
@ObservedObject var workout: Workout
|
||||
let workout: Workout
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(workout.split?.name ?? Split.unnamed)
|
||||
Text(workout.splitName ?? Split.unnamed)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
|
||||
@@ -92,8 +92,3 @@ struct WorkoutRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
WorkoutLogsView()
|
||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user