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