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

@@ -1,22 +1,25 @@
//
// ContentView.swift
// Workouts
// Workouts Watch App
//
// Created by rzen on 8/13/25 at 11:10AM.
// Created by rzen on 8/13/25 at 11:10 AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import CoreData
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
var body: some View {
VStack {
Image(systemName: "globe")
Image(systemName: "dumbbell.fill")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
Text("Workouts")
}
.padding()
}
@@ -24,4 +27,5 @@ struct ContentView: View {
#Preview {
ContentView()
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
}

View File

@@ -0,0 +1,54 @@
import Foundation
import CoreData
@objc(Exercise)
public class Exercise: NSManagedObject, Identifiable {
@NSManaged public var name: String
@NSManaged public var loadType: Int32
@NSManaged public var order: Int32
@NSManaged public var sets: Int32
@NSManaged public var reps: Int32
@NSManaged public var weight: Int32
@NSManaged public var duration: Date?
@NSManaged public var weightLastUpdated: Date?
@NSManaged public var weightReminderTimeIntervalWeeks: Int32
@NSManaged public var split: Split?
public var id: NSManagedObjectID { objectID }
var loadTypeEnum: LoadType {
get { LoadType(rawValue: Int(loadType)) ?? .weight }
set { loadType = Int32(newValue.rawValue) }
}
}
// MARK: - Fetch Request
extension Exercise {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Exercise> {
return NSFetchRequest<Exercise>(entityName: "Exercise")
}
static func orderedFetchRequest(for split: Split) -> NSFetchRequest<Exercise> {
let request = fetchRequest()
request.predicate = NSPredicate(format: "split == %@", split)
request.sortDescriptors = [NSSortDescriptor(keyPath: \Exercise.order, ascending: true)]
return request
}
}
enum LoadType: Int, CaseIterable {
case none = 0
case weight = 1
case duration = 2
var name: String {
switch self {
case .none: "None"
case .weight: "Weight"
case .duration: "Duration"
}
}
}

View File

@@ -0,0 +1,66 @@
import Foundation
import CoreData
import SwiftUI
@objc(Split)
public class Split: NSManagedObject, Identifiable {
@NSManaged public var name: String
@NSManaged public var color: String
@NSManaged public var systemImage: String
@NSManaged public var order: Int32
@NSManaged public var exercises: NSSet?
@NSManaged public var workouts: NSSet?
public var id: NSManagedObjectID { objectID }
static let unnamed = "Unnamed Split"
}
// MARK: - Convenience Accessors
extension Split {
var exercisesArray: [Exercise] {
let set = exercises as? Set<Exercise> ?? []
return set.sorted { $0.order < $1.order }
}
var workoutsArray: [Workout] {
let set = workouts as? Set<Workout> ?? []
return set.sorted { $0.start > $1.start }
}
func addToExercises(_ exercise: Exercise) {
let items = mutableSetValue(forKey: "exercises")
items.add(exercise)
}
func removeFromExercises(_ exercise: Exercise) {
let items = mutableSetValue(forKey: "exercises")
items.remove(exercise)
}
func addToWorkouts(_ workout: Workout) {
let items = mutableSetValue(forKey: "workouts")
items.add(workout)
}
func removeFromWorkouts(_ workout: Workout) {
let items = mutableSetValue(forKey: "workouts")
items.remove(workout)
}
}
// MARK: - Fetch Request
extension Split {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Split> {
return NSFetchRequest<Split>(entityName: "Split")
}
static func orderedFetchRequest() -> NSFetchRequest<Split> {
let request = fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Split.order, ascending: true)]
return request
}
}

View File

@@ -0,0 +1,71 @@
import Foundation
import CoreData
@objc(Workout)
public class Workout: NSManagedObject, Identifiable {
@NSManaged public var start: Date
@NSManaged public var end: Date?
@NSManaged private var statusRaw: String
@NSManaged public var split: Split?
@NSManaged public var logs: NSSet?
public var id: NSManagedObjectID { objectID }
var status: WorkoutStatus {
get { WorkoutStatus(rawValue: statusRaw) ?? .notStarted }
set { statusRaw = newValue.rawValue }
}
var label: String {
if status == .completed, let endDate = end {
return "\(start.formattedDate())\(endDate.formattedDate())"
} else {
return start.formattedDate()
}
}
var statusName: String {
return status.displayName
}
}
// MARK: - Convenience Accessors
extension Workout {
var logsArray: [WorkoutLog] {
let set = logs as? Set<WorkoutLog> ?? []
return set.sorted { $0.order < $1.order }
}
func addToLogs(_ log: WorkoutLog) {
let items = mutableSetValue(forKey: "logs")
items.add(log)
}
func removeFromLogs(_ log: WorkoutLog) {
let items = mutableSetValue(forKey: "logs")
items.remove(log)
}
}
// MARK: - Fetch Request
extension Workout {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Workout> {
return NSFetchRequest<Workout>(entityName: "Workout")
}
static func recentFetchRequest() -> NSFetchRequest<Workout> {
let request = fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Workout.start, ascending: false)]
return request
}
static func fetchRequest(for split: Split) -> NSFetchRequest<Workout> {
let request = fetchRequest()
request.predicate = NSPredicate(format: "split == %@", split)
request.sortDescriptors = [NSSortDescriptor(keyPath: \Workout.start, ascending: false)]
return request
}
}

View File

@@ -0,0 +1,43 @@
import Foundation
import CoreData
@objc(WorkoutLog)
public class WorkoutLog: NSManagedObject, Identifiable {
@NSManaged public var date: Date
@NSManaged public var sets: Int32
@NSManaged public var reps: Int32
@NSManaged public var weight: Int32
@NSManaged private var statusRaw: String?
@NSManaged public var order: Int32
@NSManaged public var exerciseName: String
@NSManaged public var currentStateIndex: Int32
@NSManaged public var elapsedSeconds: Int32
@NSManaged public var completed: Bool
@NSManaged public var workout: Workout?
public var id: NSManagedObjectID { objectID }
var status: WorkoutStatus? {
get {
guard let raw = statusRaw else { return nil }
return WorkoutStatus(rawValue: raw)
}
set { statusRaw = newValue?.rawValue }
}
}
// MARK: - Fetch Request
extension WorkoutLog {
@nonobjc public class func fetchRequest() -> NSFetchRequest<WorkoutLog> {
return NSFetchRequest<WorkoutLog>(entityName: "WorkoutLog")
}
static func orderedFetchRequest(for workout: Workout) -> NSFetchRequest<WorkoutLog> {
let request = fetchRequest()
request.predicate = NSPredicate(format: "workout == %@", workout)
request.sortDescriptors = [NSSortDescriptor(keyPath: \WorkoutLog.order, ascending: true)]
return request
}
}

View File

@@ -0,0 +1,23 @@
import Foundation
enum WorkoutStatus: String, CaseIterable, Codable {
case notStarted = "notStarted"
case inProgress = "inProgress"
case completed = "completed"
case skipped = "skipped"
var displayName: String {
switch self {
case .notStarted:
return "Not Started"
case .inProgress:
return "In Progress"
case .completed:
return "Completed"
case .skipped:
return "Skipped"
}
}
var name: String { displayName }
}

View File

@@ -0,0 +1,119 @@
import CoreData
import CloudKit
struct PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentCloudKitContainer
// CloudKit container identifier - same as iOS app for sync
static let cloudKitContainerIdentifier = "iCloud.dev.rzen.indie.Workouts"
var viewContext: NSManagedObjectContext {
container.viewContext
}
// MARK: - Preview Support
static var preview: PersistenceController = {
let controller = PersistenceController(inMemory: true)
let viewContext = controller.container.viewContext
// Create sample data for previews
let split = Split(context: viewContext)
split.name = "Upper Body"
split.color = "blue"
split.systemImage = "dumbbell.fill"
split.order = 0
let exercise = Exercise(context: viewContext)
exercise.name = "Bench Press"
exercise.sets = 3
exercise.reps = 10
exercise.weight = 135
exercise.order = 0
exercise.loadType = Int32(LoadType.weight.rawValue)
exercise.split = split
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return controller
}()
// MARK: - Initialization
init(inMemory: Bool = false, cloudKitEnabled: Bool = true) {
container = NSPersistentCloudKitContainer(name: "Workouts")
guard let description = container.persistentStoreDescriptions.first else {
fatalError("Failed to retrieve a persistent store description.")
}
if inMemory {
description.url = URL(fileURLWithPath: "/dev/null")
description.cloudKitContainerOptions = nil
} else if cloudKitEnabled {
// Check if CloudKit is available before enabling
let cloudKitAvailable = FileManager.default.ubiquityIdentityToken != nil
if cloudKitAvailable {
// Set CloudKit container options
let cloudKitOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: Self.cloudKitContainerIdentifier
)
description.cloudKitContainerOptions = cloudKitOptions
} else {
// CloudKit not available (not signed in, etc.)
description.cloudKitContainerOptions = nil
print("CloudKit not available - using local storage only")
}
// Enable persistent history tracking (useful even without CloudKit)
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
} else {
// CloudKit explicitly disabled
description.cloudKitContainerOptions = nil
}
container.loadPersistentStores { storeDescription, error in
if let error = error as NSError? {
// In production, handle this more gracefully
print("CoreData error: \(error), \(error.userInfo)")
#if DEBUG
fatalError("Unresolved error \(error), \(error.userInfo)")
#endif
}
}
// Configure view context
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
// Pin the viewContext to the current generation token
do {
try container.viewContext.setQueryGenerationFrom(.current)
} catch {
print("Failed to pin viewContext to the current generation: \(error)")
}
}
// MARK: - Save Context
func save() {
let context = container.viewContext
if context.hasChanges {
do {
try context.save()
} catch {
let nsError = error as NSError
print("Save error: \(nsError), \(nsError.userInfo)")
}
}
}
}

View File

@@ -0,0 +1,31 @@
import SwiftUI
extension Color {
static func color(from name: String) -> Color {
switch name.lowercased() {
case "red": return .red
case "orange": return .orange
case "yellow": return .yellow
case "green": return .green
case "mint": return .mint
case "teal": return .teal
case "cyan": return .cyan
case "blue": return .blue
case "indigo": return .indigo
case "purple": return .purple
case "pink": return .pink
case "brown": return .brown
default: return .indigo
}
}
func darker(by percentage: CGFloat = 0.2) -> Color {
return self.opacity(1.0 - percentage)
}
}
// Available colors for splits
let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
// Available system images for splits
let availableIcons = ["dumbbell.fill", "figure.strengthtraining.traditional", "figure.run", "figure.hiking", "figure.cooldown", "figure.boxing", "figure.wrestling", "figure.gymnastics", "figure.handball", "figure.core.training", "heart.fill", "bolt.fill"]

View File

@@ -0,0 +1,56 @@
import Foundation
extension Date {
func formattedDate() -> String {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter.string(from: self)
}
func formattedTime() -> String {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter.string(from: self)
}
func isSameDay(as other: Date) -> Bool {
Calendar.current.isDate(self, inSameDayAs: other)
}
func formatDate() -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter.string(from: self)
}
var abbreviatedMonth: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM"
return formatter.string(from: self)
}
var dayOfMonth: Int {
Calendar.current.component(.day, from: self)
}
var abbreviatedWeekday: String {
let formatter = DateFormatter()
formatter.dateFormat = "EEE"
return formatter.string(from: self)
}
func humanTimeInterval(to other: Date) -> String {
let interval = other.timeIntervalSince(self)
let hours = Int(interval) / 3600
let minutes = (Int(interval) % 3600) / 60
if hours > 0 {
return "\(hours)h \(minutes)m"
} else {
return "\(minutes)m"
}
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.dev.rzen.indie.Workouts</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>Workouts.xcdatamodel</string>
</dict>
</plist>

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23231" systemVersion="24D5034f" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
<entity name="Split" representedClassName="Split" syncable="YES">
<attribute name="color" attributeType="String" defaultValueString="indigo"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="systemImage" attributeType="String" defaultValueString="dumbbell.fill"/>
<relationship name="exercises" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Exercise" inverseName="split" inverseEntity="Exercise"/>
<relationship name="workouts" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Workout" inverseName="split" inverseEntity="Workout"/>
</entity>
<entity name="Exercise" representedClassName="Exercise" syncable="YES">
<attribute name="duration" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
<attribute name="loadType" attributeType="Integer 32" defaultValueString="1" usesScalarValueType="YES"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="reps" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sets" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="weight" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="weightLastUpdated" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
<attribute name="weightReminderTimeIntervalWeeks" attributeType="Integer 32" defaultValueString="2" usesScalarValueType="YES"/>
<relationship name="split" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Split" inverseName="exercises" inverseEntity="Split"/>
</entity>
<entity name="Workout" representedClassName="Workout" syncable="YES">
<attribute name="end" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="start" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
<attribute name="status" attributeType="String" defaultValueString="notStarted"/>
<relationship name="logs" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="WorkoutLog" inverseName="workout" inverseEntity="WorkoutLog"/>
<relationship name="split" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Split" inverseName="workouts" inverseEntity="Split"/>
</entity>
<entity name="WorkoutLog" representedClassName="WorkoutLog" syncable="YES">
<attribute name="completed" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="currentStateIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="date" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
<attribute name="elapsedSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="exerciseName" attributeType="String" defaultValueString=""/>
<attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="reps" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sets" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="status" optional="YES" attributeType="String" defaultValueString="notStarted"/>
<attribute name="weight" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="workout" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Workout" inverseName="logs" inverseEntity="Workout"/>
</entity>
</model>

View File

@@ -1,20 +1,24 @@
//
// WorkoutsApp.swift
// Workouts
// Workouts Watch App
//
// Created by rzen on 8/13/25 at 11:10AM.
// Created by rzen on 8/13/25 at 11:10 AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import CoreData
@main
struct Workouts_Watch_AppApp: App {
struct WorkoutsWatchApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistenceController.viewContext)
}
}
}