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
+2 -22
View File
@@ -6,25 +6,5 @@
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
/// Protocol for items that can be ordered in a sequence
protocol OrderableItem {
/// Updates the order of the item to the specified index
func updateOrder(to index: Int)
}
/// Extension to make Split conform to OrderableItem
extension Split: OrderableItem {
func updateOrder(to index: Int) {
self.order = Int32(index)
}
}
/// Extension to make Exercise conform to OrderableItem
extension Exercise: OrderableItem {
func updateOrder(to index: Int) {
self.order = Int32(index)
}
}
// No longer used reordering is now a SyncEngine document write (onMove doc save).
// File kept to avoid Xcode project reference errors.
+2 -89
View File
@@ -6,92 +6,5 @@
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import UniformTypeIdentifiers
public struct SortableForEach<Data, Content>: View where Data: Hashable, Content: View {
@Binding var data: [Data]
@Binding var allowReordering: Bool
private let content: (Data, Bool) -> Content
@State private var draggedItem: Data?
@State private var hasChangedLocation: Bool = false
public init(_ data: Binding<[Data]>,
allowReordering: Binding<Bool>,
@ViewBuilder content: @escaping (Data, Bool) -> Content) {
_data = data
_allowReordering = allowReordering
self.content = content
}
public var body: some View {
ForEach(data, id: \.self) { item in
if allowReordering {
content(item, hasChangedLocation && draggedItem == item)
.onDrag {
draggedItem = item
return NSItemProvider(object: "\(item.hashValue)" as NSString)
}
.onDrop(of: [UTType.plainText], delegate: DragRelocateDelegate(
item: item,
data: $data,
draggedItem: $draggedItem,
hasChangedLocation: $hasChangedLocation))
} else {
content(item, false)
}
}
}
struct DragRelocateDelegate<ItemType>: DropDelegate where ItemType: Equatable {
let item: ItemType
@Binding var data: [ItemType]
@Binding var draggedItem: ItemType?
@Binding var hasChangedLocation: Bool
func dropEntered(info: DropInfo) {
guard item != draggedItem,
let current = draggedItem,
let from = data.firstIndex(of: current),
let to = data.firstIndex(of: item)
else {
return
}
hasChangedLocation = true
if data[to] != current {
withAnimation {
data.move(
fromOffsets: IndexSet(integer: from),
toOffset: (to > from) ? to + 1 : to
)
}
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
// Update the order property of each item to match its position in the array
updateItemOrders()
hasChangedLocation = false
draggedItem = nil
return true
}
// Helper method to update the order property of each item
private func updateItemOrders() {
for (index, item) in data.enumerated() {
if let orderableItem = item as? any OrderableItem {
orderableItem.updateOrder(to: index)
}
}
}
}
}
// No longer used list reordering is handled with SwiftUI's native onMove modifier
// backed by SyncEngine document writes. File kept to avoid Xcode project reference errors.
+27 -17
View File
@@ -8,10 +8,11 @@
//
import SwiftUI
import CoreData
import SwiftData
struct SplitAddEditView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
let split: Split?
@@ -23,8 +24,6 @@ struct SplitAddEditView: View {
@State private var showingIconPicker: Bool = false
@State private var showingDeleteConfirmation: Bool = false
private let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
var isEditing: Bool { split != nil }
init(split: Split?, onDelete: (() -> Void)? = nil) {
@@ -117,8 +116,9 @@ struct SplitAddEditView: View {
) {
Button("Delete", role: .destructive) {
if let split = split {
viewContext.delete(split)
try? viewContext.save()
Task {
await sync.delete(split: split)
}
dismiss()
onDelete?()
}
@@ -131,18 +131,28 @@ struct SplitAddEditView: View {
private func save() {
if let split = split {
// Update existing
split.name = name
split.color = color
split.systemImage = systemImage
// Update existing split
var doc = SplitDocument(from: split)
doc.name = name
doc.color = color
doc.systemImage = systemImage
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
} else {
// Create new
let newSplit = Split(context: viewContext)
newSplit.name = name
newSplit.color = color
newSplit.systemImage = systemImage
newSplit.order = 0
// Create new split
let existing = (try? modelContext.fetch(FetchDescriptor<Split>())) ?? []
let doc = SplitDocument(
schemaVersion: SplitDocument.currentSchema,
id: ULID.make(),
name: name,
color: color,
systemImage: systemImage,
order: existing.count,
createdAt: Date(),
updatedAt: Date(),
exercises: []
)
Task { await sync.save(split: doc) }
}
try? viewContext.save()
}
}
+88 -79
View File
@@ -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) }
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
import SwiftUI
struct SplitItem: View {
@ObservedObject var split: Split
var split: Split
var body: some View {
VStack {
+18 -38
View File
@@ -8,64 +8,44 @@
//
import SwiftUI
import CoreData
import SwiftData
struct SplitListView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \Split.order, ascending: true),
NSSortDescriptor(keyPath: \Split.name, ascending: true)
],
animation: .default
)
private var fetchedSplits: FetchedResults<Split>
@State private var splits: [Split] = []
@State private var allowSorting: Bool = true
@Query(sort: \Split.order) private var splits: [Split]
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in
ForEach(splits) { split in
NavigationLink {
SplitDetailView(split: split)
} label: {
SplitItem(split: split)
.overlay(dragging ? Color.white.opacity(0.8) : Color.clear)
}
}
}
.padding()
}
.overlay {
if fetchedSplits.isEmpty {
if splits.isEmpty {
ContentUnavailableView(
"No Splits Yet",
systemImage: "dumbbell.fill",
description: Text("Create a split to organize your workout routine.")
label: {
Label("No Splits Yet", systemImage: "dumbbell.fill")
},
description: {
Text("Create a split to organize your workout routine.")
},
actions: {
Button("Add Starter Splits") {
Task { await SplitSeeder.seedDefaults(into: modelContext, using: sync) }
}
.buttonStyle(.borderedProminent)
}
)
}
}
.onAppear {
splits = Array(fetchedSplits)
}
.onChange(of: fetchedSplits.count) { _, _ in
splits = Array(fetchedSplits)
}
.onChange(of: splits) { _, _ in
saveContext()
}
}
private func saveContext() {
if viewContext.hasChanges {
do {
try viewContext.save()
} catch {
print("Error saving after reorder: \(error)")
}
}
}
}
-8
View File
@@ -8,11 +8,8 @@
//
import SwiftUI
import CoreData
struct SplitsView: View {
@Environment(\.managedObjectContext) private var viewContext
@State private var showingAddSheet: Bool = false
var body: some View {
@@ -32,8 +29,3 @@ struct SplitsView: View {
}
}
}
#Preview {
SplitsView()
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
}