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,18 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Bash(find:*)",
"Bash(xargs cat:*)",
"WebFetch(domain:git.rzen.dev)",
"Bash(xcodebuild:*)",
"Bash(xcrun simctl boot:*)",
"Bash(xcrun simctl install:*)",
"Bash(xcrun simctl launch:*)",
"Bash(xcrun simctl get_app_container:*)",
"Bash(log show:*)"
],
"deny": [],
"ask": []
}
}

Binary file not shown.

BIN
Artwork/App Icon.pxd Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
Artwork/DumbBellIcon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
Artwork/DumbBellIcon.pxd Normal file

Binary file not shown.

BIN
Artwork/DumbBellIcon2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
Artwork/DumbBellIcon2.pxd Normal file

Binary file not shown.

BIN
Artwork/Workouts.pxd Normal file

Binary file not shown.

81
CLAUDE.md Normal file
View File

@@ -0,0 +1,81 @@
# CLAUDE.md
<!-- rgb(86, 20, 150); -->
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a workout tracking iOS app built with Swift/SwiftUI, featuring both iPhone and Apple Watch companions. The app helps users manage workout splits, track exercise logs, and sync data across devices using CloudKit.
## Development Commands
### Building and Running
- **Build the project**: Open `Workouts.xcodeproj` in Xcode and build (Cmd+B)
- **Run on iOS Simulator**: Select the Workouts scheme and run (Cmd+R)
- **Run on Apple Watch Simulator**: Select the "Workouts Watch App" scheme and run
## Architecture
### Data Layer
- **CoreData**: Core persistence framework with CloudKit sync via `NSPersistentCloudKitContainer`
- **CloudKit Container**: `iCloud.dev.rzen.indie.Workouts`
- **PersistenceController**: Manages CoreData stack initialization, CloudKit configuration, and context access
- **Models**: Located in `Models/` directory as NSManagedObject subclasses
### Core Models
- **Split**: Workout routine templates with exercises, colors, and system images
- **Exercise**: Individual exercises within splits (sets, reps, weight, load type)
- **Workout**: Active workout sessions linked to splits
- **WorkoutLog**: Historical exercise completion records
- **WorkoutStatus**: Enum for tracking workout/exercise completion states
### Model Relationships
```
Split (1) ──cascade──> (many) Exercise
Split (1) ──nullify──> (many) Workout
Workout (1) ──cascade──> (many) WorkoutLog
```
### App Structure
- **Dual targets**: Main iOS app (`Workouts/`) and Watch companion (`Workouts Watch App/`)
- **Shared components**: Both apps have similar structure with platform-specific implementations
- **TabView navigation**: Main app uses tabs (Workouts, Logs, Reports, Achievements)
### CloudKit Integration
- **Automatic sync**: Configured via `NSPersistentCloudKitContainerOptions`
- **History tracking**: Enabled for CloudKit sync via `NSPersistentHistoryTrackingKey`
- **Remote change notifications**: Enabled for real-time sync updates
- **Cross-device sync**: Data syncs between iPhone and Apple Watch
### UI Patterns
- **SwiftUI-first**: Modern declarative UI throughout
- **Environment injection**: ManagedObjectContext passed via `.environment(\.managedObjectContext)`
- **Navigation**: Uses NavigationStack for hierarchical navigation
- **Form-based editing**: Consistent form patterns for data entry
### Key Directories
- `Models/`: CoreData NSManagedObject subclasses
- `Persistence/`: PersistenceController for CoreData stack management
- `Views/`: UI components organized by feature (Splits, Exercises, Workouts, etc.)
- `Utils/`: Shared utilities (date formatting, colors)
- `*.xcdatamodeld`: CoreData model definition
### CoreData Guidelines
- Each model gets its own file in `Models/`
- Use `@NSManaged` for all persistent properties
- Implement `fetchRequest()` class methods for type-safe fetching
- Use `NSSet` for to-many relationships with convenience array accessors
- Implement add/remove helper methods for relationship management
- Use appropriate delete rules: cascade for owned children, nullify for references
### UI Guidelines
- Tab-based root navigation with independent navigation stacks
- Consistent form patterns for add/edit operations
- Sheet presentations for modal operations
- Swipe gestures for common actions (edit, complete, navigate)
### Development Notes
- **Preview support**: `PersistenceController.preview` for SwiftUI previews
- **Color system**: Custom color extensions for consistent theming (`Color.color(from:)`)
- **Date formatting**: Extensions in `Date+Extensions.swift`

187
REQUIREMENTS.md Normal file
View File

@@ -0,0 +1,187 @@
# Workouts App Requirements
## Overview
A workout tracking iOS application with Apple Watch companion that helps users manage workout splits, track exercise logs, and sync data across devices using CloudKit.
## Platform Requirements
- iOS app (iPhone)
- watchOS app (Apple Watch companion)
- CloudKit sync between devices
- SwiftData for persistence with CloudKit automatic sync
## Core Data Models
### Split
- `name: String` - Name of the workout split
- `color: String` - Color theme for the split
- `systemImage: String` - SF Symbol icon
- `order: Int` - Display order
- Relationship: One-to-many with Exercise
### Exercise
- `name: String` - Exercise name
- `order: Int` - Order within split
- `sets: Int` - Number of sets
- `reps: Int` - Number of repetitions
- `weight: Int` - Weight amount
- `loadType: Int` - Type of load (weight units)
- Relationship: Many-to-one with Split
### Workout
- `start: Date` - Workout start time
- `end: Date?` - Workout end time (optional)
- `status: WorkoutStatus` - Enum (notStarted, inProgress, completed, skipped)
- Relationship: Many-to-one with Split (optional)
- Relationship: One-to-many with WorkoutLog
### WorkoutLog
- `exerciseName: String` - Name of the exercise
- `date: Date` - When performed
- `order: Int` - Order in workout
- `sets: Int` - Number of sets completed
- `reps: Int` - Number of reps completed
- `weight: Int` - Weight used
- `status: WorkoutStatus?` - Completion status
- `completed: Bool` - Whether exercise was completed
- Relationship: Many-to-one with Workout
## iOS App Features
### Main Navigation (TabView)
1. **Workouts Tab**
- List all workouts sorted by start date (newest first)
- Create new workout from split
- Edit/delete existing workouts
- Navigate to workout logs
2. **Logs Tab**
- Historical exercise completion records
- Grouped by workout
3. **Reports Tab**
- Workout statistics and progress tracking
4. **Achievements Tab**
- User achievements and milestones
### Split Management
- Create/edit/delete workout splits
- Assign colors and system images
- Reorder splits
- Add/remove/reorder exercises within splits
### Exercise Management
- Add exercises to splits
- Set default sets, reps, weight
- Reorderable list within each split
- Support for different load types
### Workout Flow
- Start workout from selected split
- Auto-populate exercises from split template
- Track exercise completion status
- Update sets/reps/weight during workout
- Mark exercises as completed/skipped
- Auto-prevent device sleep during workouts
## Watch App Features
### Main View
- List active workouts
- Show "No Workouts" state when empty
- Sync button to pull from CloudKit
- Display sync status and last sync time
### Workout Display
- Show workout name and status
- List exercises with completion indicators
- Quick completion toggles
### CloudKit Sync
- Manual sync trigger
- Hybrid approach: SwiftData for local storage + direct CloudKit API for sync
- Sync from `com.apple.coredata.cloudkit.zone`
- Fetch and save Splits, Exercises, Workouts, and WorkoutLogs
## Data Management
### Exercise Library
- YAML-based exercise definitions in `Resources/` directory
- `ExerciseListLoader` to parse YAML files
- Preset libraries:
- Bodyweight exercises (`bodyweight-exercises.yaml`)
- Planet Fitness starter (`pf-starter-exercises.yaml`)
### CloudKit Configuration
- Container ID: `iCloud.com.dev.rzen.indie.WorkoutsV2`
- Automatic sync via `ModelConfiguration(cloudKitDatabase: .automatic)`
- Development environment for debug builds
- Production environment for release/TestFlight builds
## UI/UX Requirements
### Design Patterns
- SwiftUI throughout
- NavigationStack for hierarchical navigation
- Form-based editing interfaces
- Sheet presentations for modal operations
- Swipe gestures for edit/delete/complete actions
### Visual Design
- Custom color system via Color extensions
- Consistent use of SF Symbols
- Reorderable lists using SwiftUIReorderableForEach
- Calendar-style list items for workouts
- Checkbox components for completion tracking
### Key UI Components
- `CalendarListItem` - Formatted date/time display
- `Checkbox` - Visual completion indicators
- `SplitListItemView` - Split display with icon and color
- `ExerciseListItemView` - Exercise display with sets/reps/weight
- `WorkoutLogListItemView` - Log entry display
## Technical Architecture
### Project Structure
```
Workouts/
├── Models/ # SwiftData models
├── Schema/ # Database schema and migrations
├── Views/ # UI components by feature
│ ├── Splits/
│ ├── Exercises/
│ ├── Workouts/
│ └── WorkoutLog/
├── Utils/ # Shared utilities
└── Resources/ # YAML exercise definitions
Workouts Watch App/
├── Schema/ # Watch-specific container
├── Views/ # Watch UI components
└── CloudKitSyncManager.swift # Direct CloudKit sync
```
### Key Technical Decisions
- SwiftData with CloudKit automatic sync
- Versioned schema with migration support
- Single file per model convention
- No circular relationships in data model
- Platform-specific implementations for iOS/watchOS
- Shared model definitions between platforms
## Dependencies
- **Yams**: YAML parsing for exercise definitions
- **SwiftUIReorderableForEach**: Drag-to-reorder lists
## Entitlements Required
- CloudKit
- Push Notifications (aps-environment)
- App Sandbox
- File access (read-only for user-selected files)
## Build Configuration
- Swift Package Manager for dependencies
- Separate targets for iOS and watchOS
- Shared CloudKit container between apps
- Automatic provisioning and code signing

35
Scripts/update_build_number.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/bin/sh
## IMPORTANT ##
# Add the following files to Input Files configuraiton of the build phase
# $(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)
# $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist
git=$(sh /etc/profile; which git)
number_of_commits=$("$git" rev-list HEAD --count)
git_release_version=$("$git" describe --tags --always --abbrev=0)
target_plist="$TARGET_BUILD_DIR/$INFOPLIST_PATH"
dsym_plist="$DWARF_DSYM_FOLDER_PATH/$DWARF_DSYM_FILE_NAME/Contents/Info.plist"
git_commit=`"$git" rev-parse --short HEAD`
bundle_version=`/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$target_plist"`
build_date=`date +%F`
build="v$bundle_version-$git_commit b$number_of_commits $build_date"
#echo "version=$bundle_version-$git_commit build $number_of_commits"
"$git" tag "$bundle_version"
for plist in "$target_plist" "$dsym_plist"; do
if [ -f "$plist" ]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $number_of_commits" "$plist"
# /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${version}" "$plist"
# /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${git_release_version#*v}" "$plist"
# Add build date for AppInfoKit
/usr/libexec/PlistBuddy -c "Set :BuildDate $build_date" "$plist" 2>/dev/null || \
/usr/libexec/PlistBuddy -c "Add :BuildDate string $build_date" "$plist"
fi
done

View File

@@ -1,22 +1,25 @@
// //
// ContentView.swift // 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. // Copyright 2025 Rouslan Zenetl. All Rights Reserved.
// //
import SwiftUI import SwiftUI
import CoreData
struct ContentView: View { struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
var body: some View { var body: some View {
VStack { VStack {
Image(systemName: "globe") Image(systemName: "dumbbell.fill")
.imageScale(.large) .imageScale(.large)
.foregroundStyle(.tint) .foregroundStyle(.tint)
Text("Hello, world!") Text("Workouts")
} }
.padding() .padding()
} }
@@ -24,4 +27,5 @@ struct ContentView: View {
#Preview { #Preview {
ContentView() 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 // 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. // Copyright 2025 Rouslan Zenetl. All Rights Reserved.
// //
import SwiftUI import SwiftUI
import CoreData
@main @main
struct Workouts_Watch_AppApp: App { struct WorkoutsWatchApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
.environment(\.managedObjectContext, persistenceController.viewContext)
} }
} }
} }

View File

@@ -8,6 +8,8 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
A473BF022E4CE278003EAD6F /* Workouts Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A473BF012E4CE278003EAD6F /* Workouts Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; A473BF022E4CE278003EAD6F /* Workouts Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A473BF012E4CE278003EAD6F /* Workouts Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
A47512C12F1DACCF001A9C6F /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = A47512C02F1DACCF001A9C6F /* Yams */; };
A47513342F1DADBE001A9C6F /* IndieAbout in Frameworks */ = {isa = PBXBuildFile; productRef = A47513332F1DADBE001A9C6F /* IndieAbout */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -57,6 +59,8 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A47513342F1DADBE001A9C6F /* IndieAbout in Frameworks */,
A47512C12F1DACCF001A9C6F /* Yams in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -95,6 +99,7 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = A473BF152E4CE279003EAD6F /* Build configuration list for PBXNativeTarget "Workouts" */; buildConfigurationList = A473BF152E4CE279003EAD6F /* Build configuration list for PBXNativeTarget "Workouts" */;
buildPhases = ( buildPhases = (
A47512C22F1DB000001A9C6F /* Update Build Number */,
A473BEED2E4CE276003EAD6F /* Sources */, A473BEED2E4CE276003EAD6F /* Sources */,
A473BEEE2E4CE276003EAD6F /* Frameworks */, A473BEEE2E4CE276003EAD6F /* Frameworks */,
A473BEEF2E4CE276003EAD6F /* Resources */, A473BEEF2E4CE276003EAD6F /* Resources */,
@@ -110,6 +115,8 @@
); );
name = Workouts; name = Workouts;
packageProductDependencies = ( packageProductDependencies = (
A47512C02F1DACCF001A9C6F /* Yams */,
A47513332F1DADBE001A9C6F /* IndieAbout */,
); );
productName = Workouts; productName = Workouts;
productReference = A473BEF12E4CE276003EAD6F /* Workouts.app */; productReference = A473BEF12E4CE276003EAD6F /* Workouts.app */;
@@ -145,7 +152,7 @@
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1620; LastSwiftUpdateCheck = 1620;
LastUpgradeCheck = 1620; LastUpgradeCheck = 2620;
TargetAttributes = { TargetAttributes = {
A473BEF02E4CE276003EAD6F = { A473BEF02E4CE276003EAD6F = {
CreatedOnToolsVersion = 16.2; CreatedOnToolsVersion = 16.2;
@@ -164,6 +171,10 @@
); );
mainGroup = A473BEE82E4CE276003EAD6F; mainGroup = A473BEE82E4CE276003EAD6F;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = (
A47512BF2F1DACCF001A9C6F /* XCRemoteSwiftPackageReference "Yams" */,
A47513322F1DADBE001A9C6F /* XCRemoteSwiftPackageReference "indie-about" */,
);
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = A473BEF22E4CE276003EAD6F /* Products */; productRefGroup = A473BEF22E4CE276003EAD6F /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -192,6 +203,28 @@
}; };
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
A47512C22F1DB000001A9C6F /* Update Build Number */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Update Build Number";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PROJECT_DIR}/Scripts/update_build_number.sh\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
A473BEED2E4CE276003EAD6F /* Sources */ = { A473BEED2E4CE276003EAD6F /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
@@ -252,10 +285,12 @@
CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = C32Z8JNLG6;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO; GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
@@ -274,6 +309,7 @@
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
@@ -313,10 +349,12 @@
CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = C32Z8JNLG6;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -328,6 +366,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
}; };
name = Release; name = Release;
@@ -340,7 +379,6 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\"";
DEVELOPMENT_TEAM = C32Z8JNLG6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Workouts; INFOPLIST_KEY_CFBundleDisplayName = Workouts;
@@ -370,7 +408,6 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\"";
DEVELOPMENT_TEAM = C32Z8JNLG6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Workouts; INFOPLIST_KEY_CFBundleDisplayName = Workouts;
@@ -398,11 +435,13 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Workouts/Workouts.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Workouts/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Workouts/Preview Content\"";
DEVELOPMENT_TEAM = C32Z8JNLG6; ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Workouts; INFOPLIST_KEY_CFBundleDisplayName = Workouts;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -430,11 +469,13 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Workouts/Workouts.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Workouts/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Workouts/Preview Content\"";
DEVELOPMENT_TEAM = C32Z8JNLG6; ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Workouts; INFOPLIST_KEY_CFBundleDisplayName = Workouts;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -489,6 +530,38 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
A47512BF2F1DACCF001A9C6F /* XCRemoteSwiftPackageReference "Yams" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/jpsim/Yams";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 6.2.0;
};
};
A47513322F1DADBE001A9C6F /* XCRemoteSwiftPackageReference "indie-about" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://git.rzen.dev/rzen/indie-about";
requirement = {
branch = main;
kind = branch;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
A47512C02F1DACCF001A9C6F /* Yams */ = {
isa = XCSwiftPackageProductDependency;
package = A47512BF2F1DACCF001A9C6F /* XCRemoteSwiftPackageReference "Yams" */;
productName = Yams;
};
A47513332F1DADBE001A9C6F /* IndieAbout */ = {
isa = XCSwiftPackageProductDependency;
package = A47513322F1DADBE001A9C6F /* XCRemoteSwiftPackageReference "indie-about" */;
productName = IndieAbout;
};
/* End XCSwiftPackageProductDependency section */
}; };
rootObject = A473BEE92E4CE276003EAD6F /* Project object */; rootObject = A473BEE92E4CE276003EAD6F /* Project object */;
} }

View File

@@ -0,0 +1,24 @@
{
"originHash" : "9d613082dd8a405ef810326218a4d81fdfd9ecb33be867afbc9700e52ec96e4b",
"pins" : [
{
"identity" : "indie-about",
"kind" : "remoteSourceControl",
"location" : "https://git.rzen.dev/rzen/indie-about",
"state" : {
"branch" : "main",
"revision" : "ed73ffcc5488b37ec0838ecaaa27ce050807093f"
}
},
{
"identity" : "yams",
"kind" : "remoteSourceControl",
"location" : "https://github.com/jpsim/Yams",
"state" : {
"revision" : "51b5127c7fb6ffac106ad6d199aaa33c5024895f",
"version" : "6.2.0"
}
}
],
"version" : 3
}

View File

@@ -1,35 +1,116 @@
{ {
"images" : [ "images": [
{ {
"idiom" : "universal", "filename": "icon-20@2x.png",
"platform" : "ios", "idiom": "iphone",
"size" : "1024x1024" "size": "20x20",
"scale": "2x"
}, },
{ {
"appearances" : [ "filename": "icon-20@3x.png",
{ "idiom": "iphone",
"appearance" : "luminosity", "size": "20x20",
"value" : "dark" "scale": "3x"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}, },
{ {
"appearances" : [ "filename": "icon-29@2x.png",
"idiom": "iphone",
"size": "29x29",
"scale": "2x"
},
{ {
"appearance" : "luminosity", "filename": "icon-29@3x.png",
"value" : "tinted" "idiom": "iphone",
"size": "29x29",
"scale": "3x"
},
{
"filename": "icon-40@2x.png",
"idiom": "iphone",
"size": "40x40",
"scale": "2x"
},
{
"filename": "icon-40@3x.png",
"idiom": "iphone",
"size": "40x40",
"scale": "3x"
},
{
"filename": "icon-60@2x.png",
"idiom": "iphone",
"size": "60x60",
"scale": "2x"
},
{
"filename": "icon-60@3x.png",
"idiom": "iphone",
"size": "60x60",
"scale": "3x"
},
{
"filename": "icon-20.png",
"idiom": "ipad",
"size": "20x20",
"scale": "1x"
},
{
"filename": "icon-20@2x.png",
"idiom": "ipad",
"size": "20x20",
"scale": "2x"
},
{
"filename": "icon-29.png",
"idiom": "ipad",
"size": "29x29",
"scale": "1x"
},
{
"filename": "icon-29@2x.png",
"idiom": "ipad",
"size": "29x29",
"scale": "2x"
},
{
"filename": "icon-40.png",
"idiom": "ipad",
"size": "40x40",
"scale": "1x"
},
{
"filename": "icon-40@2x.png",
"idiom": "ipad",
"size": "40x40",
"scale": "2x"
},
{
"filename": "icon-76.png",
"idiom": "ipad",
"size": "76x76",
"scale": "1x"
},
{
"filename": "icon-76@2x.png",
"idiom": "ipad",
"size": "76x76",
"scale": "2x"
},
{
"filename": "icon-83.5@2x.png",
"idiom": "ipad",
"size": "83.5x83.5",
"scale": "2x"
},
{
"filename": "icon-1024.png",
"idiom": "ios-marketing",
"size": "1024x1024",
"scale": "1x"
} }
], ],
"idiom" : "universal", "info": {
"platform" : "ios", "author": "xcode",
"size" : "1024x1024" "version": 1
}
],
"info" : {
"author" : "xcode",
"version" : 1
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -2,26 +2,39 @@
// ContentView.swift // ContentView.swift
// Workouts // Workouts
// //
// 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. // Copyright 2025 Rouslan Zenetl. All Rights Reserved.
// //
import SwiftUI import SwiftUI
import CoreData
struct ContentView: View { struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
var body: some View { var body: some View {
VStack { TabView {
Image(systemName: "globe") WorkoutLogsView()
.imageScale(.large) .tabItem {
.foregroundStyle(.tint) Label("Workout Logs", systemImage: "list.bullet.clipboard")
Text("Hello, world!") }
SplitsView()
.tabItem {
Label("Splits", systemImage: "dumbbell.fill")
}
SettingsView()
.tabItem {
Label("Settings", systemImage: "gear")
}
} }
.padding()
} }
} }
#Preview { #Preview {
ContentView() 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,83 @@
import Foundation
import CoreData
@objc(Workout)
public class Workout: NSManagedObject, Identifiable {
@NSManaged public var start: Date
@NSManaged public var end: Date?
@NSManaged public var split: Split?
@NSManaged public var logs: NSSet?
public var id: NSManagedObjectID { objectID }
var status: WorkoutStatus {
get {
willAccessValue(forKey: "status")
let raw = primitiveValue(forKey: "status") as? String ?? "notStarted"
didAccessValue(forKey: "status")
return WorkoutStatus(rawValue: raw) ?? .notStarted
}
set {
willChangeValue(forKey: "status")
setPrimitiveValue(newValue.rawValue, forKey: "status")
didChangeValue(forKey: "status")
}
}
var label: String {
if status == .completed, let endDate = end {
if start.isSameDay(as: endDate) {
return "\(start.formattedDate())\(endDate.formattedTime())"
} else {
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,48 @@
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 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 {
willAccessValue(forKey: "status")
let raw = primitiveValue(forKey: "status") as? String ?? "notStarted"
didAccessValue(forKey: "status")
return WorkoutStatus(rawValue: raw) ?? .notStarted
}
set {
willChangeValue(forKey: "status")
setPrimitiveValue(newValue.rawValue, forKey: "status")
didChangeValue(forKey: "status")
}
}
}
// 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
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,77 @@
name: Starter Set - Bodyweight
source: Home or Minimal Equipment
exercises:
- name: Pull-Up
descr: Grip a bar with hands shoulder-width or wider. Pull your chin above the bar, engaging your back and arms. Lower with control.
type: Bodyweight
split: Upper Body
- name: Inverted Row
descr: Lie underneath a bar or sturdy edge, pull your chest toward the bar while keeping your body straight. Squeeze shoulder blades together.
type: Bodyweight
split: Upper Body
- name: Pike Push-Up
descr: Begin in downward dog position. Lower your head toward the ground by bending your elbows, then press back up. Focus on shoulder engagement.
type: Bodyweight
split: Upper Body
- name: Push-Up
descr: With hands just outside shoulder width, lower your body until elbows are at 90°, then push back up. Keep your body in a straight line.
type: Bodyweight
split: Upper Body
- name: Tricep Dip
descr: Using a chair or bench, lower your body by bending your elbows behind you, then press back up. Keep elbows tight to your body.
type: Bodyweight
split: Upper Body
- name: Towel Curl
descr: Sit on the floor with a towel looped under your feet. Pull against the towel using biceps, optionally resisting with your legs.
type: Bodyweight
split: Upper Body
- name: Crunch
descr: Lie on your back with knees bent. Curl your upper back off the floor using your abdominal muscles, then return slowly.
type: Bodyweight
split: Core
- name: Russian Twist
descr: Sit with knees bent and feet off the ground. Twist your torso side to side while keeping your abs engaged.
type: Bodyweight
split: Core
- name: Bodyweight Squat
descr: Stand with feet shoulder-width apart. Lower your hips back and down, keeping your heels on the floor. Rise back to standing.
type: Bodyweight
split: Lower Body
- name: Wall Sit
descr: Lean your back against a wall and lower into a seated position. Hold as long as possible while maintaining good form.
type: Bodyweight
split: Lower Body
- name: Glute Bridge
descr: Lie on your back with knees bent. Push through your heels to lift your hips, then lower slowly. Focus on hamstrings and glutes.
type: Bodyweight
split: Lower Body
- name: Hamstring Walkout
descr: Start in a glute bridge, then slowly walk your heels outward and back in, maintaining control and tension in the hamstrings.
type: Bodyweight
split: Lower Body
- name: Side-Lying Leg Raise (Inner)
descr: Lie on your side with bottom leg straight. Raise the bottom leg upward, engaging the inner thigh.
type: Bodyweight
split: Lower Body
- name: Side-Lying Leg Raise (Outer)
descr: Lie on your side with top leg straight. Raise the top leg upward to engage the outer thigh and glute.
type: Bodyweight
split: Lower Body
- name: Calf Raise
descr: Stand on the balls of your feet (on flat ground or a step). Raise your heels, then lower slowly for a full stretch.
type: Bodyweight
split: Lower Body

View File

@@ -0,0 +1,83 @@
name: Starter Set
source: Planet Fitness
exercises:
- name: Lat Pull Down
descr: Sit upright with your knees secured under the pad. Grip the bar wider than
shoulder-width. Pull the bar down to your chest, squeezing your shoulder blades
together. Avoid leaning back excessively or using momentum.
type: Machine-Based
split: Upper Body
- name: Seated Row
descr: With your chest firmly against the pad, grip the handles and pull straight
back while keeping your elbows close to your body. Focus on retracting your shoulder
blades and avoid rounding your back.
type: Machine-Based
split: Upper Body
- name: Shoulder Press
descr: Sit with your back against the pad, grip the handles just outside shoulder-width.
Press upward without locking out your elbows. Keep your neck relaxed and avoid
shrugging your shoulders.
type: Machine-Based
split: Upper Body
- name: Chest Press
descr: Adjust the seat so the handles are at mid-chest height. Push forward until arms
are nearly extended, then return slowly. Keep wrists straight and dont let your elbows
drop too low.
type: Machine-Based
split: Upper Body
- name: Tricep Press
descr: With elbows close to your sides, press the handles downward in a controlled
motion. Avoid flaring your elbows or using your shoulders to assist the motion.
type: Machine-Based
split: Upper Body
- name: Arm Curl
descr: Position your arms over the pad and grip the handles. Curl the weight upward
while keeping your upper arms stationary. Avoid using momentum and fully control
the lowering phase.
type: Machine-Based
split: Upper Body
- name: Abdominal
descr: Sit with the pads resting against your chest. Contract your abs to curl forward,
keeping your lower back in contact with the pad. Avoid pulling with your arms
or hips.
type: Machine-Based
split: Core
- name: Rotary
descr: Rotate your torso from side to side in a controlled motion, keeping your
hips still. Focus on using your obliques to generate the twist, not momentum or
the arms.
type: Machine-Based
split: Core
- name: Leg Press
descr: Place your feet shoulder-width on the platform. Press upward through your
heels without locking your knees. Keep your back flat against the pad throughout
the motion.
type: Machine-Based
split: Lower Body
- name: Leg Extension
descr: Sit upright and align your knees with the pivot point. Extend your legs to
a straightened position, then lower with control. Avoid jerky movements or lifting
your hips off the seat.
type: Machine-Based
split: Lower Body
- name: Leg Curl
descr: Lie face down or sit depending on the version. Curl your legs toward your
glutes, focusing on hamstring engagement. Avoid arching your back or using momentum.
type: Machine-Based
split: Lower Body
- name: Adductor
descr: Sit with legs placed outside the pads. Bring your legs together using inner
thigh muscles. Control the motion both in and out, avoiding fast swings.
type: Machine-Based
split: Lower Body
- name: Abductor
descr: Sit with legs inside the pads and push outward to engage outer thighs and
glutes. Avoid leaning forward and keep the motion controlled throughout.
type: Machine-Based
split: Lower Body
- name: Calfs
descr: Place the balls of your feet on the platform with heels hanging off. Raise
your heels by contracting your calves, then slowly lower them below the platform
level for a full stretch.
type: Machine-Based
split: Lower Body

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

View File

@@ -0,0 +1,18 @@
<?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>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
</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

@@ -2,19 +2,23 @@
// WorkoutsApp.swift // WorkoutsApp.swift
// Workouts // Workouts
// //
// 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. // Copyright 2025 Rouslan Zenetl. All Rights Reserved.
// //
import SwiftUI import SwiftUI
import CoreData
@main @main
struct WorkoutsApp: App { struct WorkoutsApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
.environment(\.managedObjectContext, persistenceController.viewContext)
} }
} }
} }