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,59 @@
//
// CalendarListItem.swift
// Workouts
//
// Created by rzen on 7/18/25 at 8:44 AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct CalendarListItem: View {
var date: Date
var title: String
var subtitle: String?
var subtitle2: 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(alignment: .top) {
VStack(alignment: .leading) {
Text(title)
.font(.headline)
if let subtitle = subtitle {
Text(subtitle)
.font(.footnote)
}
if let subtitle2 = subtitle2 {
Text(subtitle2)
.font(.footnote)
}
}
if let count = count {
Spacer()
Text("\(count)")
.font(.caption)
.foregroundColor(.gray)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
}

View File

@@ -0,0 +1,45 @@
//
// CheckboxListItem.swift
// Workouts
//
// Created by rzen on 7/13/25 at 10:42 AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
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

@@ -0,0 +1,48 @@
//
// CheckboxStatus.swift
// Workouts
//
// Created by rzen on 7/20/25 at 11:07 AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
enum CheckboxStatus {
case checked
case unchecked
case intermediate
case cancelled
var color: Color {
switch self {
case .checked: .green
case .unchecked: .gray
case .intermediate: .yellow
case .cancelled: .red
}
}
var systemName: String {
switch self {
case .checked: "checkmark.circle.fill"
case .unchecked: "circle"
case .intermediate: "ellipsis.circle"
case .cancelled: "xmark.circle"
}
}
}
// MARK: - WorkoutStatus Extension
extension WorkoutStatus {
var checkboxStatus: CheckboxStatus {
switch self {
case .notStarted: .unchecked
case .inProgress: .intermediate
case .completed: .checked
case .skipped: .cancelled
}
}
}

View File

@@ -0,0 +1,54 @@
//
// ListItem.swift
// Workouts
//
// Created by rzen on 7/13/25 at 10:42 AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct ListItem: View {
var systemName: String?
var title: String?
var text: String?
var subtitle: String?
var count: Int?
var body: some View {
HStack {
if let systemName = systemName {
Image(systemName: systemName)
}
VStack(alignment: .leading) {
if let title = title {
Text("\(title)")
.font(.headline)
if let text = text {
Text("\(text)")
.font(.footnote)
}
} else {
if let text = text {
Text("\(text)")
}
}
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

@@ -0,0 +1,228 @@
//
// SFSymbolPicker.swift
// Workouts
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct SFSymbolPicker: View {
@Environment(\.dismiss) private var dismiss
@Binding var selection: String
@State private var searchText: String = ""
private let columns = [
GridItem(.adaptive(minimum: 50, maximum: 60))
]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(filteredSymbols, id: \.self) { symbol in
Button {
selection = symbol
dismiss()
} label: {
VStack {
Image(systemName: symbol)
.font(.title2)
.frame(width: 44, height: 44)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(selection == symbol ? Color.accentColor : Color.secondary.opacity(0.2))
)
.foregroundColor(selection == symbol ? .white : .primary)
}
}
.buttonStyle(.plain)
}
}
.padding()
}
.searchable(text: $searchText, prompt: "Search symbols")
.navigationTitle("Choose Icon")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
}
private var filteredSymbols: [String] {
if searchText.isEmpty {
return Self.workoutSymbols
}
return Self.workoutSymbols.filter { $0.localizedCaseInsensitiveContains(searchText) }
}
// Curated list of workout/fitness-related SF Symbols
static let workoutSymbols: [String] = [
// Fitness & Exercise
"dumbbell.fill",
"dumbbell",
"figure.strengthtraining.traditional",
"figure.strengthtraining.functional",
"figure.cross.training",
"figure.core.training",
"figure.cooldown",
"figure.flexibility",
"figure.pilates",
"figure.yoga",
"figure.highintensity.intervaltraining",
"figure.mixed.cardio",
"figure.rower",
"figure.elliptical",
"figure.stair.stepper",
"figure.step.training",
// Running & Walking
"figure.run",
"figure.run.circle",
"figure.run.circle.fill",
"figure.walk",
"figure.walk.circle",
"figure.walk.circle.fill",
"figure.hiking",
"figure.outdoor.cycle",
"figure.indoor.cycle",
// Sports
"figure.boxing",
"figure.kickboxing",
"figure.martial.arts",
"figure.wrestling",
"figure.gymnastics",
"figure.handball",
"figure.basketball",
"figure.tennis",
"figure.badminton",
"figure.racquetball",
"figure.squash",
"figure.volleyball",
"figure.baseball",
"figure.softball",
"figure.golf",
"figure.soccer",
"figure.american.football",
"figure.rugby",
"figure.hockey",
"figure.lacrosse",
"figure.cricket",
"figure.table.tennis",
"figure.fencing",
"figure.archery",
"figure.bowling",
"figure.disc.sports",
// Water Sports
"figure.pool.swim",
"figure.open.water.swim",
"figure.surfing",
"figure.waterpolo",
"figure.rowing",
"figure.sailing",
"figure.fishing",
// Winter Sports
"figure.skiing.downhill",
"figure.skiing.crosscountry",
"figure.snowboarding",
"figure.skating",
// Climbing & Adventure
"figure.climbing",
"figure.equestrian.sports",
"figure.hunting",
// Mind & Body
"figure.mind.and.body",
"figure.dance",
"figure.barre",
"figure.socialdance",
"figure.australian.football",
// General Activity
"figure.stand",
"figure.wave",
"figure.roll",
"figure.jumprope",
"figure.play",
"figure.child",
// Health & Body
"heart.fill",
"heart",
"heart.circle",
"heart.circle.fill",
"bolt.heart.fill",
"bolt.heart",
"waveform.path.ecg",
"lungs.fill",
"lungs",
// Energy & Power
"bolt.fill",
"bolt",
"bolt.circle",
"bolt.circle.fill",
"flame.fill",
"flame",
"flame.circle",
"flame.circle.fill",
// Timer & Tracking
"stopwatch",
"stopwatch.fill",
"timer",
"timer.circle",
"timer.circle.fill",
"clock",
"clock.fill",
// Progress & Goals
"trophy.fill",
"trophy",
"trophy.circle",
"trophy.circle.fill",
"medal.fill",
"medal",
"star.fill",
"star",
"star.circle",
"star.circle.fill",
"target",
"scope",
"chart.bar.fill",
"chart.line.uptrend.xyaxis",
"arrow.up.circle.fill",
// Misc
"scalemass.fill",
"scalemass",
"bed.double.fill",
"bed.double",
"moon.fill",
"moon",
"sun.max.fill",
"sun.max",
"drop.fill",
"drop",
"leaf.fill",
"leaf",
"carrot.fill",
"carrot",
"fork.knife",
"cup.and.saucer.fill",
]
}
#Preview {
SFSymbolPicker(selection: .constant("dumbbell.fill"))
}

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

View File

@@ -0,0 +1,37 @@
//
// SettingsView.swift
// Workouts
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import IndieAbout
struct SettingsView: View {
var body: some View {
NavigationStack {
Form {
Section(header: Text("Account")) {
Text("Settings coming soon")
.foregroundColor(.secondary)
}
Section {
IndieAbout(configuration: AppInfoConfiguration(
documents: [
.custom(title: "Changelog", filename: "CHANGELOG", extension: "md"),
.license(),
.acknowledgements()
]
))
}
}
.navigationTitle("Settings")
}
}
}
#Preview {
SettingsView()
}

View File

@@ -0,0 +1,30 @@
//
// OrderableItem.swift
// Workouts
//
// Created by rzen on 7/18/25 at 5:19 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
/// Protocol for items that can be ordered in a sequence
protocol OrderableItem {
/// Updates the order of the item to the specified index
func updateOrder(to index: Int)
}
/// Extension to make Split conform to OrderableItem
extension Split: OrderableItem {
func updateOrder(to index: Int) {
self.order = Int32(index)
}
}
/// Extension to make Exercise conform to OrderableItem
extension Exercise: OrderableItem {
func updateOrder(to index: Int) {
self.order = Int32(index)
}
}

View File

@@ -0,0 +1,97 @@
//
// SortableForEach.swift
// Workouts
//
// Created by rzen on 7/18/25 at 2:04 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import UniformTypeIdentifiers
public struct SortableForEach<Data, Content>: View where Data: Hashable, Content: View {
@Binding var data: [Data]
@Binding var allowReordering: Bool
private let content: (Data, Bool) -> Content
@State private var draggedItem: Data?
@State private var hasChangedLocation: Bool = false
public init(_ data: Binding<[Data]>,
allowReordering: Binding<Bool>,
@ViewBuilder content: @escaping (Data, Bool) -> Content) {
_data = data
_allowReordering = allowReordering
self.content = content
}
public var body: some View {
ForEach(data, id: \.self) { item in
if allowReordering {
content(item, hasChangedLocation && draggedItem == item)
.onDrag {
draggedItem = item
return NSItemProvider(object: "\(item.hashValue)" as NSString)
}
.onDrop(of: [UTType.plainText], delegate: DragRelocateDelegate(
item: item,
data: $data,
draggedItem: $draggedItem,
hasChangedLocation: $hasChangedLocation))
} else {
content(item, false)
}
}
}
struct DragRelocateDelegate<ItemType>: DropDelegate where ItemType: Equatable {
let item: ItemType
@Binding var data: [ItemType]
@Binding var draggedItem: ItemType?
@Binding var hasChangedLocation: Bool
func dropEntered(info: DropInfo) {
guard item != draggedItem,
let current = draggedItem,
let from = data.firstIndex(of: current),
let to = data.firstIndex(of: item)
else {
return
}
hasChangedLocation = true
if data[to] != current {
withAnimation {
data.move(
fromOffsets: IndexSet(integer: from),
toOffset: (to > from) ? to + 1 : to
)
}
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
// Update the order property of each item to match its position in the array
updateItemOrders()
hasChangedLocation = false
draggedItem = nil
return true
}
// Helper method to update the order property of each item
private func updateItemOrders() {
for (index, item) in data.enumerated() {
if let orderableItem = item as? any OrderableItem {
orderableItem.updateOrder(to: index)
}
}
}
}
}

View File

@@ -0,0 +1,148 @@
//
// SplitAddEditView.swift
// Workouts
//
// Created by rzen on 7/18/25 at 9:42 AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import CoreData
struct SplitAddEditView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(\.dismiss) private var dismiss
let split: Split?
var onDelete: (() -> Void)?
@State private var name: String = ""
@State private var color: String = "indigo"
@State private var systemImage: String = "dumbbell.fill"
@State private var showingIconPicker: Bool = false
@State private var showingDeleteConfirmation: Bool = false
private let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
var isEditing: Bool { split != nil }
init(split: Split?, onDelete: (() -> Void)? = nil) {
self.split = split
self.onDelete = onDelete
if let split = split {
_name = State(initialValue: split.name)
_color = State(initialValue: split.color)
_systemImage = State(initialValue: split.systemImage)
}
}
var body: some View {
NavigationStack {
Form {
Section(header: Text("Name")) {
TextField("Name", text: $name)
.bold()
}
Section(header: Text("Appearance")) {
Picker("Color", selection: $color) {
ForEach(availableColors, id: \.self) { colorName in
HStack {
Circle()
.fill(Color.color(from: colorName))
.frame(width: 20, height: 20)
Text(colorName.capitalized)
}
.tag(colorName)
}
}
Button {
showingIconPicker = true
} label: {
HStack {
Text("Icon")
.foregroundColor(.primary)
Spacer()
Image(systemName: systemImage)
.font(.title2)
.foregroundColor(.accentColor)
}
}
}
if let split = split {
Section(header: Text("Exercises")) {
NavigationLink {
ExerciseListView(split: split)
} label: {
ListItem(
text: "Exercises",
count: split.exercisesArray.count
)
}
}
Section {
Button("Delete Split", role: .destructive) {
showingDeleteConfirmation = true
}
}
}
}
.navigationTitle(isEditing ? "Edit Split" : "New Split")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
save()
dismiss()
}
.disabled(name.isEmpty)
}
}
.sheet(isPresented: $showingIconPicker) {
SFSymbolPicker(selection: $systemImage)
}
.confirmationDialog(
"Delete This Split?",
isPresented: $showingDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
if let split = split {
viewContext.delete(split)
try? viewContext.save()
dismiss()
onDelete?()
}
}
} message: {
Text("This will permanently delete the split and all its exercises.")
}
}
}
private func save() {
if let split = split {
// Update existing
split.name = name
split.color = color
split.systemImage = systemImage
} else {
// Create new
let newSplit = Split(context: viewContext)
newSplit.name = name
newSplit.color = color
newSplit.systemImage = systemImage
newSplit.order = 0
}
try? viewContext.save()
}
}

View File

@@ -0,0 +1,151 @@
//
// SplitDetailView.swift
// Workouts
//
// Created by rzen on 7/25/25 at 3:27 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import CoreData
struct SplitDetailView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(\.dismiss) private var dismiss
@ObservedObject var split: Split
@State private var showingExerciseAddSheet: Bool = false
@State private var showingSplitEditSheet: Bool = false
@State private var itemToEdit: Exercise? = nil
@State private var itemToDelete: Exercise? = nil
var body: some View {
NavigationStack {
Form {
Section(header: Text("What is a Split?")) {
Text("A \"split\" is simply how you divide (or \"split up\") your weekly training across different days. Instead of working every muscle group every session, you assign certain muscle groups, movement patterns, or training emphases to specific days.")
.font(.caption)
}
Section(header: Text("Exercises")) {
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 {
showingExerciseAddSheet = true
} label: {
ListItem(systemName: "plus.circle", title: "Add Exercise")
}
} else {
Text("No exercises added yet.")
Button(action: { showingExerciseAddSheet.toggle() }) {
ListItem(title: "Add Exercise")
}
}
}
}
.navigationTitle("\(split.name)")
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showingSplitEditSheet = true
} label: {
Image(systemName: "pencil")
}
}
}
.sheet(isPresented: $showingExerciseAddSheet) {
ExercisePickerView(onExerciseSelected: { exerciseNames in
addExercises(names: exerciseNames)
}, allowMultiSelect: true)
}
.sheet(isPresented: $showingSplitEditSheet) {
SplitAddEditView(split: split) {
dismiss()
}
}
.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 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,61 @@
//
// SplitItem.swift
// Workouts
//
// Created by rzen on 7/18/25 at 2:45 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct SplitItem: View {
@ObservedObject var split: Split
var body: some View {
VStack {
ZStack(alignment: .bottom) {
// Golden ratio rectangle (1:1.618)
RoundedRectangle(cornerRadius: 12)
.fill(
LinearGradient(
gradient: Gradient(colors: [splitColor, splitColor.darker(by: 0.2)]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.aspectRatio(1.618, contentMode: .fit)
.shadow(radius: 2)
GeometryReader { geo in
VStack(spacing: 4) {
Spacer()
// Icon in the center - using dynamic sizing
Image(systemName: split.systemImage)
.font(.system(size: min(geo.size.width * 0.3, 40), weight: .bold))
.scaledToFit()
.frame(maxWidth: geo.size.width * 0.6, maxHeight: geo.size.height * 0.4)
.padding(.bottom, 4)
// Name at the bottom inside the rectangle
Text(split.name)
.font(.headline)
.lineLimit(1)
.padding(.horizontal, 8)
Text("\(split.exercisesArray.count) exercises")
.font(.caption)
.padding(.bottom, 8)
}
.foregroundColor(.white)
.frame(width: geo.size.width, height: geo.size.height)
}
}
}
}
private var splitColor: Color {
Color.color(from: split.color)
}
}

View File

@@ -0,0 +1,71 @@
//
// SplitListView.swift
// Workouts
//
// Created by rzen on 7/25/25 at 6:24 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import CoreData
struct SplitListView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \Split.order, ascending: true),
NSSortDescriptor(keyPath: \Split.name, ascending: true)
],
animation: .default
)
private var fetchedSplits: FetchedResults<Split>
@State private var splits: [Split] = []
@State private var allowSorting: Bool = true
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in
NavigationLink {
SplitDetailView(split: split)
} label: {
SplitItem(split: split)
.overlay(dragging ? Color.white.opacity(0.8) : Color.clear)
}
}
}
.padding()
}
.overlay {
if fetchedSplits.isEmpty {
ContentUnavailableView(
"No Splits Yet",
systemImage: "dumbbell.fill",
description: Text("Create a split to organize your workout routine.")
)
}
}
.onAppear {
splits = Array(fetchedSplits)
}
.onChange(of: fetchedSplits.count) { _, _ in
splits = Array(fetchedSplits)
}
.onChange(of: splits) { _, _ in
saveContext()
}
}
private func saveContext() {
if viewContext.hasChanges {
do {
try viewContext.save()
} catch {
print("Error saving after reorder: \(error)")
}
}
}
}

View File

@@ -0,0 +1,39 @@
//
// SplitsView.swift
// Workouts
//
// Created by rzen on 7/17/25 at 6:55 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import CoreData
struct SplitsView: View {
@Environment(\.managedObjectContext) private var viewContext
@State private var showingAddSheet: Bool = false
var body: some View {
NavigationStack {
SplitListView()
.navigationTitle("Splits")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { showingAddSheet.toggle() }) {
Image(systemName: "plus")
}
}
}
}
.sheet(isPresented: $showingAddSheet) {
SplitAddEditView(split: nil)
}
}
}
#Preview {
SplitsView()
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
}

View File

@@ -0,0 +1,256 @@
//
// WorkoutLogListView.swift
// Workouts
//
// Created by rzen on 7/13/25 at 6:58 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import CoreData
struct WorkoutLogListView: View {
@Environment(\.managedObjectContext) private var viewContext
@ObservedObject var workout: Workout
@State private var showingAddSheet = false
@State private var itemToDelete: WorkoutLog? = nil
var sortedWorkoutLogs: [WorkoutLog] {
workout.logsArray
}
var body: some View {
Group {
if sortedWorkoutLogs.isEmpty {
ContentUnavailableView {
Label("No Exercises", systemImage: "figure.strengthtraining.traditional")
} description: {
Text("Add exercises to start your workout.")
} actions: {
Button {
showingAddSheet = true
} label: {
Text("Add Exercise")
}
.buttonStyle(.borderedProminent)
}
} else {
Form {
Section(header: Text("\(workout.label)")) {
ForEach(sortedWorkoutLogs, id: \.objectID) { log in
let workoutLogStatus = log.status.checkboxStatus
CheckboxListItem(
status: workoutLogStatus,
title: log.exerciseName,
subtitle: "\(log.sets) × \(log.reps) reps × \(log.weight) lbs"
)
.contentShape(Rectangle())
.onTapGesture {
cycleStatus(for: log)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
completeLog(log)
} label: {
Label("Complete", systemImage: "checkmark.circle.fill")
}
.tint(.green)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button {
itemToDelete = log
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
}
.onMove(perform: moveLog)
}
}
}
}
.navigationTitle("\(workout.split?.name ?? Split.unnamed)")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { showingAddSheet.toggle() }) {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddSheet) {
SplitExercisePickerSheet(
split: workout.split,
existingExerciseNames: Set(sortedWorkoutLogs.map { $0.exerciseName })
) { exercise in
addExerciseFromSplit(exercise)
}
}
.confirmationDialog(
"Delete Exercise?",
isPresented: Binding<Bool>(
get: { itemToDelete != nil },
set: { if !$0 { itemToDelete = nil } }
),
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
if let item = itemToDelete {
withAnimation {
viewContext.delete(item)
updateWorkoutStatus()
try? viewContext.save()
itemToDelete = nil
}
}
}
Button("Cancel", role: .cancel) {
itemToDelete = nil
}
}
}
private func cycleStatus(for log: WorkoutLog) {
switch log.status {
case .notStarted:
log.status = .inProgress
case .inProgress:
log.status = .completed
case .completed:
log.status = .notStarted
case .skipped:
log.status = .notStarted
}
updateWorkoutStatus()
try? viewContext.save()
}
private func completeLog(_ log: WorkoutLog) {
log.status = .completed
updateWorkoutStatus()
try? viewContext.save()
}
private func updateWorkoutStatus() {
let logs = sortedWorkoutLogs
let allCompleted = logs.allSatisfy { $0.status == .completed }
let anyInProgress = logs.contains { $0.status == .inProgress }
let allNotStarted = logs.allSatisfy { $0.status == .notStarted }
if allCompleted {
workout.status = .completed
workout.end = Date()
} else if anyInProgress || !allNotStarted {
workout.status = .inProgress
} else {
workout.status = .notStarted
}
}
private func moveLog(from source: IndexSet, to destination: Int) {
var logs = sortedWorkoutLogs
logs.move(fromOffsets: source, toOffset: destination)
for (index, log) in logs.enumerated() {
log.order = Int32(index)
}
try? viewContext.save()
}
private func addExerciseFromSplit(_ exercise: Exercise) {
let now = Date()
// Update workout start time if this is the first exercise
if sortedWorkoutLogs.isEmpty {
workout.start = now
}
workout.end = nil
let log = WorkoutLog(context: viewContext)
log.exerciseName = exercise.name
log.date = now
log.order = Int32(sortedWorkoutLogs.count)
log.sets = exercise.sets
log.reps = exercise.reps
log.weight = exercise.weight
log.status = .notStarted
log.workout = workout
try? viewContext.save()
}
}
// MARK: - Split Exercise Picker Sheet
struct SplitExercisePickerSheet: View {
@Environment(\.dismiss) private var dismiss
let split: Split?
let existingExerciseNames: Set<String>
let onExerciseSelected: (Exercise) -> Void
private var availableExercises: [Exercise] {
guard let split = split else { return [] }
return split.exercisesArray.filter { !existingExerciseNames.contains($0.name) }
}
var body: some View {
NavigationStack {
Group {
if !availableExercises.isEmpty {
List {
ForEach(availableExercises, id: \.objectID) { exercise in
Button {
onExerciseSelected(exercise)
dismiss()
} label: {
HStack {
VStack(alignment: .leading) {
Text(exercise.name)
.foregroundColor(.primary)
Text("\(exercise.sets) × \(exercise.reps) × \(exercise.weight) lbs")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "plus.circle")
.foregroundColor(.accentColor)
}
}
}
}
} else if split == nil {
ContentUnavailableView(
"No Split Selected",
systemImage: "dumbbell",
description: Text("This workout has no associated split.")
)
} else if split?.exercisesArray.isEmpty == true {
ContentUnavailableView(
"No Exercises in Split",
systemImage: "dumbbell",
description: Text("Add exercises to your split first.")
)
} else {
ContentUnavailableView(
"All Exercises Added",
systemImage: "checkmark.circle",
description: Text("You've added all exercises from this split.")
)
}
}
.navigationTitle("Add Exercise")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
}
}

View File

@@ -0,0 +1,171 @@
//
// WorkoutLogsView.swift
// Workouts
//
// Created by rzen on 7/13/25 at 6:52 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import CoreData
struct WorkoutLogsView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Workout.start, ascending: false)],
animation: .default
)
private var workouts: FetchedResults<Workout>
@State private var showingSplitPicker = false
@State private var itemToDelete: Workout? = nil
var body: some View {
NavigationStack {
List {
ForEach(workouts, id: \.objectID) { workout in
NavigationLink(destination: WorkoutLogListView(workout: workout)) {
CalendarListItem(
date: workout.start,
title: workout.split?.name ?? Split.unnamed,
subtitle: getSubtitle(for: workout),
subtitle2: workout.statusName
)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button {
itemToDelete = workout
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
}
}
.overlay {
if workouts.isEmpty {
ContentUnavailableView(
"No Workouts Yet",
systemImage: "list.bullet.clipboard",
description: Text("Start a new workout from one of your splits.")
)
}
}
.navigationTitle("Workout Logs")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Start New") {
showingSplitPicker.toggle()
}
}
}
.sheet(isPresented: $showingSplitPicker) {
SplitPickerSheet()
}
.confirmationDialog(
"Delete Workout?",
isPresented: Binding<Bool>(
get: { itemToDelete != nil },
set: { if !$0 { 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 getSubtitle(for workout: Workout) -> String {
if workout.status == .completed, let endDate = workout.end {
return workout.start.humanTimeInterval(to: endDate)
} else {
return workout.start.formattedDate()
}
}
}
// MARK: - Split Picker Sheet
struct SplitPickerSheet: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(\.dismiss) private var dismiss
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \Split.order, ascending: true),
NSSortDescriptor(keyPath: \Split.name, ascending: true)
],
animation: .default
)
private var splits: FetchedResults<Split>
var body: some View {
NavigationStack {
List {
ForEach(splits, id: \.objectID) { split in
Button {
startWorkout(with: split)
} label: {
HStack {
Image(systemName: split.systemImage)
.foregroundColor(Color.color(from: split.color))
Text(split.name)
Spacer()
Text("\(split.exercisesArray.count) exercises")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
.navigationTitle("Select a Split")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
}
}
}
private func startWorkout(with split: Split) {
let workout = Workout(context: viewContext)
workout.start = 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()
dismiss()
}
}
#Preview {
WorkoutLogsView()
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
}