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:
@@ -8,32 +8,55 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import SwiftData
|
||||
|
||||
struct ExerciseAddEditView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var showingExercisePicker = false
|
||||
|
||||
@ObservedObject var exercise: Exercise
|
||||
// The exercise entity provides initial values (read-only).
|
||||
let exercise: Exercise
|
||||
// The parent split is needed to rebuild and save the SplitDocument.
|
||||
let split: Split
|
||||
|
||||
@State private var originalWeight: Int32? = nil
|
||||
@State private var loadType: LoadType = .none
|
||||
// Local editable state
|
||||
@State private var exerciseName: String
|
||||
@State private var originalWeight: Int
|
||||
@State private var loadType: LoadType
|
||||
@State private var minutes: Int
|
||||
@State private var seconds: Int
|
||||
@State private var weightTens: Int
|
||||
@State private var weightOnes: Int
|
||||
@State private var reps: Int
|
||||
@State private var sets: Int
|
||||
@State private var weightReminderWeeks: Int
|
||||
@State private var weightLastUpdated: Date?
|
||||
|
||||
@State private var minutes = 0
|
||||
@State private var seconds = 0
|
||||
init(exercise: Exercise, split: Split) {
|
||||
self.exercise = exercise
|
||||
self.split = split
|
||||
|
||||
@State private var weight_tens = 0
|
||||
@State private var weight = 0
|
||||
|
||||
@State private var reps: Int = 0
|
||||
@State private var sets: Int = 0
|
||||
let w = exercise.weight
|
||||
_exerciseName = State(initialValue: exercise.name)
|
||||
_originalWeight = State(initialValue: w)
|
||||
_loadType = State(initialValue: exercise.loadTypeEnum)
|
||||
_minutes = State(initialValue: exercise.durationMinutes)
|
||||
_seconds = State(initialValue: exercise.durationSeconds)
|
||||
_weightTens = State(initialValue: (w / 10) * 10)
|
||||
_weightOnes = State(initialValue: w % 10)
|
||||
_reps = State(initialValue: exercise.reps)
|
||||
_sets = State(initialValue: exercise.sets)
|
||||
_weightReminderWeeks = State(initialValue: exercise.weightReminderTimeIntervalWeeks)
|
||||
_weightLastUpdated = State(initialValue: exercise.weightLastUpdated)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(header: Text("Exercise")) {
|
||||
if exercise.name.isEmpty {
|
||||
if exerciseName.isEmpty {
|
||||
Button(action: {
|
||||
showingExercisePicker = true
|
||||
}) {
|
||||
@@ -45,7 +68,7 @@ struct ExerciseAddEditView: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ListItem(title: exercise.name)
|
||||
ListItem(title: exerciseName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +97,10 @@ struct ExerciseAddEditView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Load Type"), footer: Text("For bodyweight exercises choose None. For resistance or weight training select Weight. For exercises that are time oriented (like plank or meditation) select Time.")) {
|
||||
Section(
|
||||
header: Text("Load Type"),
|
||||
footer: Text("For bodyweight exercises choose None. For resistance or weight training select Weight. For exercises that are time oriented (like plank or meditation) select Time.")
|
||||
) {
|
||||
Picker("", selection: $loadType) {
|
||||
ForEach(LoadType.allCases, id: \.self) { load in
|
||||
Text(load.name)
|
||||
@@ -87,7 +113,7 @@ struct ExerciseAddEditView: View {
|
||||
if loadType == .weight {
|
||||
Section(header: Text("Weight")) {
|
||||
HStack {
|
||||
Picker("", selection: $weight_tens) {
|
||||
Picker("", selection: $weightTens) {
|
||||
ForEach(0..<100) { lbs in
|
||||
Text("\(lbs * 10)").tag(lbs * 10)
|
||||
}
|
||||
@@ -95,7 +121,7 @@ struct ExerciseAddEditView: View {
|
||||
.frame(height: 100)
|
||||
.pickerStyle(.wheel)
|
||||
|
||||
Picker("", selection: $weight) {
|
||||
Picker("", selection: $weightOnes) {
|
||||
ForEach(0..<10) { lbs in
|
||||
Text("\(lbs)").tag(lbs)
|
||||
}
|
||||
@@ -130,36 +156,21 @@ struct ExerciseAddEditView: View {
|
||||
|
||||
Section(header: Text("Weight Increase")) {
|
||||
HStack {
|
||||
Text("Remind every \(exercise.weightReminderTimeIntervalWeeks) weeks")
|
||||
Text("Remind every \(weightReminderWeeks) weeks")
|
||||
Spacer()
|
||||
Stepper("", value: Binding(
|
||||
get: { Int(exercise.weightReminderTimeIntervalWeeks) },
|
||||
set: { exercise.weightReminderTimeIntervalWeeks = Int32($0) }
|
||||
), in: 0...366)
|
||||
Stepper("", value: $weightReminderWeeks, in: 0...366)
|
||||
}
|
||||
if let lastUpdated = exercise.weightLastUpdated {
|
||||
if let lastUpdated = weightLastUpdated {
|
||||
Text("Last weight change \(Date().humanTimeInterval(to: lastUpdated)) ago")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
originalWeight = exercise.weight
|
||||
weight_tens = Int(exercise.weight) / 10 * 10
|
||||
weight = Int(exercise.weight) % 10
|
||||
loadType = exercise.loadTypeEnum
|
||||
sets = Int(exercise.sets)
|
||||
reps = Int(exercise.reps)
|
||||
if let duration = exercise.duration {
|
||||
minutes = Int(duration.timeIntervalSince1970) / 60
|
||||
seconds = Int(duration.timeIntervalSince1970) % 60
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingExercisePicker) {
|
||||
ExercisePickerView { exerciseNames in
|
||||
exercise.name = exerciseNames.first ?? "Unnamed"
|
||||
exerciseName = exerciseNames.first ?? "Unnamed"
|
||||
}
|
||||
}
|
||||
.navigationTitle(exercise.name.isEmpty ? "New Exercise" : exercise.name)
|
||||
.navigationTitle(exerciseName.isEmpty ? "New Exercise" : exerciseName)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
@@ -169,19 +180,31 @@ struct ExerciseAddEditView: View {
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
if let originalWeight = originalWeight, originalWeight != Int32(weight_tens + weight) {
|
||||
exercise.weightLastUpdated = Date()
|
||||
}
|
||||
exercise.duration = Date(timeIntervalSince1970: Double(minutes * 60 + seconds))
|
||||
exercise.weight = Int32(weight_tens + weight)
|
||||
exercise.sets = Int32(sets)
|
||||
exercise.reps = Int32(reps)
|
||||
exercise.loadType = Int32(loadType.rawValue)
|
||||
try? viewContext.save()
|
||||
saveExercise()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveExercise() {
|
||||
let newWeight = weightTens + weightOnes
|
||||
let updatedWeightDate: Date? = newWeight != originalWeight ? Date() : weightLastUpdated
|
||||
let durationSecs = minutes * 60 + seconds
|
||||
|
||||
var doc = SplitDocument(from: split)
|
||||
if let idx = doc.exercises.firstIndex(where: { $0.id == exercise.id }) {
|
||||
doc.exercises[idx].name = exerciseName
|
||||
doc.exercises[idx].sets = sets
|
||||
doc.exercises[idx].reps = reps
|
||||
doc.exercises[idx].weight = newWeight
|
||||
doc.exercises[idx].loadType = loadType.rawValue
|
||||
doc.exercises[idx].durationSeconds = durationSecs
|
||||
doc.exercises[idx].weightLastUpdated = updatedWeightDate
|
||||
doc.exercises[idx].weightReminderWeeks = weightReminderWeeks
|
||||
}
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,156 +8,196 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import SwiftData
|
||||
|
||||
struct ExerciseListView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@ObservedObject var split: Split
|
||||
var split: Split
|
||||
|
||||
@State private var showingAddSheet: Bool = false
|
||||
@State private var itemToEdit: Exercise? = nil
|
||||
@State private var itemToDelete: Exercise? = nil
|
||||
@State private var createdWorkout: Workout? = nil
|
||||
/// ID of the just-created workout; drives programmatic navigation once the
|
||||
/// cache observer delivers the entity a beat after the file write.
|
||||
@State private var pendingWorkoutID: String? = nil
|
||||
@State private var resolvedWorkout: Workout? = nil
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
let sortedExercises = split.exercisesArray
|
||||
Form {
|
||||
let sortedExercises = split.exercisesArray
|
||||
|
||||
if !sortedExercises.isEmpty {
|
||||
ForEach(sortedExercises, id: \.objectID) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
|
||||
)
|
||||
.swipeActions {
|
||||
Button {
|
||||
itemToDelete = item
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
Button {
|
||||
itemToEdit = item
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
if !sortedExercises.isEmpty {
|
||||
ForEach(sortedExercises) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
|
||||
)
|
||||
.swipeActions {
|
||||
Button {
|
||||
itemToDelete = item
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.onMove(perform: moveExercises)
|
||||
|
||||
Button {
|
||||
showingAddSheet = true
|
||||
} label: {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
} else {
|
||||
Text("No exercises added yet.")
|
||||
Button(action: { showingAddSheet.toggle() }) {
|
||||
ListItem(title: "Add Exercise")
|
||||
.tint(.red)
|
||||
Button {
|
||||
itemToEdit = item
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
}
|
||||
}
|
||||
.onMove(perform: moveExercises)
|
||||
|
||||
Button {
|
||||
showingAddSheet = true
|
||||
} label: {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
} else {
|
||||
Text("No exercises added yet.")
|
||||
Button(action: { showingAddSheet.toggle() }) {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
}
|
||||
.navigationTitle("\(split.name)")
|
||||
}
|
||||
.navigationTitle(split.name)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Start This Split") {
|
||||
startWorkout()
|
||||
}
|
||||
.disabled(split.exercisesArray.isEmpty)
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $createdWorkout) { workout in
|
||||
// Navigate to the workout log once the entity appears in the cache.
|
||||
.navigationDestination(item: $resolvedWorkout) { workout in
|
||||
WorkoutLogListView(workout: workout)
|
||||
}
|
||||
// Poll for the entity after we write the document.
|
||||
.onChange(of: pendingWorkoutID) { _, id in
|
||||
guard let id else { return }
|
||||
pollForWorkout(id: id)
|
||||
}
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
ExercisePickerView(onExerciseSelected: { exerciseNames in
|
||||
addExercises(names: exerciseNames)
|
||||
}, allowMultiSelect: true)
|
||||
}
|
||||
.sheet(item: $itemToEdit) { item in
|
||||
ExerciseAddEditView(exercise: item)
|
||||
ExerciseAddEditView(exercise: item, split: split)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Exercise?",
|
||||
isPresented: .constant(itemToDelete != nil),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
isPresented: Binding(
|
||||
get: { itemToDelete != nil },
|
||||
set: { if !$0 { itemToDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible,
|
||||
presenting: itemToDelete
|
||||
) { item in
|
||||
Button("Delete", role: .destructive) {
|
||||
if let item = itemToDelete {
|
||||
withAnimation {
|
||||
viewContext.delete(item)
|
||||
try? viewContext.save()
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
deleteExercise(item)
|
||||
itemToDelete = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
itemToDelete = nil
|
||||
}
|
||||
} message: { item in
|
||||
Text("Remove \"\(item.name)\" from this split?")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func pollForWorkout(id: String) {
|
||||
Task {
|
||||
// Give the file→observer→cache loop a moment to complete (typically < 1 s).
|
||||
for _ in 0..<20 {
|
||||
try? await Task.sleep(for: .milliseconds(150))
|
||||
if let workout = CacheMapper.fetchWorkout(id: id, in: modelContext) {
|
||||
resolvedWorkout = workout
|
||||
pendingWorkoutID = nil
|
||||
return
|
||||
}
|
||||
}
|
||||
// If still not available after ~3 s, clear the pending ID silently.
|
||||
pendingWorkoutID = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func moveExercises(from source: IndexSet, to destination: Int) {
|
||||
var exercises = split.exercisesArray
|
||||
exercises.move(fromOffsets: source, toOffset: destination)
|
||||
for (index, exercise) in exercises.enumerated() {
|
||||
exercise.order = Int32(index)
|
||||
var doc = SplitDocument(from: split)
|
||||
doc.exercises = exercises.enumerated().map { i, ex in
|
||||
var ed = ExerciseDocument(from: ex)
|
||||
ed.order = i
|
||||
return ed
|
||||
}
|
||||
try? viewContext.save()
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
}
|
||||
|
||||
private func startWorkout() {
|
||||
let workout = Workout(context: viewContext)
|
||||
workout.start = Date()
|
||||
workout.end = 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.status = .notStarted
|
||||
workoutLog.workout = workout
|
||||
let start = Date()
|
||||
let logs = split.exercisesArray.enumerated().map { i, ex in
|
||||
WorkoutLogDocument(
|
||||
id: ULID.make(), exerciseName: ex.name, order: i,
|
||||
sets: ex.sets, reps: ex.reps, weight: ex.weight,
|
||||
loadType: ex.loadType, durationSeconds: ex.durationTotalSeconds,
|
||||
currentStateIndex: 0, completed: false,
|
||||
status: WorkoutStatus.notStarted.rawValue,
|
||||
notes: nil, date: start
|
||||
)
|
||||
}
|
||||
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)
|
||||
pendingWorkoutID = doc.id
|
||||
}
|
||||
|
||||
try? viewContext.save()
|
||||
createdWorkout = workout
|
||||
}
|
||||
|
||||
private func addExercises(names: [String]) {
|
||||
if names.count == 1 {
|
||||
let exercise = Exercise(context: viewContext)
|
||||
exercise.name = names.first ?? "Unnamed"
|
||||
exercise.order = Int32(split.exercisesArray.count)
|
||||
exercise.sets = 3
|
||||
exercise.reps = 10
|
||||
exercise.weight = 40
|
||||
exercise.loadType = Int32(LoadType.weight.rawValue)
|
||||
exercise.split = split
|
||||
try? viewContext.save()
|
||||
itemToEdit = exercise
|
||||
} else {
|
||||
let existingNames = Set(split.exercisesArray.map { $0.name })
|
||||
for name in names where !existingNames.contains(name) {
|
||||
let exercise = Exercise(context: viewContext)
|
||||
exercise.name = name
|
||||
exercise.order = Int32(split.exercisesArray.count)
|
||||
exercise.sets = 3
|
||||
exercise.reps = 10
|
||||
exercise.weight = 40
|
||||
exercise.loadType = Int32(LoadType.weight.rawValue)
|
||||
exercise.split = split
|
||||
var doc = SplitDocument(from: split)
|
||||
let existingNames = Set(doc.exercises.map { $0.name })
|
||||
let base = doc.exercises.count
|
||||
let newDocs = names
|
||||
.filter { !existingNames.contains($0) }
|
||||
.enumerated()
|
||||
.map { i, exName in
|
||||
ExerciseDocument(
|
||||
id: ULID.make(), name: exName, order: base + i,
|
||||
sets: 3, reps: 10, weight: 40,
|
||||
loadType: LoadType.weight.rawValue,
|
||||
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2
|
||||
)
|
||||
}
|
||||
try? viewContext.save()
|
||||
doc.exercises.append(contentsOf: newDocs)
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
}
|
||||
|
||||
private func deleteExercise(_ exercise: Exercise) {
|
||||
var doc = SplitDocument(from: split)
|
||||
doc.exercises.removeAll { $0.id == exercise.id }
|
||||
for i in doc.exercises.indices {
|
||||
doc.exercises[i].order = i
|
||||
}
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,11 +135,7 @@ struct ExercisePickerView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadExerciseLists()
|
||||
exerciseLists = ExerciseListLoader.loadExerciseLists()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadExerciseLists() {
|
||||
exerciseLists = ExerciseListLoader.loadExerciseLists()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user