Compare commits

..

23 Commits

Author SHA1 Message Date
rzen 2f044c3d9c fix project files 2025-07-25 18:14:09 -04:00
rzen 3fd6887ce7 wip 2025-07-25 17:42:25 -04:00
rzen 310c120ca3 wip 2025-07-25 17:42:25 -04:00
rzen d2b8625f2e wip 2025-07-25 17:42:25 -04:00
rzen a0c8b21bf4 wip 2025-07-25 17:42:25 -04:00
rzen 68d90160c6 initial pre-viable version of watch app 2025-07-25 17:42:25 -04:00
rzen 33b88cb8f0 wip 2025-07-25 17:42:25 -04:00
rzen 2ef340e5c8 wip 2025-07-25 17:42:25 -04:00
rzen e3c3f2c6f0 wip 2025-07-25 17:42:25 -04:00
rzen 6e46775f58 wip 2025-07-25 17:42:25 -04:00
rzen 34942bfc48 wip 2025-07-25 17:42:25 -04:00
rzen 66f257609f wip 2025-07-25 17:42:25 -04:00
rzen 4f01a6445f wip 2025-07-25 17:42:25 -04:00
rzen f63bb0ba41 wip 2025-07-25 17:42:25 -04:00
rzen 2d0e327334 wip 2025-07-25 17:42:25 -04:00
rzen 48bbbbf692 wip 2025-07-25 17:42:25 -04:00
rzen 39fd45e03f wip 2025-07-25 17:42:25 -04:00
rzen bdaa406876 wip 2025-07-25 17:42:25 -04:00
rzen 0545f5dbc7 wip 2025-07-25 17:42:25 -04:00
rzen d4514805e9 wip 2025-07-25 17:42:25 -04:00
rzen 6cd44579e2 initial requirements and direction for windsurf 2025-07-25 17:42:25 -04:00
rzen ba81f06c56 Initial Commit 2025-07-25 17:42:25 -04:00
rzen e7166bacca Initial commit 2025-07-25 21:30:58 +00:00
156 changed files with 5546 additions and 5274 deletions
-21
View File
@@ -1,21 +0,0 @@
{
"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:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)"
],
"deny": [],
"ask": []
}
}
+4
View File
@@ -0,0 +1,4 @@
# ---> Xcode
## User settings
xcuserdata/
+108
View File
@@ -0,0 +1,108 @@
# General Guidelines
## Technology Stack
Technology stack is described in TECH.md file.
## Data Model
Data model is defined in MODEL.md file.
## Persistence
When implementing an edit form for a SwiftData model, ensure proper loading:
1. BEFORE PRESENTING THE FORM:
- Ensure the model is fully loaded with all its relationships
- Use a deep fetch pattern to eagerly load all relationships
- Access each property to force SwiftData to materialize lazy-loaded relationships
- Consider creating a deep copy of the model if relationships are complex
2. IN THE EDIT VIEW:
- Accept the model as a @State parameter (e.g., @State var modelObject: ModelType)
- Create separate @State variables for each editable field
- Initialize each state variable in the init() method using State(initialValue:)
- Use the modelContext for persisting changes back to the data store
3. SAVING CHANGES:
- When saving, update the model object properties from state variables
- Use modelContext.save() to persist changes
- Handle errors appropriately
## User Interface and User Experience
Each view struct should be placed in its own file under "Views" directory.
The user interface and user experience should follow Apple's Human Interface Guidelines (HIG) and best practices for iOS development.
Avoid custom UI components, instead rely on available SwiftUI views and modifiers.
When creating a add/edit functionality for a model, unless otherwise instructed use a single Add/Edit View for both add and edit functionality.
Unless otherwise instructed, use a sheet to present both add and edit views.
Whenever a list view has no entries, show a placeholder view with text "No [ListName] yet." and a button "Add [ListName]".
Before presenting an add/edit view, ensure the model is fully loaded with all its relationships. Make use of async/await mechanism to load the model. Show an overlay with a loading indicator while the model is being loaded.
## Logger
Use custom logger instead of print statements.
Make a custom logger as follows:
```swift
import OSLog
struct AppLogger {
private let logger: Logger
private let subsystem: String
private let category: String
init(subsystem: String, category: String) {
self.subsystem = subsystem
self.category = category
self.logger = Logger(subsystem: subsystem, category: category)
}
func timestamp () -> String {
Date.now.formatDateET(format: "yyyy-MM-dd HH:mm:ss")
}
func formattedMessage (_ message: String) -> String {
"\(timestamp()) [\(subsystem):\(category)] \(message)"
}
func debug(_ message: String) {
logger.debug("\(formattedMessage(message))")
}
func info(_ message: String) {
logger.info("\(formattedMessage(message))")
}
func warning(_ message: String) {
logger.warning("\(formattedMessage(message))")
}
func error(_ message: String) {
logger.error("\(formattedMessage(message))")
}
}
```
```swift
extension Date {
func formatDateET(format: String = "MMMM, d yyyy @ h:mm a z") -> String {
let formatter = DateFormatter()
formatter.timeZone = TimeZone(identifier: "America/New_York")
formatter.dateFormat = format
return formatter.string(from: self)
}
static var ISO8601: String {
"yyyy-MM-dd'T'HH:mm:ssZ"
}
}
```
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.
Binary file not shown.
-81
View File
@@ -1,81 +0,0 @@
# 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`
+81
View File
@@ -0,0 +1,81 @@
# Data Model
## Guidelines
### General
- When implementing a SwiftData model, allocate each model into its own file under "Models" directory.
### Schema Versioning
- Keep schema configurations in a separate folder called "Schema"
- Setup an enum for schema versioning
- Create SchemaMigrationPlan struct in Schema/SchemaMigrationPlan.swift for managing schema migrations
- Use schema versioning to manage changes to the data model
- When adding a new model, increment the schema version
- When removing a model, increment the schema version
- When modifying a model, increment the schema version
### SwiftData Relationship Rules
- DO NOT create any relationships that are not explicitly defined in the data model
- AVOID circular references in all cases
- Infer type of relationship from the property name (to many for plural, to one for singular)
## Models
ExerciseType
- name (String)
- descr (String)
- exercises (Set<Exercise>?) // deleteRule: nullify
MuscleGroup
- name (String)
- descr (String)
- muscles (Set<Muscle>?) // deleteRule: nullify
Muscle
- name (String)
- descr (String)
- muscleGroup (MuscleGroup?) // deleteRule: nullify, inverse: MuscleGroup.muscles
- exercises (Set<Exercise>?) // deleteRule: nullify
Exercise
- type (ExerciseType?) // deleteRule: .nullify, inverse: ExerciseType.exercises
- name (String)
- setup (String)
- descr (String)
- muscles (Set<Muscle>?) // deleteRule: .nullify, inverse: Muscle.exercises
- sets (Int)
- reps (Int)
- weight (Int)
- splits (Set<SplitExerciseAssignment>?) // deleteRule: .nullify, inverse: SplitExerciseAssignment.exercise
- logs (Set<WorkoutLog>?) // deleteRule: .nullify, inverse: WorkoutLog.exercise
SplitExerciseAssignment
- split (Split?) // deleteRule: .nullify
- exercise (Exercise?) // deleteRule: .nullify
- order (Int)
- sets (Int)
- reps (Int)
- weight (Int)
Split
- name (String)
- intro (String)
- exercises (Set<SplitExerciseAssignment>?) // deleteRule: .cascade, inverse: SplitExerciseAssignment.split
WorkoutLog
- workout (Workout?) // deleteRule: .nullify
- exercise (Exercise?) // deleteRule: .nullify
- date (Date)
- sets (Int)
- reps (Int)
- weight (Int)
- completed (Bool)
Workout
- split (Split?) // deleteRule: .nullify
- start (Date)
- end (Date?)
- logs (Set<WorkoutLog>?) // deleteRule: .cascade, inverse: WorkoutLog.workout
+2
View File
@@ -0,0 +1,2 @@
# workouts
-187
View File
@@ -1,187 +0,0 @@
# 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
View File
@@ -1,35 +0,0 @@
#!/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
+5
View File
@@ -0,0 +1,5 @@
# Technology Stack
- Swift
- SwiftData
- SwiftUI
+157
View File
@@ -0,0 +1,157 @@
# User Interface
## Tabs
Each tab is a root of its own navigation stack.
- Workouts
- Reports
- Settings
## Workout Log
- toolbar
- left bar button: none
- title: Workout Log
- right bar button: Add Workout
- main view:
- List of WorkoutLog objects
- Grouped by date, in descending order
- Group header shows date and split name
- Each row shows:
- Time
- Exercise
- Sets
- Reps
- Weight
- When main view list has no entries, show a placeholder view with text "No workouts yet." and a button "Start Split"
### Start Split View
Start Split View slides on top of Workout Log View navigation stack.
- left bar button: default (back to Workout Log)
- title: Start Split
- right bar button: none
- main view:
- list of available splits
- button "Start [SplitName] Split" -> Workout Split
### Workout Split View
Workout Split View slides on top of Workout Log View navigation stack.
- left bar button: default (back to Start Split)
- title: [SplitName] Split
- right bar button: Add Exercise
- main view:
- list of exercises in the split
- actions:
- on row slide to left
- button "Edit"
- on row slide to right
- button "Completed"
- on row tap
- open Exercise View
### Exercise View
Exercise View slides on top of Workout Split View navigation stack.
- left bar button: default (back to Workout Split)
- title: [ExerciseName]
- right bar button: Edit (pencil icon)
- action: open Add/Edit Exercise Assignment View
- main view:
- form
sections:
- split
- split name
- exercise assignment (titled "Planned")
- exercise
- name
- setup
- description
- muscles
- weight
- sets (read only)
- reps (read only)
- weight (read only)
- workout log (titled "Actual")
- date (date/time picker)
- sets (integer picker)
- reps (integer picker)
- weight (integer picker)
- status
- Completed (checkbox)
- actions:
- swipe left
- open Exercise View for the previous ExerciseAssignment
- swipe right
- open Exercise View for the next ExerciseAssignment
### Add/Edit Exercise to Split View
This view should be opened as a sheet.
- left bar button: default (back to Workout Split)
- title: Add/Edit Exercise to Split
- right bar button: Save
- main view:
- before an exercise is selected
- list of available exercises
- button "Add [ExerciseName]"
- after an exercise is selected
- a form for model SplitExerciseAssignment
- sets
- reps
- weight
## Settings
- left bar button: none
- title: Settings
- right bar button: none
- main view:
- list
- Splits
- Exercises
- Muscle Groups
- Muscles
- Exercise Types
- actions:
- on row tap
- open corresponding list view (e.g. for "Splits" open "Splits List View")
### (Splits|Exercises|Muscle Groups|Muscles|Exercise Types) List View
- left bar button: default (back to Settings)
- title: [ListName]
- right bar button: Add (plus icon)
- action: open corresponding add/edit view (e.g. for "Splits" open "Splits Add/Edit View")
- main view:
- list of [ListName]
- actions:
- on row tap
- open corresponding add/edit view (e.g. for "Splits" open "Splits Add/Edit View")
### (Splits|Exercises|Muscle Groups|Muscles|Exercise Types) Add/Edit View
Add/Edit views typically open as sheets.
- left bar button: default (back to Settings)
- title: [Add/Edit] [ListName]
- right bar button: Save
- main view:
- form with editable fields
@@ -1,14 +1,14 @@
{ {
"images": [ "images" : [
{ {
"filename": "icon-1024.png", "filename" : "DumbBellIcon-light.png",
"idiom": "universal", "idiom" : "universal",
"platform": "watchos", "platform" : "watchos",
"size": "1024x1024" "size" : "1024x1024"
} }
], ],
"info": { "info" : {
"author": "xcode", "author" : "xcode",
"version": 1 "version" : 1
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

@@ -1,455 +0,0 @@
//
// WatchConnectivityManager.swift
// Workouts Watch App
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
import WatchConnectivity
import CoreData
class WatchConnectivityManager: NSObject, ObservableObject {
static let shared = WatchConnectivityManager()
private var session: WCSession?
private var viewContext: NSManagedObjectContext?
@Published var lastSyncDate: Date?
override init() {
super.init()
if WCSession.isSupported() {
session = WCSession.default
session?.delegate = self
session?.activate()
}
}
func setViewContext(_ context: NSManagedObjectContext) {
self.viewContext = context
// Process any pending application context
if let session = session, !session.receivedApplicationContext.isEmpty {
processApplicationContext(session.receivedApplicationContext)
}
}
// MARK: - Send Data to iOS
func syncToiOS() {
guard let session = session else {
print("[WC-Watch] No WCSession")
return
}
print("[WC-Watch] Syncing to iOS - state: \(session.activationState.rawValue), reachable: \(session.isReachable)")
guard session.activationState == .activated else {
print("[WC-Watch] Session not activated")
return
}
guard let context = viewContext else {
print("[WC-Watch] No view context")
return
}
context.perform {
do {
let workoutsData = try self.encodeAllWorkouts(context: context)
let payload: [String: Any] = [
"type": "syncFromWatch",
"workouts": workoutsData,
"timestamp": Date().timeIntervalSince1970
]
if session.isReachable {
session.sendMessage(payload, replyHandler: nil) { error in
print("[WC-Watch] Failed to send sync: \(error)")
}
print("[WC-Watch] Sent \(workoutsData.count) workouts to iOS via message")
} else {
// Use transferUserInfo for background delivery
session.transferUserInfo(payload)
print("[WC-Watch] Queued \(workoutsData.count) workouts to iOS via transferUserInfo")
}
} catch {
print("[WC-Watch] Failed to encode data: \(error)")
}
}
}
private func encodeAllWorkouts(context: NSManagedObjectContext) throws -> [[String: Any]] {
let request = Workout.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Workout.start, ascending: false)]
let workouts = try context.fetch(request)
return workouts.map { encodeWorkout($0) }
}
private func encodeWorkout(_ workout: Workout) -> [String: Any] {
var data: [String: Any] = [
"start": workout.start.timeIntervalSince1970,
"status": workout.status.rawValue
]
if let end = workout.end {
data["end"] = end.timeIntervalSince1970
}
if let split = workout.split {
data["splitName"] = split.name
}
data["logs"] = workout.logsArray.map { encodeWorkoutLog($0) }
return data
}
private func encodeWorkoutLog(_ log: WorkoutLog) -> [String: Any] {
var data: [String: Any] = [
"exerciseName": log.exerciseName,
"order": log.order,
"sets": log.sets,
"reps": log.reps,
"weight": log.weight,
"status": log.status.rawValue,
"currentStateIndex": log.currentStateIndex,
"completed": log.completed,
"loadType": log.loadType
]
if let duration = log.duration {
data["duration"] = duration.timeIntervalSince1970
}
if let notes = log.notes {
data["notes"] = notes
}
return data
}
// MARK: - Request Sync from iOS
func requestSync() {
guard let session = session else {
print("[WC-Watch] No WCSession")
return
}
print("[WC-Watch] Session state: \(session.activationState.rawValue), reachable: \(session.isReachable)")
guard session.isReachable else {
print("[WC-Watch] iPhone not reachable, checking pending context...")
// Try to process any pending application context
if !session.receivedApplicationContext.isEmpty {
print("[WC-Watch] Found pending context, processing...")
processApplicationContext(session.receivedApplicationContext)
} else {
print("[WC-Watch] No pending context")
}
return
}
session.sendMessage(["type": "requestSync"], replyHandler: nil) { error in
print("[WC-Watch] Failed to request sync: \(error)")
}
}
// MARK: - Process Incoming Data
private func processApplicationContext(_ context: [String: Any]) {
guard let viewContext = viewContext else {
print("View context not set")
return
}
viewContext.perform {
do {
// Process splits first (workouts reference them)
if let splitsData = context["splits"] as? [[String: Any]] {
// Get all split names from iOS
let iosSplitNames = Set(splitsData.compactMap { $0["name"] as? String })
// Delete splits not on iOS
let existingSplits = (try? viewContext.fetch(Split.fetchRequest())) ?? []
for split in existingSplits {
if !iosSplitNames.contains(split.name) {
viewContext.delete(split)
}
}
for splitData in splitsData {
self.importSplit(splitData, context: viewContext)
}
}
// Process workouts
if let workoutsData = context["workouts"] as? [[String: Any]] {
// Get all workout start dates from iOS
let iosStartDates = Set(workoutsData.compactMap { $0["start"] as? TimeInterval })
// Delete workouts not on iOS
let existingWorkouts = (try? viewContext.fetch(Workout.fetchRequest())) ?? []
for workout in existingWorkouts {
let startInterval = workout.start.timeIntervalSince1970
// Check if this workout exists on iOS (within 1 second tolerance)
let existsOnIOS = iosStartDates.contains { abs($0 - startInterval) < 1 }
if !existsOnIOS {
viewContext.delete(workout)
}
}
for workoutData in workoutsData {
self.importWorkout(workoutData, context: viewContext)
}
}
try viewContext.save()
DispatchQueue.main.async {
self.lastSyncDate = Date()
}
print("Successfully imported data from iPhone")
} catch {
print("Failed to import data: \(error)")
}
}
}
// MARK: - Import Methods
private func importSplit(_ data: [String: Any], context: NSManagedObjectContext) {
guard let idString = data["id"] as? String,
let name = data["name"] as? String else { return }
// Find existing or create new
let split = findOrCreateSplit(idString: idString, name: name, context: context)
split.name = name
split.color = data["color"] as? String ?? "blue"
split.systemImage = data["systemImage"] as? String ?? "dumbbell.fill"
split.order = Int32(data["order"] as? Int ?? 0)
// Import exercises
if let exercisesData = data["exercises"] as? [[String: Any]] {
// Get all exercise names from iOS
let iosExerciseNames = Set(exercisesData.compactMap { $0["name"] as? String })
// Delete exercises not on iOS
for exercise in split.exercisesArray {
if !iosExerciseNames.contains(exercise.name) {
context.delete(exercise)
}
}
// Import/update exercises from iOS
for exerciseData in exercisesData {
importExercise(exerciseData, split: split, context: context)
}
}
}
private func importExercise(_ data: [String: Any], split: Split, context: NSManagedObjectContext) {
guard let idString = data["id"] as? String,
let name = data["name"] as? String else { return }
let exercise = findOrCreateExercise(idString: idString, name: name, split: split, context: context)
exercise.name = name
exercise.order = Int32(data["order"] as? Int ?? 0)
exercise.sets = Int32(data["sets"] as? Int ?? 3)
exercise.reps = Int32(data["reps"] as? Int ?? 10)
exercise.weight = Int32(data["weight"] as? Int ?? 0)
exercise.loadType = Int32(data["loadType"] as? Int ?? 1)
if let durationInterval = data["duration"] as? TimeInterval {
exercise.duration = Date(timeIntervalSince1970: durationInterval)
}
exercise.split = split
}
private func importWorkout(_ data: [String: Any], context: NSManagedObjectContext) {
guard let idString = data["id"] as? String,
let startInterval = data["start"] as? TimeInterval else { return }
let startDate = Date(timeIntervalSince1970: startInterval)
let workout = findOrCreateWorkout(idString: idString, startDate: startDate, context: context)
workout.start = startDate
if let endInterval = data["end"] as? TimeInterval {
workout.end = Date(timeIntervalSince1970: endInterval)
}
if let statusRaw = data["status"] as? String,
let status = WorkoutStatus(rawValue: statusRaw) {
workout.status = status
}
// Link to split
if let splitName = data["splitName"] as? String {
workout.split = findSplitByName(splitName, context: context)
}
// Import logs
if let logsData = data["logs"] as? [[String: Any]] {
// Get all exercise names from iOS
let iosExerciseNames = Set(logsData.compactMap { $0["exerciseName"] as? String })
// Delete logs not on iOS
for log in workout.logsArray {
if !iosExerciseNames.contains(log.exerciseName) {
context.delete(log)
}
}
// Import/update logs from iOS
for logData in logsData {
importWorkoutLog(logData, workout: workout, context: context)
}
}
}
private func importWorkoutLog(_ data: [String: Any], workout: Workout, context: NSManagedObjectContext) {
guard let idString = data["id"] as? String,
let exerciseName = data["exerciseName"] as? String else { return }
let log = findOrCreateWorkoutLog(idString: idString, exerciseName: exerciseName, workout: workout, context: context)
log.exerciseName = exerciseName
log.date = Date(timeIntervalSince1970: data["date"] as? TimeInterval ?? Date().timeIntervalSince1970)
log.order = Int32(data["order"] as? Int ?? 0)
log.sets = Int32(data["sets"] as? Int ?? 3)
log.reps = Int32(data["reps"] as? Int ?? 10)
log.weight = Int32(data["weight"] as? Int ?? 0)
log.currentStateIndex = Int32(data["currentStateIndex"] as? Int ?? 0)
log.completed = data["completed"] as? Bool ?? false
log.loadType = Int32(data["loadType"] as? Int ?? 1)
if let statusRaw = data["status"] as? String,
let status = WorkoutStatus(rawValue: statusRaw) {
log.status = status
}
if let durationInterval = data["duration"] as? TimeInterval {
log.duration = Date(timeIntervalSince1970: durationInterval)
}
log.notes = data["notes"] as? String
log.workout = workout
}
// MARK: - Find or Create Helpers
private func findOrCreateSplit(idString: String, name: String, context: NSManagedObjectContext) -> Split {
// Try to find by name first (more reliable than object ID across devices)
let request = Split.fetchRequest()
request.predicate = NSPredicate(format: "name == %@", name)
request.fetchLimit = 1
if let existing = try? context.fetch(request).first {
return existing
}
return Split(context: context)
}
private func findOrCreateExercise(idString: String, name: String, split: Split, context: NSManagedObjectContext) -> Exercise {
// Find by name within split
if let existing = split.exercisesArray.first(where: { $0.name == name }) {
return existing
}
return Exercise(context: context)
}
private func findOrCreateWorkout(idString: String, startDate: Date, context: NSManagedObjectContext) -> Workout {
// Find by start date (should be unique per workout)
let request = Workout.fetchRequest()
// Match within 1 second to account for any floating point differences
let startInterval = startDate.timeIntervalSince1970
request.predicate = NSPredicate(
format: "start >= %@ AND start <= %@",
Date(timeIntervalSince1970: startInterval - 1) as NSDate,
Date(timeIntervalSince1970: startInterval + 1) as NSDate
)
request.fetchLimit = 1
if let existing = try? context.fetch(request).first {
return existing
}
return Workout(context: context)
}
private func findOrCreateWorkoutLog(idString: String, exerciseName: String, workout: Workout, context: NSManagedObjectContext) -> WorkoutLog {
// Find existing log in this workout with same exercise name
if let existing = workout.logsArray.first(where: { $0.exerciseName == exerciseName }) {
return existing
}
return WorkoutLog(context: context)
}
private func findSplitByName(_ name: String, context: NSManagedObjectContext) -> Split? {
let request = Split.fetchRequest()
request.predicate = NSPredicate(format: "name == %@", name)
request.fetchLimit = 1
return try? context.fetch(request).first
}
}
// MARK: - WCSessionDelegate
extension WatchConnectivityManager: WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
print("[WC-Watch] Activation failed: \(error)")
} else {
print("[WC-Watch] Activated with state: \(activationState.rawValue)")
// Check for any pending context
let context = session.receivedApplicationContext
print("[WC-Watch] Pending context keys: \(context.keys)")
if !context.isEmpty {
print("[WC-Watch] Processing pending context...")
processApplicationContext(context)
}
}
}
// Receive application context updates
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
print("[WC-Watch] Received application context with keys: \(applicationContext.keys)")
if let workouts = applicationContext["workouts"] as? [[String: Any]] {
print("[WC-Watch] Contains \(workouts.count) workouts")
}
processApplicationContext(applicationContext)
}
// Receive immediate messages
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
if let type = message["type"] as? String {
switch type {
case "workoutUpdate":
if let workoutData = message["workout"] as? [String: Any],
let context = viewContext {
context.perform {
self.importWorkout(workoutData, context: context)
try? context.save()
}
}
default:
break
}
}
}
}
+53 -8
View File
@@ -1,25 +1,70 @@
// //
// ContentView.swift // ContentView.swift
// Workouts Watch App // Workouts
// //
// Created by rzen on 8/13/25 at 11:10 AM. // Created by rzen on 7/15/25 at 7:09 PM.
// //
// Copyright 2025 Rouslan Zenetl. All Rights Reserved. // Copyright 2025 Rouslan Zenetl. All Rights Reserved.
// //
import SwiftUI import SwiftUI
import CoreData import SwiftData
struct ContentView: View { struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext @Environment(\.modelContext) private var modelContext
@State var activeWorkouts: [Workout] = []
var body: some View { var body: some View {
WorkoutLogsView() NavigationStack {
if activeWorkouts.isEmpty {
NoActiveWorkoutView()
} else {
ActiveWorkoutListView(workouts: activeWorkouts)
}
}
.onAppear {
loadActiveWorkouts()
}
}
func loadActiveWorkouts () {
let completedStatus = WorkoutStatus.completed.rawValue
do {
self.activeWorkouts = try modelContext.fetch(FetchDescriptor<Workout>(
predicate: #Predicate<Workout> { workout in
workout.status != completedStatus
},
sortBy: [
SortDescriptor(\Workout.start, order: .reverse)
]
))
} catch {
print("ERROR: failed to load active workouts \(error)")
}
}
}
struct NoActiveWorkoutView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "dumbbell.fill")
.font(.system(size: 40))
.foregroundStyle(.gray)
Text("No Active Workout")
.font(.headline)
Text("Start a workout in the main app")
.font(.caption)
.foregroundStyle(.gray)
.multilineTextAlignment(.center)
}
.padding()
} }
} }
#Preview { #Preview {
ContentView() ContentView()
.environment(\.managedObjectContext, PersistenceController.preview.viewContext) .modelContainer(AppContainer.preview)
} }
-77
View File
@@ -1,77 +0,0 @@
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) }
}
// Duration helpers for minutes/seconds conversion
var durationMinutes: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) / 60
}
set {
let seconds = durationSeconds
duration = Date(timeIntervalSince1970: TimeInterval(newValue * 60 + seconds))
}
}
var durationSeconds: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) % 60
}
set {
let minutes = durationMinutes
duration = Date(timeIntervalSince1970: TimeInterval(minutes * 60 + newValue))
}
}
}
// 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"
}
}
}
-66
View File
@@ -1,66 +0,0 @@
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
}
}
-83
View File
@@ -1,83 +0,0 @@
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
}
}
@@ -1,79 +0,0 @@
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 loadType: Int32
@NSManaged public var duration: Date?
@NSManaged public var notes: String?
@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")
}
}
var loadTypeEnum: LoadType {
get { LoadType(rawValue: Int(loadType)) ?? .weight }
set { loadType = Int32(newValue.rawValue) }
}
// Duration helpers for minutes/seconds conversion
var durationMinutes: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) / 60
}
set {
let seconds = durationSeconds
duration = Date(timeIntervalSince1970: TimeInterval(newValue * 60 + seconds))
}
}
var durationSeconds: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) % 60
}
set {
let minutes = durationMinutes
duration = Date(timeIntervalSince1970: TimeInterval(minutes * 60 + newValue))
}
}
}
// 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
}
}
@@ -1,23 +0,0 @@
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 }
}
@@ -1,131 +0,0 @@
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"
// App Group identifier for shared storage between iOS and Watch
static let appGroupIdentifier = "group.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 {
// Use App Group container for shared storage between iOS and Watch
if let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier) {
let storeURL = appGroupURL.appendingPathComponent("Workouts.sqlite")
description.url = storeURL
print("Using shared App Group store at: \(storeURL)")
}
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)")
}
}
}
}
@@ -0,0 +1,205 @@
import Foundation
import SwiftData
final class AppContainer {
static let logger = AppLogger(
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Workouts.watchkitapp",
category: "AppContainer"
)
static func create() -> ModelContainer {
// Using the current models directly without migration plan to avoid reference errors
let schema = Schema(SchemaVersion.models)
#if targetEnvironment(simulator) && os(watchOS)
// Use local-only storage for watchOS simulator
let configuration = ModelConfiguration(isStoredInMemoryOnly: false)
logger.info("Creating local-only database for watchOS simulator")
do {
let container = try ModelContainer(for: schema, configurations: configuration)
// Populate with test data if needed
Task { @MainActor in
await populateSimulatorData(container: container)
}
return container
} catch {
logger.error("Failed to create simulator ModelContainer: \(error.localizedDescription)")
fatalError("Failed to create simulator ModelContainer: \(error.localizedDescription)")
}
#else
// Use CloudKit for real devices
let configuration = ModelConfiguration(cloudKitDatabase: .automatic)
logger.info("Creating CloudKit database for real device")
let container = try! ModelContainer(for: schema, configurations: configuration)
return container
#endif
}
@MainActor
static var preview: ModelContainer {
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
do {
let schema = Schema(SchemaVersion.models)
let container = try ModelContainer(for: schema, configurations: configuration)
return container
} catch {
fatalError("Failed to create preview ModelContainer: \(error.localizedDescription)")
}
}
@MainActor
private static func populateSimulatorData(container: ModelContainer) async {
let context = container.mainContext
// Check if data already exists
let fetchDescriptor = FetchDescriptor<Split>()
guard (try? context.fetch(fetchDescriptor))?.isEmpty ?? true else {
logger.info("Simulator database already has data, skipping population")
return // Data already exists
}
logger.info("Populating simulator database with test data from pf-starter-exercises.yaml")
// Create splits
let upperBodySplit = Split(name: "Upper Body", color: "blue", systemImage: "figure.strengthtraining.traditional", order: 0)
let lowerBodySplit = Split(name: "Lower Body", color: "green", systemImage: "figure.run", order: 1)
let fullBodySplit = Split(name: "Full Body", color: "purple", systemImage: "figure.mixed.cardio", order: 2)
let coreSplit = Split(name: "Core", color: "red", systemImage: "figure.core.training", order: 3)
context.insert(upperBodySplit)
context.insert(lowerBodySplit)
context.insert(fullBodySplit)
context.insert(coreSplit)
// Create exercises based on pf-starter-exercises.yaml
// Upper Body Exercises
let latPullDown = Exercise(split: upperBodySplit, exerciseName: "Lat Pull Down", order: 0, sets: 3, reps: 12, weight: 120)
let seatedRow = Exercise(split: upperBodySplit, exerciseName: "Seated Row", order: 1, sets: 3, reps: 12, weight: 110)
let shoulderPress = Exercise(split: upperBodySplit, exerciseName: "Shoulder Press", order: 2, sets: 3, reps: 10, weight: 90)
let chestPress = Exercise(split: upperBodySplit, exerciseName: "Chest Press", order: 3, sets: 3, reps: 10, weight: 130)
let tricepPress = Exercise(split: upperBodySplit, exerciseName: "Tricep Press", order: 4, sets: 3, reps: 12, weight: 70)
let armCurl = Exercise(split: upperBodySplit, exerciseName: "Arm Curl", order: 5, sets: 3, reps: 12, weight: 60)
context.insert(latPullDown)
context.insert(seatedRow)
context.insert(shoulderPress)
context.insert(chestPress)
context.insert(tricepPress)
context.insert(armCurl)
// Core Exercises
let abdominal = Exercise(split: coreSplit, exerciseName: "Abdominal", order: 0, sets: 3, reps: 15, weight: 80)
let rotary = Exercise(split: coreSplit, exerciseName: "Rotary", order: 1, sets: 3, reps: 15, weight: 70)
let plank = Exercise(split: coreSplit, exerciseName: "Plank", order: 2, sets: 3, reps: 1, weight: 0) // Reps as time in minutes
let russianTwists = Exercise(split: coreSplit, exerciseName: "Russian Twists", order: 3, sets: 3, reps: 20, weight: 25)
context.insert(abdominal)
context.insert(rotary)
context.insert(plank)
context.insert(russianTwists)
// Lower Body Exercises
let legPress = Exercise(split: lowerBodySplit, exerciseName: "Leg Press", order: 0, sets: 3, reps: 12, weight: 200)
let legExtension = Exercise(split: lowerBodySplit, exerciseName: "Leg Extension", order: 1, sets: 3, reps: 12, weight: 110)
let legCurl = Exercise(split: lowerBodySplit, exerciseName: "Leg Curl", order: 2, sets: 3, reps: 12, weight: 90)
let adductor = Exercise(split: lowerBodySplit, exerciseName: "Adductor", order: 3, sets: 3, reps: 15, weight: 100)
let abductor = Exercise(split: lowerBodySplit, exerciseName: "Abductor", order: 4, sets: 3, reps: 15, weight: 90)
let calfs = Exercise(split: lowerBodySplit, exerciseName: "Calfs", order: 5, sets: 3, reps: 15, weight: 120)
context.insert(legPress)
context.insert(legExtension)
context.insert(legCurl)
context.insert(adductor)
context.insert(abductor)
context.insert(calfs)
// Full Body Exercises (selected from both upper and lower)
let fullBodyChestPress = Exercise(split: fullBodySplit, exerciseName: "Chest Press", order: 0, sets: 3, reps: 10, weight: 130)
let fullBodyLatPullDown = Exercise(split: fullBodySplit, exerciseName: "Lat Pull Down", order: 1, sets: 3, reps: 12, weight: 120)
let fullBodyLegPress = Exercise(split: fullBodySplit, exerciseName: "Leg Press", order: 2, sets: 3, reps: 12, weight: 200)
let fullBodyAbdominal = Exercise(split: fullBodySplit, exerciseName: "Abdominal", order: 3, sets: 3, reps: 15, weight: 80)
context.insert(fullBodyChestPress)
context.insert(fullBodyLatPullDown)
context.insert(fullBodyLegPress)
context.insert(fullBodyAbdominal)
// Create workouts
let now = Date()
// Upper Body Workout (in progress)
let upperBodyWorkout = Workout(start: now, end: now, split: upperBodySplit)
upperBodyWorkout.status = 2
// upperBodyWorkout.status = .inProgress
upperBodyWorkout.end = nil
context.insert(upperBodyWorkout)
// Lower Body Workout (scheduled for tomorrow)
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: now) ?? now
let lowerBodyWorkout = Workout(start: tomorrow, end: tomorrow, split: lowerBodySplit)
lowerBodyWorkout.status = 1
// lowerBodyWorkout.status = .notStarted
context.insert(lowerBodyWorkout)
// Full Body Workout (completed yesterday)
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now) ?? now
let fullBodyWorkout = Workout(start: yesterday, end: yesterday, split: fullBodySplit)
fullBodyWorkout.status = 3
// fullBodyWorkout.status = .completed
context.insert(fullBodyWorkout)
// Create workout logs for Upper Body workout (in progress)
let chestPressLog = WorkoutLog(workout: upperBodyWorkout, exerciseName: chestPress.name, date: now, order: 0, sets: chestPress.sets, reps: chestPress.reps, weight: chestPress.weight, status: .completed, completed: true)
let shoulderPressLog = WorkoutLog(workout: upperBodyWorkout, exerciseName: shoulderPress.name, date: now, order: 1, sets: shoulderPress.sets, reps: shoulderPress.reps, weight: shoulderPress.weight, status: .completed, completed: true)
let latPullDownLog = WorkoutLog(workout: upperBodyWorkout, exerciseName: latPullDown.name, date: now, order: 2, sets: latPullDown.sets, reps: latPullDown.reps, weight: latPullDown.weight, status: .inProgress, completed: false)
let seatedRowLog = WorkoutLog(workout: upperBodyWorkout, exerciseName: seatedRow.name, date: now, order: 3, sets: seatedRow.sets, reps: seatedRow.reps, weight: seatedRow.weight, status: .notStarted, completed: false)
let tricepPressLog = WorkoutLog(workout: upperBodyWorkout, exerciseName: tricepPress.name, date: now, order: 4, sets: tricepPress.sets, reps: tricepPress.reps, weight: tricepPress.weight, status: .notStarted, completed: false)
let armCurlLog = WorkoutLog(workout: upperBodyWorkout, exerciseName: armCurl.name, date: now, order: 5, sets: armCurl.sets, reps: armCurl.reps, weight: armCurl.weight, status: .notStarted, completed: false)
context.insert(chestPressLog)
context.insert(shoulderPressLog)
context.insert(latPullDownLog)
context.insert(seatedRowLog)
context.insert(tricepPressLog)
context.insert(armCurlLog)
// Create workout logs for Lower Body workout (scheduled)
let legPressLog = WorkoutLog(workout: lowerBodyWorkout, exerciseName: legPress.name, date: tomorrow, order: 0, sets: legPress.sets, reps: legPress.reps, weight: legPress.weight, status: .notStarted, completed: false)
let legExtensionLog = WorkoutLog(workout: lowerBodyWorkout, exerciseName: legExtension.name, date: tomorrow, order: 1, sets: legExtension.sets, reps: legExtension.reps, weight: legExtension.weight, status: .notStarted, completed: false)
let legCurlLog = WorkoutLog(workout: lowerBodyWorkout, exerciseName: legCurl.name, date: tomorrow, order: 2, sets: legCurl.sets, reps: legCurl.reps, weight: legCurl.weight, status: .notStarted, completed: false)
let adductorLog = WorkoutLog(workout: lowerBodyWorkout, exerciseName: adductor.name, date: tomorrow, order: 3, sets: adductor.sets, reps: adductor.reps, weight: adductor.weight, status: .notStarted, completed: false)
let abductorLog = WorkoutLog(workout: lowerBodyWorkout, exerciseName: abductor.name, date: tomorrow, order: 4, sets: abductor.sets, reps: abductor.reps, weight: abductor.weight, status: .notStarted, completed: false)
let calfsLog = WorkoutLog(workout: lowerBodyWorkout, exerciseName: calfs.name, date: tomorrow, order: 5, sets: calfs.sets, reps: calfs.reps, weight: calfs.weight, status: .notStarted, completed: false)
context.insert(legPressLog)
context.insert(legExtensionLog)
context.insert(legCurlLog)
context.insert(adductorLog)
context.insert(abductorLog)
context.insert(calfsLog)
// Create workout logs for Full Body workout (completed)
let fullBodyChestPressLog = WorkoutLog(workout: fullBodyWorkout, exerciseName: fullBodyChestPress.name, date: yesterday, order: 0, sets: fullBodyChestPress.sets, reps: fullBodyChestPress.reps, weight: fullBodyChestPress.weight, status: .completed, completed: true)
let fullBodyLatPullDownLog = WorkoutLog(workout: fullBodyWorkout, exerciseName: fullBodyLatPullDown.name, date: yesterday, order: 1, sets: fullBodyLatPullDown.sets, reps: fullBodyLatPullDown.reps, weight: fullBodyLatPullDown.weight, status: .completed, completed: true)
let fullBodyLegPressLog = WorkoutLog(workout: fullBodyWorkout, exerciseName: fullBodyLegPress.name, date: yesterday, order: 2, sets: fullBodyLegPress.sets, reps: fullBodyLegPress.reps, weight: fullBodyLegPress.weight, status: .completed, completed: true)
let fullBodyAbdominalLog = WorkoutLog(workout: fullBodyWorkout, exerciseName: fullBodyAbdominal.name, date: yesterday, order: 3, sets: fullBodyAbdominal.sets, reps: fullBodyAbdominal.reps, weight: fullBodyAbdominal.weight, status: .completed, completed: true)
context.insert(fullBodyChestPressLog)
context.insert(fullBodyLatPullDownLog)
context.insert(fullBodyLegPressLog)
context.insert(fullBodyAbdominalLog)
do {
try context.save()
logger.info("Successfully populated simulator database with test data from pf-starter-exercises.yaml")
} catch {
logger.error("Failed to save test data: \(error.localizedDescription)")
}
}
}
@@ -0,0 +1,10 @@
import SwiftData
enum SchemaVersion {
static var models: [any PersistentModel.Type] = [
// Split.self,
// Exercise.self,
Workout.self,
WorkoutLog.self
]
}
+22
View File
@@ -0,0 +1,22 @@
import Foundation
import OSLog
struct AppLogger {
private let logger: Logger
init(subsystem: String, category: String) {
self.logger = Logger(subsystem: subsystem, category: category)
}
func debug(_ message: String) {
logger.debug("\(message)")
}
func info(_ message: String) {
logger.info("\(message)")
}
func error(_ message: String) {
logger.error("\(message)")
}
}
@@ -1,31 +0,0 @@
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"]
@@ -0,0 +1,21 @@
import SwiftUI
extension Color {
static func color(from name: String) -> Color {
switch name {
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
}
}
}
@@ -1,56 +0,0 @@
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"
}
}
}
@@ -0,0 +1,9 @@
import Foundation
extension Date {
func formatDate() -> String {
let formatter = DateFormatter()
formatter.dateStyle = .short
return formatter.string(from: self)
}
}
@@ -0,0 +1,14 @@
import Foundation
extension Date {
func formatDateET(format: String = "MMMM, d yyyy @ h:mm a z") -> String {
let formatter = DateFormatter()
formatter.timeZone = TimeZone(identifier: "America/New_York")
formatter.dateFormat = format
return formatter.string(from: self)
}
static var ISO8601: String {
"yyyy-MM-dd'T'HH:mm:ssZ"
}
}
@@ -0,0 +1,9 @@
import Foundation
extension Date {
func formattedDate() -> String {
let formatter = DateFormatter()
formatter.dateStyle = .short
return formatter.string(from: self)
}
}
@@ -0,0 +1,33 @@
import Foundation
import WatchKit
struct HapticFeedback {
static func success() {
WKInterfaceDevice.current().play(.success)
}
static func notification() {
WKInterfaceDevice.current().play(.notification)
}
static func click() {
WKInterfaceDevice.current().play(.click)
}
static func doubleTap() {
WKInterfaceDevice.current().play(.click)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
WKInterfaceDevice.current().play(.click)
}
}
static func tripleTap() {
WKInterfaceDevice.current().play(.notification)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
WKInterfaceDevice.current().play(.notification)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
WKInterfaceDevice.current().play(.notification)
}
}
}
@@ -0,0 +1,18 @@
//
// TimeInterval+minutesSecons.swift
// Workouts
//
// Created by rzen on 7/23/25 at 4:22PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
extension Int {
var secondsFormatted: String {
let minutes = self / 60
let seconds = self % 60
return String(format: "%02d:%02d", minutes, seconds)
}
}
@@ -1,309 +0,0 @@
//
// ExerciseProgressView.swift
// Workouts Watch App
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import WatchKit
struct ExerciseProgressView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(\.dismiss) private var dismiss
@ObservedObject var workoutLog: WorkoutLog
@State private var currentPage: Int = 0
@State private var showingCancelConfirm = false
private var totalSets: Int {
max(1, Int(workoutLog.sets))
}
private var totalPages: Int {
// Set 1, Rest 1, Set 2, Rest 2, ..., Set N, Done
// = N sets + (N-1) rests + 1 done = 2N
totalSets * 2
}
private var firstUnfinishedSetPage: Int {
// currentStateIndex is the number of completed sets
let completedSets = Int(workoutLog.currentStateIndex)
if completedSets >= totalSets {
// All done, go to done page
return totalPages - 1
}
// Each set is at page index * 2 (Set1=0, Set2=2, Set3=4...)
return completedSets * 2
}
var body: some View {
TabView(selection: $currentPage) {
ForEach(0..<totalPages, id: \.self) { index in
pageView(for: index)
.tag(index)
}
}
.tabViewStyle(.page)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
showingCancelConfirm = true
} label: {
Image(systemName: "xmark")
}
}
}
.confirmationDialog("Cancel Exercise?", isPresented: $showingCancelConfirm, titleVisibility: .visible) {
Button("Cancel Exercise", role: .destructive) {
dismiss()
}
Button("Continue", role: .cancel) { }
}
.onAppear {
// Skip to first unfinished set
currentPage = firstUnfinishedSetPage
}
.onChange(of: currentPage) { _, newPage in
updateProgress(for: newPage)
}
}
@ViewBuilder
private func pageView(for index: Int) -> some View {
let lastPageIndex = totalPages - 1
if index == lastPageIndex {
// Done page
DonePageView {
completeExercise()
dismiss()
}
} else if index % 2 == 0 {
// Set page (0, 2, 4, ...)
let setNumber = (index / 2) + 1
SetPageView(
setNumber: setNumber,
totalSets: totalSets,
reps: Int(workoutLog.reps),
isTimeBased: workoutLog.loadTypeEnum == .duration,
durationMinutes: workoutLog.durationMinutes,
durationSeconds: workoutLog.durationSeconds
)
} else {
// Rest page (1, 3, 5, ...)
let restNumber = (index / 2) + 1
RestPageView(restNumber: restNumber)
}
}
private func updateProgress(for pageIndex: Int) {
// Calculate which set we're on based on page index
// Pages: Set1(0), Rest1(1), Set2(2), Rest2(3), Set3(4), Done(5)
// After completing Set 1 and moving to Rest 1, progress should be 1
let setIndex = (pageIndex + 1) / 2
let clampedProgress = min(setIndex, totalSets)
if clampedProgress != Int(workoutLog.currentStateIndex) {
workoutLog.currentStateIndex = Int32(clampedProgress)
if clampedProgress >= totalSets {
workoutLog.status = .completed
workoutLog.completed = true
} else if clampedProgress > 0 {
workoutLog.status = .inProgress
workoutLog.completed = false
}
updateWorkoutStatus()
try? viewContext.save()
// Sync to iOS
WatchConnectivityManager.shared.syncToiOS()
}
}
private func completeExercise() {
workoutLog.currentStateIndex = Int32(totalSets)
workoutLog.status = .completed
workoutLog.completed = true
updateWorkoutStatus()
try? viewContext.save()
// Sync to iOS
WatchConnectivityManager.shared.syncToiOS()
}
private func updateWorkoutStatus() {
guard let workout = workoutLog.workout else { return }
let logs = workout.logsArray
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
}
}
}
// MARK: - Set Page View
struct SetPageView: View {
let setNumber: Int
let totalSets: Int
let reps: Int
let isTimeBased: Bool
let durationMinutes: Int
let durationSeconds: Int
var body: some View {
VStack(spacing: 8) {
Text("Set \(setNumber) of \(totalSets)")
.font(.headline)
.foregroundColor(.secondary)
Text("\(setNumber)")
.font(.system(size: 72, weight: .bold, design: .rounded))
.foregroundColor(.green)
if isTimeBased {
Text(formattedDuration)
.font(.title3)
.foregroundColor(.secondary)
} else {
Text("\(reps) reps")
.font(.title3)
.foregroundColor(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
WKInterfaceDevice.current().play(.start)
}
}
private var formattedDuration: String {
if durationMinutes > 0 && durationSeconds > 0 {
return "\(durationMinutes)m \(durationSeconds)s"
} else if durationMinutes > 0 {
return "\(durationMinutes) min"
} else {
return "\(durationSeconds) sec"
}
}
}
// MARK: - Rest Page View
struct RestPageView: View {
let restNumber: Int
@State private var elapsedSeconds: Int = 0
@State private var timer: Timer?
var body: some View {
VStack(spacing: 8) {
Text("Rest")
.font(.headline)
.foregroundColor(.secondary)
Text(formattedTime)
.font(.system(size: 56, weight: .bold, design: .monospaced))
.foregroundColor(.orange)
Text("Swipe to continue")
.font(.caption2)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
startTimer()
WKInterfaceDevice.current().play(.start)
}
.onDisappear {
stopTimer()
}
}
private var formattedTime: String {
let minutes = elapsedSeconds / 60
let seconds = elapsedSeconds % 60
return String(format: "%d:%02d", minutes, seconds)
}
private func startTimer() {
elapsedSeconds = 0
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
elapsedSeconds += 1
checkHapticPing()
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private func checkHapticPing() {
// Haptic ping every 10 seconds with pattern:
// 10s: single, 20s: double, 30s: triple, 40s: single, 50s: double, etc.
guard elapsedSeconds > 0 && elapsedSeconds % 10 == 0 else { return }
let cyclePosition = (elapsedSeconds / 10) % 3
let pingCount: Int
switch cyclePosition {
case 1: pingCount = 1 // 10s, 40s, 70s...
case 2: pingCount = 2 // 20s, 50s, 80s...
case 0: pingCount = 3 // 30s, 60s, 90s...
default: pingCount = 1
}
playHapticPings(count: pingCount)
}
private func playHapticPings(count: Int) {
for i in 0..<count {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.2) {
WKInterfaceDevice.current().play(.click)
}
}
}
}
// MARK: - Done Page View
struct DonePageView: View {
let onDone: () -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60))
.foregroundColor(.green)
Text("Done!")
.font(.title2)
.fontWeight(.bold)
Text("Tap to finish")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.onTapGesture {
WKInterfaceDevice.current().play(.success)
onDone()
}
.onAppear {
WKInterfaceDevice.current().play(.success)
}
}
}
@@ -0,0 +1,36 @@
//
// ExerciseDoneCard.swift
// Workouts
//
// Created by rzen on 7/23/25 at 4:29PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct ExerciseDoneCard: View {
let elapsedSeconds: Int
let onComplete: () -> Void
var body: some View {
VStack(spacing: 20) {
Button(action: onComplete) {
Text("Done in \(10 - elapsedSeconds)s")
.font(.headline)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.green)
.padding(.horizontal)
}
.padding()
}
private var timeFormatted: String {
let minutes = elapsedSeconds / 60
let seconds = elapsedSeconds % 60
return String(format: "%02d:%02d", minutes, seconds)
}
}
@@ -0,0 +1,55 @@
//
// ExerciseIntroView.swift
// Workouts
//
// Created by rzen on 7/23/25 at 4:19PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct ExerciseIntroCard: View {
let log: WorkoutLog
var body: some View {
VStack(alignment: .center, spacing: 16) {
Text(log.exerciseName)
.font(.title)
.lineLimit(1)
.minimumScaleFactor(0.5)
.layoutPriority(1)
HStack(alignment: .bottom) {
Text("\(log.weight)")
Text("lbs")
.fontWeight(.light)
.padding([.trailing], 10)
Text("\(log.sets)")
Text("×")
.fontWeight(.light)
Text("\(log.reps)")
}
.font(.title3)
.lineLimit(1)
.minimumScaleFactor(0.5)
.layoutPriority(1)
Text(log.status?.name ?? "Not Started")
.foregroundStyle(Color.accentColor)
}
.padding()
// VStack(spacing: 20) {
// Text(title)
// .font(.title)
//
// Text(elapsedSeconds.secondsFormatted)
// .font(.system(size: 48, weight: .semibold, design: .monospaced))
// .foregroundStyle(Color.accentColor)
// }
// .padding()
}
}
@@ -0,0 +1,30 @@
//
// ExerciseRestCard.swift
// Workouts
//
// Created by rzen on 7/23/25 at 4:28PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct ExerciseRestCard: View {
let elapsedSeconds: Int
var body: some View {
VStack(spacing: 20) {
Text("Resting for")
.font(.title)
.lineLimit(1)
.minimumScaleFactor(0.5)
.layoutPriority(1)
Text(elapsedSeconds.secondsFormatted)
.font(.system(size: 48, weight: .semibold, design: .monospaced))
.foregroundStyle(Color.green)
}
.padding()
}
}
@@ -0,0 +1,28 @@
//
// ExerciseSetCard.swift
// Workouts
//
// Created by rzen on 7/23/25 at 4:26PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct ExerciseSetCard: View {
let set: Int
let elapsedSeconds: Int
var body: some View {
VStack(spacing: 20) {
Text("Set \(set)")
.font(.title)
Text(elapsedSeconds.secondsFormatted)
.font(.system(size: 48, weight: .semibold, design: .monospaced))
.foregroundStyle(Color.accentColor)
}
.padding()
}
}
@@ -0,0 +1,140 @@
//
// ExerciseProgressControlView 2.swift
// Workouts
//
// Created by rzen on 7/23/25 at 9:15AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct ExerciseProgressControlView: View {
let log: WorkoutLog
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@State private var exerciseStates: [ExerciseState] = []
@State private var currentStateIndex: Int = 0
@State private var elapsedSeconds: Int = 0
@State private var timer: Timer? = nil
@State private var previousStateIndex: Int = 0
var body: some View {
TabView(selection: $currentStateIndex) {
ForEach(Array(exerciseStates.enumerated()), id: \.element.id) { index, state in
if state.isIntro {
ExerciseIntroCard(log: log)
.tag(index)
} else if state.isSet {
ExerciseSetCard(set: state.setNumber ?? 0, elapsedSeconds: elapsedSeconds)
.tag(index)
} else if state.isRest {
ExerciseRestCard(elapsedSeconds: elapsedSeconds)
.tag(index)
} else if state.isDone {
ExerciseDoneCard(elapsedSeconds: elapsedSeconds, onComplete: completeExercise)
.tag(index)
}
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onChange(of: currentStateIndex) { oldValue, newValue in
if oldValue != newValue {
elapsedSeconds = 0
moveToNextState()
}
}
.onAppear {
setupExerciseStates()
currentStateIndex = log.currentStateIndex ?? 0
startTimer()
}
.onDisappear {
stopTimer()
}
}
private func setupExerciseStates() {
var states: [ExerciseState] = []
states.append(.intro)
for i in 1...log.sets {
states.append(.set(number: i))
if i < log.sets {
states.append(.rest(afterSet: i))
}
}
states.append(.done)
exerciseStates = states
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
elapsedSeconds += 1
// Check if we need to provide haptic feedback during rest periods
if currentStateIndex >= 0 && currentStateIndex < exerciseStates.count {
let currentState = exerciseStates[currentStateIndex]
if currentState.isRest {
provideRestHapticFeedback()
} else if currentState.isDone && elapsedSeconds >= 10 {
// Auto-complete after 10 seconds on the DONE state
completeExercise()
}
}
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private func moveToNextState() {
if currentStateIndex < exerciseStates.count - 1 {
elapsedSeconds = 0
withAnimation {
currentStateIndex += 1
log.currentStateIndex = currentStateIndex
log.elapsedSeconds = elapsedSeconds
log.status = .inProgress
try? modelContext.save()
}
} else {
// We've reached the end (DONE state)
completeExercise()
}
}
private func provideRestHapticFeedback() {
// Provide haptic feedback based on elapsed time
if elapsedSeconds % 60 == 0 && elapsedSeconds > 0 {
// Triple tap every 60 seconds
HapticFeedback.tripleTap()
} else if elapsedSeconds % 30 == 0 && elapsedSeconds > 0 {
// Double tap every 30 seconds
HapticFeedback.doubleTap()
} else if elapsedSeconds % 10 == 0 && elapsedSeconds > 0 {
// Single tap every 10 seconds
HapticFeedback.success()
}
}
private func completeExercise() {
// Update the workout log status to completed
log.status = .completed
// reset index in case we wish to re-run the exercise
log.currentStateIndex = 0
// Provide "tada" haptic feedback
HapticFeedback.tripleTap()
// Dismiss this view to return to WorkoutDetailView
dismiss()
}
}
@@ -0,0 +1,317 @@
import SwiftUI
import SwiftData
import WatchKit
// Enum to track the current phase of the exercise
enum ExercisePhase {
case notStarted
case exercising(setNumber: Int)
case resting(setNumber: Int, elapsedSeconds: Int)
case completed
}
struct ExerciseProgressView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
let log: WorkoutLog
@State private var phase: ExercisePhase = .notStarted
@State private var hapticSeconds: Int = 0
@State private var restSeconds: Int = 0
@State private var hapticTimer: Timer? = nil
@State private var restTimer: Timer? = nil
var body: some View {
ScrollView {
VStack(spacing: 15) {
exerciseHeader
switch phase {
case .notStarted:
startPhaseView
case .exercising(let setNumber):
exercisingPhaseView(setNumber: setNumber)
case .resting(let setNumber, let elapsedSeconds):
restingPhaseView(setNumber: setNumber, elapsedSeconds: elapsedSeconds)
case .completed:
completedPhaseView
}
}
.padding()
}
.navigationTitle(log.exerciseName)
.navigationBarTitleDisplayMode(.inline)
.onDisappear {
stopTimers()
}
.gesture(
DragGesture(minimumDistance: 50)
.onEnded { gesture in
if gesture.translation.width < 0 {
// Swipe left - progress to next phase
handleSwipeLeft()
} else if gesture.translation.height < 0 && gesture.translation.height < -50 {
// Swipe up - cancel current set
handleSwipeUp()
}
}
)
}
// MARK: - View Components
private var exerciseHeader: some View {
VStack(alignment: .leading, spacing: 5) {
Text(log.exerciseName)
.font(.headline)
.foregroundColor(.primary)
Text("\(log.sets) sets × \(log.reps) reps")
.font(.subheadline)
.foregroundColor(.secondary)
Text("\(log.weight) lbs")
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 5)
}
private var startPhaseView: some View {
VStack(spacing: 20) {
Text("Ready to start?")
.font(.headline)
Button(action: startFirstSet) {
Text("Start First Set")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
}
}
private func exercisingPhaseView(setNumber: Int) -> some View {
VStack(spacing: 20) {
Text("Set \(setNumber) of \(log.sets)")
.font(.headline)
Text("Exercising...")
.foregroundColor(.secondary)
HStack(spacing: 20) {
Button(action: completeSet) {
Text("Complete")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.green)
Button(action: cancelSet) {
Text("Cancel")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.red)
}
Text("Or swipe left to complete")
.font(.caption)
.foregroundColor(.secondary)
Text("Swipe up to cancel")
.font(.caption)
.foregroundColor(.secondary)
}
}
private func restingPhaseView(setNumber: Int, elapsedSeconds: Int) -> some View {
VStack(spacing: 20) {
Text("Rest after Set \(setNumber)")
.font(.headline)
Text("Rest time: \(formatSeconds(elapsedSeconds))")
.foregroundColor(.secondary)
if setNumber < (log.sets) {
Button(action: { startNextSet(after: setNumber) }) {
Text("Start Set \(setNumber + 1)")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
Text("Or swipe left to start next set")
.font(.caption)
.foregroundColor(.secondary)
} else {
Button(action: completeExercise) {
Text("Complete Exercise")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.green)
Text("Or swipe left to complete")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
private var completedPhaseView: some View {
VStack(spacing: 20) {
Text("Exercise Completed!")
.font(.headline)
.foregroundColor(.green)
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 50))
.foregroundColor(.green)
Button(action: { dismiss() }) {
Text("Return to Workout")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
}
}
// MARK: - Action Handlers
private func handleSwipeLeft() {
switch phase {
case .notStarted:
startFirstSet()
case .exercising:
completeSet()
case .resting(let setNumber, _):
if setNumber < (log.sets) {
startNextSet(after: setNumber)
} else {
completeExercise()
}
case .completed:
dismiss()
}
}
private func handleSwipeUp() {
if case .exercising = phase {
cancelSet()
}
}
private func startFirstSet() {
phase = .exercising(setNumber: 1)
startHapticTimer()
}
private func startNextSet(after completedSetNumber: Int) {
stopTimers()
let nextSetNumber = completedSetNumber + 1
phase = .exercising(setNumber: nextSetNumber)
startHapticTimer()
}
private func completeSet() {
stopTimers()
if case .exercising(let setNumber) = phase {
// Start rest timer
phase = .resting(setNumber: setNumber, elapsedSeconds: 0)
startRestTimer()
startHapticTimer()
// Play completion haptic
HapticFeedback.success()
}
}
private func cancelSet() {
// Just go back to the previous state
stopTimers()
phase = .notStarted
}
private func completeExercise() {
stopTimers()
// Update workout log
log.completed = true
log.status = .completed
try? modelContext.save()
// Show completion screen
phase = .completed
// Play completion haptic
HapticFeedback.success()
}
// MARK: - Timer Management
private func startHapticTimer() {
hapticSeconds = 0
hapticTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
hapticSeconds += 1
// Provide haptic feedback based on time intervals
if hapticSeconds % 60 == 0 {
// Triple tap every 60 seconds
HapticFeedback.tripleTap()
} else if hapticSeconds % 30 == 0 {
// Double tap every 30 seconds
HapticFeedback.doubleTap()
} else if hapticSeconds % 10 == 0 {
// Light tap every 10 seconds
HapticFeedback.click()
}
}
}
private func startRestTimer() {
restSeconds = 0
restTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
restSeconds += 1
if case .resting(let setNumber, _) = phase {
phase = .resting(setNumber: setNumber, elapsedSeconds: restSeconds)
}
}
}
private func stopTimers() {
hapticTimer?.invalidate()
hapticTimer = nil
restTimer?.invalidate()
restTimer = nil
}
// MARK: - Helper Functions
private func formatSeconds(_ seconds: Int) -> String {
let minutes = seconds / 60
let remainingSeconds = seconds % 60
return String(format: "%d:%02d", minutes, remainingSeconds)
}
}
//#Preview {
// let config = ModelConfiguration(isStoredInMemoryOnly: true)
// let container = try! ModelContainer(for: SchemaV1.models, configurations: config)
//
// // Create sample data
// let exercise = Exercise(name: "Bench Press", sets: 3, reps: 8, weight: 60.0)
// let workout = Workout(name: "Chest Day", date: Date())
// let log = WorkoutLog(exercise: exercise, workout: workout)
//
// NavigationStack {
// ExerciseProgressView(log: log)
// .modelContainer(container)
// }
//}
@@ -0,0 +1,72 @@
//
// ExerciseState.swift
// Workouts
//
// Created by rzen on 7/23/25 at 9:14AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
enum ExerciseState: Identifiable {
case intro
case set(number: Int)
case rest(afterSet: Int)
case done
var id: String {
switch self {
case .intro:
return "detail"
case .set(let number):
return "set_\(number)"
case .rest(let afterSet):
return "rest_\(afterSet)"
case .done:
return "done"
}
}
var setNumber: Int? {
switch self {
case .intro, .rest, .done: return nil
case .set (let number): return number
}
}
var afterSet: Int? {
switch self {
case .intro, .set, .done: return nil
case .rest (let afterSet): return afterSet
}
}
var isIntro: Bool {
if case .intro = self {
return true
}
return false
}
var isSet: Bool {
if case .set = self {
return true
}
return false
}
var isRest: Bool {
if case .rest = self {
return true
}
return false
}
var isDone: Bool {
if case .done = self {
return true
}
return false
}
}
@@ -0,0 +1,47 @@
//
// ExerciseStateView 2.swift
// Workouts
//
// Created by rzen on 7/23/25 at 9:15AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct ExerciseStateView: View {
let title: String
let isRest: Bool
let isDone: Bool
let elapsedSeconds: Int
let onComplete: () -> Void
var body: some View {
VStack(spacing: 20) {
Text(title)
.font(.title)
Text(timeFormatted)
.font(.system(size: 48, weight: .semibold, design: .monospaced))
.foregroundStyle(isRest ? .orange : .accentColor)
if isDone {
Button(action: onComplete) {
Text("Done in \(10 - elapsedSeconds)s")
.font(.headline)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.green)
.padding(.horizontal)
}
}
.padding()
}
private var timeFormatted: String {
let minutes = elapsedSeconds / 60
let seconds = elapsedSeconds % 60
return String(format: "%02d:%02d", minutes, seconds)
}
}
@@ -1,229 +0,0 @@
//
// WorkoutLogListView.swift
// Workouts Watch App
//
// 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 showingExercisePicker = false
@State private var selectedLog: WorkoutLog?
var sortedWorkoutLogs: [WorkoutLog] {
workout.logsArray
}
var body: some View {
List {
Section(header: Text(workout.label)) {
ForEach(sortedWorkoutLogs, id: \.objectID) { log in
Button {
selectedLog = log
} label: {
WorkoutLogRowLabel(log: log)
}
.buttonStyle(.plain)
}
}
Section {
Button {
showingExercisePicker = true
} label: {
HStack {
Image(systemName: "plus.circle.fill")
.foregroundColor(.green)
Text("Add Exercise")
}
}
}
}
.overlay {
if sortedWorkoutLogs.isEmpty {
ContentUnavailableView(
"No Exercises",
systemImage: "figure.strengthtraining.traditional",
description: Text("Tap + to add exercises.")
)
}
}
.navigationTitle(workout.split?.name ?? Split.unnamed)
.navigationDestination(item: $selectedLog) { log in
ExerciseProgressView(workoutLog: log)
}
.sheet(isPresented: $showingExercisePicker) {
ExercisePickerView(workout: workout)
}
}
}
// MARK: - Workout Log Row Label
struct WorkoutLogRowLabel: View {
@ObservedObject var log: WorkoutLog
var body: some View {
HStack {
statusIcon
.foregroundColor(statusColor)
VStack(alignment: .leading, spacing: 2) {
Text(log.exerciseName)
.font(.headline)
.lineLimit(1)
Text(subtitle)
.font(.caption2)
.foregroundColor(.secondary)
}
Spacer()
}
}
private var statusIcon: Image {
switch log.status {
case .completed:
Image(systemName: "checkmark.circle.fill")
case .inProgress:
Image(systemName: "circle.dotted")
case .notStarted:
Image(systemName: "circle")
case .skipped:
Image(systemName: "xmark.circle")
}
}
private var statusColor: Color {
switch log.status {
case .completed:
.green
case .inProgress:
.orange
case .notStarted:
.secondary
case .skipped:
.secondary
}
}
private var subtitle: String {
if log.loadTypeEnum == .duration {
let mins = log.durationMinutes
let secs = log.durationSeconds
if mins > 0 && secs > 0 {
return "\(log.sets) × \(mins)m \(secs)s"
} else if mins > 0 {
return "\(log.sets) × \(mins) min"
} else {
return "\(log.sets) × \(secs) sec"
}
} else {
return "\(log.sets) × \(log.reps) × \(log.weight) lbs"
}
}
}
// MARK: - Exercise Picker View
struct ExercisePickerView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(\.dismiss) private var dismiss
@ObservedObject var workout: Workout
private var availableExercises: [Exercise] {
guard let split = workout.split else { return [] }
let existingNames = Set(workout.logsArray.map { $0.exerciseName })
return split.exercisesArray.filter { !existingNames.contains($0.name) }
}
var body: some View {
NavigationStack {
List {
if availableExercises.isEmpty {
Text("All exercises added")
.foregroundColor(.secondary)
} else {
ForEach(availableExercises, id: \.objectID) { exercise in
Button {
addExercise(exercise)
} label: {
VStack(alignment: .leading) {
Text(exercise.name)
.font(.headline)
Text(exerciseSubtitle(exercise))
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
}
}
.navigationTitle("Add Exercise")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
}
private func addExercise(_ exercise: Exercise) {
let log = WorkoutLog(context: viewContext)
log.exerciseName = exercise.name
log.date = Date()
log.order = Int32(workout.logsArray.count)
log.sets = exercise.sets
log.reps = exercise.reps
log.weight = exercise.weight
log.loadType = exercise.loadType
log.duration = exercise.duration
log.status = .notStarted
log.workout = workout
// Update workout start if first exercise
if workout.logsArray.count == 1 {
workout.start = Date()
}
try? viewContext.save()
// Sync to iOS
WatchConnectivityManager.shared.syncToiOS()
dismiss()
}
private func exerciseSubtitle(_ exercise: Exercise) -> String {
let loadType = LoadType(rawValue: Int(exercise.loadType)) ?? .weight
if loadType == .duration {
let mins = exercise.durationMinutes
let secs = exercise.durationSeconds
if mins > 0 && secs > 0 {
return "\(exercise.sets) × \(mins)m \(secs)s"
} else if mins > 0 {
return "\(exercise.sets) × \(mins) min"
} else {
return "\(exercise.sets) × \(secs) sec"
}
} else {
return "\(exercise.sets) × \(exercise.reps) × \(exercise.weight) lbs"
}
}
}
#Preview {
WorkoutLogListView(workout: Workout())
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
}
@@ -1,99 +0,0 @@
//
// WorkoutLogsView.swift
// Workouts Watch App
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import CoreData
struct WorkoutLogsView: View {
@Environment(\.managedObjectContext) private var viewContext
@EnvironmentObject var connectivityManager: WatchConnectivityManager
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Workout.start, ascending: false)],
animation: .default
)
private var workouts: FetchedResults<Workout>
var body: some View {
NavigationStack {
List {
ForEach(workouts, id: \.objectID) { workout in
NavigationLink(destination: WorkoutLogListView(workout: workout)) {
WorkoutRow(workout: workout)
}
}
}
.overlay {
if workouts.isEmpty {
ContentUnavailableView(
"No Workouts",
systemImage: "list.bullet.clipboard",
description: Text("Tap sync or start a workout from iPhone.")
)
}
}
.navigationTitle("Workouts")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
connectivityManager.requestSync()
} label: {
Image(systemName: "arrow.triangle.2.circlepath")
}
}
}
}
}
}
// MARK: - Workout Row
struct WorkoutRow: View {
@ObservedObject var workout: Workout
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(workout.split?.name ?? Split.unnamed)
.font(.headline)
.lineLimit(1)
HStack {
Text(workout.start.formatDate())
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
statusIndicator
}
}
.padding(.vertical, 4)
}
@ViewBuilder
private var statusIndicator: some View {
switch workout.status {
case .completed:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
case .inProgress:
Image(systemName: "circle.dotted")
.foregroundColor(.orange)
case .notStarted:
Image(systemName: "circle")
.foregroundColor(.secondary)
case .skipped:
Image(systemName: "xmark.circle")
.foregroundColor(.secondary)
}
}
}
#Preview {
WorkoutLogsView()
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
}
@@ -0,0 +1,62 @@
//
// ActiveWorkoutListView.swift
// Workouts
//
// Created by rzen on 7/20/25 at 6:35 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
struct ActiveWorkoutListView: View {
@Environment(\.modelContext) private var modelContext
let workouts: [Workout]
var body: some View {
List {
ForEach(workouts) { workout in
NavigationLink {
WorkoutDetailView(workout: workout)
} label: {
WorkoutCardView(workout: workout)
}
.listRowBackground(
RoundedRectangle(cornerRadius: 12)
.fill(Color.secondary.opacity(0.2))
.padding(
EdgeInsets(
top: 4,
leading: 8,
bottom: 4,
trailing: 8
)
)
)
// .swipeActions (edge: .trailing, allowsFullSwipe: false) {
// Button {
// //
// } label: {
// Label("Delete", systemImage: "trash")
// .frame(height: 40)
// }
// .tint(.red)
// }
}
}
.listStyle(.carousel)
}
}
#Preview {
let container = AppContainer.preview
let split = Split(name: "Upper Body", color: "blue", systemImage: "figure.strengthtraining.traditional")
let workout1 = Workout(start: Date(), end: Date(), split: split)
let split2 = Split(name: "Lower Body", color: "red", systemImage: "figure.run")
let workout2 = Workout(start: Date().addingTimeInterval(-3600), end: Date(), split: split2)
ActiveWorkoutListView(workouts: [workout1, workout2])
.modelContainer(container)
}
@@ -0,0 +1,36 @@
//
// WorkoutCardView.swift
// Workouts
//
// Created by rzen on 7/22/25 at 9:54PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct WorkoutCardView: View {
let workout: Workout
var body: some View {
VStack(alignment: .leading) {
if let split = workout.split {
Image(systemName: split.systemImage)
.font(.system(size: 48))
.foregroundStyle(split.getColor())
} else {
Image(systemName: "dumbbell.fill")
.font(.system(size: 24))
.foregroundStyle(.gray)
}
Text(workout.split?.name ?? "Workout")
.font(.headline)
.foregroundStyle(.white)
Text(workout.statusName)
.font(.caption)
.foregroundStyle(Color.accentColor)
}
}
}
@@ -0,0 +1,50 @@
//
// WorkoutDetailView.swift
// Workouts
//
// Created by rzen on 7/22/25 at 9:54PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct WorkoutDetailView: View {
let workout: Workout
var body: some View {
VStack(alignment: .center, spacing: 8) {
if let logs = workout.logs?.sorted(by: { $0.order < $1.order }), !logs.isEmpty {
List {
ForEach(logs) { log in
NavigationLink {
ExerciseProgressControlView(log: log)
} label: {
WorkoutLogCardView(log: log)
}
.listRowBackground(
RoundedRectangle(cornerRadius: 12)
.fill(Color.secondary.opacity(0.2))
.padding(
EdgeInsets(
top: 4,
leading: 8,
bottom: 4,
trailing: 8
)
)
)
}
}
.listStyle(.carousel)
} else {
Text("No exercises in this workout")
.font(.body)
.foregroundStyle(.secondary)
.padding()
Spacer()
}
}
}
}
@@ -0,0 +1,34 @@
//
// WorkoutLogCardView.swift
// Workouts
//
// Created by rzen on 7/22/25 at 9:56PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct WorkoutLogCardView: View {
let log: WorkoutLog
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(log.exerciseName)
.font(.headline)
.lineLimit(1)
Text(log.status?.name ?? "Not Started")
.font(.caption)
.foregroundStyle(Color.accentColor)
HStack(spacing: 12) {
Text("\(log.weight) lbs")
Spacer()
Text("\(log.sets) × \(log.reps)")
}
}
}
}
@@ -0,0 +1,69 @@
//
// WorkoutLogDetailView.swift
// Workouts
//
// Created by rzen on 7/22/25 at 9:57PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct WorkoutLogDetailView: View {
let log: WorkoutLog
var body: some View {
NavigationLink {
ExerciseProgressControlView(log: log)
} label: {
VStack(alignment: .center) {
Text(log.exerciseName)
.font(.title)
.lineLimit(1)
.minimumScaleFactor(0.5)
.layoutPriority(1)
HStack (alignment: .bottom) {
Text("\(log.weight)")
Text( "lbs")
.fontWeight(.light)
.padding([.trailing], 10)
Text("\(log.sets)")
Text("×")
.fontWeight(.light)
Text("\(log.reps)")
}
.font(.title3)
.lineLimit(1)
.minimumScaleFactor(0.5)
.layoutPriority(1)
Text(log.status?.name ?? "Not Started")
.foregroundStyle(Color.accentColor)
Text("Tap to start")
.foregroundStyle(Color.accentColor)
}
.padding()
}
.buttonStyle(.plain)
}
private func statusColor(for status: WorkoutStatus?) -> Color {
guard let status = status else { return .secondary }
switch status {
case .notStarted:
return .secondary
case .inProgress:
return .blue
case .completed:
return .green
case .skipped:
return .red
}
}
}
@@ -0,0 +1,117 @@
import SwiftUI
import SwiftData
struct WorkoutLogListView: View {
@Environment(\.modelContext) private var modelContext
let workout: Workout
@State private var selectedLogIndex: Int = 0
var body: some View {
VStack {
if let logs = workout.logs?.sorted(by: { $0.order < $1.order }), !logs.isEmpty {
TabView(selection: $selectedLogIndex) {
ForEach(Array(logs.enumerated()), id: \.element.id) { index, log in
WorkoutLogCard(log: log, index: index)
.tag(index)
}
}
.tabViewStyle(.page)
// .indexViewStyle(.page(backgroundDisplayMode: .always))
} else {
Text("No exercises in this workout")
.foregroundStyle(.secondary)
}
}
.navigationTitle(workout.split?.name ?? "Workout")
.navigationBarTitleDisplayMode(.inline)
}
}
struct WorkoutLogCard: View {
let log: WorkoutLog
let index: Int
var body: some View {
VStack(spacing: 12) {
Text(log.exerciseName)
.font(.headline)
.multilineTextAlignment(.center)
HStack {
VStack {
Text("\(log.sets)")
.font(.title2)
Text("Sets")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
VStack {
Text("\(log.reps)")
.font(.title2)
Text("Reps")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
VStack {
Text("\(log.weight)")
.font(.title2)
Text("Weight")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
NavigationLink {
ExerciseProgressView(log: log)
} label: {
Text("Start")
.font(.headline)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.blue)
.cornerRadius(8)
}
.buttonStyle(PlainButtonStyle())
Text(log.status?.name ?? "Not Started")
.font(.caption)
.foregroundStyle(statusColor(for: log.status))
}
.padding()
.background(Color.secondary.opacity(0.2))
.cornerRadius(12)
.padding(.horizontal)
}
private func statusColor(for status: WorkoutStatus?) -> Color {
guard let status = status else { return .secondary }
switch status {
case .notStarted:
return .secondary
case .inProgress:
return .blue
case .completed:
return .green
case .skipped:
return .red
}
}
}
#Preview {
let container = AppContainer.preview
let workout = Workout(start: Date(), end: Date(), split: nil)
let log1 = WorkoutLog(workout: workout, exerciseName: "Bench Press", date: Date(), sets: 3, reps: 10, weight: 135)
let log2 = WorkoutLog(workout: workout, exerciseName: "Squats", date: Date(), order: 1, sets: 3, reps: 8, weight: 225)
return WorkoutLogListView(workout: workout)
.modelContainer(container)
}
@@ -5,16 +5,12 @@
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key> <key>com.apple.developer.icloud-container-identifiers</key>
<array/> <array>
<string>iCloud.com.dev.rzen.indie.Workouts</string>
</array>
<key>com.apple.developer.icloud-services</key> <key>com.apple.developer.icloud-services</key>
<array> <array>
<string>CloudKit</string> <string>CloudKit</string>
</array> </array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.dev.rzen.indie.Workouts</string>
</array>
</dict> </dict>
</plist> </plist>
@@ -1,46 +0,0 @@
<?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="duration" optional="YES" 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="loadType" attributeType="Integer 32" defaultValueString="1" usesScalarValueType="YES"/>
<attribute name="notes" optional="YES" 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>
+8 -16
View File
@@ -1,31 +1,23 @@
// //
// WorkoutsApp.swift // WorksoutsApp.swift
// Workouts Watch App // Workouts
// //
// Created by rzen on 8/13/25 at 11:10 AM. // Created by rzen on 7/15/25 at 7:09 PM.
// //
// Copyright 2025 Rouslan Zenetl. All Rights Reserved. // Copyright 2025 Rouslan Zenetl. All Rights Reserved.
// //
import SwiftUI import SwiftUI
import CoreData import SwiftData
@main @main
struct WorkoutsWatchApp: App { struct Workouts_Watch_AppApp: App {
let persistenceController = PersistenceController.shared let container = AppContainer.create()
let connectivityManager = WatchConnectivityManager.shared
init() {
// Set up iPhone connectivity with Core Data context
connectivityManager.setViewContext(persistenceController.viewContext)
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
.environment(\.managedObjectContext, persistenceController.viewContext) .modelContainer(container)
.environmentObject(connectivityManager)
} }
} }
} }
@@ -0,0 +1,45 @@
////
//// ExerciseDetailView.swift
//// Workouts
////
//// Created by rzen on 7/23/25 at 9:17AM.
////
//// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
////
//
//import SwiftUI
//
//struct ExerciseDetailView: View {
// let log: WorkoutLog
// let onStart: () -> Void
//
// var body: some View {
// VStack(alignment: .center, spacing: 16) {
// Text(log.exerciseName)
// .font(.title)
// .lineLimit(1)
// .minimumScaleFactor(0.5)
// .layoutPriority(1)
//
// HStack(alignment: .bottom) {
// Text("\(log.weight)")
// Text("lbs")
// .fontWeight(.light)
// .padding([.trailing], 10)
//
// Text("\(log.sets)")
// Text("×")
// .fontWeight(.light)
// Text("\(log.reps)")
// }
// .font(.title3)
// .lineLimit(1)
// .minimumScaleFactor(0.5)
// .layoutPriority(1)
//
// Text(log.status?.name ?? "Not Started")
// .foregroundStyle(Color.accentColor)
// }
// .padding()
// }
//}
@@ -0,0 +1,35 @@
////
//// ExerciseProgressControlView.swift
//// Workouts
////
//// Created by rzen on 7/20/25 at 7:19 PM.
////
//// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
////
//
//import SwiftUI
//import SwiftData
//
//
//
//
//
//
//// Detail view shown as the first item in the exercise progress carousel
//
//
//// Helper extension to safely access array elements
////extension Array {
//// subscript(safe index: Index) -> Element? {
//// return indices.contains(index) ? self[index] : nil
//// }
////}
//
////#Preview {
//// let container = AppContainer.preview
//// let workout = Workout(start: Date(), end: nil, split: nil)
//// let log = WorkoutLog(workout: workout, exerciseName: "Bench Press", date: Date(), sets: 3, reps: 10, weight: 135)
////
//// ExerciseProgressControlView(log: log)
//// .modelContainer(container)
////}
+206 -191
View File
@@ -7,29 +7,31 @@
objects = { objects = {
/* 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, ); }; }; A45FA1FE2E27171B00581607 /* Workouts Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A45FA1F12E27171A00581607 /* Workouts Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
A47512C12F1DACCF001A9C6F /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = A47512C02F1DACCF001A9C6F /* Yams */; }; A45FA2732E29B12500581607 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2722E29B12500581607 /* Yams */; };
A47513342F1DADBE001A9C6F /* IndieAbout in Frameworks */ = {isa = PBXBuildFile; productRef = A47513332F1DADBE001A9C6F /* IndieAbout */; }; A45FA27F2E2A930900581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */; };
A45FA2822E2A933B00581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */; };
A45FA2872E2AC66B00581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2862E2AC66B00581607 /* SwiftUIReorderableForEach */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
A473BF032E4CE278003EAD6F /* PBXContainerItemProxy */ = { A45FA1FC2E27171B00581607 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = A473BEE92E4CE276003EAD6F /* Project object */; containerPortal = A45FA0892E21B3DC00581607 /* Project object */;
proxyType = 1; proxyType = 1;
remoteGlobalIDString = A473BF002E4CE278003EAD6F; remoteGlobalIDString = A45FA1F02E27171A00581607;
remoteInfo = "Workouts Watch App"; remoteInfo = "Workouts Watch App";
}; };
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
A473BF142E4CE279003EAD6F /* Embed Watch Content */ = { A45FA2022E27171B00581607 /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase; isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16; dstSubfolderSpec = 16;
files = ( files = (
A473BF022E4CE278003EAD6F /* Workouts Watch App.app in Embed Watch Content */, A45FA1FE2E27171B00581607 /* Workouts Watch App.app in Embed Watch Content */,
); );
name = "Embed Watch Content"; name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -37,17 +39,48 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
A473BEF12E4CE276003EAD6F /* Workouts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Workouts.app; sourceTree = BUILT_PRODUCTS_DIR; }; A45FA0912E21B3DD00581607 /* Workouts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Workouts.app; sourceTree = BUILT_PRODUCTS_DIR; };
A473BF012E4CE278003EAD6F /* Workouts Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Workouts Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; A45FA1F12E27171A00581607 /* Workouts Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Workouts Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
A45FA0A12E21B3DE00581607 /* Exceptions for "Workouts" folder in "Workouts" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
_ATTIC_/ContentView_backup.swift,
_ATTIC_/ExerciseProgressView_backup.swift,
Info.plist,
);
target = A45FA0902E21B3DD00581607 /* Workouts */;
};
A45FA2C52E2D3CBD00581607 /* Exceptions for "Workouts" folder in "Workouts Watch App" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
_ATTIC_/ContentView_backup.swift,
_ATTIC_/ExerciseProgressView_backup.swift,
Models/Exercise.swift,
Models/Split.swift,
Models/Workout.swift,
Models/WorkoutLog.swift,
Schema/SchemaV1.swift,
Views/Common/CheckboxStatus.swift,
Views/WorkoutLog/WorkoutStatus.swift,
);
target = A45FA1F02E27171A00581607 /* Workouts Watch App */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
A473BEF32E4CE276003EAD6F /* Workouts */ = { A45FA0932E21B3DD00581607 /* Workouts */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
A45FA0A12E21B3DE00581607 /* Exceptions for "Workouts" folder in "Workouts" target */,
A45FA2C52E2D3CBD00581607 /* Exceptions for "Workouts" folder in "Workouts Watch App" target */,
);
path = Workouts; path = Workouts;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A473BF052E4CE278003EAD6F /* Workouts Watch App */ = { A45FA1F22E27171A00581607 /* Workouts Watch App */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
path = "Workouts Watch App"; path = "Workouts Watch App";
sourceTree = "<group>"; sourceTree = "<group>";
@@ -55,16 +88,18 @@
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
A473BEEE2E4CE276003EAD6F /* Frameworks */ = { A45FA08E2E21B3DD00581607 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A47513342F1DADBE001A9C6F /* IndieAbout in Frameworks */, A45FA2822E2A933B00581607 /* SwiftUIReorderableForEach in Frameworks */,
A47512C12F1DACCF001A9C6F /* Yams in Frameworks */, A45FA2732E29B12500581607 /* Yams in Frameworks */,
A45FA27F2E2A930900581607 /* SwiftUIReorderableForEach in Frameworks */,
A45FA2872E2AC66B00581607 /* SwiftUIReorderableForEach in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
A473BEFE2E4CE278003EAD6F /* Frameworks */ = { A45FA1EE2E27171A00581607 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@@ -74,20 +109,20 @@
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
A473BEE82E4CE276003EAD6F = { A45FA0882E21B3DC00581607 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A473BEF32E4CE276003EAD6F /* Workouts */, A45FA0932E21B3DD00581607 /* Workouts */,
A473BF052E4CE278003EAD6F /* Workouts Watch App */, A45FA1F22E27171A00581607 /* Workouts Watch App */,
A473BEF22E4CE276003EAD6F /* Products */, A45FA0922E21B3DD00581607 /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A473BEF22E4CE276003EAD6F /* Products */ = { A45FA0922E21B3DD00581607 /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A473BEF12E4CE276003EAD6F /* Workouts.app */, A45FA0912E21B3DD00581607 /* Workouts.app */,
A473BF012E4CE278003EAD6F /* Workouts Watch App.app */, A45FA1F12E27171A00581607 /* Workouts Watch App.app */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -95,106 +130,107 @@
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
A473BEF02E4CE276003EAD6F /* Workouts */ = { A45FA0902E21B3DD00581607 /* Workouts */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = A473BF152E4CE279003EAD6F /* Build configuration list for PBXNativeTarget "Workouts" */; buildConfigurationList = A45FA0A22E21B3DE00581607 /* Build configuration list for PBXNativeTarget "Workouts" */;
buildPhases = ( buildPhases = (
A47512C22F1DB000001A9C6F /* Update Build Number */, A45FA08D2E21B3DD00581607 /* Sources */,
A473BEED2E4CE276003EAD6F /* Sources */, A45FA08E2E21B3DD00581607 /* Frameworks */,
A473BEEE2E4CE276003EAD6F /* Frameworks */, A45FA08F2E21B3DD00581607 /* Resources */,
A473BEEF2E4CE276003EAD6F /* Resources */, A45FA2022E27171B00581607 /* Embed Watch Content */,
A473BF142E4CE279003EAD6F /* Embed Watch Content */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
A473BF042E4CE278003EAD6F /* PBXTargetDependency */, A45FA1FD2E27171B00581607 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
A473BEF32E4CE276003EAD6F /* Workouts */, A45FA0932E21B3DD00581607 /* Workouts */,
); );
name = Workouts; name = Workouts;
packageProductDependencies = ( packageProductDependencies = (
A47512C02F1DACCF001A9C6F /* Yams */, A45FA2722E29B12500581607 /* Yams */,
A47513332F1DADBE001A9C6F /* IndieAbout */, A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */,
A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */,
A45FA2862E2AC66B00581607 /* SwiftUIReorderableForEach */,
); );
productName = Workouts; productName = Workouts;
productReference = A473BEF12E4CE276003EAD6F /* Workouts.app */; productReference = A45FA0912E21B3DD00581607 /* Workouts.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
A473BF002E4CE278003EAD6F /* Workouts Watch App */ = { A45FA1F02E27171A00581607 /* Workouts Watch App */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = A473BF112E4CE279003EAD6F /* Build configuration list for PBXNativeTarget "Workouts Watch App" */; buildConfigurationList = A45FA1FF2E27171B00581607 /* Build configuration list for PBXNativeTarget "Workouts Watch App" */;
buildPhases = ( buildPhases = (
A473BEFD2E4CE278003EAD6F /* Sources */, A45FA1ED2E27171A00581607 /* Sources */,
A473BEFE2E4CE278003EAD6F /* Frameworks */, A45FA1EE2E27171A00581607 /* Frameworks */,
A473BEFF2E4CE278003EAD6F /* Resources */, A45FA1EF2E27171A00581607 /* Resources */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
A473BF052E4CE278003EAD6F /* Workouts Watch App */, A45FA1F22E27171A00581607 /* Workouts Watch App */,
); );
name = "Workouts Watch App"; name = "Workouts Watch App";
packageProductDependencies = ( packageProductDependencies = (
); );
productName = "Workouts Watch App"; productName = "Workouts Watch App";
productReference = A473BF012E4CE278003EAD6F /* Workouts Watch App.app */; productReference = A45FA1F12E27171A00581607 /* Workouts Watch App.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
A473BEE92E4CE276003EAD6F /* Project object */ = { A45FA0892E21B3DC00581607 /* Project object */ = {
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1620; LastSwiftUpdateCheck = 1620;
LastUpgradeCheck = 2620; LastUpgradeCheck = 1620;
TargetAttributes = { TargetAttributes = {
A473BEF02E4CE276003EAD6F = { A45FA0902E21B3DD00581607 = {
CreatedOnToolsVersion = 16.2; CreatedOnToolsVersion = 16.2;
}; };
A473BF002E4CE278003EAD6F = { A45FA1F02E27171A00581607 = {
CreatedOnToolsVersion = 16.2; CreatedOnToolsVersion = 16.2;
}; };
}; };
}; };
buildConfigurationList = A473BEEC2E4CE276003EAD6F /* Build configuration list for PBXProject "Workouts" */; buildConfigurationList = A45FA08C2E21B3DC00581607 /* Build configuration list for PBXProject "Workouts" */;
developmentRegion = en; developmentRegion = en;
hasScannedForEncodings = 0; hasScannedForEncodings = 0;
knownRegions = ( knownRegions = (
en, en,
Base, Base,
); );
mainGroup = A473BEE82E4CE276003EAD6F; mainGroup = A45FA0882E21B3DC00581607;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
A47512BF2F1DACCF001A9C6F /* XCRemoteSwiftPackageReference "Yams" */, A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */,
A47513322F1DADBE001A9C6F /* XCRemoteSwiftPackageReference "indie-about" */, A45FA2852E2AC66B00581607 /* XCLocalSwiftPackageReference "../swiftui-reorderable-foreach" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = A473BEF22E4CE276003EAD6F /* Products */; productRefGroup = A45FA0922E21B3DD00581607 /* Products */;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
targets = ( targets = (
A473BEF02E4CE276003EAD6F /* Workouts */, A45FA0902E21B3DD00581607 /* Workouts */,
A473BF002E4CE278003EAD6F /* Workouts Watch App */, A45FA1F02E27171A00581607 /* Workouts Watch App */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */
A473BEEF2E4CE276003EAD6F /* Resources */ = { A45FA08F2E21B3DD00581607 /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
A473BEFF2E4CE278003EAD6F /* Resources */ = { A45FA1EF2E27171A00581607 /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@@ -203,37 +239,15 @@
}; };
/* 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 */ = { A45FA08D2E21B3DD00581607 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
A473BEFD2E4CE278003EAD6F /* Sources */ = { A45FA1ED2E27171A00581607 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@@ -243,15 +257,77 @@
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
A473BF042E4CE278003EAD6F /* PBXTargetDependency */ = { A45FA1FD2E27171B00581607 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = A473BF002E4CE278003EAD6F /* Workouts Watch App */; target = A45FA1F02E27171A00581607 /* Workouts Watch App */;
targetProxy = A473BF032E4CE278003EAD6F /* PBXContainerItemProxy */; targetProxy = A45FA1FC2E27171B00581607 /* PBXContainerItemProxy */;
}; };
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
A473BF0F2E4CE279003EAD6F /* Debug */ = { A45FA0A32E21B3DE00581607 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Workouts/Workouts.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Workouts/Preview Content\"";
DEVELOPMENT_TEAM = C32Z8JNLG6;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Workouts/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.rzen.indie.Workouts;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
A45FA0A42E21B3DE00581607 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Workouts/Workouts.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Workouts/Preview Content\"";
DEVELOPMENT_TEAM = C32Z8JNLG6;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Workouts/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.rzen.indie.Workouts;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
A45FA0A52E21B3DE00581607 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
@@ -285,12 +361,11 @@
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 = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES;
EXCLUDED_SOURCE_FILE_NAMES = "_ATTIC_/*";
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;
@@ -305,17 +380,18 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
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; SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
name = Debug; name = Debug;
}; };
A473BF102E4CE279003EAD6F /* Release */ = { A45FA0A62E21B3DE00581607 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
@@ -349,12 +425,11 @@
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 = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES;
EXCLUDED_SOURCE_FILE_NAMES = "_ATTIC_/*";
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;
@@ -363,15 +438,17 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
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; SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
}; };
name = Release; name = Release;
}; };
A473BF122E4CE279003EAD6F /* Debug */ = { A45FA2002E27171B00581607 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
@@ -380,6 +457,7 @@
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;
@@ -397,11 +475,11 @@
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4; TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.0; WATCHOS_DEPLOYMENT_TARGET = 11.2;
}; };
name = Debug; name = Debug;
}; };
A473BF132E4CE279003EAD6F /* Release */ = { A45FA2012E27171B00581607 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
@@ -410,6 +488,7 @@
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;
@@ -427,143 +506,79 @@
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4; TARGETED_DEVICE_FAMILY = 4;
VALIDATE_PRODUCT = YES; WATCHOS_DEPLOYMENT_TARGET = 11.2;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
};
A473BF162E4CE279003EAD6F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Workouts/Workouts.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Workouts/Preview Content\"";
ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Workouts;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.rzen.indie.Workouts;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
A473BF172E4CE279003EAD6F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Workouts/Workouts.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Workouts/Preview Content\"";
ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Workouts;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.rzen.indie.Workouts;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
}; };
name = Release; name = Release;
}; };
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
A473BEEC2E4CE276003EAD6F /* Build configuration list for PBXProject "Workouts" */ = { A45FA08C2E21B3DC00581607 /* Build configuration list for PBXProject "Workouts" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
A473BF0F2E4CE279003EAD6F /* Debug */, A45FA0A52E21B3DE00581607 /* Debug */,
A473BF102E4CE279003EAD6F /* Release */, A45FA0A62E21B3DE00581607 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
A473BF112E4CE279003EAD6F /* Build configuration list for PBXNativeTarget "Workouts Watch App" */ = { A45FA0A22E21B3DE00581607 /* Build configuration list for PBXNativeTarget "Workouts" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
A473BF122E4CE279003EAD6F /* Debug */, A45FA0A32E21B3DE00581607 /* Debug */,
A473BF132E4CE279003EAD6F /* Release */, A45FA0A42E21B3DE00581607 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
A473BF152E4CE279003EAD6F /* Build configuration list for PBXNativeTarget "Workouts" */ = { A45FA1FF2E27171B00581607 /* Build configuration list for PBXNativeTarget "Workouts Watch App" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
A473BF162E4CE279003EAD6F /* Debug */, A45FA2002E27171B00581607 /* Debug */,
A473BF172E4CE279003EAD6F /* Release */, A45FA2012E27171B00581607 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
A45FA2852E2AC66B00581607 /* XCLocalSwiftPackageReference "../swiftui-reorderable-foreach" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "../swiftui-reorderable-foreach";
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */
A47512BF2F1DACCF001A9C6F /* XCRemoteSwiftPackageReference "Yams" */ = { A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/jpsim/Yams"; repositoryURL = "https://github.com/jpsim/Yams";
requirement = { requirement = {
kind = upToNextMajorVersion; kind = upToNextMajorVersion;
minimumVersion = 6.2.0; minimumVersion = 6.0.2;
};
};
A47513322F1DADBE001A9C6F /* XCRemoteSwiftPackageReference "indie-about" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://git.rzen.dev/rzen/indie-about";
requirement = {
branch = main;
kind = branch;
}; };
}; };
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
A47512C02F1DACCF001A9C6F /* Yams */ = { A45FA2722E29B12500581607 /* Yams */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = A47512BF2F1DACCF001A9C6F /* XCRemoteSwiftPackageReference "Yams" */; package = A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */;
productName = Yams; productName = Yams;
}; };
A47513332F1DADBE001A9C6F /* IndieAbout */ = { A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = A47513322F1DADBE001A9C6F /* XCRemoteSwiftPackageReference "indie-about" */; productName = SwiftUIReorderableForEach;
productName = IndieAbout; };
A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */ = {
isa = XCSwiftPackageProductDependency;
productName = SwiftUIReorderableForEach;
};
A45FA2862E2AC66B00581607 /* SwiftUIReorderableForEach */ = {
isa = XCSwiftPackageProductDependency;
productName = SwiftUIReorderableForEach;
}; };
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = A473BEE92E4CE276003EAD6F /* Project object */; rootObject = A45FA0892E21B3DC00581607 /* Project object */;
} }
@@ -1,22 +1,13 @@
{ {
"originHash" : "9d613082dd8a405ef810326218a4d81fdfd9ecb33be867afbc9700e52ec96e4b", "originHash" : "81702b8f7fa6fc73458821cd4dfa7dde6684a9d1d4b6d63ecde0b6b832d8d75e",
"pins" : [ "pins" : [
{
"identity" : "indie-about",
"kind" : "remoteSourceControl",
"location" : "https://git.rzen.dev/rzen/indie-about",
"state" : {
"branch" : "main",
"revision" : "ed73ffcc5488b37ec0838ecaaa27ce050807093f"
}
},
{ {
"identity" : "yams", "identity" : "yams",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/jpsim/Yams", "location" : "https://github.com/jpsim/Yams",
"state" : { "state" : {
"revision" : "51b5127c7fb6ffac106ad6d199aaa33c5024895f", "revision" : "f4d4d6827d36092d151ad7f6fef1991c1b7192f6",
"version" : "6.2.0" "version" : "6.0.2"
} }
} }
], ],
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Bucket
uuid = "EEDBFE9D-0066-4E41-BFBC-8B95DBCF47E3"
type = "1"
version = "2.0">
</Bucket>
@@ -7,9 +7,14 @@
<key>Workouts Watch App.xcscheme_^#shared#^_</key> <key>Workouts Watch App.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>0</integer> <integer>1</integer>
</dict> </dict>
<key>Workouts.xcscheme_^#shared#^_</key> <key>Workouts.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>Worksouts Watch App.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>1</integer> <integer>1</integer>
@@ -1,116 +1,38 @@
{ {
"images": [ "images" : [
{ {
"filename": "icon-20@2x.png", "filename" : "DumbBellIcon.png",
"idiom": "iphone", "idiom" : "universal",
"size": "20x20", "platform" : "ios",
"scale": "2x" "size" : "1024x1024"
}, },
{ {
"filename": "icon-20@3x.png", "appearances" : [
"idiom": "iphone", {
"size": "20x20", "appearance" : "luminosity",
"scale": "3x" "value" : "dark"
}
],
"filename" : "DumbBellIcon 1.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}, },
{ {
"filename": "icon-29@2x.png", "appearances" : [
"idiom": "iphone", {
"size": "29x29", "appearance" : "luminosity",
"scale": "2x" "value" : "tinted"
}, }
{ ],
"filename": "icon-29@3x.png", "filename" : "DumbBellIcon 2.png",
"idiom": "iphone", "idiom" : "universal",
"size": "29x29", "platform" : "ios",
"scale": "3x" "size" : "1024x1024"
},
{
"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"
} }
], ],
"info": { "info" : {
"author": "xcode", "author" : "xcode",
"version": 1 "version" : 1
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1023 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

@@ -1,345 +0,0 @@
//
// WatchConnectivityManager.swift
// Workouts
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
import WatchConnectivity
import CoreData
class WatchConnectivityManager: NSObject, ObservableObject {
static let shared = WatchConnectivityManager()
private var session: WCSession?
private var viewContext: NSManagedObjectContext?
override init() {
super.init()
if WCSession.isSupported() {
session = WCSession.default
session?.delegate = self
session?.activate()
}
}
func setViewContext(_ context: NSManagedObjectContext) {
self.viewContext = context
}
// MARK: - Send Data to Watch
func syncAllData() {
guard let session = session else {
print("[WC-iOS] No WCSession")
return
}
print("[WC-iOS] Session state: \(session.activationState.rawValue), reachable: \(session.isReachable), paired: \(session.isPaired), installed: \(session.isWatchAppInstalled)")
guard session.activationState == .activated else {
print("[WC-iOS] Session not activated")
return
}
guard let context = viewContext else {
print("[WC-iOS] No view context")
return
}
context.perform {
do {
let workoutsData = try self.encodeAllWorkouts(context: context)
let splitsData = try self.encodeAllSplits(context: context)
let payload: [String: Any] = [
"workouts": workoutsData,
"splits": splitsData,
"timestamp": Date().timeIntervalSince1970
]
// Use updateApplicationContext for persistent state
try session.updateApplicationContext(payload)
print("[WC-iOS] Synced \(workoutsData.count) workouts, \(splitsData.count) splits to Watch")
} catch {
print("Failed to sync data: \(error)")
}
}
}
func sendWorkoutUpdate(_ workout: Workout) {
guard let session = session, session.activationState == .activated else { return }
do {
let workoutData = try encodeWorkout(workout)
let message: [String: Any] = [
"type": "workoutUpdate",
"workout": workoutData
]
if session.isReachable {
session.sendMessage(message, replyHandler: nil) { error in
print("Failed to send workout update: \(error)")
}
} else {
// Queue for later via application context
syncAllData()
}
} catch {
print("Failed to encode workout: \(error)")
}
}
// MARK: - Encoding
private func encodeAllWorkouts(context: NSManagedObjectContext) throws -> [[String: Any]] {
let request = Workout.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Workout.start, ascending: false)]
let workouts = try context.fetch(request)
return try workouts.map { try encodeWorkout($0) }
}
private func encodeAllSplits(context: NSManagedObjectContext) throws -> [[String: Any]] {
let request = Split.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Split.order, ascending: true)]
let splits = try context.fetch(request)
return try splits.map { try encodeSplit($0) }
}
private func encodeWorkout(_ workout: Workout) throws -> [String: Any] {
var data: [String: Any] = [
"id": workout.objectID.uriRepresentation().absoluteString,
"start": workout.start.timeIntervalSince1970,
"status": workout.status.rawValue
]
if let end = workout.end {
data["end"] = end.timeIntervalSince1970
}
if let split = workout.split {
data["splitId"] = split.objectID.uriRepresentation().absoluteString
data["splitName"] = split.name
}
data["logs"] = workout.logsArray.map { encodeWorkoutLog($0) }
return data
}
private func encodeWorkoutLog(_ log: WorkoutLog) -> [String: Any] {
var data: [String: Any] = [
"id": log.objectID.uriRepresentation().absoluteString,
"exerciseName": log.exerciseName,
"date": log.date.timeIntervalSince1970,
"order": log.order,
"sets": log.sets,
"reps": log.reps,
"weight": log.weight,
"status": log.status.rawValue,
"currentStateIndex": log.currentStateIndex,
"completed": log.completed,
"loadType": log.loadType
]
if let duration = log.duration {
data["duration"] = duration.timeIntervalSince1970
}
if let notes = log.notes {
data["notes"] = notes
}
return data
}
private func encodeSplit(_ split: Split) throws -> [String: Any] {
var data: [String: Any] = [
"id": split.objectID.uriRepresentation().absoluteString,
"name": split.name,
"color": split.color,
"systemImage": split.systemImage,
"order": split.order
]
data["exercises"] = split.exercisesArray.map { encodeExercise($0) }
return data
}
private func encodeExercise(_ exercise: Exercise) -> [String: Any] {
var data: [String: Any] = [
"id": exercise.objectID.uriRepresentation().absoluteString,
"name": exercise.name,
"order": exercise.order,
"sets": exercise.sets,
"reps": exercise.reps,
"weight": exercise.weight,
"loadType": exercise.loadType
]
if let duration = exercise.duration {
data["duration"] = duration.timeIntervalSince1970
}
return data
}
}
// MARK: - WCSessionDelegate
extension WatchConnectivityManager: WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
print("[WC-iOS] Activation failed: \(error)")
} else {
print("[WC-iOS] Activated with state: \(activationState.rawValue), paired: \(session.isPaired), installed: \(session.isWatchAppInstalled)")
// Sync data when session activates
DispatchQueue.main.async {
self.syncAllData()
}
}
}
func sessionDidBecomeInactive(_ session: WCSession) {
print("[WC-iOS] Session became inactive")
}
func sessionDidDeactivate(_ session: WCSession) {
print("[WC-iOS] Session deactivated")
// Reactivate for switching watches
session.activate()
}
func sessionReachabilityDidChange(_ session: WCSession) {
print("[WC-iOS] Reachability changed: \(session.isReachable)")
if session.isReachable {
syncAllData()
}
}
// Receive messages from Watch (for bidirectional sync)
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
print("[WC-iOS] Received message with keys: \(message.keys)")
if let type = message["type"] as? String {
switch type {
case "requestSync":
syncAllData()
case "syncFromWatch":
processWatchSync(message)
default:
break
}
}
}
// Receive user info transfers from Watch (background delivery)
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
print("[WC-iOS] Received userInfo with keys: \(userInfo.keys)")
if let type = userInfo["type"] as? String, type == "syncFromWatch" {
processWatchSync(userInfo)
}
}
// MARK: - Process Watch Sync
private func processWatchSync(_ data: [String: Any]) {
guard let viewContext = viewContext else {
print("[WC-iOS] No view context for Watch sync")
return
}
guard let workoutsData = data["workouts"] as? [[String: Any]] else {
print("[WC-iOS] No workouts in Watch sync data")
return
}
print("[WC-iOS] Processing \(workoutsData.count) workouts from Watch")
DispatchQueue.main.async {
viewContext.perform {
for workoutData in workoutsData {
self.updateWorkoutFromWatch(workoutData, context: viewContext)
}
do {
try viewContext.save()
print("[WC-iOS] Successfully saved Watch sync data")
// Refresh all objects to ensure SwiftUI observes changes
viewContext.refreshAllObjects()
} catch {
print("[WC-iOS] Failed to save Watch sync: \(error)")
}
}
}
}
private func updateWorkoutFromWatch(_ data: [String: Any], context: NSManagedObjectContext) {
guard let startInterval = data["start"] as? TimeInterval else { return }
// Find workout by start date
let request = Workout.fetchRequest()
let startDate = Date(timeIntervalSince1970: startInterval)
request.predicate = NSPredicate(
format: "start >= %@ AND start <= %@",
Date(timeIntervalSince1970: startInterval - 1) as NSDate,
Date(timeIntervalSince1970: startInterval + 1) as NSDate
)
request.fetchLimit = 1
guard let workout = try? context.fetch(request).first else {
print("[WC-iOS] Workout not found for start date: \(startDate)")
return
}
// Update workout status
if let statusRaw = data["status"] as? String,
let status = WorkoutStatus(rawValue: statusRaw) {
workout.status = status
}
if let endInterval = data["end"] as? TimeInterval {
workout.end = Date(timeIntervalSince1970: endInterval)
}
// Update logs
if let logsData = data["logs"] as? [[String: Any]] {
for logData in logsData {
updateWorkoutLogFromWatch(logData, workout: workout, context: context)
}
}
}
private func updateWorkoutLogFromWatch(_ data: [String: Any], workout: Workout, context: NSManagedObjectContext) {
guard let exerciseName = data["exerciseName"] as? String else { return }
// Find log by exercise name in this workout
guard let log = workout.logsArray.first(where: { $0.exerciseName == exerciseName }) else {
print("[WC-iOS] Log not found for exercise: \(exerciseName)")
return
}
// Update status and progress
if let statusRaw = data["status"] as? String,
let status = WorkoutStatus(rawValue: statusRaw) {
log.status = status
}
if let currentStateIndex = data["currentStateIndex"] as? Int {
log.currentStateIndex = Int32(currentStateIndex)
}
if let completed = data["completed"] as? Bool {
log.completed = completed
}
// Update other fields that might have changed
if let notes = data["notes"] as? String {
log.notes = notes
}
}
}
+37 -5
View File
@@ -2,24 +2,56 @@
// ContentView.swift // ContentView.swift
// Workouts // Workouts
// //
// Created by rzen on 8/13/25 at 11:10 AM. // Created by rzen on 7/11/25 at 5:04PM.
// //
// Copyright 2025 Rouslan Zenetl. All Rights Reserved. // Copyright 2025 Rouslan Zenetl. All Rights Reserved.
// //
import SwiftUI import SwiftUI
import CoreData import SwiftData
struct ContentView: View { struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext @Environment(\.modelContext) private var modelContext
var body: some View { var body: some View {
WorkoutLogsView() TabView {
SplitsView()
.tabItem {
Label("Workouts", systemImage: "figure.strengthtraining.traditional")
}
WorkoutListView()
.tabItem {
Label("Logs", systemImage: "list.bullet.clipboard.fill")
}
NavigationStack {
Text("Reports Placeholder")
.navigationTitle("Reports")
}
.tabItem {
Label("Reports", systemImage: "chart.bar")
}
NavigationStack {
Text("Achivements")
.navigationTitle("Achievements")
}
.tabItem {
Label("Achivements", systemImage: "star.fill")
}
// SettingsView()
// .tabItem {
// Label("Settings", systemImage: "gear")
// }
}
.observeCloudKitChanges()
} }
} }
#Preview { #Preview {
ContentView() ContentView()
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
} }
@@ -2,7 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>_XCCurrentVersionName</key> <key>UIBackgroundModes</key>
<string>Workouts.xcdatamodel</string> <array>
<string>remote-notification</string>
</array>
</dict> </dict>
</plist> </plist>
+22 -73
View File
@@ -1,77 +1,26 @@
import Foundation import Foundation
import CoreData import SwiftData
@objc(Exercise) @Model
public class Exercise: NSManagedObject, Identifiable { final class Exercise {
@NSManaged public var name: String var name: String = ""
@NSManaged public var loadType: Int32 var order: Int = 0
@NSManaged public var order: Int32 var sets: Int = 0
@NSManaged public var sets: Int32 var reps: Int = 0
@NSManaged public var reps: Int32 var weight: Int = 0
@NSManaged public var weight: Int32 var weightLastUpdated: Date = Date()
@NSManaged public var duration: Date? var weightReminderTimeIntervalWeeks: Int = 2
@NSManaged public var weightLastUpdated: Date?
@NSManaged public var weightReminderTimeIntervalWeeks: Int32 @Relationship(deleteRule: .nullify)
var split: Split?
@NSManaged public var split: Split?
init(split: Split, exerciseName: String, order: Int, sets: Int, reps: Int, weight: Int, weightReminderTimeIntervalWeeks: Int = 2) {
public var id: NSManagedObjectID { objectID } self.split = split
self.name = exerciseName
var loadTypeEnum: LoadType { self.order = order
get { LoadType(rawValue: Int(loadType)) ?? .weight } self.sets = sets
set { loadType = Int32(newValue.rawValue) } self.reps = reps
} self.weight = weight
self.weightReminderTimeIntervalWeeks = weightReminderTimeIntervalWeeks
// Duration helpers for minutes/seconds conversion
var durationMinutes: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) / 60
}
set {
let seconds = durationSeconds
duration = Date(timeIntervalSince1970: TimeInterval(newValue * 60 + seconds))
}
}
var durationSeconds: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) % 60
}
set {
let minutes = durationMinutes
duration = Date(timeIntervalSince1970: TimeInterval(minutes * 60 + newValue))
}
}
}
// 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"
}
} }
} }
+88 -56
View File
@@ -1,66 +1,98 @@
import Foundation import Foundation
import CoreData import SwiftData
import SwiftUI import SwiftUI
import UniformTypeIdentifiers
@objc(Split) @Model
public class Split: NSManagedObject, Identifiable { final class Split {
@NSManaged public var name: String var name: String = ""
@NSManaged public var color: String var color: String = "indigo"
@NSManaged public var systemImage: String var systemImage: String = "dumbbell.fill"
@NSManaged public var order: Int32 var order: Int = 0
@NSManaged public var exercises: NSSet? // Returns the SwiftUI Color for the stored color name
@NSManaged public var workouts: NSSet? func getColor () -> Color {
return Color.color(from: self.color)
public var id: NSManagedObjectID { objectID } }
@Relationship(deleteRule: .cascade, inverse: \Exercise.split)
var exercises: [Exercise]? = []
@Relationship(deleteRule: .nullify, inverse: \Workout.split)
var workouts: [Workout]? = []
init(name: String, color: String = "indigo", systemImage: String = "dumbbell.fill", order: Int = 0) {
self.name = name
self.color = color
self.systemImage = systemImage
self.order = order
}
static let unnamed = "Unnamed Split" static let unnamed = "Unnamed Split"
} }
// MARK: - Convenience Accessors // MARK: - Identifiable Conformance
extension Split { extension Split: Identifiable {
var exercisesArray: [Exercise] { public var id: String {
let set = exercises as? Set<Exercise> ?? [] // Use the name as a unique identifier for the split
return set.sorted { $0.order < $1.order } // This is sufficient for UI purposes
} return self.name
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 //// MARK: - Private Form View
//
extension Split { //fileprivate struct SplitFormView: View {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Split> { // @Binding var model: Split
return NSFetchRequest<Split>(entityName: "Split") //
} // // Available colors for splits
// private let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
static func orderedFetchRequest() -> NSFetchRequest<Split> { //
let request = fetchRequest() // // Available system images for splits
request.sortDescriptors = [NSSortDescriptor(keyPath: \Split.order, ascending: true)] // private 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"]
return request //
} // var body: some View {
} // Section(header: Text("Name")) {
// TextField("Name", text: $model.name)
// .bold()
// }
//
// Section(header: Text("Appearance")) {
// Picker("Color", selection: $model.color) {
// ForEach(availableColors, id: \.self) { colorName in
// let tempSplit = Split(name: "", color: colorName)
// HStack {
// Circle()
// .fill(tempSplit.getColor())
// .frame(width: 20, height: 20)
// Text(colorName.capitalized)
// }
// .tag(colorName)
// }
// }
//
// Picker("Icon", selection: $model.systemImage) {
// ForEach(availableIcons, id: \.self) { iconName in
// HStack {
// Image(systemName: iconName)
// .frame(width: 24, height: 24)
// Text(iconName.replacingOccurrences(of: ".fill", with: "").replacingOccurrences(of: "figure.", with: "").capitalized)
// }
// .tag(iconName)
// }
// }
// }
//
// Section(header: Text("Exercises")) {
// NavigationLink {
// ExerciseListView(split: model)
// } label: {
// ListItem(
// text: "Exercises",
// count: model.exercises?.count ?? 0
// )
// }
// }
// }
//}
+38 -70
View File
@@ -1,83 +1,51 @@
import Foundation import Foundation
import CoreData import SwiftData
@objc(Workout) @Model
public class Workout: NSManagedObject, Identifiable { final class Workout {
@NSManaged public var start: Date var start: Date = Date()
@NSManaged public var end: Date? var end: Date?
var status: Int = 1
// var status: WorkoutStatus = WorkoutStatus.notStarted
@NSManaged public var split: Split? //case notStarted = 1
@NSManaged public var logs: NSSet? //case inProgress = 2
//case completed = 3
//case skipped = 4
public var id: NSManagedObjectID { objectID }
@Relationship(deleteRule: .nullify)
var status: WorkoutStatus { var split: Split?
get {
willAccessValue(forKey: "status") @Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout)
let raw = primitiveValue(forKey: "status") as? String ?? "notStarted" var logs: [WorkoutLog]? = []
didAccessValue(forKey: "status")
return WorkoutStatus(rawValue: raw) ?? .notStarted init(start: Date, end: Date, split: Split?) {
} self.start = start
set { self.end = end
willChangeValue(forKey: "status") self.split = split
setPrimitiveValue(newValue.rawValue, forKey: "status")
didChangeValue(forKey: "status")
}
} }
var label: String { var label: String {
if status == .completed, let endDate = end { if status == 3, let endDate = end {
if start.isSameDay(as: endDate) { // if status == .completed, let endDate = end {
return "\(start.formattedDate())\(endDate.formattedTime())" return "\(start.formattedDate())\(endDate.formattedDate())"
} else {
return "\(start.formattedDate())\(endDate.formattedDate())"
}
} else { } else {
return start.formattedDate() return start.formattedDate()
} }
} }
var statusName: String { var statusName: String {
return status.displayName if status == 1 {
} return "Not Started"
} } else if status == 2 {
return "In Progress"
// MARK: - Convenience Accessors } else if status == 3 {
return "Completed"
extension Workout { } else if status == 4 {
var logsArray: [WorkoutLog] { return "Skipped"
let set = logs as? Set<WorkoutLog> ?? [] } else {
return set.sorted { $0.order < $1.order } return "In progress"
} }
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
} }
} }
+29 -75
View File
@@ -1,79 +1,33 @@
import Foundation import Foundation
import CoreData import SwiftData
@objc(WorkoutLog) @Model
public class WorkoutLog: NSManagedObject, Identifiable { final class WorkoutLog {
@NSManaged public var date: Date var date: Date = Date()
@NSManaged public var sets: Int32 var sets: Int = 0
@NSManaged public var reps: Int32 var reps: Int = 0
@NSManaged public var weight: Int32 var weight: Int = 0
@NSManaged public var order: Int32 var status: WorkoutStatus? = WorkoutStatus.notStarted
@NSManaged public var exerciseName: String var order: Int = 0
@NSManaged public var currentStateIndex: Int32 var exerciseName: String = ""
@NSManaged public var elapsedSeconds: Int32
@NSManaged public var completed: Bool var currentStateIndex: Int? = nil
@NSManaged public var loadType: Int32 var elapsedSeconds: Int? = nil
@NSManaged public var duration: Date?
@NSManaged public var notes: String? var completed: Bool = false
@NSManaged public var workout: Workout? @Relationship(deleteRule: .nullify)
var workout: Workout?
public var id: NSManagedObjectID { objectID }
init(workout: Workout, exerciseName: String, date: Date, order: Int = 0, sets: Int, reps: Int, weight: Int, status: WorkoutStatus = .notStarted, completed: Bool = false) {
var status: WorkoutStatus { self.date = date
get { self.order = order
willAccessValue(forKey: "status") self.sets = sets
let raw = primitiveValue(forKey: "status") as? String ?? "notStarted" self.reps = reps
didAccessValue(forKey: "status") self.weight = weight
return WorkoutStatus(rawValue: raw) ?? .notStarted self.status = status
} self.workout = workout
set { self.exerciseName = exerciseName
willChangeValue(forKey: "status") self.completed = completed
setPrimitiveValue(newValue.rawValue, forKey: "status")
didChangeValue(forKey: "status")
}
}
var loadTypeEnum: LoadType {
get { LoadType(rawValue: Int(loadType)) ?? .weight }
set { loadType = Int32(newValue.rawValue) }
}
// Duration helpers for minutes/seconds conversion
var durationMinutes: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) / 60
}
set {
let seconds = durationSeconds
duration = Date(timeIntervalSince1970: TimeInterval(newValue * 60 + seconds))
}
}
var durationSeconds: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) % 60
}
set {
let minutes = durationMinutes
duration = Date(timeIntervalSince1970: TimeInterval(minutes * 60 + newValue))
}
}
}
// 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
} }
} }
-23
View File
@@ -1,23 +0,0 @@
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 }
}
@@ -1,131 +0,0 @@
import CoreData
import CloudKit
struct PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentCloudKitContainer
// CloudKit container identifier
static let cloudKitContainerIdentifier = "iCloud.dev.rzen.indie.Workouts"
// App Group identifier for shared storage between iOS and Watch
static let appGroupIdentifier = "group.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 {
// Use App Group container for shared storage between iOS and Watch
if let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier) {
let storeURL = appGroupURL.appendingPathComponent("Workouts.sqlite")
description.url = storeURL
print("Using shared App Group store at: \(storeURL)")
}
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)")
}
}
}
}
@@ -0,0 +1,42 @@
[
{
"name": "Bodyweight",
"descr": "Exercises that use your own body as resistance, requiring no equipment."
},
{
"name": "Free Weight",
"descr": "Exercises using handheld weights such as dumbbells, barbells, or kettlebells."
},
{
"name": "Resistance Band",
"descr": "Exercises performed with elastic bands that provide variable resistance."
},
{
"name": "Machine-Based",
"descr": "Exercises performed on gym equipment with guided movement paths."
},
{
"name": "Suspension",
"descr": "Exercises using bodyweight and adjustable straps anchored to a point."
},
{
"name": "Calisthenics",
"descr": "Advanced bodyweight movements focusing on control, balance, and strength."
},
{
"name": "Stretching - Static",
"descr": "Stretching muscles by holding a position for a prolonged time."
},
{
"name": "Stretching - Dynamic",
"descr": "Active movements that stretch muscles and joints through full range of motion."
},
{
"name": "Plyometric",
"descr": "Explosive movements designed to build power and speed."
},
{
"name": "Isometric",
"descr": "Exercises that involve holding a position under tension without movement."
}
]
+151
View File
@@ -0,0 +1,151 @@
[
{
"name": "Lat Pull Down",
"setup": "Seat: 3, Thigh Pad: 4",
"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.",
"sets": 3,
"reps": 10,
"weight": 60,
"type": "Machine-Based",
"muscles": ["Latissimus Dorsi", "Trapezius", "Rhomboids", "Biceps Brachii"]
},
{
"name": "Seated Row",
"setup": "Seat: 2, Chest Pad: 3",
"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.",
"sets": 3,
"reps": 10,
"weight": 60,
"type": "Machine-Based",
"muscles": ["Latissimus Dorsi", "Rhomboids", "Trapezius", "Biceps Brachii"]
},
{
"name": "Shoulder Press",
"setup": "Seat: 4",
"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.",
"sets": 3,
"reps": 10,
"weight": 30,
"type": "Machine-Based",
"muscles": ["Deltoid (Anterior)", "Deltoid (Lateral)", "Triceps Brachii"]
},
{
"name": "Chest Press",
"setup": "Seat: 3",
"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.",
"sets": 3,
"reps": 10,
"weight": 40,
"type": "Machine-Based",
"muscles": ["Pectoralis Major", "Triceps Brachii", "Deltoid (Anterior)"]
},
{
"name": "Tricep Press",
"setup": "Seat: 2",
"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.",
"sets": 3,
"reps": 10,
"weight": 60,
"type": "Machine-Based",
"muscles": ["Triceps Brachii"]
},
{
"name": "Arm Curl",
"setup": "Seat: 3",
"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.",
"sets": 3,
"reps": 10,
"weight": 30,
"type": "Machine-Based",
"muscles": ["Biceps Brachii", "Brachialis"]
},
{
"name": "Abdominal",
"setup": "Seat: 2, Back Pad: 3",
"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.",
"sets": 3,
"reps": 10,
"weight": 60,
"type": "Machine-Based",
"muscles": ["Rectus Abdominis", "Internal Obliques"]
},
{
"name": "Rotary",
"setup": "Seat: 3, Start Angle: Centered",
"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.",
"sets": 3,
"reps": 10,
"weight": 60,
"type": "Machine-Based",
"muscles": ["Internal Obliques", "External Obliques"]
},
{
"name": "Leg Press",
"setup": "Seat Angle: 4, Platform: Middle",
"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.",
"sets": 3,
"reps": 10,
"weight": 130,
"type": "Machine-Based",
"muscles": [
"Gluteus Maximus",
"Rectus Femoris",
"Vastus Lateralis",
"Vastus Medialis",
"Vastus Intermedius",
"Biceps Femoris",
"Semitendinosus",
"Semimembranosus"
]
},
{
"name": "Leg Extension",
"setup": "Seat: 3, Pad: Above ankle",
"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.",
"sets": 3,
"reps": 10,
"weight": 70,
"type": "Machine-Based",
"muscles": ["Rectus Femoris", "Vastus Lateralis", "Vastus Medialis", "Vastus Intermedius"]
},
{
"name": "Leg Curl",
"setup": "Seat: 2, Pad: Above ankle",
"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.",
"sets": 3,
"reps": 10,
"weight": 60,
"type": "Machine-Based",
"muscles": ["Biceps Femoris", "Semitendinosus", "Semimembranosus"]
},
{
"name": "Adductor",
"setup": "Seat: 3, Start Position: Wide",
"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.",
"sets": 3,
"reps": 10,
"weight": 110,
"type": "Machine-Based",
"muscles": ["Adductor Longus", "Adductor Brevis", "Adductor Magnus", "Gracilis"]
},
{
"name": "Abductor",
"setup": "Seat: 3, Start Position: Narrow",
"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.",
"sets": 3,
"reps": 10,
"weight": 110,
"type": "Machine-Based",
"muscles": ["Gluteus Medius", "Tensor Fasciae Latae"]
},
{
"name": "Calfs",
"setup": "Seat: 3, Toe Bar: Midfoot",
"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.",
"sets": 3,
"reps": 10,
"weight": 60,
"type": "Machine-Based",
"muscles": ["Gastrocnemius", "Soleus"]
}
]
+183
View File
@@ -0,0 +1,183 @@
name: Beginner
source: Planet Fitness
exercises:
- name: Lat Pull Down
setup: 'Seat: 3, Thigh Pad: 4'
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.
sets: 3
reps: 10
weight: 60
type: Machine-Based
muscles:
- Latissimus Dorsi
- Trapezius
- Rhomboids
- Biceps Brachii
- name: Seated Row
setup: 'Seat: 2, Chest Pad: 3'
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.
sets: 3
reps: 10
weight: 60
type: Machine-Based
muscles:
- Latissimus Dorsi
- Rhomboids
- Trapezius
- Biceps Brachii
- name: Shoulder Press
setup: 'Seat: 4'
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.
sets: 3
reps: 10
weight: 30
type: Machine-Based
muscles:
- Deltoid (Anterior)
- Deltoid (Lateral)
- Triceps Brachii
- name: Chest Press
setup: 'Seat: 3'
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.
sets: 3
reps: 10
weight: 40
type: Machine-Based
muscles:
- Pectoralis Major
- Triceps Brachii
- Deltoid (Anterior)
- name: Tricep Press
setup: 'Seat: 2'
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.
sets: 3
reps: 10
weight: 60
type: Machine-Based
muscles:
- Triceps Brachii
- name: Arm Curl
setup: 'Seat: 3'
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.
sets: 3
reps: 10
weight: 30
type: Machine-Based
muscles:
- Biceps Brachii
- Brachialis
- name: Abdominal
setup: 'Seat: 2, Back Pad: 3'
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.
sets: 3
reps: 10
weight: 60
type: Machine-Based
muscles:
- Rectus Abdominis
- Internal Obliques
- name: Rotary
setup: 'Seat: 3, Start Angle: Centered'
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.
sets: 3
reps: 10
weight: 60
type: Machine-Based
muscles:
- Internal Obliques
- External Obliques
- name: Leg Press
setup: 'Seat Angle: 4, Platform: Middle'
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.
sets: 3
reps: 10
weight: 130
type: Machine-Based
muscles:
- Gluteus Maximus
- Rectus Femoris
- Vastus Lateralis
- Vastus Medialis
- Vastus Intermedius
- Biceps Femoris
- Semitendinosus
- Semimembranosus
- name: Leg Extension
setup: 'Seat: 3, Pad: Above ankle'
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.
sets: 3
reps: 10
weight: 70
type: Machine-Based
muscles:
- Rectus Femoris
- Vastus Lateralis
- Vastus Medialis
- Vastus Intermedius
- name: Leg Curl
setup: 'Seat: 2, Pad: Above ankle'
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.
sets: 3
reps: 10
weight: 60
type: Machine-Based
muscles:
- Biceps Femoris
- Semitendinosus
- Semimembranosus
- name: Adductor
setup: 'Seat: 3, Start Position: Wide'
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.
sets: 3
reps: 10
weight: 110
type: Machine-Based
muscles:
- Adductor Longus
- Adductor Brevis
- Adductor Magnus
- Gracilis
- name: Abductor
setup: 'Seat: 3, Start Position: Narrow'
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.
sets: 3
reps: 10
weight: 110
type: Machine-Based
muscles:
- Gluteus Medius
- Tensor Fasciae Latae
- name: Calfs
setup: 'Seat: 3, Toe Bar: Midfoot'
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.
sets: 3
reps: 10
weight: 60
type: Machine-Based
muscles:
- Gastrocnemius
- Soleus
@@ -0,0 +1,58 @@
[
{
"name": "Chest",
"descr": "Muscles at the front of the upper torso responsible for pushing movements."
},
{
"name": "Back",
"descr": "Muscles along the spine and upper back involved in pulling, posture, and lifting."
},
{
"name": "Shoulders",
"descr": "Muscles surrounding the shoulder joint, enabling arm rotation and elevation."
},
{
"name": "Arms",
"descr": "Upper limb muscles responsible for flexion, extension, and grip strength."
},
{
"name": "Abdominals",
"descr": "Core muscles on the front and sides of the torso, key for stability and trunk movement."
},
{
"name": "Glutes",
"descr": "Powerful hip muscles that drive hip extension, rotation, and posture."
},
{
"name": "Quadriceps",
"descr": "Four muscles at the front of the thigh responsible for knee extension."
},
{
"name": "Hamstrings",
"descr": "Muscles at the back of the thigh that bend the knee and extend the hip."
},
{
"name": "Calves",
"descr": "Muscles of the lower leg that control ankle movement and propulsion."
},
{
"name": "Forearms",
"descr": "Muscles between the elbow and wrist that manage wrist and finger motion."
},
{
"name": "Neck",
"descr": "Muscles supporting the head and enabling neck movement and posture."
},
{
"name": "Hip Flexors",
"descr": "Muscles at the front of the hip that lift the thigh toward the torso."
},
{
"name": "Adductors",
"descr": "Inner thigh muscles that pull the legs toward the midline of the body."
},
{
"name": "Abductors",
"descr": "Outer hip muscles that move the legs away from the bodys midline."
}
]
+60
View File
@@ -0,0 +1,60 @@
[
{ "name": "Pectoralis Major", "muscleGroup": "Chest", "descr": "Large chest muscle located on the front of the upper torso, responsible for arm flexion and pushing." },
{ "name": "Pectoralis Minor", "muscleGroup": "Chest", "descr": "Thin, triangular muscle beneath the pectoralis major, stabilizes the scapula." },
{ "name": "Latissimus Dorsi", "muscleGroup": "Back", "descr": "Broad muscle on the mid to lower back, responsible for pulling and shoulder extension." },
{ "name": "Trapezius", "muscleGroup": "Back", "descr": "Large muscle covering the upper back and neck, involved in shoulder movement and posture." },
{ "name": "Rhomboids", "muscleGroup": "Back", "descr": "Muscles between the shoulder blades, responsible for scapular retraction." },
{ "name": "Erector Spinae", "muscleGroup": "Back", "descr": "Long vertical muscles along the spine that maintain posture and extend the back." },
{ "name": "Deltoid (Anterior)", "muscleGroup": "Shoulders", "descr": "Front portion of the shoulder muscle, raises the arm forward." },
{ "name": "Deltoid (Lateral)", "muscleGroup": "Shoulders", "descr": "Middle portion of the shoulder muscle, raises the arm to the side." },
{ "name": "Deltoid (Posterior)", "muscleGroup": "Shoulders", "descr": "Rear portion of the shoulder muscle, moves the arm backward." },
{ "name": "Rotator Cuff Muscles", "muscleGroup": "Shoulders", "descr": "Group of four muscles surrounding the shoulder joint, stabilizing and rotating the arm." },
{ "name": "Biceps Brachii", "muscleGroup": "Arms", "descr": "Front upper arm muscle, responsible for elbow flexion and forearm rotation." },
{ "name": "Triceps Brachii", "muscleGroup": "Arms", "descr": "Back upper arm muscle, responsible for elbow extension." },
{ "name": "Brachialis", "muscleGroup": "Arms", "descr": "Muscle beneath the biceps, assists in elbow flexion." },
{ "name": "Brachioradialis", "muscleGroup": "Arms", "descr": "Forearm muscle on the thumb side, aids in elbow flexion." },
{ "name": "Rectus Abdominis", "muscleGroup": "Abdominals", "descr": "Vertical muscle on the front of the abdomen, responsible for trunk flexion (the 'six-pack')." },
{ "name": "Transverse Abdominis", "muscleGroup": "Abdominals", "descr": "Deepest abdominal muscle, wraps around the torso to stabilize the core." },
{ "name": "Internal Obliques", "muscleGroup": "Abdominals", "descr": "Muscles located beneath the external obliques, involved in trunk rotation and lateral flexion." },
{ "name": "External Obliques", "muscleGroup": "Abdominals", "descr": "Muscles on the sides of the abdomen, responsible for trunk twisting and side bending." },
{ "name": "Gluteus Maximus", "muscleGroup": "Glutes", "descr": "Largest glute muscle located in the buttocks, responsible for hip extension and rotation." },
{ "name": "Gluteus Medius", "muscleGroup": "Glutes", "descr": "Muscle on the outer surface of the pelvis, important for hip abduction and stability." },
{ "name": "Gluteus Minimus", "muscleGroup": "Glutes", "descr": "Smallest glute muscle, located beneath the medius, assists in hip abduction." },
{ "name": "Rectus Femoris", "muscleGroup": "Quadriceps", "descr": "Front thigh muscle, part of the quadriceps group, helps extend the knee and flex the hip." },
{ "name": "Vastus Lateralis", "muscleGroup": "Quadriceps", "descr": "Outer thigh muscle, part of the quadriceps, involved in knee extension." },
{ "name": "Vastus Medialis", "muscleGroup": "Quadriceps", "descr": "Inner thigh muscle, part of the quadriceps, assists in knee extension and stabilization." },
{ "name": "Vastus Intermedius", "muscleGroup": "Quadriceps", "descr": "Deep thigh muscle beneath rectus femoris, assists in knee extension." },
{ "name": "Biceps Femoris", "muscleGroup": "Hamstrings", "descr": "Muscle on the back of the thigh, responsible for knee flexion and hip extension." },
{ "name": "Semitendinosus", "muscleGroup": "Hamstrings", "descr": "Medial hamstring muscle, assists in knee flexion and internal rotation." },
{ "name": "Semimembranosus", "muscleGroup": "Hamstrings", "descr": "Deep medial hamstring muscle, also assists in knee flexion and hip extension." },
{ "name": "Gastrocnemius", "muscleGroup": "Calves", "descr": "Large calf muscle visible on the back of the lower leg, responsible for plantar flexion of the foot." },
{ "name": "Soleus", "muscleGroup": "Calves", "descr": "Flat muscle beneath the gastrocnemius, aids in plantar flexion when the knee is bent." },
{ "name": "Flexor Carpi Radialis", "muscleGroup": "Forearms", "descr": "Muscle on the front of the forearm, flexes and abducts the wrist." },
{ "name": "Flexor Carpi Ulnaris", "muscleGroup": "Forearms", "descr": "Forearm muscle that flexes and adducts the wrist." },
{ "name": "Extensor Carpi Radialis", "muscleGroup": "Forearms", "descr": "Posterior forearm muscle that extends and abducts the wrist." },
{ "name": "Pronator Teres", "muscleGroup": "Forearms", "descr": "Muscle running across the forearm that pronates the forearm (palm down)." },
{ "name": "Sternocleidomastoid", "muscleGroup": "Neck", "descr": "Prominent neck muscle responsible for rotating and flexing the head." },
{ "name": "Splenius Capitis", "muscleGroup": "Neck", "descr": "Back of neck muscle that extends and rotates the head." },
{ "name": "Scalenes", "muscleGroup": "Neck", "descr": "Lateral neck muscles that assist in neck flexion and elevate the ribs during breathing." },
{ "name": "Iliopsoas", "muscleGroup": "Hip Flexors", "descr": "Deep muscle group connecting the lower spine to the femur, main hip flexor." },
{ "name": "Rectus Femoris", "muscleGroup": "Hip Flexors", "descr": "Also part of the quadriceps, helps flex the hip and extend the knee." },
{ "name": "Sartorius", "muscleGroup": "Hip Flexors", "descr": "Longest muscle in the body, crosses the thigh to flex and rotate the hip and knee." },
{ "name": "Adductor Longus", "muscleGroup": "Adductors", "descr": "Medial thigh muscle that adducts the leg and assists with hip flexion." },
{ "name": "Adductor Brevis", "muscleGroup": "Adductors", "descr": "Short adductor muscle that helps pull the thigh inward." },
{ "name": "Adductor Magnus", "muscleGroup": "Adductors", "descr": "Large, deep inner thigh muscle that performs hip adduction and extension." },
{ "name": "Gracilis", "muscleGroup": "Adductors", "descr": "Thin inner thigh muscle that assists in adduction and knee flexion." },
{ "name": "Tensor Fasciae Latae", "muscleGroup": "Abductors", "descr": "Lateral hip muscle that abducts and medially rotates the thigh." }
]
+135
View File
@@ -0,0 +1,135 @@
- name: Pectoralis Major
muscleGroup: Chest
descr: Large chest muscle located on the front of the upper torso, responsible for arm flexion and pushing.
- name: Pectoralis Minor
muscleGroup: Chest
descr: Thin, triangular muscle beneath the pectoralis major, stabilizes the scapula.
- name: Latissimus Dorsi
muscleGroup: Back
descr: Broad muscle on the mid to lower back, responsible for pulling and shoulder extension.
- name: Trapezius
muscleGroup: Back
descr: Large muscle covering the upper back and neck, involved in shoulder movement and posture.
- name: Rhomboids
muscleGroup: Back
descr: Muscles between the shoulder blades, responsible for scapular retraction.
- name: Erector Spinae
muscleGroup: Back
descr: Long vertical muscles along the spine that maintain posture and extend the back.
- name: Deltoid (Anterior)
muscleGroup: Shoulders
descr: Front portion of the shoulder muscle, raises the arm forward.
- name: Deltoid (Lateral)
muscleGroup: Shoulders
descr: Middle portion of the shoulder muscle, raises the arm to the side.
- name: Deltoid (Posterior)
muscleGroup: Shoulders
descr: Rear portion of the shoulder muscle, moves the arm backward.
- name: Rotator Cuff Muscles
muscleGroup: Shoulders
descr: Group of four muscles surrounding the shoulder joint, stabilizing and rotating the arm.
- name: Biceps Brachii
muscleGroup: Arms
descr: Front upper arm muscle, responsible for elbow flexion and forearm rotation.
- name: Triceps Brachii
muscleGroup: Arms
descr: Back upper arm muscle, responsible for elbow extension.
- name: Brachialis
muscleGroup: Arms
descr: Muscle beneath the biceps, assists in elbow flexion.
- name: Brachioradialis
muscleGroup: Arms
descr: Forearm muscle on the thumb side, aids in elbow flexion.
- name: Rectus Abdominis
muscleGroup: Abdominals
descr: Vertical muscle on the front of the abdomen, responsible for trunk flexion (the 'six-pack').
- name: Transverse Abdominis
muscleGroup: Abdominals
descr: Deepest abdominal muscle, wraps around the torso to stabilize the core.
- name: Internal Obliques
muscleGroup: Abdominals
descr: Muscles located beneath the external obliques, involved in trunk rotation and lateral flexion.
- name: External Obliques
muscleGroup: Abdominals
descr: Muscles on the sides of the abdomen, responsible for trunk twisting and side bending.
- name: Gluteus Maximus
muscleGroup: Glutes
descr: Largest glute muscle located in the buttocks, responsible for hip extension and rotation.
- name: Gluteus Medius
muscleGroup: Glutes
descr: Muscle on the outer surface of the pelvis, important for hip abduction and stability.
- name: Gluteus Minimus
muscleGroup: Glutes
descr: Smallest glute muscle, located beneath the medius, assists in hip abduction.
- name: Rectus Femoris
muscleGroup: Quadriceps
descr: Front thigh muscle, part of the quadriceps group, helps extend the knee and flex the hip.
- name: Vastus Lateralis
muscleGroup: Quadriceps
descr: Outer thigh muscle, part of the quadriceps, involved in knee extension.
- name: Vastus Medialis
muscleGroup: Quadriceps
descr: Inner thigh muscle, part of the quadriceps, assists in knee extension and stabilization.
- name: Vastus Intermedius
muscleGroup: Quadriceps
descr: Deep thigh muscle beneath rectus femoris, assists in knee extension.
- name: Biceps Femoris
muscleGroup: Hamstrings
descr: Muscle on the back of the thigh, responsible for knee flexion and hip extension.
- name: Semitendinosus
muscleGroup: Hamstrings
descr: Medial hamstring muscle, assists in knee flexion and internal rotation.
- name: Semimembranosus
muscleGroup: Hamstrings
descr: Deep medial hamstring muscle, also assists in knee flexion and hip extension.
- name: Gastrocnemius
muscleGroup: Calves
descr: Large calf muscle visible on the back of the lower leg, responsible for plantar flexion of the foot.
- name: Soleus
muscleGroup: Calves
descr: Flat muscle beneath the gastrocnemius, aids in plantar flexion when the knee is bent.
- name: Flexor Carpi Radialis
muscleGroup: Forearms
descr: Muscle on the front of the forearm, flexes and abducts the wrist.
- name: Flexor Carpi Ulnaris
muscleGroup: Forearms
descr: Forearm muscle that flexes and adducts the wrist.
- name: Extensor Carpi Radialis
muscleGroup: Forearms
descr: Posterior forearm muscle that extends and abducts the wrist.
- name: Pronator Teres
muscleGroup: Forearms
descr: Muscle running across the forearm that pronates the forearm (palm down).
- name: Sternocleidomastoid
muscleGroup: Neck
descr: Prominent neck muscle responsible for rotating and flexing the head.
- name: Splenius Capitis
muscleGroup: Neck
descr: Back of neck muscle that extends and rotates the head.
- name: Scalenes
muscleGroup: Neck
descr: Lateral neck muscles that assist in neck flexion and elevate the ribs during breathing.
- name: Iliopsoas
muscleGroup: Hip Flexors
descr: Deep muscle group connecting the lower spine to the femur, main hip flexor.
- name: Rectus Femoris
muscleGroup: Hip Flexors
descr: Also part of the quadriceps, helps flex the hip and extend the knee.
- name: Sartorius
muscleGroup: Hip Flexors
descr: Longest muscle in the body, crosses the thigh to flex and rotate the hip and knee.
- name: Adductor Longus
muscleGroup: Adductors
descr: Medial thigh muscle that adducts the leg and assists with hip flexion.
- name: Adductor Brevis
muscleGroup: Adductors
descr: Short adductor muscle that helps pull the thigh inward.
- name: Adductor Magnus
muscleGroup: Adductors
descr: Large, deep inner thigh muscle that performs hip adduction and extension.
- name: Gracilis
muscleGroup: Adductors
descr: Thin inner thigh muscle that assists in adduction and knee flexion.
- name: Tensor Fasciae Latae
muscleGroup: Abductors
descr: Lateral hip muscle that abducts and medially rotates the thigh.
+50
View File
@@ -0,0 +1,50 @@
[
{
"name": "Upper Body",
"intro": "Focuses on muscles of the chest, back, shoulders, arms, and core. Ideal for developing strength and symmetry above the waist.",
"splitExerciseAssignments": [
{ "exercise": "Lat Pull Down", "weight": 70, "sets": 3, "reps": 10 },
{ "exercise": "Seated Row", "weight": 80, "sets": 3, "reps": 10 },
{ "exercise": "Shoulder Press", "weight": 40, "sets": 3, "reps": 10 },
{ "exercise": "Chest Press", "weight": 50, "sets": 3, "reps": 10 },
{ "exercise": "Tricep Press", "weight": 40, "sets": 3, "reps": 10 },
{ "exercise": "Arm Curl", "weight": 35, "sets": 3, "reps": 10 },
{ "exercise": "Abdominal", "weight": 50, "sets": 3, "reps": 15 },
{ "exercise": "Rotary", "weight": 40, "sets": 3, "reps": 12 }
]
},
{
"name": "Lower Body",
"intro": "Targets the legs, glutes, calves, and lower core. Essential for building power, stability, and balanced physique.",
"splitExerciseAssignments": [
{ "exercise": "Leg Press", "weight": 120, "sets": 3, "reps": 10 },
{ "exercise": "Leg Extension", "weight": 60, "sets": 3, "reps": 10 },
{ "exercise": "Leg Curl", "weight": 55, "sets": 3, "reps": 10 },
{ "exercise": "Adductor", "weight": 50, "sets": 3, "reps": 12 },
{ "exercise": "Abductor", "weight": 50, "sets": 3, "reps": 12 },
{ "exercise": "Calfs", "weight": 70, "sets": 3, "reps": 15 },
{ "exercise": "Abdominal", "weight": 50, "sets": 3, "reps": 15 },
{ "exercise": "Rotary", "weight": 40, "sets": 3, "reps": 12 }
]
},
{
"name": "Full Body",
"intro": "Combines upper and lower body exercises to target all major muscle groups in a single session. Ideal for general fitness and efficient training.",
"splitExerciseAssignments": [
{ "exercise": "Lat Pull Down", "weight": 70, "sets": 3, "reps": 10 },
{ "exercise": "Seated Row", "weight": 80, "sets": 3, "reps": 10 },
{ "exercise": "Shoulder Press", "weight": 40, "sets": 3, "reps": 10 },
{ "exercise": "Chest Press", "weight": 50, "sets": 3, "reps": 10 },
{ "exercise": "Tricep Press", "weight": 40, "sets": 3, "reps": 10 },
{ "exercise": "Arm Curl", "weight": 35, "sets": 3, "reps": 10 },
{ "exercise": "Lat Pull Down", "weight": 70, "sets": 3, "reps": 10 },
{ "exercise": "Shoulder Press", "weight": 40, "sets": 3, "reps": 10 },
{ "exercise": "Chest Press", "weight": 50, "sets": 3, "reps": 10 },
{ "exercise": "Leg Press", "weight": 120, "sets": 3, "reps": 10 },
{ "exercise": "Leg Curl", "weight": 55, "sets": 3, "reps": 10 },
{ "exercise": "Calfs", "weight": 70, "sets": 3, "reps": 15 },
{ "exercise": "Abdominal", "weight": 50, "sets": 3, "reps": 15 },
{ "exercise": "Rotary", "weight": 40, "sets": 3, "reps": 12 }
]
}
]
@@ -1,77 +0,0 @@
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
+30
View File
@@ -0,0 +1,30 @@
import Foundation
import SwiftData
final class AppContainer {
static let logger = AppLogger(
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Workouts",
category: "AppContainer"
)
static func create() -> ModelContainer {
// Using the current models directly without migration plan to avoid reference errors
let schema = Schema(SchemaVersion.models)
let configuration = ModelConfiguration(cloudKitDatabase: .automatic)
let container = try! ModelContainer(for: schema, configurations: configuration)
return container
}
@MainActor
static var preview: ModelContainer {
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
do {
let schema = Schema(SchemaVersion.models)
let container = try ModelContainer(for: schema, configurations: configuration)
return container
} catch {
fatalError("Failed to create preview ModelContainer: \(error.localizedDescription)")
}
}
}

Some files were not shown because too many files have changed in this diff Show More