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:
30
Workouts/Views/Splits/OrderableItem.swift
Normal file
30
Workouts/Views/Splits/OrderableItem.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
97
Workouts/Views/Splits/SortableForEach.swift
Normal file
97
Workouts/Views/Splits/SortableForEach.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
148
Workouts/Views/Splits/SplitAddEditView.swift
Normal file
148
Workouts/Views/Splits/SplitAddEditView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
151
Workouts/Views/Splits/SplitDetailView.swift
Normal file
151
Workouts/Views/Splits/SplitDetailView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
61
Workouts/Views/Splits/SplitItem.swift
Normal file
61
Workouts/Views/Splits/SplitItem.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
71
Workouts/Views/Splits/SplitListView.swift
Normal file
71
Workouts/Views/Splits/SplitListView.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
Workouts/Views/Splits/SplitsView.swift
Normal file
39
Workouts/Views/Splits/SplitsView.swift
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user