wip
This commit is contained in:
@ -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 */;
|
||||
|
@ -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()
|
||||
}
|
||||
|
30
Workouts/Models/OrderableItem.swift
Normal file
30
Workouts/Models/OrderableItem.swift
Normal 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
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
56
Workouts/Views/Splits/DraggableSplitItem.swift
Normal file
56
Workouts/Views/Splits/DraggableSplitItem.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
99
Workouts/Views/Splits/SortableForEach.swift
Normal file
99
Workouts/Views/Splits/SortableForEach.swift
Normal file
@ -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<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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<Split>(
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<Bool>(
|
||||
|
Reference in New Issue
Block a user