This commit is contained in:
2025-07-17 07:04:38 -04:00
parent 2d0e327334
commit f63bb0ba41
25 changed files with 592 additions and 92 deletions

View File

@ -5,11 +5,7 @@ import SwiftUI
@Model
final class Exercise {
var name: String = ""
var setup: String = ""
var descr: String = ""
var sets: Int = 0
var reps: Int = 0
var weight: Int = 0
@Relationship(deleteRule: .nullify, inverse: \ExerciseType.exercises)
var type: ExerciseType?
@ -23,13 +19,9 @@ final class Exercise {
@Relationship(deleteRule: .nullify, inverse: \WorkoutLog.exercise)
var logs: [WorkoutLog]? = []
init(name: String, setup: String, descr: String, sets: Int, reps: Int, weight: Int) {
init(name: String, descr: String) {
self.name = name
self.setup = setup
self.descr = descr
self.sets = sets
self.reps = reps
self.weight = weight
}
static let unnamed = "Unnamed Exercise"
@ -37,7 +29,7 @@ final class Exercise {
extension Exercise: EditableEntity {
static func createNew() -> Exercise {
return Exercise(name: "", setup: "", descr: "", sets: 3, reps: 10, weight: 30)
return Exercise(name: "", descr: "")
}
static var navigationTitle: String {
@ -76,20 +68,5 @@ fileprivate struct ExerciseFormView: View {
TextEditor(text: $model.descr)
.frame(minHeight: 100)
}
Section(header: Text("Setup")) {
TextEditor(text: $model.setup)
.frame(minHeight: 100)
}
Section(header: Text("Weight")) {
HStack {
Text("\(model.weight)")
.bold()
Text("lbs")
Spacer()
Stepper("", value: $model.weight, in: 0...1000)
}
}
}
}

View File

@ -6,6 +6,27 @@ import SwiftUI
final class Split {
var name: String = ""
var intro: String = ""
var color: String = "indigo"
var systemImage: String = "dumbbell.fill"
// Returns the SwiftUI Color for the stored color name
func getColor() -> Color {
switch color {
case "red": return .red
case "orange": return .orange
case "yellow": return .yellow
case "green": return .green
case "mint": return .mint
case "teal": return .teal
case "cyan": return .cyan
case "blue": return .blue
case "indigo": return .indigo
case "purple": return .purple
case "pink": return .pink
case "brown": return .brown
default: return .indigo
}
}
@Relationship(deleteRule: .cascade, inverse: \SplitExerciseAssignment.split)
var exercises: [SplitExerciseAssignment]? = []
@ -13,9 +34,11 @@ final class Split {
@Relationship(deleteRule: .nullify, inverse: \Workout.split)
var workouts: [Workout]? = []
init(name: String, intro: String) {
init(name: String, intro: String, color: String = "indigo", systemImage: String = "dumbbell.fill") {
self.name = name
self.intro = intro
self.color = color
self.systemImage = systemImage
}
static let unnamed = "Unnamed Split"
@ -53,6 +76,12 @@ fileprivate struct SplitFormView: View {
@State private var itemToEdit: SplitExerciseAssignment? = nil
@State private var itemToDelete: SplitExerciseAssignment? = nil
// Available colors for splits
private let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
// Available system images for splits
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 {
Section(header: Text("Name")) {
TextField("Name", text: $model.name)
@ -64,6 +93,32 @@ fileprivate struct SplitFormView: View {
.frame(minHeight: 100)
}
Section(header: Text("Appearance")) {
Picker("Color", selection: $model.color) {
ForEach(availableColors, id: \.self) { colorName in
let tempSplit = Split(name: "", intro: "", 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 {
NavigationStack {
@ -109,9 +164,9 @@ fileprivate struct SplitFormView: View {
ExercisePickerView { exercise in
itemToEdit = SplitExerciseAssignment(
order: 0,
sets: exercise.sets,
reps: exercise.reps,
weight: exercise.weight,
sets: 3,
reps: 10,
weight: 40,
split: model,
exercise: exercise
)

View File

@ -7,6 +7,9 @@ final class WorkoutLog {
var sets: Int = 0
var reps: Int = 0
var weight: Int = 0
var status: WorkoutStatus? = WorkoutStatus.notStarted
var order: Int = 0
var completed: Bool = false
@Relationship(deleteRule: .nullify)
@ -15,13 +18,15 @@ final class WorkoutLog {
@Relationship(deleteRule: .nullify)
var exercise: Exercise?
init(workout: Workout, exercise: Exercise, date: Date, sets: Int, reps: Int, weight: Int, completed: Bool) {
init(workout: Workout, exercise: Exercise, date: Date, order: Int = 0, sets: Int, reps: Int, weight: Int, status: WorkoutStatus = .notStarted, completed: Bool = false) {
self.date = date
self.order = order
self.sets = sets
self.reps = reps
self.weight = weight
self.completed = completed
self.status = status
self.workout = workout
self.exercise = exercise
self.completed = completed
}
}

View File

@ -0,0 +1,22 @@
//
// WorkoutStatus.swift
// Workouts
//
// Created by rzen on 7/16/25 at 7:03PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
enum WorkoutStatus: Int, Codable {
case notStarted = 1
case inProgress = 2
case completed = 3
var checkboxStatus: CheckboxStatus {
switch (self) {
case .notStarted: .unchecked
case .inProgress: .intermediate
case .completed: .checked
}
}
}

View File

@ -92,8 +92,7 @@ struct DataLoader {
// 4. Load Exercises
let exerciseData = try loadJSON(forResource: "exercises", type: [ExerciseData].self)
for data in exerciseData {
let exercise = Exercise(name: data.name, setup: data.setup, descr: data.descr,
sets: data.sets, reps: data.reps, weight: data.weight)
let exercise = Exercise(name: data.name, descr: data.descr)
// Set exercise type
if let type = exerciseTypes[data.type] {

View File

@ -0,0 +1,16 @@
import SwiftData
enum SchemaV2: VersionedSchema {
static var versionIdentifier: Schema.Version = .init(1, 0, 1)
static var models: [any PersistentModel.Type] = [
Exercise.self,
ExerciseType.self,
Muscle.self,
MuscleGroup.self,
Split.self,
SplitExerciseAssignment.self,
Workout.self,
WorkoutLog.self
]
}

View File

@ -0,0 +1,16 @@
import SwiftData
enum SchemaV3: VersionedSchema {
static var versionIdentifier: Schema.Version = .init(1, 0, 2)
static var models: [any PersistentModel.Type] = [
Exercise.self,
ExerciseType.self,
Muscle.self,
MuscleGroup.self,
Split.self,
SplitExerciseAssignment.self,
Workout.self,
WorkoutLog.self
]
}

View File

@ -2,6 +2,8 @@ import SwiftData
enum SchemaVersion: Int {
case v1
case v2
case v3
static var current: SchemaVersion { .v1 }
static var current: SchemaVersion { .v3 }
}

View File

@ -8,9 +8,10 @@ final class WorkoutsContainer {
)
static func create() -> ModelContainer {
let schema = Schema(versionedSchema: SchemaV1.self)
// Using the current models directly without migration plan to avoid reference errors
let schema = Schema(SchemaV2.models)
let configuration = ModelConfiguration(cloudKitDatabase: .automatic)
let container = try! ModelContainer(for: schema, migrationPlan: WorkoutsMigrationPlan.self, configurations: [configuration])
let container = try! ModelContainer(for: schema, configurations: configuration)
return container
}
@ -19,7 +20,7 @@ final class WorkoutsContainer {
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
do {
let schema = Schema(SchemaV1.models)
let schema = Schema(SchemaV2.models)
let container = try ModelContainer(for: schema, configurations: configuration)
let context = ModelContext(container)

View File

@ -2,10 +2,28 @@ import SwiftData
struct WorkoutsMigrationPlan: SchemaMigrationPlan {
static var schemas: [VersionedSchema.Type] = [
SchemaV1.self
SchemaV1.self,
SchemaV2.self
]
static var stages: [MigrationStage] = [
// Add migration stages here in the future
// Migration from V1 to V2: Add status field to WorkoutLog
MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// Get all WorkoutLog instances
let workoutLogs = try? context.fetch(FetchDescriptor<WorkoutLog>())
// Update each WorkoutLog with appropriate status based on completed flag
workoutLogs?.forEach { workoutLog in
// If completed is true, set status to .completed, otherwise set to .notStarted
workoutLog.status = workoutLog.completed ? WorkoutStatus.completed : WorkoutStatus.notStarted
}
},
didMigrate: { _ in
// No additional actions needed after migration
}
)
]
}

View File

@ -0,0 +1,68 @@
//
// ListItem.swift
// Workouts
//
// Created by rzen on 7/13/25 at 10:42AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
enum CheckboxStatus {
case checked
case unchecked
case intermediate
var color: Color {
switch (self) {
case .checked: .green
case .unchecked: .gray
case .intermediate: .yellow
}
}
var systemName: String {
switch (self) {
case .checked: "checkmark.circle.fill"
case .unchecked: "circle"
case .intermediate: "ellipsis.circle"
}
}
}
struct CheckboxListItem: View {
var status: CheckboxStatus
var title: String
var subtitle: String?
var count: Int?
var body: some View {
HStack (alignment: .top) {
Image(systemName: status.systemName)
.resizable()
.scaledToFit()
.frame(width: 30)
.foregroundStyle(status.color)
VStack (alignment: .leading) {
Text("\(title)")
.font(.headline)
HStack (alignment: .bottom) {
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())
}
}

View File

@ -22,44 +22,38 @@ struct SplitPickerView: View {
var body: some View {
NavigationStack {
VStack {
Form {
Section (header: Text("This Split")) {
List {
ForEach(splits) { split in
Button(action: {
onSplitSelected(split)
dismiss()
}) {
HStack {
Text(split.name)
.font(.headline)
Spacer()
Text("\(split.exercises?.count ?? 0)")
.font(.caption)
}
.contentShape(Rectangle())
ScrollView {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
ForEach(splits) { split in
Button(action: {
onSplitSelected(split)
dismiss()
}) {
VStack {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(split.getColor())
.aspectRatio(1, contentMode: .fit)
.shadow(radius: 2)
Image(systemName: split.systemImage)
.font(.system(size: 30))
.foregroundColor(.white)
}
.buttonStyle(.plain)
Text(split.name)
.font(.headline)
.lineLimit(1)
Text("\(split.exercises?.count ?? 0) exercises")
.font(.caption)
.foregroundColor(.secondary)
}
}
.buttonStyle(PlainButtonStyle())
}
// Section (header: Text("Additional Exercises")) {
// List {
// ForEach(exercises) { exercise in
// Button(action: {
// onExerciseSelected(exercise)
// dismiss()
// }) {
// Text(exercise.name)
// }
// .contentShape(Rectangle())
// .buttonStyle(.plain)
// }
// }
// }
}
.padding()
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {

View File

@ -8,6 +8,7 @@
//
import SwiftUI
import SwiftData
struct WorkoutLogView: View {
@Environment(\.modelContext) private var modelContext
@ -21,7 +22,7 @@ struct WorkoutLogView: View {
var sortedWorkoutLogs: [WorkoutLog] {
if let logs = workout.logs {
logs.sorted(by: {
$0.completed == $1.completed ? $0.exercise!.name < $1.exercise!.name : !$0.completed
$0.order == $1.order ? $0.exercise!.name < $1.exercise!.name : $0.order < $1.order
})
} else {
[]
@ -33,33 +34,54 @@ struct WorkoutLogView: View {
Section (header: Text("\(workout.label)")) {
List {
ForEach (sortedWorkoutLogs) { log in
let badges = log.completed ? [Badge(text: "Completed", color: .green)] : []
ListItem(
title: log.exercise?.name ?? "Untitled Exercise",
subtitle: "\(log.sets) sets × \(log.reps) reps × \(log.weight) lbs",
badges: badges
// Handle optional status, defaulting to a status based on completed flag if nil
let _ = print("DEBUG: workoutLog.status=\(log.status)")
let workoutLogStatus = log.status?.checkboxStatus ?? (log.completed ? CheckboxStatus.checked : CheckboxStatus.unchecked)
CheckboxListItem(
status: workoutLogStatus,
title: log.exercise?.name ?? Exercise.unnamed,
subtitle: "\(log.sets) sets × \(log.reps) reps × \(log.weight) lbs"
)
.swipeActions(edge: .leading, allowsFullSwipe: false) {
if (log.completed) {
let status = log.status ?? WorkoutStatus.notStarted
if [.inProgress,.completed].contains(status) {
Button {
withAnimation {
log.completed = false
log.status = .notStarted
try? modelContext.save()
}
} label: {
Label("Complete", systemImage: "circle.fill")
Label("Not Started", systemImage: WorkoutStatus.notStarted.checkboxStatus.systemName)
}
.tint(.green)
} else {
.tint(WorkoutStatus.notStarted.checkboxStatus.color)
}
if [.notStarted,.completed].contains(status) {
Button {
withAnimation {
log.completed = true
log.status = .inProgress
try? modelContext.save()
}
} label: {
Label("Reset", systemImage: "checkmark.circle.fill")
Label("In Progress", systemImage: WorkoutStatus.inProgress.checkboxStatus.systemName)
}
.tint(.green)
.tint(WorkoutStatus.inProgress.checkboxStatus.color)
}
if [.notStarted,.inProgress].contains(status) {
Button {
withAnimation {
log.status = .completed
try? modelContext.save()
}
} label: {
Label("Complete", systemImage: WorkoutStatus.completed.checkboxStatus.systemName)
}
.tint(WorkoutStatus.completed.checkboxStatus.color)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
@ -89,13 +111,14 @@ struct WorkoutLogView: View {
}
.sheet(isPresented: $showingAddSheet) {
ExercisePickerView { exercise in
let setsRepsWeight = getSetsRepsWeight(exercise, in: modelContext)
let workoutLog = WorkoutLog(
workout: workout,
exercise: exercise,
date: Date(),
sets: exercise.sets,
reps: exercise.reps,
weight: exercise.weight,
sets: setsRepsWeight.sets,
reps: setsRepsWeight.reps,
weight: setsRepsWeight.weight,
completed: false
)
workout.logs?.append(workoutLog)
@ -130,4 +153,34 @@ struct WorkoutLogView: View {
}
}
func getSetsRepsWeight(_ exercise: Exercise, in modelContext: ModelContext) -> SetsRepsWeight {
// Use a single expression predicate that works with SwiftData
let exerciseID = exercise.persistentModelID
print("Searching for exercise ID: \(exerciseID)")
var descriptor = FetchDescriptor<WorkoutLog>(
predicate: #Predicate<WorkoutLog> { log in
log.exercise?.persistentModelID == exerciseID
},
sortBy: [SortDescriptor(\WorkoutLog.date, order: .reverse)]
)
descriptor.fetchLimit = 1
let results = try? modelContext.fetch(descriptor)
if let log = results?.first {
return SetsRepsWeight(sets: log.sets, reps: log.reps, weight: log.weight)
} else {
return SetsRepsWeight(sets: 3, reps: 10, weight: 40)
}
}
}
struct SetsRepsWeight {
let sets: Int
let reps: Int
let weight: Int
}

View File

@ -100,10 +100,10 @@ struct WorkoutsView: View {
workout: workout,
exercise: exercise,
date: Date(),
order: assignment.order,
sets: assignment.sets,
reps: assignment.reps,
weight: assignment.weight,
completed: false
weight: assignment.weight
)
modelContext.insert(workoutLog)
} else {