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,13 +8,13 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import SwiftData
|
||||
|
||||
struct SplitDetailView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@ObservedObject var split: Split
|
||||
var split: Split
|
||||
|
||||
@State private var showingExerciseAddSheet: Bool = false
|
||||
@State private var showingSplitEditSheet: Bool = false
|
||||
@@ -22,54 +22,52 @@ struct SplitDetailView: View {
|
||||
@State private var itemToDelete: Exercise? = nil
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(header: Text("What is a Split?")) {
|
||||
Text("A \"split\" is simply how you divide (or \"split up\") your weekly training across different days. Instead of working every muscle group every session, you assign certain muscle groups, movement patterns, or training emphases to specific days.")
|
||||
.font(.caption)
|
||||
}
|
||||
Form {
|
||||
Section(header: Text("What is a Split?")) {
|
||||
Text("A \"split\" is simply how you divide (or \"split up\") your weekly training across different days. Instead of working every muscle group every session, you assign certain muscle groups, movement patterns, or training emphases to specific days.")
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Section(header: Text("Exercises")) {
|
||||
let sortedExercises = split.exercisesArray
|
||||
Section(header: Text("Exercises")) {
|
||||
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")
|
||||
}
|
||||
.tint(.red)
|
||||
Button {
|
||||
itemToEdit = item
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
}
|
||||
.onMove(perform: moveExercises)
|
||||
}
|
||||
.onMove(perform: moveExercises)
|
||||
|
||||
Button {
|
||||
showingExerciseAddSheet = true
|
||||
} label: {
|
||||
ListItem(systemName: "plus.circle", title: "Add Exercise")
|
||||
}
|
||||
} else {
|
||||
Text("No exercises added yet.")
|
||||
Button(action: { showingExerciseAddSheet.toggle() }) {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
Button {
|
||||
showingExerciseAddSheet = true
|
||||
} label: {
|
||||
ListItem(systemName: "plus.circle", title: "Add Exercise")
|
||||
}
|
||||
} else {
|
||||
Text("No exercises added yet.")
|
||||
Button(action: { showingExerciseAddSheet.toggle() }) {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("\(split.name)")
|
||||
}
|
||||
.navigationTitle(split.name)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
@@ -90,62 +88,73 @@ struct SplitDetailView: View {
|
||||
}
|
||||
}
|
||||
.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?")
|
||||
}
|
||||
}
|
||||
|
||||
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 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) }
|
||||
|
||||
// If a single exercise was added, open the edit sheet once the cache refreshes.
|
||||
// We rely on the observer to populate it — no direct entity reference needed.
|
||||
}
|
||||
|
||||
private func deleteExercise(_ exercise: Exercise) {
|
||||
var doc = SplitDocument(from: split)
|
||||
doc.exercises.removeAll { $0.id == exercise.id }
|
||||
// Re-number orders after removal
|
||||
for i in doc.exercises.indices {
|
||||
doc.exercises[i].order = i
|
||||
}
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user