Add CoreData-based workout tracking app with iOS and watchOS targets
- Migrate from SwiftData to CoreData with CloudKit sync - Add core models: Split, Exercise, Workout, WorkoutLog - Implement tab-based UI: Workout Logs, Splits, Settings - Add SF Symbols picker for split icons - Add exercise picker filtered by split with exclusion of added exercises - Integrate IndieAbout for settings/about section - Add Yams for YAML exercise definition parsing - Include starter exercise libraries (bodyweight, Planet Fitness) - Add Date extensions for formatting (formattedTime, isSameDay) - Format workout date ranges to show time-only for same-day end dates - Add build number update script - Add app icons
This commit is contained in:
187
Workouts/Views/Exercises/ExerciseAddEditView.swift
Normal file
187
Workouts/Views/Exercises/ExerciseAddEditView.swift
Normal file
@@ -0,0 +1,187 @@
|
||||
//
|
||||
// ExerciseAddEditView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/15/25 at 7:12 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct ExerciseAddEditView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showingExercisePicker = false
|
||||
|
||||
@ObservedObject var exercise: Exercise
|
||||
|
||||
@State private var originalWeight: Int32? = nil
|
||||
@State private var loadType: LoadType = .none
|
||||
|
||||
@State private var minutes = 0
|
||||
@State private var seconds = 0
|
||||
|
||||
@State private var weight_tens = 0
|
||||
@State private var weight = 0
|
||||
|
||||
@State private var reps: Int = 0
|
||||
@State private var sets: Int = 0
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(header: Text("Exercise")) {
|
||||
if exercise.name.isEmpty {
|
||||
Button(action: {
|
||||
showingExercisePicker = true
|
||||
}) {
|
||||
HStack {
|
||||
Text("Select Exercise")
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ListItem(title: exercise.name)
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Sets/Reps")) {
|
||||
HStack(alignment: .bottom) {
|
||||
VStack(alignment: .center) {
|
||||
Text("Sets")
|
||||
Picker("", selection: $sets) {
|
||||
ForEach(0..<20) { s in
|
||||
Text("\(s)").tag(s)
|
||||
}
|
||||
}
|
||||
.frame(height: 100)
|
||||
.pickerStyle(.wheel)
|
||||
}
|
||||
VStack(alignment: .center) {
|
||||
Text("Reps")
|
||||
Picker("", selection: $reps) {
|
||||
ForEach(0..<100) { r in
|
||||
Text("\(r)").tag(r)
|
||||
}
|
||||
}
|
||||
.frame(height: 100)
|
||||
.pickerStyle(.wheel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Load Type"), footer: Text("For bodyweight exercises choose None. For resistance or weight training select Weight. For exercises that are time oriented (like plank or meditation) select Time.")) {
|
||||
Picker("", selection: $loadType) {
|
||||
ForEach(LoadType.allCases, id: \.self) { load in
|
||||
Text(load.name)
|
||||
.tag(load)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
if loadType == .weight {
|
||||
Section(header: Text("Weight")) {
|
||||
HStack {
|
||||
Picker("", selection: $weight_tens) {
|
||||
ForEach(0..<100) { lbs in
|
||||
Text("\(lbs * 10)").tag(lbs * 10)
|
||||
}
|
||||
}
|
||||
.frame(height: 100)
|
||||
.pickerStyle(.wheel)
|
||||
|
||||
Picker("", selection: $weight) {
|
||||
ForEach(0..<10) { lbs in
|
||||
Text("\(lbs)").tag(lbs)
|
||||
}
|
||||
}
|
||||
.frame(height: 100)
|
||||
.pickerStyle(.wheel)
|
||||
|
||||
Text("lbs")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if loadType == .duration {
|
||||
Section(header: Text("Duration")) {
|
||||
HStack {
|
||||
Picker("Minutes", selection: $minutes) {
|
||||
ForEach(0..<60) { minute in
|
||||
Text("\(minute) min").tag(minute)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
|
||||
Picker("Seconds", selection: $seconds) {
|
||||
ForEach(0..<60) { second in
|
||||
Text("\(second) sec").tag(second)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Weight Increase")) {
|
||||
HStack {
|
||||
Text("Remind every \(exercise.weightReminderTimeIntervalWeeks) weeks")
|
||||
Spacer()
|
||||
Stepper("", value: Binding(
|
||||
get: { Int(exercise.weightReminderTimeIntervalWeeks) },
|
||||
set: { exercise.weightReminderTimeIntervalWeeks = Int32($0) }
|
||||
), in: 0...366)
|
||||
}
|
||||
if let lastUpdated = exercise.weightLastUpdated {
|
||||
Text("Last weight change \(Date().humanTimeInterval(to: lastUpdated)) ago")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
originalWeight = exercise.weight
|
||||
weight_tens = Int(exercise.weight) / 10 * 10
|
||||
weight = Int(exercise.weight) % 10
|
||||
loadType = exercise.loadTypeEnum
|
||||
sets = Int(exercise.sets)
|
||||
reps = Int(exercise.reps)
|
||||
if let duration = exercise.duration {
|
||||
minutes = Int(duration.timeIntervalSince1970) / 60
|
||||
seconds = Int(duration.timeIntervalSince1970) % 60
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingExercisePicker) {
|
||||
ExercisePickerView { exerciseNames in
|
||||
exercise.name = exerciseNames.first ?? "Unnamed"
|
||||
}
|
||||
}
|
||||
.navigationTitle(exercise.name.isEmpty ? "New Exercise" : exercise.name)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
if let originalWeight = originalWeight, originalWeight != Int32(weight_tens + weight) {
|
||||
exercise.weightLastUpdated = Date()
|
||||
}
|
||||
exercise.duration = Date(timeIntervalSince1970: Double(minutes * 60 + seconds))
|
||||
exercise.weight = Int32(weight_tens + weight)
|
||||
exercise.sets = Int32(sets)
|
||||
exercise.reps = Int32(reps)
|
||||
exercise.loadType = Int32(loadType.rawValue)
|
||||
try? viewContext.save()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
Workouts/Views/Exercises/ExerciseListLoader.swift
Normal file
75
Workouts/Views/Exercises/ExerciseListLoader.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
//
|
||||
// ExerciseListLoader.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Yams
|
||||
|
||||
class ExerciseListLoader {
|
||||
struct ExerciseListData: Codable {
|
||||
let name: String
|
||||
let source: String
|
||||
let exercises: [ExerciseItem]
|
||||
|
||||
struct ExerciseItem: Codable, Identifiable {
|
||||
let name: String
|
||||
let descr: String
|
||||
let type: String
|
||||
let split: String
|
||||
|
||||
var id: String { name }
|
||||
}
|
||||
}
|
||||
|
||||
static func loadExerciseLists() -> [String: ExerciseListData] {
|
||||
var exerciseLists: [String: ExerciseListData] = [:]
|
||||
|
||||
guard let resourcePath = Bundle.main.resourcePath else {
|
||||
print("Could not find resource path")
|
||||
return exerciseLists
|
||||
}
|
||||
|
||||
do {
|
||||
let fileManager = FileManager.default
|
||||
let resourceURL = URL(fileURLWithPath: resourcePath)
|
||||
let yamlFiles = try fileManager.contentsOfDirectory(at: resourceURL, includingPropertiesForKeys: nil)
|
||||
.filter { $0.pathExtension == "yaml" && $0.lastPathComponent.hasSuffix(".exercises.yaml") }
|
||||
|
||||
for yamlFile in yamlFiles {
|
||||
let fileName = yamlFile.lastPathComponent
|
||||
do {
|
||||
let yamlString = try String(contentsOf: yamlFile, encoding: .utf8)
|
||||
if let exerciseList = try Yams.load(yaml: yamlString) as? [String: Any],
|
||||
let name = exerciseList["name"] as? String,
|
||||
let source = exerciseList["source"] as? String,
|
||||
let exercisesData = exerciseList["exercises"] as? [[String: Any]] {
|
||||
|
||||
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 split = exerciseData["split"] as? String {
|
||||
let exercise = ExerciseListData.ExerciseItem(name: name, descr: descr, type: type, split: split)
|
||||
exercises.append(exercise)
|
||||
}
|
||||
}
|
||||
|
||||
let exerciseList = ExerciseListData(name: name, source: source, exercises: exercises)
|
||||
exerciseLists[fileName] = exerciseList
|
||||
}
|
||||
} catch {
|
||||
print("Error loading YAML file \(fileName): \(error)")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Error listing directory contents: \(error)")
|
||||
}
|
||||
|
||||
return exerciseLists
|
||||
}
|
||||
}
|
||||
163
Workouts/Views/Exercises/ExerciseListView.swift
Normal file
163
Workouts/Views/Exercises/ExerciseListView.swift
Normal file
@@ -0,0 +1,163 @@
|
||||
//
|
||||
// ExerciseListView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 8:38 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct ExerciseListView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@ObservedObject var split: Split
|
||||
|
||||
@State private var showingAddSheet: Bool = false
|
||||
@State private var itemToEdit: Exercise? = nil
|
||||
@State private var itemToDelete: Exercise? = nil
|
||||
@State private var createdWorkout: Workout? = nil
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
let sortedExercises = split.exercisesArray
|
||||
|
||||
if !sortedExercises.isEmpty {
|
||||
ForEach(sortedExercises, id: \.objectID) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
|
||||
)
|
||||
.swipeActions {
|
||||
Button {
|
||||
itemToDelete = item
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
Button {
|
||||
itemToEdit = item
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
}
|
||||
}
|
||||
.onMove(perform: moveExercises)
|
||||
|
||||
Button {
|
||||
showingAddSheet = true
|
||||
} label: {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
} else {
|
||||
Text("No exercises added yet.")
|
||||
Button(action: { showingAddSheet.toggle() }) {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("\(split.name)")
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Start This Split") {
|
||||
startWorkout()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $createdWorkout) { workout in
|
||||
WorkoutLogListView(workout: workout)
|
||||
}
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
ExercisePickerView(onExerciseSelected: { exerciseNames in
|
||||
addExercises(names: exerciseNames)
|
||||
}, allowMultiSelect: true)
|
||||
}
|
||||
.sheet(item: $itemToEdit) { item in
|
||||
ExerciseAddEditView(exercise: item)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Exercise?",
|
||||
isPresented: .constant(itemToDelete != nil),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let item = itemToDelete {
|
||||
withAnimation {
|
||||
viewContext.delete(item)
|
||||
try? viewContext.save()
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func moveExercises(from source: IndexSet, to destination: Int) {
|
||||
var exercises = split.exercisesArray
|
||||
exercises.move(fromOffsets: source, toOffset: destination)
|
||||
for (index, exercise) in exercises.enumerated() {
|
||||
exercise.order = Int32(index)
|
||||
}
|
||||
try? viewContext.save()
|
||||
}
|
||||
|
||||
private func startWorkout() {
|
||||
let workout = Workout(context: viewContext)
|
||||
workout.start = Date()
|
||||
workout.end = Date()
|
||||
workout.status = .notStarted
|
||||
workout.split = split
|
||||
|
||||
for exercise in split.exercisesArray {
|
||||
let workoutLog = WorkoutLog(context: viewContext)
|
||||
workoutLog.exerciseName = exercise.name
|
||||
workoutLog.date = Date()
|
||||
workoutLog.order = exercise.order
|
||||
workoutLog.sets = exercise.sets
|
||||
workoutLog.reps = exercise.reps
|
||||
workoutLog.weight = exercise.weight
|
||||
workoutLog.status = .notStarted
|
||||
workoutLog.workout = workout
|
||||
}
|
||||
|
||||
try? viewContext.save()
|
||||
createdWorkout = workout
|
||||
}
|
||||
|
||||
private func addExercises(names: [String]) {
|
||||
if names.count == 1 {
|
||||
let exercise = Exercise(context: viewContext)
|
||||
exercise.name = names.first ?? "Unnamed"
|
||||
exercise.order = Int32(split.exercisesArray.count)
|
||||
exercise.sets = 3
|
||||
exercise.reps = 10
|
||||
exercise.weight = 40
|
||||
exercise.loadType = Int32(LoadType.weight.rawValue)
|
||||
exercise.split = split
|
||||
try? viewContext.save()
|
||||
itemToEdit = exercise
|
||||
} else {
|
||||
let existingNames = Set(split.exercisesArray.map { $0.name })
|
||||
for name in names where !existingNames.contains(name) {
|
||||
let exercise = Exercise(context: viewContext)
|
||||
exercise.name = name
|
||||
exercise.order = Int32(split.exercisesArray.count)
|
||||
exercise.sets = 3
|
||||
exercise.reps = 10
|
||||
exercise.weight = 40
|
||||
exercise.loadType = Int32(LoadType.weight.rawValue)
|
||||
exercise.split = split
|
||||
}
|
||||
try? viewContext.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
145
Workouts/Views/Exercises/ExercisePickerView.swift
Normal file
145
Workouts/Views/Exercises/ExercisePickerView.swift
Normal file
@@ -0,0 +1,145 @@
|
||||
//
|
||||
// ExercisePickerView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/13/25 at 7:17 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Exercise Lists")
|
||||
} else if let fileName = selectedListName, let list = exerciseLists[fileName] {
|
||||
// Show exercises in the selected list grouped by split
|
||||
List {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(list.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadExerciseLists()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadExerciseLists() {
|
||||
exerciseLists = ExerciseListLoader.loadExerciseLists()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user