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:
2026-01-19 06:42:15 -05:00
parent 2bfeb6a165
commit 13313a32d3
77 changed files with 3876 additions and 48 deletions

View 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()
}
}
}
}
}
}

View 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
}
}

View 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()
}
}
}

View 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()
}
}