This commit is contained in:
2025-07-18 17:41:23 -04:00
parent 66f257609f
commit 34942bfc48
9 changed files with 337 additions and 111 deletions

View File

@ -9,6 +9,9 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
A45FA1FE2E27171B00581607 /* Worksouts Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A45FA1F12E27171A00581607 /* Worksouts Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; A45FA1FE2E27171B00581607 /* Worksouts Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A45FA1F12E27171A00581607 /* Worksouts Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
A45FA2732E29B12500581607 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2722E29B12500581607 /* Yams */; }; A45FA2732E29B12500581607 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2722E29B12500581607 /* Yams */; };
A45FA27F2E2A930900581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */; };
A45FA2822E2A933B00581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */; };
A45FA2872E2AC66B00581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2862E2AC66B00581607 /* SwiftUIReorderableForEach */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -71,7 +74,10 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A45FA2822E2A933B00581607 /* SwiftUIReorderableForEach in Frameworks */,
A45FA2732E29B12500581607 /* Yams in Frameworks */, A45FA2732E29B12500581607 /* Yams in Frameworks */,
A45FA27F2E2A930900581607 /* SwiftUIReorderableForEach in Frameworks */,
A45FA2872E2AC66B00581607 /* SwiftUIReorderableForEach in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -126,6 +132,9 @@
name = Workouts; name = Workouts;
packageProductDependencies = ( packageProductDependencies = (
A45FA2722E29B12500581607 /* Yams */, A45FA2722E29B12500581607 /* Yams */,
A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */,
A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */,
A45FA2862E2AC66B00581607 /* SwiftUIReorderableForEach */,
); );
productName = Workouts; productName = Workouts;
productReference = A45FA0912E21B3DD00581607 /* Workouts.app */; productReference = A45FA0912E21B3DD00581607 /* Workouts.app */;
@ -182,6 +191,7 @@
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */, A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */,
A45FA2852E2AC66B00581607 /* XCLocalSwiftPackageReference "../swiftui-reorderable-foreach" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = A45FA0922E21B3DD00581607 /* Products */; productRefGroup = A45FA0922E21B3DD00581607 /* Products */;
@ -512,6 +522,13 @@
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
A45FA2852E2AC66B00581607 /* XCLocalSwiftPackageReference "../swiftui-reorderable-foreach" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "../swiftui-reorderable-foreach";
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */
A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */ = { A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
@ -529,6 +546,18 @@
package = A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */; package = A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */;
productName = Yams; productName = Yams;
}; };
A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */ = {
isa = XCSwiftPackageProductDependency;
productName = SwiftUIReorderableForEach;
};
A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */ = {
isa = XCSwiftPackageProductDependency;
productName = SwiftUIReorderableForEach;
};
A45FA2862E2AC66B00581607 /* SwiftUIReorderableForEach */ = {
isa = XCSwiftPackageProductDependency;
productName = SwiftUIReorderableForEach;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = A45FA0892E21B3DC00581607 /* Project object */; rootObject = A45FA0892E21B3DC00581607 /* Project object */;

View File

@ -15,7 +15,6 @@ struct ContentView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
var body: some View { var body: some View {
NavigationView {
TabView { TabView {
SplitsView() SplitsView()
.tabItem { .tabItem {
@ -42,7 +41,6 @@ struct ContentView: View {
Label("Settings", systemImage: "gear") Label("Settings", systemImage: "gear")
} }
} }
}
.observeCloudKitChanges() .observeCloudKitChanges()
} }
} }

View File

@ -0,0 +1,30 @@
//
// OrderableItem.swift
// Workouts
//
// Created by rzen on 7/18/25 at 5:19 PM.
//
// 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 = index
}
}
/// Extension to make SplitExerciseAssignment conform to OrderableItem
extension SplitExerciseAssignment: OrderableItem {
func updateOrder(to index: Int) {
self.order = index
}
}

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
import SwiftData import SwiftData
import SwiftUI import SwiftUI
import UniformTypeIdentifiers
@Model @Model
final class Split { final class Split {
@ -53,6 +54,16 @@ extension Split: EditableEntity {
} }
} }
// MARK: - Identifiable Conformance
extension Split: Identifiable {
public var id: String {
// Use the name as a unique identifier for the split
// This is sufficient for UI purposes
return self.name
}
}
// MARK: - Private Form View // MARK: - Private Form View
fileprivate struct SplitFormView: View { fileprivate struct SplitFormView: View {

View File

@ -0,0 +1,56 @@
//
// DraggableSplitItem.swift
// Workouts
//
// Created by rzen on 7/18/25 at 2:45PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct DraggableSplitItem: View {
var name: String
var color: Color
var systemImageName: String
var exerciseCount: Int
var body: some View {
VStack {
ZStack(alignment: .bottom) {
// Golden ratio rectangle (1:1.618)
RoundedRectangle(cornerRadius: 12)
.fill(
LinearGradient(
gradient: Gradient(colors: [color, color.darker(by: 0.2)]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.aspectRatio(1.618, contentMode: .fit)
.shadow(radius: 2)
VStack {
// Icon in the center
Image(systemName: systemImageName)
.font(.system(size: 40, weight: .bold))
.offset(y: -15)
// Name at the bottom inside the rectangle
Text(name)
.font(.headline)
.lineLimit(1)
.padding(.horizontal, 8)
Text("\(exerciseCount) exercises")
.font(.caption)
.padding(.bottom, 8)
}
.foregroundColor(.white)
}
}
}
}

View File

@ -0,0 +1,99 @@
//
// SortableForEach.swift
// Workouts
//
// Created by rzen on 7/18/25 at 2:04PM.
//
// 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() {
// Only update orders if we're working with items that have an 'order' property
for (index, item) in data.enumerated() {
// Use key path and dynamic member lookup to set the order if available
if let orderableItem = item as? any OrderableItem {
orderableItem.updateOrder(to: index)
}
}
}
}
}

View File

@ -8,6 +8,7 @@
// //
import SwiftUI import SwiftUI
import SwiftData
struct SplitExercisesListView: View { struct SplitExercisesListView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@ -18,6 +19,7 @@ struct SplitExercisesListView: View {
@State private var showingAddSheet: Bool = false @State private var showingAddSheet: Bool = false
@State private var itemToEdit: SplitExerciseAssignment? = nil @State private var itemToEdit: SplitExerciseAssignment? = nil
@State private var itemToDelete: SplitExerciseAssignment? = nil @State private var itemToDelete: SplitExerciseAssignment? = nil
@State private var createdWorkout: Workout? = nil
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@ -67,24 +69,59 @@ struct SplitExercisesListView: View {
.navigationTitle("\(model.name)") .navigationTitle("\(model.name)")
} }
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .primaryAction) {
Button(action: { showingAddSheet.toggle() }) { Button("Start This Split") {
Image(systemName: "plus") let split = model
} let workout = Workout(start: Date(), split: split)
} modelContext.insert(workout)
} if let exercises = split.exercises {
.sheet (isPresented: $showingAddSheet) { for assignment in exercises {
ExercisePickerView { exerciseName in let workoutLog = WorkoutLog(
itemToEdit = SplitExerciseAssignment( workout: workout,
split: model, exerciseName: assignment.exerciseName,
exerciseName: exerciseName, date: Date(),
order: 0, order: assignment.order,
sets: 3, sets: assignment.sets,
reps: 10, reps: assignment.reps,
weight: 40 weight: assignment.weight
) )
modelContext.insert(workoutLog)
} }
} }
try? modelContext.save()
// Set the created workout to trigger navigation
createdWorkout = workout
}
}
}
.navigationDestination(item: $createdWorkout, destination: { workout in
WorkoutLogView(workout: workout)
})
// .sheet(item: $createdWorkout) { workout in
// NavigationStack {
// WorkoutLogView(workout: workout)
// }
// }
// .toolbar {
// ToolbarItem(placement: .navigationBarTrailing) {
// Button(action: { showingAddSheet.toggle() }) {
// Image(systemName: "plus")
// }
// }
// }
// .sheet (isPresented: $showingAddSheet) {
// ExercisePickerView { exerciseName in
// itemToEdit = SplitExerciseAssignment(
// split: model,
// exerciseName: exerciseName,
// order: 0,
// sets: 3,
// reps: 10,
// weight: 40
// )
// }
// }
.sheet(item: $itemToEdit) { item in .sheet(item: $itemToEdit) { item in
SplitExerciseAssignmentAddEditView(model: item) SplitExerciseAssignmentAddEditView(model: item)
} }

View File

@ -14,78 +14,43 @@ struct SplitsView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Query(sort: [ @State var splits: [Split] = []
SortDescriptor(\Split.order),
SortDescriptor(\Split.name)
]) private var splits: [Split]
@State private var showingAddSheet: Bool = false @State private var showingAddSheet: Bool = false
@State private var allowSorting: Bool = true
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { ScrollView {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
ForEach(splits) { split in SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in
NavigationLink { NavigationLink {
SplitExercisesListView(model: split) SplitExercisesListView(model: split)
} label: { } label: {
VStack { DraggableSplitItem(
ZStack(alignment: .bottom) { name: split.name,
// Golden ratio rectangle (1:1.618) color: Color.color(from: split.color),
RoundedRectangle(cornerRadius: 12) systemImageName: split.systemImage,
.fill( exerciseCount: split.exercises?.count ?? 0
LinearGradient(
gradient: Gradient(colors: [split.getColor(), split.getColor().darker(by: 0.2)]),
startPoint: .topLeading,
endPoint: .bottomTrailing
) )
) .overlay(dragging ? Color.white.opacity(0.8) : Color.clear)
.aspectRatio(1.618, contentMode: .fit)
.shadow(radius: 2)
VStack {
// Icon in the center
Image(systemName: split.systemImage)
.font(.system(size: 40, weight: .medium))
.foregroundColor(.white)
.offset(y: -15)
// Name at the bottom inside the rectangle
Text(split.name)
.font(.headline)
.foregroundColor(.white)
.lineLimit(1)
.padding(.horizontal, 8)
.padding(.bottom, 8)
} }
} }
// Exercise count below the rectangle
Text("\(split.exercises?.count ?? 0) exercises")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
.onMove(perform: { indices, destination in
var splitArray = Array(splits)
splitArray.move(fromOffsets: indices, toOffset: destination)
for (index, split) in splitArray.enumerated() {
split.order = index
}
if let modelContext = splitArray.first?.modelContext {
do {
try modelContext.save()
} catch {
print("Error saving after reordering: \(error)")
}
}
})
} }
.padding() .padding()
} }
.navigationTitle("Splits") .navigationTitle("Splits")
.onAppear {
do {
self.splits = try modelContext.fetch(FetchDescriptor<Split>(
sortBy: [
SortDescriptor(\Split.order),
SortDescriptor(\Split.name)
]
))
} catch {
print("ERROR: failed to load splits \(error)")
}
} }
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
@ -94,6 +59,7 @@ struct SplitsView: View {
} }
} }
} }
}
.sheet (isPresented: $showingAddSheet) { .sheet (isPresented: $showingAddSheet) {
SplitAddEditView(model: Split(name: "New Split")) SplitAddEditView(model: Split(name: "New Split"))
} }

View File

@ -59,16 +59,16 @@ struct WorkoutsView: View {
} }
} }
.navigationTitle("Workouts") .navigationTitle("Workouts")
.toolbar { // .toolbar {
ToolbarItem(placement: .primaryAction) { // ToolbarItem(placement: .primaryAction) {
Button("Start Workout") { // Button("Start Workout") {
showingSplitPicker = true // showingSplitPicker = true
} // }
} // }
} // }
.sheet(item: $itemToEdit) { item in // .sheet(item: $itemToEdit) { item in
WorkoutEditView(workout: item) // WorkoutEditView(workout: item)
} // }
.confirmationDialog( .confirmationDialog(
"Delete?", "Delete?",
isPresented: Binding<Bool>( isPresented: Binding<Bool>(