This commit is contained in:
2025-07-19 16:42:47 -04:00
parent 6e46775f58
commit e3c3f2c6f0
38 changed files with 556 additions and 367 deletions

View File

@ -10,35 +10,51 @@
import SwiftUI
import SwiftData
struct SplitExercisesListView: View {
struct ExerciseListView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
var model: Split
// Use a @Query to observe the Split and its exercises
@Query private var splits: [Split]
private var split: Split
@State private var showingAddSheet: Bool = false
@State private var itemToEdit: SplitExerciseAssignment? = nil
@State private var itemToDelete: SplitExerciseAssignment? = nil
@State private var itemToEdit: Exercise? = nil
@State private var itemToDelete: Exercise? = nil
@State private var createdWorkout: Workout? = nil
// Initialize with a Split and set up a query to observe it
init(split: Split) {
self.split = split
// Create a predicate to fetch only this specific split
let splitId = split.persistentModelID
self._splits = Query(filter: #Predicate<Split> { s in
s.persistentModelID == splitId
})
}
var body: some View {
// Use the first Split from our query if available, otherwise fall back to the original split
let currentSplit = splits.first ?? split
NavigationStack {
Form {
List {
if let assignments = model.exercises, !assignments.isEmpty {
let sortedAssignments = assignments.sorted(by: { $0.order == $1.order ? $0.exerciseName < $1.exerciseName : $0.order < $1.order })
if let assignments = currentSplit.exercises, !assignments.isEmpty {
let sortedAssignments = assignments.sorted(by: { $0.order == $1.order ? $0.name < $1.name : $0.order < $1.order })
ForEach(sortedAssignments) { item in
ListItem(
title: item.exerciseName,
title: item.name,
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs \(item.order)"
)
.swipeActions {
Button {
itemToDelete = item
} label: {
Label("Delete", systemImage: "circle")
Label("Delete", systemImage: "trash")
}
.tint(.red)
Button {
itemToEdit = item
} label: {
@ -76,19 +92,19 @@ struct SplitExercisesListView: View {
}
}
}
.navigationTitle("\(model.name)")
.navigationTitle("\(currentSplit.name)")
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Start This Split") {
let split = model
let workout = Workout(start: Date(), split: split)
let split = currentSplit
let workout = Workout(start: Date(), end: Date(), split: split)
modelContext.insert(workout)
if let exercises = split.exercises {
for assignment in exercises {
let workoutLog = WorkoutLog(
workout: workout,
exerciseName: assignment.exerciseName,
exerciseName: assignment.name,
date: Date(),
order: assignment.order,
sets: assignment.sets,
@ -106,7 +122,7 @@ struct SplitExercisesListView: View {
}
}
.navigationDestination(item: $createdWorkout, destination: { workout in
WorkoutLogView(workout: workout)
WorkoutLogListView(workout: workout)
})
// .toolbar {
// ToolbarItem(placement: .navigationBarTrailing) {
@ -116,19 +132,49 @@ struct SplitExercisesListView: View {
// }
// }
.sheet (isPresented: $showingAddSheet) {
ExercisePickerView { exerciseName in
itemToEdit = SplitExerciseAssignment(
split: model,
exerciseName: exerciseName,
order: 0,
sets: 3,
reps: 10,
weight: 40
)
}
ExercisePickerView(onExerciseSelected: { exerciseNames in
let splitId = currentSplit.persistentModelID
print("exerciseNames: \(exerciseNames)")
if exerciseNames.count == 1 {
itemToEdit = Exercise(
split: split,
exerciseName: exerciseNames.first ?? "Exercise.unnamed",
order: 0,
sets: 3,
reps: 10,
weight: 40
)
} else {
for exerciseName in exerciseNames {
var duplicateExercise: [Exercise]? = nil
do {
duplicateExercise = try modelContext.fetch(FetchDescriptor<Exercise>(predicate: #Predicate{ exercise in
exerciseName == exercise.name && splitId == exercise.split?.persistentModelID
}))
} catch {
print("ERROR: failed to fetch \(exerciseName)")
}
if let dup = duplicateExercise, dup.count > 0 {
print("Skipping duplicate \(exerciseName) found \(dup.count) duplicate(s)")
} else {
print("Creating \(exerciseName) for \(split.name)")
modelContext.insert(Exercise(
split: split,
exerciseName: exerciseName,
order: 0,
sets: 3,
reps: 10,
weight: 40
))
}
}
}
try? modelContext.save()
}, allowMultiSelect: true)
}
.sheet(item: $itemToEdit) { item in
SplitExerciseAssignmentAddEditView(model: item)
ExerciseAddEditView(model: item)
}
.confirmationDialog(
"Delete Exercise?",

View File

@ -1,129 +0,0 @@
//
// ExerciseView.swift
// Workouts
//
// Created by rzen on 7/18/25 at 5:44PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
struct ExerciseView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@Bindable var workoutLog: WorkoutLog
@State var allLogs: [WorkoutLog]
var currentIndex: Int = 0
@State private var progress: Int = 0
@State private var navigateTo: WorkoutLog? = nil
let notStartedColor = Color.white
let completedColor = Color.green
var body: some View {
Form {
Section(header: Text("Navigation")) {
HStack {
Button(action: navigateToPrevious) {
HStack {
Image(systemName: "chevron.left")
Text("Previous")
}
}
.disabled(currentIndex <= 0)
Spacer()
Text("\(currentIndex)")
Spacer()
Button(action: navigateToNext) {
HStack {
Text("Next")
Image(systemName: "chevron.right")
}
}
.disabled(currentIndex >= allLogs.count - 1)
}
.padding(.vertical, 8)
}
Section (header: Text("Progress")) {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: workoutLog.sets), spacing: 2) {
ForEach (1...workoutLog.sets, id: \.self) { index in
ZStack {
let completed = index <= progress
let color = completed ? completedColor : notStartedColor
RoundedRectangle(cornerRadius: 8)
.fill(
LinearGradient(
gradient: Gradient(colors: [color, color.darker(by: 0.2)]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.aspectRatio(0.618, contentMode: .fit)
.shadow(radius: 2)
Text("\(index)")
.foregroundColor(.primary)
.colorInvert()
}
.onTapGesture {
if progress == index {
progress = 0
} else {
progress = index
}
let _ = print("progress set to \(progress)")
}
}
}
}
Section (header: Text("Plan")) {
Stepper("\(workoutLog.sets) sets", value: $workoutLog.sets, in: 1...10)
.font(.title)
Stepper("\(workoutLog.reps) reps", value: $workoutLog.reps, in: 1...25)
.font(.title)
HStack {
Text("\(workoutLog.weight) lbs")
VStack (alignment: .trailing) {
Stepper("", value: $workoutLog.weight, in: 1...200)
Stepper("", value: $workoutLog.weight, in: 1...200, step: 5)
}
}
.font(.title)
}
}
.navigationTitle("\(workoutLog.exerciseName)")
.navigationDestination(item: $navigateTo) { nextLog in
ExerciseView(
workoutLog: nextLog,
allLogs: allLogs,
currentIndex: allLogs.firstIndex(of: nextLog) ?? 0
)
}
// .onAppear {
// allLogs = modelContext.fetch(FetchDescriptor(sortBy: [
// SortDescriptor(\WorkoutLog.order),
// SortDescriptor(\WorkoutLog.name)
// ]))
// }
}
private func navigateToPrevious() {
guard currentIndex > 0 else { return }
let previousIndex = currentIndex - 1
navigateTo = allLogs[previousIndex]
}
private func navigateToNext() {
guard currentIndex < allLogs.count - 1 else { return }
let nextIndex = currentIndex + 1
navigateTo = allLogs[nextIndex]
}
}

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 Exercise: OrderableItem {
func updateOrder(to index: Int) {
self.order = index
}
}

View File

@ -10,6 +10,9 @@
import SwiftUI
struct SplitAddEditView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State var model: Split
private let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
@ -17,46 +20,62 @@ struct SplitAddEditView: View {
private let availableIcons = ["dumbbell.fill", "figure.strengthtraining.traditional", "figure.run", "figure.hiking", "figure.cooldown", "figure.boxing", "figure.wrestling", "figure.gymnastics", "figure.handball", "figure.core.training", "heart.fill", "bolt.fill"]
var body: some View {
Form {
Section(header: Text("Name")) {
TextField("Name", text: $model.name)
.bold()
}
Section(header: Text("Appearance")) {
Picker("Color", selection: $model.color) {
ForEach(availableColors, id: \.self) { colorName in
let tempSplit = Split(name: "", color: colorName)
HStack {
Circle()
.fill(tempSplit.getColor())
.frame(width: 20, height: 20)
Text(colorName.capitalized)
NavigationStack {
Form {
Section(header: Text("Name")) {
TextField("Name", text: $model.name)
.bold()
}
Section(header: Text("Appearance")) {
Picker("Color", selection: $model.color) {
ForEach(availableColors, id: \.self) { colorName in
let tempSplit = Split(name: "", color: colorName)
HStack {
Circle()
.fill(tempSplit.getColor())
.frame(width: 20, height: 20)
Text(colorName.capitalized)
}
.tag(colorName)
}
}
Picker("Icon", selection: $model.systemImage) {
ForEach(availableIcons, id: \.self) { iconName in
HStack {
Image(systemName: iconName)
.frame(width: 24, height: 24)
Text(iconName.replacingOccurrences(of: ".fill", with: "").replacingOccurrences(of: "figure.", with: "").capitalized)
}
.tag(iconName)
}
.tag(colorName)
}
}
Picker("Icon", selection: $model.systemImage) {
ForEach(availableIcons, id: \.self) { iconName in
HStack {
Image(systemName: iconName)
.frame(width: 24, height: 24)
Text(iconName.replacingOccurrences(of: ".fill", with: "").replacingOccurrences(of: "figure.", with: "").capitalized)
}
.tag(iconName)
Section(header: Text("Exercises")) {
NavigationLink {
ExerciseListView(split: model)
} label: {
ListItem(
text: "Exercises",
count: model.exercises?.count ?? 0
)
}
}
}
Section(header: Text("Exercises")) {
NavigationLink {
SplitExercisesListView(model: model)
} label: {
ListItem(
text: "Exercises",
count: model.exercises?.count ?? 0
)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
try? modelContext.save()
dismiss()
}
}
}
}

View File

@ -9,7 +9,7 @@
import SwiftUI
struct DraggableSplitItem: View {
struct SplitItem: View {
var name: String
var color: Color
@ -31,15 +31,15 @@ struct DraggableSplitItem: View {
.aspectRatio(1.618, contentMode: .fit)
.shadow(radius: 2)
GeometryReader { geometry in
GeometryReader { geo in
VStack(spacing: 4) {
Spacer()
// Icon in the center - now using dynamic sizing
Image(systemName: systemImageName)
.font(.system(size: min(geometry.size.width * 0.3, 40), weight: .bold))
.font(.system(size: min(geo.size.width * 0.3, 40), weight: .bold))
.scaledToFit()
.frame(maxWidth: geometry.size.width * 0.6, maxHeight: geometry.size.height * 0.4)
.frame(maxWidth: geo.size.width * 0.6, maxHeight: geo.size.height * 0.4)
.padding(.bottom, 4)
// Name at the bottom inside the rectangle
@ -53,7 +53,7 @@ struct DraggableSplitItem: View {
.padding(.bottom, 8)
}
.foregroundColor(.white)
.frame(width: geometry.size.width, height: geometry.size.height)
.frame(width: geo.size.width, height: geo.size.height)
}
}
}

View File

@ -0,0 +1,81 @@
//
// SplitPickerView.swift
// Workouts
//
// Created by rzen on 7/13/25 at 7:17PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
struct SplitPickerView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@Query(sort: [SortDescriptor(\Split.name)]) private var splits: [Split]
var onSplitSelected: (Split) -> Void
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
ForEach(splits) { split in
Button(action: {
onSplitSelected(split)
dismiss()
}) {
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)
}
}
.buttonStyle(PlainButtonStyle())
}
}
.padding()
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
}
}
}
}

View File

@ -25,9 +25,9 @@ struct SplitsView: View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in
NavigationLink {
SplitExercisesListView(model: split)
ExerciseListView(split: split)
} label: {
DraggableSplitItem(
SplitItem(
name: split.name,
color: Color.color(from: split.color),
systemImageName: split.systemImage,
@ -40,18 +40,7 @@ struct SplitsView: View {
.padding()
}
.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)")
}
}
.onAppear(perform: loadSplits)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { showingAddSheet.toggle() }) {
@ -65,4 +54,17 @@ struct SplitsView: View {
}
}
func loadSplits () {
do {
self.splits = try modelContext.fetch(FetchDescriptor<Split>(
sortBy: [
SortDescriptor(\Split.order),
SortDescriptor(\Split.name)
]
))
} catch {
print("ERROR: failed to load splits \(error)")
}
}
}