From 34942bfc4868d94300ca51296e41c19c7fd3f6ff Mon Sep 17 00:00:00 2001 From: rzen Date: Fri, 18 Jul 2025 17:41:23 -0400 Subject: [PATCH] wip --- Workouts.xcodeproj/project.pbxproj | 29 ++++++ Workouts/ContentView.swift | 46 +++++---- Workouts/Models/OrderableItem.swift | 30 ++++++ Workouts/Models/Split.swift | 11 +++ .../Views/Splits/DraggableSplitItem.swift | 56 +++++++++++ Workouts/Views/Splits/SortableForEach.swift | 99 +++++++++++++++++++ .../Views/Splits/SplitExercisesListView.swift | 67 ++++++++++--- Workouts/Views/Splits/SplitsView.swift | 90 ++++++----------- Workouts/Views/Workouts/WorkoutsView.swift | 20 ++-- 9 files changed, 337 insertions(+), 111 deletions(-) create mode 100644 Workouts/Models/OrderableItem.swift create mode 100644 Workouts/Views/Splits/DraggableSplitItem.swift create mode 100644 Workouts/Views/Splits/SortableForEach.swift diff --git a/Workouts.xcodeproj/project.pbxproj b/Workouts.xcodeproj/project.pbxproj index d4b38dd..4c11185 100644 --- a/Workouts.xcodeproj/project.pbxproj +++ b/Workouts.xcodeproj/project.pbxproj @@ -9,6 +9,9 @@ /* Begin PBXBuildFile section */ 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 */; }; + 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 */ /* Begin PBXContainerItemProxy section */ @@ -71,7 +74,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A45FA2822E2A933B00581607 /* SwiftUIReorderableForEach in Frameworks */, A45FA2732E29B12500581607 /* Yams in Frameworks */, + A45FA27F2E2A930900581607 /* SwiftUIReorderableForEach in Frameworks */, + A45FA2872E2AC66B00581607 /* SwiftUIReorderableForEach in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -126,6 +132,9 @@ name = Workouts; packageProductDependencies = ( A45FA2722E29B12500581607 /* Yams */, + A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */, + A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */, + A45FA2862E2AC66B00581607 /* SwiftUIReorderableForEach */, ); productName = Workouts; productReference = A45FA0912E21B3DD00581607 /* Workouts.app */; @@ -182,6 +191,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */, + A45FA2852E2AC66B00581607 /* XCLocalSwiftPackageReference "../swiftui-reorderable-foreach" */, ); preferredProjectObjectVersion = 77; productRefGroup = A45FA0922E21B3DD00581607 /* Products */; @@ -512,6 +522,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + A45FA2852E2AC66B00581607 /* XCLocalSwiftPackageReference "../swiftui-reorderable-foreach" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../swiftui-reorderable-foreach"; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */ = { isa = XCRemoteSwiftPackageReference; @@ -529,6 +546,18 @@ package = A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */; productName = Yams; }; + A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */ = { + isa = XCSwiftPackageProductDependency; + productName = SwiftUIReorderableForEach; + }; + A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */ = { + isa = XCSwiftPackageProductDependency; + productName = SwiftUIReorderableForEach; + }; + A45FA2862E2AC66B00581607 /* SwiftUIReorderableForEach */ = { + isa = XCSwiftPackageProductDependency; + productName = SwiftUIReorderableForEach; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = A45FA0892E21B3DC00581607 /* Project object */; diff --git a/Workouts/ContentView.swift b/Workouts/ContentView.swift index 6d82d16..5208368 100644 --- a/Workouts/ContentView.swift +++ b/Workouts/ContentView.swift @@ -15,33 +15,31 @@ struct ContentView: View { @Environment(\.modelContext) private var modelContext var body: some View { - NavigationView { - TabView { - SplitsView() - .tabItem { - Label("Workouts", systemImage: "figure.strengthtraining.traditional") - } - - WorkoutsView() - .tabItem { - Label("Logs", systemImage: "list.bullet.clipboard.fill") - } - - - // Reports Tab - NavigationStack { - Text("Reports Placeholder") - .navigationTitle("Reports") - } + TabView { + SplitsView() .tabItem { - Label("Reports", systemImage: "chart.bar") + Label("Workouts", systemImage: "figure.strengthtraining.traditional") } - - SettingsView() - .tabItem { - Label("Settings", systemImage: "gear") - } + + WorkoutsView() + .tabItem { + Label("Logs", systemImage: "list.bullet.clipboard.fill") + } + + + // Reports Tab + NavigationStack { + Text("Reports Placeholder") + .navigationTitle("Reports") } + .tabItem { + Label("Reports", systemImage: "chart.bar") + } + + SettingsView() + .tabItem { + Label("Settings", systemImage: "gear") + } } .observeCloudKitChanges() } diff --git a/Workouts/Models/OrderableItem.swift b/Workouts/Models/OrderableItem.swift new file mode 100644 index 0000000..0ba0b9f --- /dev/null +++ b/Workouts/Models/OrderableItem.swift @@ -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 + } +} diff --git a/Workouts/Models/Split.swift b/Workouts/Models/Split.swift index c75961a..766f132 100644 --- a/Workouts/Models/Split.swift +++ b/Workouts/Models/Split.swift @@ -1,6 +1,7 @@ import Foundation import SwiftData import SwiftUI +import UniformTypeIdentifiers @Model 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 fileprivate struct SplitFormView: View { diff --git a/Workouts/Views/Splits/DraggableSplitItem.swift b/Workouts/Views/Splits/DraggableSplitItem.swift new file mode 100644 index 0000000..04ecad1 --- /dev/null +++ b/Workouts/Views/Splits/DraggableSplitItem.swift @@ -0,0 +1,56 @@ +// +// DraggableSplitItem.swift +// Workouts +// +// Created by rzen on 7/18/25 at 2:45 PM. +// +// 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) + } + } + } +} + + diff --git a/Workouts/Views/Splits/SortableForEach.swift b/Workouts/Views/Splits/SortableForEach.swift new file mode 100644 index 0000000..57c3e87 --- /dev/null +++ b/Workouts/Views/Splits/SortableForEach.swift @@ -0,0 +1,99 @@ +// +// SortableForEach.swift +// Workouts +// +// Created by rzen on 7/18/25 at 2:04 PM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import UniformTypeIdentifiers + +public struct SortableForEach: 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, + @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: 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) + } + } + } + } +} diff --git a/Workouts/Views/Splits/SplitExercisesListView.swift b/Workouts/Views/Splits/SplitExercisesListView.swift index 5e0b8f2..4fdd612 100644 --- a/Workouts/Views/Splits/SplitExercisesListView.swift +++ b/Workouts/Views/Splits/SplitExercisesListView.swift @@ -8,6 +8,7 @@ // import SwiftUI +import SwiftData struct SplitExercisesListView: View { @Environment(\.modelContext) private var modelContext @@ -18,6 +19,7 @@ struct SplitExercisesListView: View { @State private var showingAddSheet: Bool = false @State private var itemToEdit: SplitExerciseAssignment? = nil @State private var itemToDelete: SplitExerciseAssignment? = nil + @State private var createdWorkout: Workout? = nil var body: some View { NavigationStack { @@ -67,24 +69,59 @@ struct SplitExercisesListView: View { .navigationTitle("\(model.name)") } .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { showingAddSheet.toggle() }) { - Image(systemName: "plus") + ToolbarItem(placement: .primaryAction) { + Button("Start This Split") { + let split = model + let workout = Workout(start: Date(), split: split) + modelContext.insert(workout) + if let exercises = split.exercises { + for assignment in exercises { + let workoutLog = WorkoutLog( + workout: workout, + exerciseName: assignment.exerciseName, + date: Date(), + order: assignment.order, + sets: assignment.sets, + reps: assignment.reps, + weight: assignment.weight + ) + modelContext.insert(workoutLog) + } + } + try? modelContext.save() + + // Set the created workout to trigger navigation + createdWorkout = workout } } } - .sheet (isPresented: $showingAddSheet) { - ExercisePickerView { exerciseName in - itemToEdit = SplitExerciseAssignment( - split: model, - exerciseName: exerciseName, - order: 0, - sets: 3, - reps: 10, - weight: 40 - ) - } - } + .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 SplitExerciseAssignmentAddEditView(model: item) } diff --git a/Workouts/Views/Splits/SplitsView.swift b/Workouts/Views/Splits/SplitsView.swift index 79d1045..5c1632b 100644 --- a/Workouts/Views/Splits/SplitsView.swift +++ b/Workouts/Views/Splits/SplitsView.swift @@ -14,83 +14,49 @@ struct SplitsView: View { @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss - @Query(sort: [ - SortDescriptor(\Split.order), - SortDescriptor(\Split.name) - ]) private var splits: [Split] - + @State var splits: [Split] = [] + @State private var showingAddSheet: Bool = false + @State private var allowSorting: Bool = true var body: some View { NavigationStack { ScrollView { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) { - ForEach(splits) { split in + SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in NavigationLink { SplitExercisesListView(model: split) } label: { - VStack { - ZStack(alignment: .bottom) { - // Golden ratio rectangle (1:1.618) - RoundedRectangle(cornerRadius: 12) - .fill( - LinearGradient( - gradient: Gradient(colors: [split.getColor(), split.getColor().darker(by: 0.2)]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .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) - } + DraggableSplitItem( + name: split.name, + color: Color.color(from: split.color), + systemImageName: split.systemImage, + exerciseCount: split.exercises?.count ?? 0 + ) + .overlay(dragging ? Color.white.opacity(0.8) : Color.clear) } } - .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() } .navigationTitle("Splits") - } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { showingAddSheet.toggle() }) { - Image(systemName: "plus") + .onAppear { + do { + self.splits = try modelContext.fetch(FetchDescriptor( + sortBy: [ + SortDescriptor(\Split.order), + SortDescriptor(\Split.name) + ] + )) + } catch { + print("ERROR: failed to load splits \(error)") + } + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { showingAddSheet.toggle() }) { + Image(systemName: "plus") + } } } } diff --git a/Workouts/Views/Workouts/WorkoutsView.swift b/Workouts/Views/Workouts/WorkoutsView.swift index 82d88a2..578bff2 100644 --- a/Workouts/Views/Workouts/WorkoutsView.swift +++ b/Workouts/Views/Workouts/WorkoutsView.swift @@ -59,16 +59,16 @@ struct WorkoutsView: View { } } .navigationTitle("Workouts") - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button("Start Workout") { - showingSplitPicker = true - } - } - } - .sheet(item: $itemToEdit) { item in - WorkoutEditView(workout: item) - } +// .toolbar { +// ToolbarItem(placement: .primaryAction) { +// Button("Start Workout") { +// showingSplitPicker = true +// } +// } +// } +// .sheet(item: $itemToEdit) { item in +// WorkoutEditView(workout: item) +// } .confirmationDialog( "Delete?", isPresented: Binding(