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

@ -347,6 +347,7 @@
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
EXCLUDED_SOURCE_FILE_NAMES = "_ATTIC_/*";
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@ -410,6 +411,7 @@
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
EXCLUDED_SOURCE_FILE_NAMES = "_ATTIC_/*";
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;

View File

@ -21,7 +21,7 @@ struct ContentView: View {
Label("Workouts", systemImage: "figure.strengthtraining.traditional")
}
WorkoutsView()
WorkoutListView()
.tabItem {
Label("Logs", systemImage: "list.bullet.clipboard.fill")
}
@ -36,10 +36,10 @@ struct ContentView: View {
Label("Reports", systemImage: "chart.bar")
}
SettingsView()
.tabItem {
Label("Settings", systemImage: "gear")
}
// SettingsView()
// .tabItem {
// Label("Settings", systemImage: "gear")
// }
}
.observeCloudKitChanges()
}

View File

@ -2,8 +2,8 @@ import Foundation
import SwiftData
@Model
final class SplitExerciseAssignment {
var exerciseName: String = ""
final class Exercise {
var name: String = ""
var order: Int = 0
var sets: Int = 0
var reps: Int = 0
@ -14,7 +14,7 @@ final class SplitExerciseAssignment {
init(split: Split, exerciseName: String, order: Int, sets: Int, reps: Int, weight: Int) {
self.split = split
self.exerciseName = exerciseName
self.name = exerciseName
self.order = order
self.sets = sets
self.reps = reps

View File

@ -15,8 +15,8 @@ final class Split {
return Color.color(from: self.color)
}
@Relationship(deleteRule: .cascade, inverse: \SplitExerciseAssignment.split)
var exercises: [SplitExerciseAssignment]? = []
@Relationship(deleteRule: .cascade, inverse: \Exercise.split)
var exercises: [Exercise]? = []
@Relationship(deleteRule: .nullify, inverse: \Workout.split)
var workouts: [Workout]? = []
@ -109,7 +109,7 @@ fileprivate struct SplitFormView: View {
Section(header: Text("Exercises")) {
NavigationLink {
SplitExercisesListView(model: model)
ExerciseListView(split: model)
} label: {
ListItem(
text: "Exercises",

View File

@ -5,6 +5,7 @@ import SwiftData
final class Workout {
var start: Date = Date()
var end: Date?
var status: WorkoutStatus? = WorkoutStatus.notStarted
@Relationship(deleteRule: .nullify)
var split: Split?
@ -12,13 +13,17 @@ final class Workout {
@Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout)
var logs: [WorkoutLog]? = []
init(start: Date, end: Date? = nil, split: Split?) {
init(start: Date, end: Date, split: Split?) {
self.start = start
self.end = end
self.split = split
}
var label: String {
start.formattedDate()
if status == .completed, let endDate = end {
return "\(start.formattedDate())\(endDate.formattedDate())"
} else {
return start.formattedDate()
}
}
}

View File

@ -1,12 +0,0 @@
//
// ListableItem.swift
// Workouts
//
// Created by rzen on 7/13/25 at 10:40AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
protocol ListableItem {
var name: String { get set }
}

View File

@ -6,64 +6,78 @@ exercises:
shoulder-width. Pull the bar down to your chest, squeezing your shoulder blades
together. Avoid leaning back excessively or using momentum.
type: Machine-Based
split: Upper Body
- name: Seated Row
descr: With your chest firmly against the pad, grip the handles and pull straight
back while keeping your elbows close to your body. Focus on retracting your shoulder
blades and avoid rounding your back.
type: Machine-Based
split: Upper Body
- name: Shoulder Press
descr: Sit with your back against the pad, grip the handles just outside shoulder-width.
Press upward without locking out your elbows. Keep your neck relaxed and avoid
shrugging your shoulders.
type: Machine-Based
split: Upper Body
- name: Chest Press
descr: Adjust the seat so the handles are at mid-chest height. Push forward until arms
are nearly extended, then return slowly. Keep wrists straight and dont let your elbows
drop too low.
type: Machine-Based
split: Upper Body
- name: Tricep Press
descr: With elbows close to your sides, press the handles downward in a controlled
motion. Avoid flaring your elbows or using your shoulders to assist the motion.
type: Machine-Based
split: Upper Body
- name: Arm Curl
descr: Position your arms over the pad and grip the handles. Curl the weight upward
while keeping your upper arms stationary. Avoid using momentum and fully control
the lowering phase.
type: Machine-Based
split: Upper Body
- name: Abdominal
descr: Sit with the pads resting against your chest. Contract your abs to curl forward,
keeping your lower back in contact with the pad. Avoid pulling with your arms
or hips.
type: Machine-Based
split: Core
- name: Rotary
descr: Rotate your torso from side to side in a controlled motion, keeping your
hips still. Focus on using your obliques to generate the twist, not momentum or
the arms.
type: Machine-Based
split: Core
- name: Leg Press
descr: Place your feet shoulder-width on the platform. Press upward through your
heels without locking your knees. Keep your back flat against the pad throughout
the motion.
type: Machine-Based
split: Lower Body
- name: Leg Extension
descr: Sit upright and align your knees with the pivot point. Extend your legs to
a straightened position, then lower with control. Avoid jerky movements or lifting
your hips off the seat.
type: Machine-Based
split: Lower Body
- name: Leg Curl
descr: Lie face down or sit depending on the version. Curl your legs toward your
glutes, focusing on hamstring engagement. Avoid arching your back or using momentum.
type: Machine-Based
split: Lower Body
- name: Adductor
descr: Sit with legs placed outside the pads. Bring your legs together using inner
thigh muscles. Control the motion both in and out, avoiding fast swings.
type: Machine-Based
split: Lower Body
- name: Abductor
descr: Sit with legs inside the pads and push outward to engage outer thighs and
glutes. Avoid leaning forward and keep the motion controlled throughout.
type: Machine-Based
split: Lower Body
- name: Calfs
descr: Place the balls of your feet on the platform with heels hanging off. Raise
your heels by contracting your calves, then slowly lower them below the platform
level for a full stretch.
type: Machine-Based
split: Lower Body

View File

@ -1,15 +1,15 @@
import Foundation
import SwiftData
final class WorkoutsContainer {
final class AppContainer {
static let logger = AppLogger(
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Workouts",
category: "WorkoutsContainer"
category: "AppContainer"
)
static func create() -> ModelContainer {
// Using the current models directly without migration plan to avoid reference errors
let schema = Schema(SchemaV1.models)
let schema = Schema(SchemaVersion.models)
let configuration = ModelConfiguration(cloudKitDatabase: .automatic)
let container = try! ModelContainer(for: schema, configurations: configuration)
return container
@ -20,10 +20,8 @@ final class WorkoutsContainer {
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
do {
let schema = Schema(SchemaV1.models)
let schema = Schema(SchemaVersion.models)
let container = try ModelContainer(for: schema, configurations: configuration)
let context = ModelContext(container)
return container
} catch {
fatalError("Failed to create preview ModelContainer: \(error.localizedDescription)")

View File

@ -12,23 +12,14 @@ struct CloudKitSyncObserver: ViewModifier {
.onReceive(NotificationCenter.default.publisher(for: .cloudKitDataDidChange)) { _ in
refreshID = UUID()
Task { @MainActor in
// do {
// for entity in modelContext.container.schema.entities {
// fetchAll<entity.Type>(of: entity.Type, from: modelContext)
// }
// } catch {
// print("ERROR: failed to fetch data on CloudKit change")
// }
//
// try? modelContext.fetch(FetchDescriptor<Exercise>())
// try? modelContext.fetch(FetchDescriptor<ExerciseType>())
// try? modelContext.fetch(FetchDescriptor<Muscle>())
// try? modelContext.fetch(FetchDescriptor<MuscleGroup>())
// try? modelContext.fetch(FetchDescriptor<Exercise>())
// try? modelContext.fetch(FetchDescriptor<Exercise>())
// try? modelContext.fetch(FetchDescriptor<Exercise>())
// try? modelContext.fetch(FetchDescriptor<Exercise>())
// TODO: add more entities?
do {
let _ = try await fetchAll(of: Exercise.self, from: modelContext)
let _ = try await fetchAll(of: Split.self, from: modelContext)
let _ = try await fetchAll(of: Workout.self, from: modelContext)
let _ = try await fetchAll(of: WorkoutLog.self, from: modelContext)
} catch {
print("ERROR: failed to fetch \(error.localizedDescription)")
}
}
}
}

View File

@ -5,7 +5,7 @@ enum SchemaV1: VersionedSchema {
static var models: [any PersistentModel.Type] = [
Split.self,
SplitExerciseAssignment.self,
Exercise.self,
Workout.self,
WorkoutLog.self
]

View File

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

View File

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

View File

@ -2,8 +2,18 @@ import SwiftData
enum SchemaVersion: Int {
case v1
case v2
case v3
static var current: SchemaVersion { .v3 }
static var current: SchemaVersion { .v1 }
static var schemas: [VersionedSchema.Type] {
[
SchemaV1.self
]
}
static var models: [any PersistentModel.Type] {
switch (Self.current) {
case .v1: SchemaV1.models
}
}
}

View File

@ -1,24 +1,29 @@
import SwiftData
import Foundation
struct WorkoutsMigrationPlan: SchemaMigrationPlan {
static var schemas: [VersionedSchema.Type] = [
SchemaV1.self,
SchemaV2.self
]
static var schemas: [VersionedSchema.Type] = SchemaVersion.schemas
static var stages: [MigrationStage] = [
// Migration from V1 to V2: Add status field to WorkoutLog
MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
toVersion: SchemaV1.self,
willMigrate: { context in
// Get all WorkoutLog instances
let workoutLogs = try? context.fetch(FetchDescriptor<WorkoutLog>())
print("migrating from v1 to v1")
let workouts = try? context.fetch(FetchDescriptor<Workout>())
workouts?.forEach { workout in
if let status = workout.status {
// 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
} else {
workout.status = .notStarted
}
// if let endDate = workout.end {
//
// } else {
// workout.end = Date()
// }
workout.end = Date()
}
},
didMigrate: { _ in

View File

@ -0,0 +1,50 @@
//
// Date+humanTimeInterval.swift
// Workouts
//
// Created by rzen on 7/19/25 at 1:06PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
extension Date {
func humanTimeInterval(to referenceDate: Date = Date()) -> String {
let seconds = Int(referenceDate.timeIntervalSince(self))
let absSeconds = abs(seconds)
let minute = 60
let hour = 3600
let day = 86400
let week = 604800
let month = 2592000
let year = 31536000
switch absSeconds {
case 0..<5:
return "just now"
case 5..<minute:
return "\(absSeconds) seconds"
case minute..<hour:
let minutes = absSeconds / minute
return "\(minutes) minute\(minutes == 1 ? "" : "s")"
case hour..<day:
let hours = absSeconds / hour
return "\(hours) hour\(hours == 1 ? "" : "s")"
case day..<week:
let days = absSeconds / day
return "\(days) day\(days == 1 ? "" : "s")"
case week..<month:
let weeks = absSeconds / week
return "\(weeks) week\(weeks == 1 ? "" : "s")"
case month..<year:
let months = absSeconds / month
return "\(months) month\(months == 1 ? "" : "s")"
default:
let years = absSeconds / year
return "\(years) year\(years == 1 ? "" : "s")"
}
}
}

View File

@ -13,6 +13,7 @@ struct CalendarListItem: View {
var date: Date
var title: String
var subtitle: String?
var subtitle2: String?
var count: Int?
var body: some View {
@ -31,7 +32,7 @@ struct CalendarListItem: View {
}
.padding([.trailing], 10)
}
HStack {
HStack (alignment: .top) {
VStack (alignment: .leading) {
Text("\(title)")
.font(.headline)
@ -39,6 +40,10 @@ struct CalendarListItem: View {
Text("\(subtitle)")
.font(.footnote)
}
if let subtitle = subtitle2 {
Text("\(subtitle)")
.font(.footnote)
}
}
if let count = count {
Spacer()

View File

@ -14,7 +14,7 @@ struct ListItem: View {
var text: String?
var subtitle: String?
var count: Int?
var badges: [Badge]? = []
// var badges: [Badge]? = []
var body: some View {
HStack {
@ -32,11 +32,11 @@ struct ListItem: View {
}
}
HStack (alignment: .bottom) {
if let badges = badges {
ForEach (badges, id: \.self) { badge in
BadgeView(badge: badge)
}
}
// if let badges = badges {
// ForEach (badges, id: \.self) { badge in
// BadgeView(badge: badge)
// }
// }
if let subtitle = subtitle {
Text("\(subtitle)")
.font(.footnote)

View File

@ -9,12 +9,12 @@
import SwiftUI
struct SplitExerciseAssignmentAddEditView: View {
struct ExerciseAddEditView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State private var showingExercisePicker = false
@State var model: SplitExerciseAssignment
@State var model: Exercise
var body: some View {
NavigationStack {
@ -24,7 +24,7 @@ struct SplitExerciseAssignmentAddEditView: View {
showingExercisePicker = true
}) {
HStack {
Text(model.exerciseName.isEmpty ? "Select Exercise" : model.exerciseName)
Text(model.name.isEmpty ? "Select Exercise" : model.name)
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.gray)
@ -54,11 +54,11 @@ struct SplitExerciseAssignmentAddEditView: View {
}
}
.sheet(isPresented: $showingExercisePicker) {
ExercisePickerView { exerciseName in
model.exerciseName = exerciseName
ExercisePickerView { exerciseNames in
model.name = exerciseNames.first ?? "Exercise.unnamed"
}
}
.navigationTitle(model.exerciseName.isEmpty ? "New Exercise" : model.exerciseName)
.navigationTitle(model.name.isEmpty ? "New Exercise" : model.name)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {

View File

@ -1,7 +1,8 @@
import Foundation
import Yams
struct ExerciseList: Codable {
class ExerciseListLoader {
struct ExerciseListData: Codable {
let name: String
let source: String
let exercises: [ExerciseItem]
@ -10,14 +11,14 @@ struct ExerciseList: Codable {
let name: String
let descr: String
let type: String
let split: String
var id: String { name }
}
}
}
class ExerciseListLoader {
static func loadExerciseLists() -> [String: ExerciseList] {
var exerciseLists: [String: ExerciseList] = [:]
static func loadExerciseLists() -> [String: ExerciseListData] {
var exerciseLists: [String: ExerciseListData] = [:]
guard let resourcePath = Bundle.main.resourcePath else {
print("Could not find resource path")
@ -39,18 +40,19 @@ class ExerciseListLoader {
let source = exerciseList["source"] as? String,
let exercisesData = exerciseList["exercises"] as? [[String: Any]] {
var exercises: [ExerciseList.ExerciseItem] = []
var exercises: [ExerciseListData.ExerciseItem] = []
for exerciseData in exercisesData {
if let name = exerciseData["name"] as? String,
let descr = exerciseData["descr"] as? String,
let type = exerciseData["type"] as? String {
let exercise = ExerciseList.ExerciseItem(name: name, descr: descr, type: type)
let type = exerciseData["type"] as? String,
let split = exerciseData["split"] as? String {
let exercise = ExerciseListData.ExerciseItem(name: name, descr: descr, type: type, split: split)
exercises.append(exercise)
}
}
let exerciseList = ExerciseList(name: name, source: source, exercises: exercises)
let exerciseList = ExerciseListData(name: name, source: source, exercises: exercises)
exerciseLists[fileName] = exerciseList
}
} catch {

View File

@ -0,0 +1,153 @@
//
// ExercisePickerView.swift
// Workouts
//
// Created by rzen on 7/13/25 at 7:17 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
struct ExercisePickerView: View {
@Environment(\.dismiss) private var dismiss
@State private var exerciseLists: [String: ExerciseListLoader.ExerciseListData] = [:]
@State private var selectedListName: String? = nil
@State private var selectedExercises: Set<String> = []
var onExerciseSelected: ([String]) -> Void
var allowMultiSelect: Bool = false
init(onExerciseSelected: @escaping ([String]) -> Void, allowMultiSelect: Bool = false) {
self.onExerciseSelected = onExerciseSelected
self.allowMultiSelect = allowMultiSelect
}
var body: some View {
NavigationStack {
Group {
Text("Multi-Select: \(allowMultiSelect)")
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 grouped by split
List {
// Group exercises by split
let exercisesByGroup = Dictionary(grouping: list.exercises) { $0.split }
let sortedGroups = exercisesByGroup.keys.sorted()
ForEach(sortedGroups, id: \.self) { splitName in
Section(header: Text(splitName)) {
ForEach(exercisesByGroup[splitName]?.sorted(by: { $0.name < $1.name }) ?? [], id: \.id) { exercise in
if allowMultiSelect {
Button(action: {
if selectedExercises.contains(exercise.name) {
selectedExercises.remove(exercise.name)
} else {
selectedExercises.insert(exercise.name)
}
}) {
HStack {
VStack(alignment: .leading) {
Text(exercise.name)
.font(.headline)
Text(exercise.type)
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 2)
Spacer()
if selectedExercises.contains(exercise.name) {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
} else {
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 {
if let _ = selectedListName {
ToolbarItem(placement: .navigationBarLeading) {
Button("Back") {
selectedListName = nil
selectedExercises.removeAll()
}
}
}
if allowMultiSelect {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Select") {
if !selectedExercises.isEmpty {
onExerciseSelected(Array(selectedExercises))
dismiss()
}
}
.disabled(selectedExercises.isEmpty)
}
}
}
}
}
.toolbar {
if let _ = selectedListName {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
}
}
}
.onAppear {
loadExerciseLists()
}
}
private func loadExerciseLists() {
exerciseLists = ExerciseListLoader.loadExerciseLists()
}
}

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,
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

@ -23,7 +23,7 @@ extension Split: OrderableItem {
}
/// Extension to make SplitExerciseAssignment conform to OrderableItem
extension SplitExerciseAssignment: 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,6 +20,7 @@ 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 {
NavigationStack {
Form {
Section(header: Text("Name")) {
TextField("Name", text: $model.name)
@ -51,7 +55,7 @@ struct SplitAddEditView: View {
Section(header: Text("Exercises")) {
NavigationLink {
SplitExercisesListView(model: model)
ExerciseListView(split: model)
} label: {
ListItem(
text: "Exercises",
@ -60,5 +64,20 @@ struct SplitAddEditView: View {
}
}
}
.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

@ -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)")
}
}
}

View File

@ -87,12 +87,12 @@ struct WorkoutLogEditView: View {
// Find the matching exercise in split.exercises by name
if let exercises = split?.exercises {
for exerciseAssignment in exercises {
if exerciseAssignment.exerciseName == workoutLog.exerciseName {
for exercise in exercises {
if exercise.name == 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
exercise.sets = workoutLog.sets
exercise.reps = workoutLog.reps
exercise.weight = workoutLog.weight
// Save the changes to the split
try? modelContext.save()

View File

@ -10,7 +10,7 @@
import SwiftUI
import SwiftData
struct WorkoutLogView: View {
struct WorkoutLogListView: View {
@Environment(\.modelContext) private var modelContext
@State var workout: Workout
@ -47,10 +47,7 @@ struct WorkoutLogView: View {
if [.inProgress,.completed].contains(status) {
Button {
withAnimation {
log.status = .notStarted
try? modelContext.save()
}
resetWorkout(log)
} label: {
Label("Not Started", systemImage: WorkoutStatus.notStarted.checkboxStatus.systemName)
}
@ -59,10 +56,7 @@ struct WorkoutLogView: View {
if [.notStarted,.completed].contains(status) {
Button {
withAnimation {
log.status = .inProgress
try? modelContext.save()
}
startWorkout(log)
} label: {
Label("In Progress", systemImage: WorkoutStatus.inProgress.checkboxStatus.systemName)
}
@ -71,10 +65,7 @@ struct WorkoutLogView: View {
if [.notStarted,.inProgress].contains(status) {
Button {
withAnimation {
log.status = .completed
try? modelContext.save()
}
completeWorkout(log)
} label: {
Label("Complete", systemImage: WorkoutStatus.completed.checkboxStatus.systemName)
}
@ -110,11 +101,11 @@ struct WorkoutLogView: View {
}
}
.sheet(isPresented: $showingAddSheet) {
ExercisePickerView { exerciseName in
let setsRepsWeight = getSetsRepsWeight(exerciseName, in: modelContext)
ExercisePickerView { exerciseNames in
let setsRepsWeight = getSetsRepsWeight(exerciseNames.first ?? "Exercise.unnamed", in: modelContext)
let workoutLog = WorkoutLog(
workout: workout,
exerciseName: exerciseName,
exerciseName: exerciseNames.first ?? "Exercise.unnamed",
date: Date(),
sets: setsRepsWeight.sets,
reps: setsRepsWeight.reps,
@ -154,6 +145,46 @@ struct WorkoutLogView: View {
}
func startWorkout (_ log: WorkoutLog) {
withAnimation {
log.status = .inProgress
updateWorkout(log)
}
}
func resetWorkout (_ log: WorkoutLog) {
withAnimation {
log.status = .notStarted
updateWorkout(log)
}
}
func completeWorkout (_ log: WorkoutLog) {
withAnimation {
log.status = .completed
updateWorkout(log)
}
}
func updateWorkout (_ log: WorkoutLog) {
if let workout = log.workout {
if let _ = workout.logs?.first(where: { $0.status != .completed }) {
if let notStartedLogs = workout.logs?.filter({ $0.status == .notStarted }) {
if notStartedLogs.count == workout.logs?.count ?? 0 {
workout.status = .notStarted
}
}
if let _ = workout.logs?.first(where: { $0.status == .inProgress }) {
workout.status = .inProgress
}
} else {
workout.status = .completed
workout.end = Date()
}
try? modelContext.save()
}
}
func getSetsRepsWeight(_ exerciseName: String, in modelContext: ModelContext) -> SetsRepsWeight {
// Use a single expression predicate that works with SwiftData
print("Searching for exercise name: \(exerciseName)")

View File

@ -13,6 +13,17 @@ enum WorkoutStatus: Int, Codable {
case completed = 3
case skipped = 4
static var unnamed = "Undetermined"
var name: String {
switch (self) {
case .notStarted: "Not Started"
case .inProgress: "In Progress"
case .completed: "Completed"
case .skipped: "Skipped"
}
}
var checkboxStatus: CheckboxStatus {
switch (self) {
case .notStarted: .unchecked

View File

@ -1,92 +0,0 @@
//
// ExercisePickerView.swift
// Workouts
//
// Created by rzen on 7/13/25 at 7:17 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
struct ExercisePickerView: View {
@Environment(\.dismiss) private var dismiss
@State private var exerciseLists: [String: ExerciseList] = [:]
@State private var selectedListName: String? = nil
var onExerciseSelected: (String) -> Void
var body: some View {
NavigationStack {
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
}
}
}
}
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
}
}
.onAppear {
loadExerciseLists()
}
}
private func loadExerciseLists() {
exerciseLists = ExerciseListLoader.loadExerciseLists()
}
}

View File

@ -20,44 +20,20 @@ struct WorkoutEditView: View {
var body: some View {
NavigationStack {
Form {
// Section (header: Text("Split")) {
// Text("\(workout.split?.name ?? Split.unnamed)")
// }
Section (header: Text("Split")) {
Text("\(workout.split?.name ?? Split.unnamed)")
}
Section (header: Text("Status")) {
Text("\(workout.status?.name ?? WorkoutStatus.unnamed)")
}
Section (header: Text("Start/End")) {
DatePicker("Started", selection: $workout.start)
Toggle("Workout Ended", isOn: Binding(
get: { workout.end != nil },
set: { newValue in
withAnimation {
if newValue {
workoutEndDate = Date()
workout.end = workoutEndDate
} else {
workout.end = nil
}
}
}
))
if workout.end != nil {
if workout.status == .completed {
DatePicker("Ended", selection: $workoutEndDate)
}
}
// Section (header: Text("Workout Log")) {
// if let workoutLogs = workout.logs {
// List {
// ForEach (workoutLogs) { log in
// ListItem(
// title: log.exerciseName,
// subtitle: "\(log.sets) sets × \(log.reps) reps × \(log.weight) lbs"
// )
// }
// }
// } else {
// Text("No workout logs yet")
// }
// }
}
.navigationTitle("\(workout.split?.name ?? Split.unnamed) Split")
.toolbar {
@ -70,6 +46,9 @@ struct WorkoutEditView: View {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
try? modelContext.save()
if workout.status == .completed {
workout.end = workoutEndDate
}
dismiss()
}
}

View File

@ -10,7 +10,7 @@
import SwiftUI
import SwiftData
struct WorkoutsView: View {
struct WorkoutListView: View {
private let logger = AppLogger(
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Workouts",
category: "WorkoutsView"
@ -33,11 +33,12 @@ struct WorkoutsView: View {
} else {
List {
ForEach (workouts) { workout in
NavigationLink(destination: WorkoutLogView(workout: workout)) {
NavigationLink(destination: WorkoutLogListView(workout: workout)) {
CalendarListItem(
date: workout.start,
title: workout.split?.name ?? Split.unnamed,
subtitle: workout.label
subtitle: "\(workout.status == .completed ? workout.start.humanTimeInterval(to: (workout.end ?? Date())) : "\(workout.start.formattedDate()) - \(workout.status?.name ?? WorkoutStatus.unnamed)" )",
subtitle2: "\(workout.status?.name ?? WorkoutStatus.unnamed)"
)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
@ -59,16 +60,9 @@ struct WorkoutsView: View {
}
}
.navigationTitle("Workouts")
// .toolbar {
// ToolbarItem(placement: .primaryAction) {
// Button("Start Workout") {
// showingSplitPicker = true
// }
// }
// }
// .sheet(item: $itemToEdit) { item in
// WorkoutEditView(workout: item)
// }
.sheet(item: $itemToEdit) { item in
WorkoutEditView(workout: item)
}
.confirmationDialog(
"Delete?",
isPresented: Binding<Bool>(
@ -94,18 +88,18 @@ struct WorkoutsView: View {
}
.sheet(isPresented: $showingSplitPicker) {
SplitPickerView { split in
let workout = Workout(start: Date(), split: split)
let workout = Workout(start: Date(), end: Date(), split: split)
modelContext.insert(workout)
if let exercises = split.exercises {
for assignment in exercises {
for exercise in exercises {
let workoutLog = WorkoutLog(
workout: workout,
exerciseName: assignment.exerciseName,
exerciseName: exercise.name,
date: Date(),
order: assignment.order,
sets: assignment.sets,
reps: assignment.reps,
weight: assignment.weight
order: exercise.order,
sets: exercise.sets,
reps: exercise.reps,
weight: exercise.weight
)
modelContext.insert(workoutLog)
}

View File

@ -19,7 +19,7 @@ struct WorkoutsApp: App {
@State private var cloudKitObserver: NSObjectProtocol?
init() {
self.container = WorkoutsContainer.create()
self.container = AppContainer.create()
// Set up CloudKit notification observation
setupCloudKitObservation()

View File

@ -77,7 +77,7 @@ struct SettingsView: View {
private func clearAllData () {
do {
try deleteAllObjects(ofType: Split.self, from: modelContext)
try deleteAllObjects(ofType: SplitExerciseAssignment.self, from: modelContext)
try deleteAllObjects(ofType: Exercise.self, from: modelContext)
try deleteAllObjects(ofType: Workout.self, from: modelContext)
try deleteAllObjects(ofType: WorkoutLog.self, from: modelContext)
try modelContext.save()