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:
256
Workouts/Views/WorkoutLogs/WorkoutLogListView.swift
Normal file
256
Workouts/Views/WorkoutLogs/WorkoutLogListView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
171
Workouts/Views/WorkoutLogs/WorkoutLogsView.swift
Normal file
171
Workouts/Views/WorkoutLogs/WorkoutLogsView.swift
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user