This commit is contained in:
2025-07-18 10:03:58 -04:00
parent 4f01a6445f
commit 66f257609f
37 changed files with 845 additions and 704 deletions

View File

@ -18,14 +18,9 @@ enum AppStorageKeys {
struct SettingsView: View {
@Environment(\.modelContext) private var modelContext
@State private var showingPopulateData = false
@State private var showingClearAllDataConfirmation = false
var splitsCount: Int? { try? modelContext.fetchCount(FetchDescriptor<Split>()) }
var musclesCount: Int? { try? modelContext.fetchCount(FetchDescriptor<Muscle>()) }
var muscleGroupsCount: Int? { try? modelContext.fetchCount(FetchDescriptor<MuscleGroup>()) }
var exerciseTypeCount: Int? { try? modelContext.fetchCount(FetchDescriptor<ExerciseType>()) }
var exercisesCount: Int? { try? modelContext.fetchCount(FetchDescriptor<Exercise>()) }
var body: some View {
NavigationStack {
@ -40,67 +35,9 @@ struct SettingsView: View {
.foregroundColor(.gray)
}
}
NavigationLink(destination: MuscleGroupsListView()) {
HStack {
Text("Muscle Groups")
Spacer()
Text("\(muscleGroupsCount ?? 0)")
.font(.caption)
.foregroundColor(.gray)
}
}
NavigationLink(destination: MusclesListView()) {
HStack {
Text("Muscles")
Spacer()
Text("\(musclesCount ?? 0)")
.font(.caption)
.foregroundColor(.gray)
}
}
NavigationLink(destination: ExerciseTypeListView()) {
HStack {
Text("Exercise Types")
Spacer()
Text("\(exerciseTypeCount ?? 0)")
.font(.caption)
.foregroundColor(.gray)
}
}
NavigationLink(destination: ExercisesListView()) {
HStack {
Text("Exercises")
Spacer()
Text("\(exercisesCount ?? 0)")
.font(.caption)
.foregroundColor(.gray)
}
}
}
Section(header: Text("Developer")) {
Button(action: {
showingPopulateData = true
}) {
HStack {
Label("Populate Data", systemImage: "plus")
Spacer()
}
}
.confirmationDialog(
"Populate Data?",
isPresented: $showingPopulateData,
titleVisibility: .hidden
) {
Button("Populate Data") {
DataLoader.create(modelContext: modelContext)
}
Button("Cancel", role: .cancel) {}
// } message: {
// Text("This action cannot be undone. All data will be permanently deleted.")
}
Button(action: {
showingClearAllDataConfirmation = true
}) {
@ -139,10 +76,6 @@ struct SettingsView: View {
private func clearAllData () {
do {
try deleteAllObjects(ofType: ExerciseType.self, from: modelContext)
try deleteAllObjects(ofType: Exercise.self, from: modelContext)
try deleteAllObjects(ofType: Muscle.self, from: modelContext)
try deleteAllObjects(ofType: MuscleGroup.self, from: modelContext)
try deleteAllObjects(ofType: Split.self, from: modelContext)
try deleteAllObjects(ofType: SplitExerciseAssignment.self, from: modelContext)
try deleteAllObjects(ofType: Workout.self, from: modelContext)
@ -154,30 +87,6 @@ struct SettingsView: View {
}
}
struct ExercisesListView: View {
var body: some View {
EntityListView<Exercise>(sort: [SortDescriptor(\Exercise.name)])
}
}
struct ExerciseTypeListView: View {
var body: some View {
EntityListView<ExerciseType>(sort: [SortDescriptor(\ExerciseType.name)])
}
}
struct MuscleGroupsListView: View {
var body: some View {
EntityListView<MuscleGroup>(sort: [SortDescriptor(\MuscleGroup.name)])
}
}
struct MusclesListView: View {
var body: some View {
EntityListView<Muscle>(sort: [SortDescriptor(\Muscle.name)])
}
}
struct SplitsListView: View {
var body: some View {
EntityListView<Split>(sort: [SortDescriptor(\Split.name)])

View File

@ -12,15 +12,24 @@ import SwiftUI
struct SplitExerciseAssignmentAddEditView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State private var showingExercisePicker = false
@State var model: SplitExerciseAssignment
var body: some View {
NavigationStack {
Form {
Section (header: Text("Setup")) {
TextEditor(text: $model.setup)
.frame(minHeight: 60)
Section(header: Text("Exercise")) {
Button(action: {
showingExercisePicker = true
}) {
HStack {
Text(model.exerciseName.isEmpty ? "Select Exercise" : model.exerciseName)
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.gray)
}
}
}
Section (header: Text("Sets/Reps")) {
@ -44,7 +53,12 @@ struct SplitExerciseAssignmentAddEditView: View {
}
}
}
.navigationTitle("\(model.exercise?.name ?? Exercise.unnamed)")
.sheet(isPresented: $showingExercisePicker) {
ExercisePickerView { exerciseName in
model.exerciseName = exerciseName
}
}
.navigationTitle(model.exerciseName.isEmpty ? "New Exercise" : model.exerciseName)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {

View File

@ -0,0 +1,64 @@
//
// SplitAddEditView.swift
// Workouts
//
// Created by rzen on 7/18/25 at 9:42AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct SplitAddEditView: View {
@State var model: Split
private let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
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)
}
.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 {
SplitExercisesListView(model: model)
} label: {
ListItem(
text: "Exercises",
count: model.exercises?.count ?? 0
)
}
}
}
}
}

View File

@ -0,0 +1,108 @@
//
// SplitExercisesListView.swift
// Workouts
//
// Created by rzen on 7/18/25 at 8:38AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct SplitExercisesListView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
var model: Split
@State private var showingAddSheet: Bool = false
@State private var itemToEdit: SplitExerciseAssignment? = nil
@State private var itemToDelete: SplitExerciseAssignment? = nil
var body: some View {
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 })
ForEach(sortedAssignments) { item in
ListItem(
title: item.exerciseName,
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs \(item.order)"
)
.swipeActions {
Button {
itemToDelete = item
} label: {
Label("Delete", systemImage: "circle")
}
Button {
itemToEdit = item
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.indigo)
}
}
.onMove(perform: { indices, destination in
var exerciseArray = Array(sortedAssignments)
exerciseArray.move(fromOffsets: indices, toOffset: destination)
for (index, exercise) in exerciseArray.enumerated() {
exercise.order = index
}
if let modelContext = exerciseArray.first?.modelContext {
do {
try modelContext.save()
} catch {
print("Error saving after reordering: \(error)")
}
}
})
} else {
Text("No exercises added yet.")
}
}
}
.navigationTitle("\(model.name)")
}
.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)
}
.confirmationDialog(
"Delete Exercise?",
isPresented: .constant(itemToDelete != nil),
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
if let item = itemToDelete {
withAnimation {
modelContext.delete(item)
try? modelContext.save()
itemToDelete = nil
}
}
}
}
}
}

View File

@ -0,0 +1,102 @@
//
// SplitsView.swift
// Workouts
//
// Created by rzen on 7/17/25 at 6:55PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
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 private var showingAddSheet: Bool = false
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
ForEach(splits) { split 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)
}
}
}
.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")
}
}
}
.sheet (isPresented: $showingAddSheet) {
SplitAddEditView(model: Split(name: "New Split"))
}
}
}

View File

@ -0,0 +1,89 @@
//
// CalendarListItem.swift
// Workouts
//
// Created by rzen on 7/18/25 at 8:44AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct CalendarListItem: View {
var date: Date
var title: String
var subtitle: String?
var count: Int?
var body: some View {
HStack (alignment: .top) {
ZStack {
VStack {
Text("\(date.abbreviatedWeekday)")
.font(.caption)
.foregroundColor(.secondary)
Text("\(date.dayOfMonth)")
.font(.headline)
.foregroundColor(.accentColor)
Text("\(date.abbreviatedMonth)")
.font(.caption)
.foregroundColor(.secondary)
}
.padding([.trailing], 10)
}
HStack {
VStack (alignment: .leading) {
Text("\(title)")
.font(.headline)
if let subtitle = subtitle {
Text("\(subtitle)")
.font(.footnote)
}
}
if let count = count {
Spacer()
Text("\(count)")
.font(.caption)
.foregroundColor(.gray)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
}
extension Date {
private static let monthFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale.current
formatter.dateFormat = "MMM"
return formatter
}()
private static let dayFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale.current
formatter.dateFormat = "d"
return formatter
}()
private static let weekdayFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale.current
formatter.dateFormat = "E"
return formatter
}()
var abbreviatedMonth: String {
Date.monthFormatter.string(from: self)
}
var dayOfMonth: String {
Date.dayFormatter.string(from: self)
}
var abbreviatedWeekday: String {
Date.weekdayFormatter.string(from: self)
}
}

View File

@ -1,8 +1,8 @@
//
// SplitPickerView.swift
// ExercisePickerView.swift
// Workouts
//
// Created by rzen on 7/13/25 at 7:17PM.
// Created by rzen on 7/13/25 at 7:17 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
@ -11,37 +11,65 @@ import SwiftUI
import SwiftData
struct ExercisePickerView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@Query(sort: [SortDescriptor(\ExerciseType.name)]) private var exerciseTypes: [ExerciseType]
@State private var exerciseLists: [String: ExerciseList] = [:]
@State private var selectedListName: String? = nil
// @Query(sort: [SortDescriptor(\Exercise.name)]) private var exercises: [Exercise]
var onExerciseSelected: (Exercise) -> Void
var onExerciseSelected: (String) -> Void
var body: some View {
NavigationStack {
VStack {
Form {
ForEach (exerciseTypes) { exerciseType in
if let exercises = exerciseType.exercises, !exercises.isEmpty {
let sortedExercises = exercises.sorted(by: { $0.name < $1.name })
Section (header: Text("\(exerciseType.name)")) {
List {
ForEach(sortedExercises) { exercise in
Button(action: {
onExerciseSelected(exercise)
dismiss()
}) {
ListItem(text: exercise.name)
}
.buttonStyle(.plain)
Group {
if selectedListName == nil {
// Show list of exercise list files
List {
ForEach(Array(exerciseLists.keys.sorted()), id: \.self) { fileName in
if let list = exerciseLists[fileName] {
Button(action: {
selectedListName = fileName
}) {
VStack(alignment: .leading) {
Text(list.name)
.font(.headline)
Text(list.source)
.font(.subheadline)
.foregroundColor(.secondary)
Text("\(list.exercises.count) exercises")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}
}
}
}
.navigationTitle("Exercise Lists")
} else if let fileName = selectedListName, let list = exerciseLists[fileName] {
// Show exercises in the selected list
List {
ForEach(list.exercises) { exercise in
Button(action: {
onExerciseSelected(exercise.name)
dismiss()
}) {
VStack(alignment: .leading) {
Text(exercise.name)
.font(.headline)
Text(exercise.type)
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 2)
}
}
}
.navigationTitle(list.name)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Back") {
selectedListName = nil
}
}
}
}
}
@ -53,5 +81,12 @@ struct ExercisePickerView: View {
}
}
}
.onAppear {
loadExerciseLists()
}
}
private func loadExerciseLists() {
exerciseLists = ExerciseListLoader.loadExerciseLists()
}
}

View File

@ -15,10 +15,8 @@ struct SplitPickerView: View {
@Environment(\.dismiss) private var dismiss
@Query(sort: [SortDescriptor(\Split.name)]) private var splits: [Split]
@Query(sort: [SortDescriptor(\Exercise.name)]) private var exercises: [Exercise]
var onSplitSelected: (Split) -> Void
// var onExerciseSelected: (Exercise) -> Void
var body: some View {
NavigationStack {

View File

@ -49,7 +49,7 @@ struct WorkoutEditView: View {
// List {
// ForEach (workoutLogs) { log in
// ListItem(
// title: log.exercise?.name ?? Exercise.unnamed,
// title: log.exerciseName,
// subtitle: "\(log.sets) sets × \(log.reps) reps × \(log.weight) lbs"
// )
// }

View File

@ -21,7 +21,7 @@ struct WorkoutLogEditView: View {
NavigationStack {
Form {
Section (header: Text("Exercise")) {
Text("\(workoutLog.exercise?.name ?? Exercise.unnamed)")
Text(workoutLog.exerciseName)
.font(.headline)
}
@ -86,20 +86,20 @@ struct WorkoutLogEditView: View {
let split = workoutLog.workout?.split
// Find the matching exercise in split.exercises by name
// if let exercises = split?.exercises {
// for exerciseAssignment in exercises {
// if exerciseAssignment.exercise.name == workoutLog.exercise.name {
// // Update the sets, reps, and weight in the split exercise assignment
// exerciseAssignment.sets = workoutLog.sets
// exerciseAssignment.reps = workoutLog.reps
// exerciseAssignment.weight = workoutLog.weight
//
// // Save the changes to the split
// try? modelContext.save()
// break
// }
// }
// }
if let exercises = split?.exercises {
for exerciseAssignment in exercises {
if exerciseAssignment.exerciseName == workoutLog.exerciseName {
// Update the sets, reps, and weight in the split exercise assignment
exerciseAssignment.sets = workoutLog.sets
exerciseAssignment.reps = workoutLog.reps
exerciseAssignment.weight = workoutLog.weight
// Save the changes to the split
try? modelContext.save()
break
}
}
}
}
}

View File

@ -22,7 +22,7 @@ struct WorkoutLogView: View {
var sortedWorkoutLogs: [WorkoutLog] {
if let logs = workout.logs {
logs.sorted(by: {
$0.order == $1.order ? $0.exercise!.name < $1.exercise!.name : $0.order < $1.order
$0.order == $1.order ? $0.exerciseName < $1.exerciseName : $0.order < $1.order
})
} else {
[]
@ -41,7 +41,7 @@ struct WorkoutLogView: View {
CheckboxListItem(
status: workoutLogStatus,
title: log.exercise?.name ?? Exercise.unnamed,
title: log.exerciseName,
subtitle: "\(log.sets) sets × \(log.reps) reps × \(log.weight) lbs"
)
@ -85,11 +85,12 @@ struct WorkoutLogView: View {
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
Button {
itemToDelete = log
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.secondary)
Button {
itemToEdit = log
} label: {
@ -110,11 +111,11 @@ struct WorkoutLogView: View {
}
}
.sheet(isPresented: $showingAddSheet) {
ExercisePickerView { exercise in
let setsRepsWeight = getSetsRepsWeight(exercise, in: modelContext)
ExercisePickerView { exerciseName in
let setsRepsWeight = getSetsRepsWeight(exerciseName, in: modelContext)
let workoutLog = WorkoutLog(
workout: workout,
exercise: exercise,
exerciseName: exerciseName,
date: Date(),
sets: setsRepsWeight.sets,
reps: setsRepsWeight.reps,
@ -149,20 +150,18 @@ struct WorkoutLogView: View {
itemToDelete = nil
}
} message: {
Text("Are you sure you want to delete workout started \(itemToDelete?.exercise?.name ?? "this item")?")
Text("Are you sure you want to delete workout started \(itemToDelete?.exerciseName ?? "this item")?")
}
}
func getSetsRepsWeight(_ exercise: Exercise, in modelContext: ModelContext) -> SetsRepsWeight {
func getSetsRepsWeight(_ exerciseName: String, in modelContext: ModelContext) -> SetsRepsWeight {
// Use a single expression predicate that works with SwiftData
let exerciseID = exercise.persistentModelID
print("Searching for exercise ID: \(exerciseID)")
print("Searching for exercise name: \(exerciseName)")
var descriptor = FetchDescriptor<WorkoutLog>(
predicate: #Predicate<WorkoutLog> { log in
log.exercise?.persistentModelID == exerciseID
log.exerciseName == exerciseName
},
sortBy: [SortDescriptor(\WorkoutLog.date, order: .reverse)]
)

View File

@ -34,7 +34,8 @@ struct WorkoutsView: View {
List {
ForEach (workouts) { workout in
NavigationLink(destination: WorkoutLogView(workout: workout)) {
ListItem(
CalendarListItem(
date: workout.start,
title: workout.split?.name ?? Split.unnamed,
subtitle: workout.label
)
@ -95,23 +96,18 @@ struct WorkoutsView: View {
SplitPickerView { split in
let workout = Workout(start: Date(), split: split)
modelContext.insert(workout)
if let exercises = split.exercises {
for assignment in exercises {
if let exercise = assignment.exercise {
let workoutLog = WorkoutLog(
workout: workout,
exercise: exercise,
date: Date(),
order: assignment.order,
sets: assignment.sets,
reps: assignment.reps,
weight: assignment.weight
)
modelContext.insert(workoutLog)
} else {
logger.debug("An exercise entity for a split is nil")
}
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()