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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SplitItem: View {
|
||||
@ObservedObject var split: Split
|
||||
var split: Split
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user