Compare commits
1 Commits
main
..
e7166bacca
| Author | SHA1 | Date | |
|---|---|---|---|
| e7166bacca |
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# ---> Xcode
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 93 KiB |
@@ -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`
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "icon-1024.png",
|
||||
"idiom": "universal",
|
||||
"platform": "watchos",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 34 KiB |
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// Workouts Watch App
|
||||
//
|
||||
// Created by rzen on 8/13/25 at 11:10 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
var body: some View {
|
||||
WorkoutLogsView()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array/>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</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>
|
||||
</plist>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>_XCCurrentVersionName</key>
|
||||
<string>Workouts.xcdatamodel</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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>
|
||||
@@ -1,31 +0,0 @@
|
||||
//
|
||||
// WorkoutsApp.swift
|
||||
// Workouts Watch App
|
||||
//
|
||||
// Created by rzen on 8/13/25 at 11:10 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
@main
|
||||
struct WorkoutsWatchApp: App {
|
||||
let persistenceController = PersistenceController.shared
|
||||
let connectivityManager = WatchConnectivityManager.shared
|
||||
|
||||
init() {
|
||||
// Set up iPhone connectivity with Core Data context
|
||||
connectivityManager.setViewContext(persistenceController.viewContext)
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(\.managedObjectContext, persistenceController.viewContext)
|
||||
.environmentObject(connectivityManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,569 +0,0 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
A473BF022E4CE278003EAD6F /* Workouts Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A473BF012E4CE278003EAD6F /* Workouts Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
A47512C12F1DACCF001A9C6F /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = A47512C02F1DACCF001A9C6F /* Yams */; };
|
||||
A47513342F1DADBE001A9C6F /* IndieAbout in Frameworks */ = {isa = PBXBuildFile; productRef = A47513332F1DADBE001A9C6F /* IndieAbout */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
A473BF032E4CE278003EAD6F /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = A473BEE92E4CE276003EAD6F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = A473BF002E4CE278003EAD6F;
|
||||
remoteInfo = "Workouts Watch App";
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
A473BF142E4CE279003EAD6F /* Embed Watch Content */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
|
||||
dstSubfolderSpec = 16;
|
||||
files = (
|
||||
A473BF022E4CE278003EAD6F /* Workouts Watch App.app in Embed Watch Content */,
|
||||
);
|
||||
name = "Embed Watch Content";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
A473BEF12E4CE276003EAD6F /* 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; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
A473BEF32E4CE276003EAD6F /* Workouts */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Workouts;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A473BF052E4CE278003EAD6F /* Workouts Watch App */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = "Workouts Watch App";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
A473BEEE2E4CE276003EAD6F /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A47513342F1DADBE001A9C6F /* IndieAbout in Frameworks */,
|
||||
A47512C12F1DACCF001A9C6F /* Yams in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A473BEFE2E4CE278003EAD6F /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
A473BEE82E4CE276003EAD6F = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A473BEF32E4CE276003EAD6F /* Workouts */,
|
||||
A473BF052E4CE278003EAD6F /* Workouts Watch App */,
|
||||
A473BEF22E4CE276003EAD6F /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A473BEF22E4CE276003EAD6F /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A473BEF12E4CE276003EAD6F /* Workouts.app */,
|
||||
A473BF012E4CE278003EAD6F /* Workouts Watch App.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
A473BEF02E4CE276003EAD6F /* Workouts */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A473BF152E4CE279003EAD6F /* Build configuration list for PBXNativeTarget "Workouts" */;
|
||||
buildPhases = (
|
||||
A47512C22F1DB000001A9C6F /* Update Build Number */,
|
||||
A473BEED2E4CE276003EAD6F /* Sources */,
|
||||
A473BEEE2E4CE276003EAD6F /* Frameworks */,
|
||||
A473BEEF2E4CE276003EAD6F /* Resources */,
|
||||
A473BF142E4CE279003EAD6F /* Embed Watch Content */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
A473BF042E4CE278003EAD6F /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A473BEF32E4CE276003EAD6F /* Workouts */,
|
||||
);
|
||||
name = Workouts;
|
||||
packageProductDependencies = (
|
||||
A47512C02F1DACCF001A9C6F /* Yams */,
|
||||
A47513332F1DADBE001A9C6F /* IndieAbout */,
|
||||
);
|
||||
productName = Workouts;
|
||||
productReference = A473BEF12E4CE276003EAD6F /* Workouts.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
A473BF002E4CE278003EAD6F /* Workouts Watch App */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A473BF112E4CE279003EAD6F /* Build configuration list for PBXNativeTarget "Workouts Watch App" */;
|
||||
buildPhases = (
|
||||
A473BEFD2E4CE278003EAD6F /* Sources */,
|
||||
A473BEFE2E4CE278003EAD6F /* Frameworks */,
|
||||
A473BEFF2E4CE278003EAD6F /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A473BF052E4CE278003EAD6F /* Workouts Watch App */,
|
||||
);
|
||||
name = "Workouts Watch App";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "Workouts Watch App";
|
||||
productReference = A473BF012E4CE278003EAD6F /* Workouts Watch App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
A473BEE92E4CE276003EAD6F /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1620;
|
||||
LastUpgradeCheck = 2620;
|
||||
TargetAttributes = {
|
||||
A473BEF02E4CE276003EAD6F = {
|
||||
CreatedOnToolsVersion = 16.2;
|
||||
};
|
||||
A473BF002E4CE278003EAD6F = {
|
||||
CreatedOnToolsVersion = 16.2;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = A473BEEC2E4CE276003EAD6F /* Build configuration list for PBXProject "Workouts" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = A473BEE82E4CE276003EAD6F;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
A47512BF2F1DACCF001A9C6F /* XCRemoteSwiftPackageReference "Yams" */,
|
||||
A47513322F1DADBE001A9C6F /* XCRemoteSwiftPackageReference "indie-about" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = A473BEF22E4CE276003EAD6F /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
A473BEF02E4CE276003EAD6F /* Workouts */,
|
||||
A473BF002E4CE278003EAD6F /* Workouts Watch App */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
A473BEEF2E4CE276003EAD6F /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A473BEFF2E4CE278003EAD6F /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* 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 */
|
||||
A473BEED2E4CE276003EAD6F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A473BEFD2E4CE278003EAD6F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
A473BF042E4CE278003EAD6F /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = A473BF002E4CE278003EAD6F /* Workouts Watch App */;
|
||||
targetProxy = A473BF032E4CE278003EAD6F /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
A473BF0F2E4CE279003EAD6F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = C32Z8JNLG6;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A473BF102E4CE279003EAD6F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = C32Z8JNLG6;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A473BF122E4CE279003EAD6F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Workouts Watch App/Workouts Watch App.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\"";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Workouts;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.rzen.indie.Workouts.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 26.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A473BF132E4CE279003EAD6F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Workouts Watch App/Workouts Watch App.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\"";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Workouts;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.rzen.indie.Workouts.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
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;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
A473BEEC2E4CE276003EAD6F /* Build configuration list for PBXProject "Workouts" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A473BF0F2E4CE279003EAD6F /* Debug */,
|
||||
A473BF102E4CE279003EAD6F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
A473BF112E4CE279003EAD6F /* Build configuration list for PBXNativeTarget "Workouts Watch App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A473BF122E4CE279003EAD6F /* Debug */,
|
||||
A473BF132E4CE279003EAD6F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
A473BF152E4CE279003EAD6F /* Build configuration list for PBXNativeTarget "Workouts" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A473BF162E4CE279003EAD6F /* Debug */,
|
||||
A473BF172E4CE279003EAD6F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
A47512BF2F1DACCF001A9C6F /* XCRemoteSwiftPackageReference "Yams" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/jpsim/Yams";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 6.2.0;
|
||||
};
|
||||
};
|
||||
A47513322F1DADBE001A9C6F /* XCRemoteSwiftPackageReference "indie-about" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://git.rzen.dev/rzen/indie-about";
|
||||
requirement = {
|
||||
branch = main;
|
||||
kind = branch;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
A47512C02F1DACCF001A9C6F /* Yams */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = A47512BF2F1DACCF001A9C6F /* XCRemoteSwiftPackageReference "Yams" */;
|
||||
productName = Yams;
|
||||
};
|
||||
A47513332F1DADBE001A9C6F /* IndieAbout */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = A47513322F1DADBE001A9C6F /* XCRemoteSwiftPackageReference "indie-about" */;
|
||||
productName = IndieAbout;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = A473BEE92E4CE276003EAD6F /* Project object */;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"originHash" : "9d613082dd8a405ef810326218a4d81fdfd9ecb33be867afbc9700e52ec96e4b",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "indie-about",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://git.rzen.dev/rzen/indie-about",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "ed73ffcc5488b37ec0838ecaaa27ce050807093f"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "yams",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/jpsim/Yams",
|
||||
"state" : {
|
||||
"revision" : "51b5127c7fb6ffac106ad6d199aaa33c5024895f",
|
||||
"version" : "6.2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>Workouts Watch App.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
<key>Workouts.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "icon-20@2x.png",
|
||||
"idiom": "iphone",
|
||||
"size": "20x20",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-20@3x.png",
|
||||
"idiom": "iphone",
|
||||
"size": "20x20",
|
||||
"scale": "3x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-29@2x.png",
|
||||
"idiom": "iphone",
|
||||
"size": "29x29",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-29@3x.png",
|
||||
"idiom": "iphone",
|
||||
"size": "29x29",
|
||||
"scale": "3x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-40@2x.png",
|
||||
"idiom": "iphone",
|
||||
"size": "40x40",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-40@3x.png",
|
||||
"idiom": "iphone",
|
||||
"size": "40x40",
|
||||
"scale": "3x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-60@2x.png",
|
||||
"idiom": "iphone",
|
||||
"size": "60x60",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-60@3x.png",
|
||||
"idiom": "iphone",
|
||||
"size": "60x60",
|
||||
"scale": "3x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-20.png",
|
||||
"idiom": "ipad",
|
||||
"size": "20x20",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-20@2x.png",
|
||||
"idiom": "ipad",
|
||||
"size": "20x20",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-29.png",
|
||||
"idiom": "ipad",
|
||||
"size": "29x29",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-29@2x.png",
|
||||
"idiom": "ipad",
|
||||
"size": "29x29",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-40.png",
|
||||
"idiom": "ipad",
|
||||
"size": "40x40",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-40@2x.png",
|
||||
"idiom": "ipad",
|
||||
"size": "40x40",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-76.png",
|
||||
"idiom": "ipad",
|
||||
"size": "76x76",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-76@2x.png",
|
||||
"idiom": "ipad",
|
||||
"size": "76x76",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-83.5@2x.png",
|
||||
"idiom": "ipad",
|
||||
"size": "83.5x83.5",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "icon-1024.png",
|
||||
"idiom": "ios-marketing",
|
||||
"size": "1024x1024",
|
||||
"scale": "1x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 1023 B |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 10 KiB |
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 8/13/25 at 11:10 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
var body: some View {
|
||||
WorkoutLogsView()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,83 +0,0 @@
|
||||
name: Starter Set
|
||||
source: Planet Fitness
|
||||
exercises:
|
||||
- name: Lat Pull Down
|
||||
descr: Sit upright with your knees secured under the pad. Grip the bar wider than
|
||||
shoulder-width. Pull the bar down to your chest, squeezing your shoulder blades
|
||||
together. Avoid leaning back excessively or using momentum.
|
||||
type: Machine-Based
|
||||
split: Upper Body
|
||||
- name: Seated Row
|
||||
descr: With your chest firmly against the pad, grip the handles and pull straight
|
||||
back while keeping your elbows close to your body. Focus on retracting your shoulder
|
||||
blades and avoid rounding your back.
|
||||
type: Machine-Based
|
||||
split: Upper Body
|
||||
- name: Shoulder Press
|
||||
descr: Sit with your back against the pad, grip the handles just outside shoulder-width.
|
||||
Press upward without locking out your elbows. Keep your neck relaxed and avoid
|
||||
shrugging your shoulders.
|
||||
type: Machine-Based
|
||||
split: Upper Body
|
||||
- name: Chest Press
|
||||
descr: Adjust the seat so the handles are at mid-chest height. Push forward until arms
|
||||
are nearly extended, then return slowly. Keep wrists straight and dont let your elbows
|
||||
drop too low.
|
||||
type: Machine-Based
|
||||
split: Upper Body
|
||||
- name: Tricep Press
|
||||
descr: With elbows close to your sides, press the handles downward in a controlled
|
||||
motion. Avoid flaring your elbows or using your shoulders to assist the motion.
|
||||
type: Machine-Based
|
||||
split: Upper Body
|
||||
- name: Arm Curl
|
||||
descr: Position your arms over the pad and grip the handles. Curl the weight upward
|
||||
while keeping your upper arms stationary. Avoid using momentum and fully control
|
||||
the lowering phase.
|
||||
type: Machine-Based
|
||||
split: Upper Body
|
||||
- name: Abdominal
|
||||
descr: Sit with the pads resting against your chest. Contract your abs to curl forward,
|
||||
keeping your lower back in contact with the pad. Avoid pulling with your arms
|
||||
or hips.
|
||||
type: Machine-Based
|
||||
split: Core
|
||||
- name: Rotary
|
||||
descr: Rotate your torso from side to side in a controlled motion, keeping your
|
||||
hips still. Focus on using your obliques to generate the twist, not momentum or
|
||||
the arms.
|
||||
type: Machine-Based
|
||||
split: Core
|
||||
- name: Leg Press
|
||||
descr: Place your feet shoulder-width on the platform. Press upward through your
|
||||
heels without locking your knees. Keep your back flat against the pad throughout
|
||||
the motion.
|
||||
type: Machine-Based
|
||||
split: Lower Body
|
||||
- name: Leg Extension
|
||||
descr: Sit upright and align your knees with the pivot point. Extend your legs to
|
||||
a straightened position, then lower with control. Avoid jerky movements or lifting
|
||||
your hips off the seat.
|
||||
type: Machine-Based
|
||||
split: Lower Body
|
||||
- name: Leg Curl
|
||||
descr: Lie face down or sit depending on the version. Curl your legs toward your
|
||||
glutes, focusing on hamstring engagement. Avoid arching your back or using momentum.
|
||||
type: Machine-Based
|
||||
split: Lower Body
|
||||
- name: Adductor
|
||||
descr: Sit with legs placed outside the pads. Bring your legs together using inner
|
||||
thigh muscles. Control the motion both in and out, avoiding fast swings.
|
||||
type: Machine-Based
|
||||
split: Lower Body
|
||||
- name: Abductor
|
||||
descr: Sit with legs inside the pads and push outward to engage outer thighs and
|
||||
glutes. Avoid leaning forward and keep the motion controlled throughout.
|
||||
type: Machine-Based
|
||||
split: Lower Body
|
||||
- name: Calfs
|
||||
descr: Place the balls of your feet on the platform with heels hanging off. Raise
|
||||
your heels by contracting your calves, then slowly lower them below the platform
|
||||
level for a full stretch.
|
||||
type: Machine-Based
|
||||
split: Lower Body
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
//
|
||||
// CalendarListItem.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 8:44 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CalendarListItem: View {
|
||||
var date: Date
|
||||
var title: String
|
||||
var subtitle: String?
|
||||
var subtitle2: String?
|
||||
var count: Int?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
ZStack {
|
||||
VStack {
|
||||
Text(date.abbreviatedWeekday)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(date.dayOfMonth)")
|
||||
.font(.headline)
|
||||
.foregroundColor(.accentColor)
|
||||
Text(date.abbreviatedMonth)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding([.trailing], 10)
|
||||
}
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
if let subtitle = subtitle {
|
||||
Text(subtitle)
|
||||
.font(.footnote)
|
||||
}
|
||||
if let subtitle2 = subtitle2 {
|
||||
Text(subtitle2)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
if let count = count {
|
||||
Spacer()
|
||||
Text("\(count)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
//
|
||||
// CheckboxListItem.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/13/25 at 10:42 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CheckboxListItem: View {
|
||||
var status: CheckboxStatus
|
||||
var title: String
|
||||
var subtitle: String?
|
||||
var count: Int?
|
||||
var onCheckboxTap: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
Button {
|
||||
onCheckboxTap?()
|
||||
} label: {
|
||||
Image(systemName: status.systemName)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 30, height: 30)
|
||||
.foregroundStyle(status.color)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
VStack(alignment: .leading) {
|
||||
Text("\(title)")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
HStack(alignment: .bottom) {
|
||||
if let subtitle = subtitle {
|
||||
Text("\(subtitle)")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if let count = count {
|
||||
Text("\(count)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
//
|
||||
// CheckboxStatus.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/20/25 at 11:07 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum CheckboxStatus {
|
||||
case checked
|
||||
case unchecked
|
||||
case intermediate
|
||||
case cancelled
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .checked: .green
|
||||
case .unchecked: .gray
|
||||
case .intermediate: .yellow
|
||||
case .cancelled: .red
|
||||
}
|
||||
}
|
||||
|
||||
var systemName: String {
|
||||
switch self {
|
||||
case .checked: "checkmark.circle.fill"
|
||||
case .unchecked: "circle"
|
||||
case .intermediate: "ellipsis.circle"
|
||||
case .cancelled: "xmark.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WorkoutStatus Extension
|
||||
|
||||
extension WorkoutStatus {
|
||||
var checkboxStatus: CheckboxStatus {
|
||||
switch self {
|
||||
case .notStarted: .unchecked
|
||||
case .inProgress: .intermediate
|
||||
case .completed: .checked
|
||||
case .skipped: .cancelled
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
//
|
||||
// ListItem.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/13/25 at 10:42 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ListItem: View {
|
||||
var systemName: String?
|
||||
var title: String?
|
||||
var text: String?
|
||||
var subtitle: String?
|
||||
var count: Int?
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if let systemName = systemName {
|
||||
Image(systemName: systemName)
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
if let title = title {
|
||||
Text("\(title)")
|
||||
.font(.headline)
|
||||
if let text = text {
|
||||
Text("\(text)")
|
||||
.font(.footnote)
|
||||
}
|
||||
} else {
|
||||
if let text = text {
|
||||
Text("\(text)")
|
||||
}
|
||||
}
|
||||
HStack(alignment: .bottom) {
|
||||
if let subtitle = subtitle {
|
||||
Text("\(subtitle)")
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let count = count {
|
||||
Spacer()
|
||||
Text("\(count)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
//
|
||||
// SFSymbolPicker.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SFSymbolPicker: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Binding var selection: String
|
||||
|
||||
@State private var searchText: String = ""
|
||||
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 50, maximum: 60))
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
ForEach(filteredSymbols, id: \.self) { symbol in
|
||||
Button {
|
||||
selection = symbol
|
||||
dismiss()
|
||||
} label: {
|
||||
VStack {
|
||||
Image(systemName: symbol)
|
||||
.font(.title2)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(selection == symbol ? Color.accentColor : Color.secondary.opacity(0.2))
|
||||
)
|
||||
.foregroundColor(selection == symbol ? .white : .primary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search symbols")
|
||||
.navigationTitle("Choose Icon")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredSymbols: [String] {
|
||||
if searchText.isEmpty {
|
||||
return Self.workoutSymbols
|
||||
}
|
||||
return Self.workoutSymbols.filter { $0.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
|
||||
// Curated list of workout/fitness-related SF Symbols
|
||||
static let workoutSymbols: [String] = [
|
||||
// Fitness & Exercise
|
||||
"dumbbell.fill",
|
||||
"dumbbell",
|
||||
"figure.strengthtraining.traditional",
|
||||
"figure.strengthtraining.functional",
|
||||
"figure.cross.training",
|
||||
"figure.core.training",
|
||||
"figure.cooldown",
|
||||
"figure.flexibility",
|
||||
"figure.pilates",
|
||||
"figure.yoga",
|
||||
"figure.highintensity.intervaltraining",
|
||||
"figure.mixed.cardio",
|
||||
"figure.rower",
|
||||
"figure.elliptical",
|
||||
"figure.stair.stepper",
|
||||
"figure.step.training",
|
||||
|
||||
// Running & Walking
|
||||
"figure.run",
|
||||
"figure.run.circle",
|
||||
"figure.run.circle.fill",
|
||||
"figure.walk",
|
||||
"figure.walk.circle",
|
||||
"figure.walk.circle.fill",
|
||||
"figure.hiking",
|
||||
"figure.outdoor.cycle",
|
||||
"figure.indoor.cycle",
|
||||
|
||||
// Sports
|
||||
"figure.boxing",
|
||||
"figure.kickboxing",
|
||||
"figure.martial.arts",
|
||||
"figure.wrestling",
|
||||
"figure.gymnastics",
|
||||
"figure.handball",
|
||||
"figure.basketball",
|
||||
"figure.tennis",
|
||||
"figure.badminton",
|
||||
"figure.racquetball",
|
||||
"figure.squash",
|
||||
"figure.volleyball",
|
||||
"figure.baseball",
|
||||
"figure.softball",
|
||||
"figure.golf",
|
||||
"figure.soccer",
|
||||
"figure.american.football",
|
||||
"figure.rugby",
|
||||
"figure.hockey",
|
||||
"figure.lacrosse",
|
||||
"figure.cricket",
|
||||
"figure.table.tennis",
|
||||
"figure.fencing",
|
||||
"figure.archery",
|
||||
"figure.bowling",
|
||||
"figure.disc.sports",
|
||||
|
||||
// Water Sports
|
||||
"figure.pool.swim",
|
||||
"figure.open.water.swim",
|
||||
"figure.surfing",
|
||||
"figure.waterpolo",
|
||||
"figure.rowing",
|
||||
"figure.sailing",
|
||||
"figure.fishing",
|
||||
|
||||
// Winter Sports
|
||||
"figure.skiing.downhill",
|
||||
"figure.skiing.crosscountry",
|
||||
"figure.snowboarding",
|
||||
"figure.skating",
|
||||
|
||||
// Climbing & Adventure
|
||||
"figure.climbing",
|
||||
"figure.equestrian.sports",
|
||||
"figure.hunting",
|
||||
|
||||
// Mind & Body
|
||||
"figure.mind.and.body",
|
||||
"figure.dance",
|
||||
"figure.barre",
|
||||
"figure.socialdance",
|
||||
"figure.australian.football",
|
||||
|
||||
// General Activity
|
||||
"figure.stand",
|
||||
"figure.wave",
|
||||
"figure.roll",
|
||||
"figure.jumprope",
|
||||
"figure.play",
|
||||
"figure.child",
|
||||
|
||||
// Health & Body
|
||||
"heart.fill",
|
||||
"heart",
|
||||
"heart.circle",
|
||||
"heart.circle.fill",
|
||||
"bolt.heart.fill",
|
||||
"bolt.heart",
|
||||
"waveform.path.ecg",
|
||||
"lungs.fill",
|
||||
"lungs",
|
||||
|
||||
// Energy & Power
|
||||
"bolt.fill",
|
||||
"bolt",
|
||||
"bolt.circle",
|
||||
"bolt.circle.fill",
|
||||
"flame.fill",
|
||||
"flame",
|
||||
"flame.circle",
|
||||
"flame.circle.fill",
|
||||
|
||||
// Timer & Tracking
|
||||
"stopwatch",
|
||||
"stopwatch.fill",
|
||||
"timer",
|
||||
"timer.circle",
|
||||
"timer.circle.fill",
|
||||
"clock",
|
||||
"clock.fill",
|
||||
|
||||
// Progress & Goals
|
||||
"trophy.fill",
|
||||
"trophy",
|
||||
"trophy.circle",
|
||||
"trophy.circle.fill",
|
||||
"medal.fill",
|
||||
"medal",
|
||||
"star.fill",
|
||||
"star",
|
||||
"star.circle",
|
||||
"star.circle.fill",
|
||||
"target",
|
||||
"scope",
|
||||
"chart.bar.fill",
|
||||
"chart.line.uptrend.xyaxis",
|
||||
"arrow.up.circle.fill",
|
||||
|
||||
// Misc
|
||||
"scalemass.fill",
|
||||
"scalemass",
|
||||
"bed.double.fill",
|
||||
"bed.double",
|
||||
"moon.fill",
|
||||
"moon",
|
||||
"sun.max.fill",
|
||||
"sun.max",
|
||||
"drop.fill",
|
||||
"drop",
|
||||
"leaf.fill",
|
||||
"leaf",
|
||||
"carrot.fill",
|
||||
"carrot",
|
||||
"fork.knife",
|
||||
"cup.and.saucer.fill",
|
||||
]
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SFSymbolPicker(selection: .constant("dumbbell.fill"))
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
//
|
||||
// ExerciseAddEditView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/15/25 at 7:12 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct ExerciseAddEditView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showingExercisePicker = false
|
||||
|
||||
@ObservedObject var exercise: Exercise
|
||||
|
||||
@State private var originalWeight: Int32? = nil
|
||||
@State private var loadType: LoadType = .none
|
||||
|
||||
@State private var minutes = 0
|
||||
@State private var seconds = 0
|
||||
|
||||
@State private var weight_tens = 0
|
||||
@State private var weight = 0
|
||||
|
||||
@State private var reps: Int = 0
|
||||
@State private var sets: Int = 0
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(header: Text("Exercise")) {
|
||||
if exercise.name.isEmpty {
|
||||
Button(action: {
|
||||
showingExercisePicker = true
|
||||
}) {
|
||||
HStack {
|
||||
Text("Select Exercise")
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ListItem(title: exercise.name)
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Sets/Reps")) {
|
||||
HStack(alignment: .bottom) {
|
||||
VStack(alignment: .center) {
|
||||
Text("Sets")
|
||||
Picker("", selection: $sets) {
|
||||
ForEach(0..<20) { s in
|
||||
Text("\(s)").tag(s)
|
||||
}
|
||||
}
|
||||
.frame(height: 100)
|
||||
.pickerStyle(.wheel)
|
||||
}
|
||||
VStack(alignment: .center) {
|
||||
Text("Reps")
|
||||
Picker("", selection: $reps) {
|
||||
ForEach(0..<100) { r in
|
||||
Text("\(r)").tag(r)
|
||||
}
|
||||
}
|
||||
.frame(height: 100)
|
||||
.pickerStyle(.wheel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Load Type"), footer: Text("For bodyweight exercises choose None. For resistance or weight training select Weight. For exercises that are time oriented (like plank or meditation) select Time.")) {
|
||||
Picker("", selection: $loadType) {
|
||||
ForEach(LoadType.allCases, id: \.self) { load in
|
||||
Text(load.name)
|
||||
.tag(load)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
if loadType == .weight {
|
||||
Section(header: Text("Weight")) {
|
||||
HStack {
|
||||
Picker("", selection: $weight_tens) {
|
||||
ForEach(0..<100) { lbs in
|
||||
Text("\(lbs * 10)").tag(lbs * 10)
|
||||
}
|
||||
}
|
||||
.frame(height: 100)
|
||||
.pickerStyle(.wheel)
|
||||
|
||||
Picker("", selection: $weight) {
|
||||
ForEach(0..<10) { lbs in
|
||||
Text("\(lbs)").tag(lbs)
|
||||
}
|
||||
}
|
||||
.frame(height: 100)
|
||||
.pickerStyle(.wheel)
|
||||
|
||||
Text("lbs")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if loadType == .duration {
|
||||
Section(header: Text("Duration")) {
|
||||
HStack {
|
||||
Picker("Minutes", selection: $minutes) {
|
||||
ForEach(0..<60) { minute in
|
||||
Text("\(minute) min").tag(minute)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
|
||||
Picker("Seconds", selection: $seconds) {
|
||||
ForEach(0..<60) { second in
|
||||
Text("\(second) sec").tag(second)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Weight Increase")) {
|
||||
HStack {
|
||||
Text("Remind every \(exercise.weightReminderTimeIntervalWeeks) weeks")
|
||||
Spacer()
|
||||
Stepper("", value: Binding(
|
||||
get: { Int(exercise.weightReminderTimeIntervalWeeks) },
|
||||
set: { exercise.weightReminderTimeIntervalWeeks = Int32($0) }
|
||||
), in: 0...366)
|
||||
}
|
||||
if let lastUpdated = exercise.weightLastUpdated {
|
||||
Text("Last weight change \(Date().humanTimeInterval(to: lastUpdated)) ago")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
originalWeight = exercise.weight
|
||||
weight_tens = Int(exercise.weight) / 10 * 10
|
||||
weight = Int(exercise.weight) % 10
|
||||
loadType = exercise.loadTypeEnum
|
||||
sets = Int(exercise.sets)
|
||||
reps = Int(exercise.reps)
|
||||
if let duration = exercise.duration {
|
||||
minutes = Int(duration.timeIntervalSince1970) / 60
|
||||
seconds = Int(duration.timeIntervalSince1970) % 60
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingExercisePicker) {
|
||||
ExercisePickerView { exerciseNames in
|
||||
exercise.name = exerciseNames.first ?? "Unnamed"
|
||||
}
|
||||
}
|
||||
.navigationTitle(exercise.name.isEmpty ? "New Exercise" : exercise.name)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
if let originalWeight = originalWeight, originalWeight != Int32(weight_tens + weight) {
|
||||
exercise.weightLastUpdated = Date()
|
||||
}
|
||||
exercise.duration = Date(timeIntervalSince1970: Double(minutes * 60 + seconds))
|
||||
exercise.weight = Int32(weight_tens + weight)
|
||||
exercise.sets = Int32(sets)
|
||||
exercise.reps = Int32(reps)
|
||||
exercise.loadType = Int32(loadType.rawValue)
|
||||
try? viewContext.save()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//
|
||||
// ExerciseListLoader.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Yams
|
||||
|
||||
class ExerciseListLoader {
|
||||
struct ExerciseListData: Codable {
|
||||
let name: String
|
||||
let source: String
|
||||
let exercises: [ExerciseItem]
|
||||
|
||||
struct ExerciseItem: Codable, Identifiable {
|
||||
let name: String
|
||||
let descr: String
|
||||
let type: String
|
||||
let split: String
|
||||
|
||||
var id: String { name }
|
||||
}
|
||||
}
|
||||
|
||||
static func loadExerciseLists() -> [String: ExerciseListData] {
|
||||
var exerciseLists: [String: ExerciseListData] = [:]
|
||||
|
||||
guard let resourcePath = Bundle.main.resourcePath else {
|
||||
print("Could not find resource path")
|
||||
return exerciseLists
|
||||
}
|
||||
|
||||
do {
|
||||
let fileManager = FileManager.default
|
||||
let resourceURL = URL(fileURLWithPath: resourcePath)
|
||||
let yamlFiles = try fileManager.contentsOfDirectory(at: resourceURL, includingPropertiesForKeys: nil)
|
||||
.filter { $0.pathExtension == "yaml" && $0.lastPathComponent.hasSuffix(".exercises.yaml") }
|
||||
|
||||
for yamlFile in yamlFiles {
|
||||
let fileName = yamlFile.lastPathComponent
|
||||
do {
|
||||
let yamlString = try String(contentsOf: yamlFile, encoding: .utf8)
|
||||
if let exerciseList = try Yams.load(yaml: yamlString) as? [String: Any],
|
||||
let name = exerciseList["name"] as? String,
|
||||
let source = exerciseList["source"] as? String,
|
||||
let exercisesData = exerciseList["exercises"] as? [[String: Any]] {
|
||||
|
||||
var exercises: [ExerciseListData.ExerciseItem] = []
|
||||
|
||||
for exerciseData in exercisesData {
|
||||
if let name = exerciseData["name"] as? String,
|
||||
let descr = exerciseData["descr"] as? String,
|
||||
let type = exerciseData["type"] as? String,
|
||||
let split = exerciseData["split"] as? String {
|
||||
let exercise = ExerciseListData.ExerciseItem(name: name, descr: descr, type: type, split: split)
|
||||
exercises.append(exercise)
|
||||
}
|
||||
}
|
||||
|
||||
let exerciseList = ExerciseListData(name: name, source: source, exercises: exercises)
|
||||
exerciseLists[fileName] = exerciseList
|
||||
}
|
||||
} catch {
|
||||
print("Error loading YAML file \(fileName): \(error)")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Error listing directory contents: \(error)")
|
||||
}
|
||||
|
||||
return exerciseLists
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
//
|
||||
// ExerciseListView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 8:38 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct ExerciseListView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@ObservedObject var split: Split
|
||||
|
||||
@State private var showingAddSheet: Bool = false
|
||||
@State private var itemToEdit: Exercise? = nil
|
||||
@State private var itemToDelete: Exercise? = nil
|
||||
@State private var createdWorkout: Workout? = nil
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
let sortedExercises = split.exercisesArray
|
||||
|
||||
if !sortedExercises.isEmpty {
|
||||
ForEach(sortedExercises, id: \.objectID) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
|
||||
)
|
||||
.swipeActions {
|
||||
Button {
|
||||
itemToDelete = item
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
Button {
|
||||
itemToEdit = item
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
}
|
||||
}
|
||||
.onMove(perform: moveExercises)
|
||||
|
||||
Button {
|
||||
showingAddSheet = true
|
||||
} label: {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
} else {
|
||||
Text("No exercises added yet.")
|
||||
Button(action: { showingAddSheet.toggle() }) {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("\(split.name)")
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Start This Split") {
|
||||
startWorkout()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $createdWorkout) { workout in
|
||||
WorkoutLogListView(workout: workout)
|
||||
}
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
ExercisePickerView(onExerciseSelected: { exerciseNames in
|
||||
addExercises(names: exerciseNames)
|
||||
}, allowMultiSelect: true)
|
||||
}
|
||||
.sheet(item: $itemToEdit) { item in
|
||||
ExerciseAddEditView(exercise: item)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Exercise?",
|
||||
isPresented: .constant(itemToDelete != nil),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let item = itemToDelete {
|
||||
withAnimation {
|
||||
viewContext.delete(item)
|
||||
try? viewContext.save()
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func moveExercises(from source: IndexSet, to destination: Int) {
|
||||
var exercises = split.exercisesArray
|
||||
exercises.move(fromOffsets: source, toOffset: destination)
|
||||
for (index, exercise) in exercises.enumerated() {
|
||||
exercise.order = Int32(index)
|
||||
}
|
||||
try? viewContext.save()
|
||||
}
|
||||
|
||||
private func startWorkout() {
|
||||
let workout = Workout(context: viewContext)
|
||||
workout.start = Date()
|
||||
workout.end = Date()
|
||||
workout.status = .notStarted
|
||||
workout.split = split
|
||||
|
||||
for exercise in split.exercisesArray {
|
||||
let workoutLog = WorkoutLog(context: viewContext)
|
||||
workoutLog.exerciseName = exercise.name
|
||||
workoutLog.date = Date()
|
||||
workoutLog.order = exercise.order
|
||||
workoutLog.sets = exercise.sets
|
||||
workoutLog.reps = exercise.reps
|
||||
workoutLog.weight = exercise.weight
|
||||
workoutLog.status = .notStarted
|
||||
workoutLog.workout = workout
|
||||
}
|
||||
|
||||
try? viewContext.save()
|
||||
createdWorkout = workout
|
||||
}
|
||||
|
||||
private func addExercises(names: [String]) {
|
||||
if names.count == 1 {
|
||||
let exercise = Exercise(context: viewContext)
|
||||
exercise.name = names.first ?? "Unnamed"
|
||||
exercise.order = Int32(split.exercisesArray.count)
|
||||
exercise.sets = 3
|
||||
exercise.reps = 10
|
||||
exercise.weight = 40
|
||||
exercise.loadType = Int32(LoadType.weight.rawValue)
|
||||
exercise.split = split
|
||||
try? viewContext.save()
|
||||
itemToEdit = exercise
|
||||
} else {
|
||||
let existingNames = Set(split.exercisesArray.map { $0.name })
|
||||
for name in names where !existingNames.contains(name) {
|
||||
let exercise = Exercise(context: viewContext)
|
||||
exercise.name = name
|
||||
exercise.order = Int32(split.exercisesArray.count)
|
||||
exercise.sets = 3
|
||||
exercise.reps = 10
|
||||
exercise.weight = 40
|
||||
exercise.loadType = Int32(LoadType.weight.rawValue)
|
||||
exercise.split = split
|
||||
}
|
||||
try? viewContext.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
//
|
||||
// ExercisePickerView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/13/25 at 7:17 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ExercisePickerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var exerciseLists: [String: ExerciseListLoader.ExerciseListData] = [:]
|
||||
@State private var selectedListName: String? = nil
|
||||
@State private var selectedExercises: Set<String> = []
|
||||
|
||||
var onExerciseSelected: ([String]) -> Void
|
||||
var allowMultiSelect: Bool = false
|
||||
|
||||
init(onExerciseSelected: @escaping ([String]) -> Void, allowMultiSelect: Bool = false) {
|
||||
self.onExerciseSelected = onExerciseSelected
|
||||
self.allowMultiSelect = allowMultiSelect
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if selectedListName == nil {
|
||||
// Show list of exercise list files
|
||||
List {
|
||||
ForEach(Array(exerciseLists.keys.sorted()), id: \.self) { fileName in
|
||||
if let list = exerciseLists[fileName] {
|
||||
Button(action: {
|
||||
selectedListName = fileName
|
||||
}) {
|
||||
VStack(alignment: .leading) {
|
||||
Text(list.name)
|
||||
.font(.headline)
|
||||
Text(list.source)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(list.exercises.count) exercises")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Exercise Lists")
|
||||
} else if let fileName = selectedListName, let list = exerciseLists[fileName] {
|
||||
// Show exercises in the selected list grouped by split
|
||||
List {
|
||||
let exercisesByGroup = Dictionary(grouping: list.exercises) { $0.split }
|
||||
let sortedGroups = exercisesByGroup.keys.sorted()
|
||||
|
||||
ForEach(sortedGroups, id: \.self) { splitName in
|
||||
Section(header: Text(splitName)) {
|
||||
ForEach(exercisesByGroup[splitName]?.sorted(by: { $0.name < $1.name }) ?? [], id: \.id) { exercise in
|
||||
if allowMultiSelect {
|
||||
Button(action: {
|
||||
if selectedExercises.contains(exercise.name) {
|
||||
selectedExercises.remove(exercise.name)
|
||||
} else {
|
||||
selectedExercises.insert(exercise.name)
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(exercise.name)
|
||||
.font(.headline)
|
||||
Text(exercise.type)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedExercises.contains(exercise.name) {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(action: {
|
||||
onExerciseSelected([exercise.name])
|
||||
dismiss()
|
||||
}) {
|
||||
VStack(alignment: .leading) {
|
||||
Text(exercise.name)
|
||||
.font(.headline)
|
||||
Text(exercise.type)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Back") {
|
||||
selectedListName = nil
|
||||
selectedExercises.removeAll()
|
||||
}
|
||||
}
|
||||
if allowMultiSelect {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Select") {
|
||||
if !selectedExercises.isEmpty {
|
||||
onExerciseSelected(Array(selectedExercises))
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.disabled(selectedExercises.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(list.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadExerciseLists()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadExerciseLists() {
|
||||
exerciseLists = ExerciseListLoader.loadExerciseLists()
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
//
|
||||
// SettingsView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import IndieAbout
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
@FetchRequest(
|
||||
sortDescriptors: [
|
||||
NSSortDescriptor(keyPath: \Split.order, ascending: true),
|
||||
NSSortDescriptor(keyPath: \Split.name, ascending: true)
|
||||
],
|
||||
animation: .default
|
||||
)
|
||||
private var splits: FetchedResults<Split>
|
||||
|
||||
@State private var showingAddSplitSheet = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// MARK: - Splits Section
|
||||
Section(header: Text("Splits")) {
|
||||
if splits.isEmpty {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "dumbbell.fill")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
Text("No Splits Yet")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
Text("Create a split to organize your workout routine.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.vertical)
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
ForEach(splits, id: \.objectID) { split in
|
||||
NavigationLink {
|
||||
SplitDetailView(split: split)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: split.systemImage)
|
||||
.foregroundColor(Color.color(from: split.color))
|
||||
.frame(width: 24)
|
||||
Text(split.name)
|
||||
Spacer()
|
||||
Text("\(split.exercisesArray.count)")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
showingAddSplitSheet = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
Text("Add Split")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Account Section
|
||||
Section(header: Text("Account")) {
|
||||
Text("Settings coming soon")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
// MARK: - About Section
|
||||
Section {
|
||||
IndieAbout(configuration: AppInfoConfiguration(
|
||||
documents: [
|
||||
.custom(title: "Changelog", filename: "CHANGELOG", extension: "md"),
|
||||
.license(),
|
||||
.acknowledgements()
|
||||
]
|
||||
))
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.sheet(isPresented: $showingAddSplitSheet) {
|
||||
SplitAddEditView(split: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SettingsView()
|
||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
//
|
||||
// OrderableItem.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 5:19 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Protocol for items that can be ordered in a sequence
|
||||
protocol OrderableItem {
|
||||
/// Updates the order of the item to the specified index
|
||||
func updateOrder(to index: Int)
|
||||
}
|
||||
|
||||
/// Extension to make Split conform to OrderableItem
|
||||
extension Split: OrderableItem {
|
||||
func updateOrder(to index: Int) {
|
||||
self.order = Int32(index)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension to make Exercise conform to OrderableItem
|
||||
extension Exercise: OrderableItem {
|
||||
func updateOrder(to index: Int) {
|
||||
self.order = Int32(index)
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
//
|
||||
// SortableForEach.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 2:04 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
public struct SortableForEach<Data, Content>: View where Data: Hashable, Content: View {
|
||||
@Binding var data: [Data]
|
||||
@Binding var allowReordering: Bool
|
||||
private let content: (Data, Bool) -> Content
|
||||
|
||||
@State private var draggedItem: Data?
|
||||
@State private var hasChangedLocation: Bool = false
|
||||
|
||||
public init(_ data: Binding<[Data]>,
|
||||
allowReordering: Binding<Bool>,
|
||||
@ViewBuilder content: @escaping (Data, Bool) -> Content) {
|
||||
_data = data
|
||||
_allowReordering = allowReordering
|
||||
self.content = content
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ForEach(data, id: \.self) { item in
|
||||
if allowReordering {
|
||||
content(item, hasChangedLocation && draggedItem == item)
|
||||
.onDrag {
|
||||
draggedItem = item
|
||||
return NSItemProvider(object: "\(item.hashValue)" as NSString)
|
||||
}
|
||||
.onDrop(of: [UTType.plainText], delegate: DragRelocateDelegate(
|
||||
item: item,
|
||||
data: $data,
|
||||
draggedItem: $draggedItem,
|
||||
hasChangedLocation: $hasChangedLocation))
|
||||
} else {
|
||||
content(item, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DragRelocateDelegate<ItemType>: DropDelegate where ItemType: Equatable {
|
||||
let item: ItemType
|
||||
@Binding var data: [ItemType]
|
||||
@Binding var draggedItem: ItemType?
|
||||
@Binding var hasChangedLocation: Bool
|
||||
|
||||
func dropEntered(info: DropInfo) {
|
||||
guard item != draggedItem,
|
||||
let current = draggedItem,
|
||||
let from = data.firstIndex(of: current),
|
||||
let to = data.firstIndex(of: item)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
hasChangedLocation = true
|
||||
|
||||
if data[to] != current {
|
||||
withAnimation {
|
||||
data.move(
|
||||
fromOffsets: IndexSet(integer: from),
|
||||
toOffset: (to > from) ? to + 1 : to
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
DropProposal(operation: .move)
|
||||
}
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
// Update the order property of each item to match its position in the array
|
||||
updateItemOrders()
|
||||
|
||||
hasChangedLocation = false
|
||||
draggedItem = nil
|
||||
return true
|
||||
}
|
||||
|
||||
// Helper method to update the order property of each item
|
||||
private func updateItemOrders() {
|
||||
for (index, item) in data.enumerated() {
|
||||
if let orderableItem = item as? any OrderableItem {
|
||||
orderableItem.updateOrder(to: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
//
|
||||
// SplitAddEditView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 9:42 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct SplitAddEditView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let split: Split?
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
@State private var name: String = ""
|
||||
@State private var color: String = "indigo"
|
||||
@State private var systemImage: String = "dumbbell.fill"
|
||||
@State private var showingIconPicker: Bool = false
|
||||
@State private var showingDeleteConfirmation: Bool = false
|
||||
|
||||
private let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
|
||||
|
||||
var isEditing: Bool { split != nil }
|
||||
|
||||
init(split: Split?, onDelete: (() -> Void)? = nil) {
|
||||
self.split = split
|
||||
self.onDelete = onDelete
|
||||
if let split = split {
|
||||
_name = State(initialValue: split.name)
|
||||
_color = State(initialValue: split.color)
|
||||
_systemImage = State(initialValue: split.systemImage)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(header: Text("Name")) {
|
||||
TextField("Name", text: $name)
|
||||
.bold()
|
||||
}
|
||||
|
||||
Section(header: Text("Appearance")) {
|
||||
Picker("Color", selection: $color) {
|
||||
ForEach(availableColors, id: \.self) { colorName in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color.color(from: colorName))
|
||||
.frame(width: 20, height: 20)
|
||||
Text(colorName.capitalized)
|
||||
}
|
||||
.tag(colorName)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
showingIconPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Icon")
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Image(systemName: systemImage)
|
||||
.font(.title2)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let split = split {
|
||||
Section(header: Text("Exercises")) {
|
||||
NavigationLink {
|
||||
ExerciseListView(split: split)
|
||||
} label: {
|
||||
ListItem(
|
||||
text: "Exercises",
|
||||
count: split.exercisesArray.count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Delete Split", role: .destructive) {
|
||||
showingDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(isEditing ? "Edit Split" : "New Split")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
save()
|
||||
dismiss()
|
||||
}
|
||||
.disabled(name.isEmpty)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingIconPicker) {
|
||||
SFSymbolPicker(selection: $systemImage)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete This Split?",
|
||||
isPresented: $showingDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let split = split {
|
||||
viewContext.delete(split)
|
||||
try? viewContext.save()
|
||||
dismiss()
|
||||
onDelete?()
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("This will permanently delete the split and all its exercises.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
if let split = split {
|
||||
// Update existing
|
||||
split.name = name
|
||||
split.color = color
|
||||
split.systemImage = systemImage
|
||||
} else {
|
||||
// Create new
|
||||
let newSplit = Split(context: viewContext)
|
||||
newSplit.name = name
|
||||
newSplit.color = color
|
||||
newSplit.systemImage = systemImage
|
||||
newSplit.order = 0
|
||||
}
|
||||
try? viewContext.save()
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
//
|
||||
// SplitDetailView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/25/25 at 3:27 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct SplitDetailView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@ObservedObject var split: Split
|
||||
|
||||
@State private var showingExerciseAddSheet: Bool = false
|
||||
@State private var showingSplitEditSheet: Bool = false
|
||||
@State private var itemToEdit: Exercise? = nil
|
||||
@State private var itemToDelete: Exercise? = nil
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(header: Text("What is a Split?")) {
|
||||
Text("A \"split\" is simply how you divide (or \"split up\") your weekly training across different days. Instead of working every muscle group every session, you assign certain muscle groups, movement patterns, or training emphases to specific days.")
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Section(header: Text("Exercises")) {
|
||||
let sortedExercises = split.exercisesArray
|
||||
|
||||
if !sortedExercises.isEmpty {
|
||||
ForEach(sortedExercises, id: \.objectID) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
|
||||
)
|
||||
.swipeActions {
|
||||
Button {
|
||||
itemToDelete = item
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
Button {
|
||||
itemToEdit = item
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
}
|
||||
}
|
||||
.onMove(perform: moveExercises)
|
||||
|
||||
Button {
|
||||
showingExerciseAddSheet = true
|
||||
} label: {
|
||||
ListItem(systemName: "plus.circle", title: "Add Exercise")
|
||||
}
|
||||
} else {
|
||||
Text("No exercises added yet.")
|
||||
Button(action: { showingExerciseAddSheet.toggle() }) {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("\(split.name)")
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showingSplitEditSheet = true
|
||||
} label: {
|
||||
Image(systemName: "pencil")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingExerciseAddSheet) {
|
||||
ExercisePickerView(onExerciseSelected: { exerciseNames in
|
||||
addExercises(names: exerciseNames)
|
||||
}, allowMultiSelect: true)
|
||||
}
|
||||
.sheet(isPresented: $showingSplitEditSheet) {
|
||||
SplitAddEditView(split: split) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.sheet(item: $itemToEdit) { item in
|
||||
ExerciseAddEditView(exercise: item)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Exercise?",
|
||||
isPresented: .constant(itemToDelete != nil),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let item = itemToDelete {
|
||||
withAnimation {
|
||||
viewContext.delete(item)
|
||||
try? viewContext.save()
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func moveExercises(from source: IndexSet, to destination: Int) {
|
||||
var exercises = split.exercisesArray
|
||||
exercises.move(fromOffsets: source, toOffset: destination)
|
||||
for (index, exercise) in exercises.enumerated() {
|
||||
exercise.order = Int32(index)
|
||||
}
|
||||
try? viewContext.save()
|
||||
}
|
||||
|
||||
private func addExercises(names: [String]) {
|
||||
if names.count == 1 {
|
||||
let exercise = Exercise(context: viewContext)
|
||||
exercise.name = names.first ?? "Unnamed"
|
||||
exercise.order = Int32(split.exercisesArray.count)
|
||||
exercise.sets = 3
|
||||
exercise.reps = 10
|
||||
exercise.weight = 40
|
||||
exercise.loadType = Int32(LoadType.weight.rawValue)
|
||||
exercise.split = split
|
||||
try? viewContext.save()
|
||||
itemToEdit = exercise
|
||||
} else {
|
||||
let existingNames = Set(split.exercisesArray.map { $0.name })
|
||||
for name in names where !existingNames.contains(name) {
|
||||
let exercise = Exercise(context: viewContext)
|
||||
exercise.name = name
|
||||
exercise.order = Int32(split.exercisesArray.count)
|
||||
exercise.sets = 3
|
||||
exercise.reps = 10
|
||||
exercise.weight = 40
|
||||
exercise.loadType = Int32(LoadType.weight.rawValue)
|
||||
exercise.split = split
|
||||
}
|
||||
try? viewContext.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
//
|
||||
// SplitItem.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 2:45 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SplitItem: View {
|
||||
@ObservedObject var split: Split
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ZStack(alignment: .bottom) {
|
||||
// Golden ratio rectangle (1:1.618)
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [splitColor, splitColor.darker(by: 0.2)]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.aspectRatio(1.618, contentMode: .fit)
|
||||
.shadow(radius: 2)
|
||||
|
||||
GeometryReader { geo in
|
||||
VStack(spacing: 4) {
|
||||
Spacer()
|
||||
|
||||
// Icon in the center - using dynamic sizing
|
||||
Image(systemName: split.systemImage)
|
||||
.font(.system(size: min(geo.size.width * 0.3, 40), weight: .bold))
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: geo.size.width * 0.6, maxHeight: geo.size.height * 0.4)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
// Name at the bottom inside the rectangle
|
||||
Text(split.name)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
Text("\(split.exercisesArray.count) exercises")
|
||||
.font(.caption)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var splitColor: Color {
|
||||
Color.color(from: split.color)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
//
|
||||
// SplitListView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/25/25 at 6:24 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct SplitListView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
@FetchRequest(
|
||||
sortDescriptors: [
|
||||
NSSortDescriptor(keyPath: \Split.order, ascending: true),
|
||||
NSSortDescriptor(keyPath: \Split.name, ascending: true)
|
||||
],
|
||||
animation: .default
|
||||
)
|
||||
private var fetchedSplits: FetchedResults<Split>
|
||||
|
||||
@State private var splits: [Split] = []
|
||||
@State private var allowSorting: Bool = true
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
|
||||
SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in
|
||||
NavigationLink {
|
||||
SplitDetailView(split: split)
|
||||
} label: {
|
||||
SplitItem(split: split)
|
||||
.overlay(dragging ? Color.white.opacity(0.8) : Color.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.overlay {
|
||||
if fetchedSplits.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Splits Yet",
|
||||
systemImage: "dumbbell.fill",
|
||||
description: Text("Create a split to organize your workout routine.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
splits = Array(fetchedSplits)
|
||||
}
|
||||
.onChange(of: fetchedSplits.count) { _, _ in
|
||||
splits = Array(fetchedSplits)
|
||||
}
|
||||
.onChange(of: splits) { _, _ in
|
||||
saveContext()
|
||||
}
|
||||
}
|
||||
|
||||
private func saveContext() {
|
||||
if viewContext.hasChanges {
|
||||
do {
|
||||
try viewContext.save()
|
||||
} catch {
|
||||
print("Error saving after reorder: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
//
|
||||
// SplitsView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/17/25 at 6:55 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct SplitsView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
@State private var showingAddSheet: Bool = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
SplitListView()
|
||||
.navigationTitle("Splits")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { showingAddSheet.toggle() }) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
SplitAddEditView(split: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SplitsView()
|
||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
//
|
||||
// ExerciseView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 5:44 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import Charts
|
||||
|
||||
struct ExerciseView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@ObservedObject var workoutLog: WorkoutLog
|
||||
|
||||
@State private var progress: Int = 0
|
||||
@State private var showingPlanEdit = false
|
||||
@State private var showingNotesEdit = false
|
||||
|
||||
let notStartedColor = Color.white
|
||||
let completedColor = Color.green
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
// MARK: - Progress Section
|
||||
Section(header: Text("Progress")) {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: Int(workoutLog.sets)), spacing: 4) {
|
||||
ForEach(1...max(1, Int(workoutLog.sets)), id: \.self) { index in
|
||||
ZStack {
|
||||
let completed = index <= progress
|
||||
let color = completed ? completedColor : notStartedColor
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [color, color.darker(by: 0.2)]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.aspectRatio(0.618, contentMode: .fit)
|
||||
.shadow(radius: 2)
|
||||
Text("\(index)")
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.primary)
|
||||
.colorInvert()
|
||||
}
|
||||
.onTapGesture {
|
||||
let totalSets = Int(workoutLog.sets)
|
||||
let isLastTile = index == totalSets
|
||||
let wasAlreadyAtThisProgress = progress == index
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
if wasAlreadyAtThisProgress {
|
||||
progress = 0
|
||||
} else {
|
||||
progress = index
|
||||
}
|
||||
}
|
||||
|
||||
updateLogStatus()
|
||||
|
||||
// If tapping the last tile to complete, go back to list
|
||||
if isLastTile && !wasAlreadyAtThisProgress {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Plan Section (Read-only with Edit button)
|
||||
Section {
|
||||
PlanTilesView(workoutLog: workoutLog)
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Plan")
|
||||
Spacer()
|
||||
Button("Edit") {
|
||||
showingPlanEdit = true
|
||||
}
|
||||
.font(.subheadline)
|
||||
.textCase(.none)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notes Section (Read-only with Edit button)
|
||||
Section {
|
||||
if let notes = workoutLog.notes, !notes.isEmpty {
|
||||
Text(notes)
|
||||
.foregroundColor(.primary)
|
||||
} else {
|
||||
Text("No notes")
|
||||
.foregroundColor(.secondary)
|
||||
.italic()
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Notes")
|
||||
Spacer()
|
||||
Button("Edit") {
|
||||
showingNotesEdit = true
|
||||
}
|
||||
.font(.subheadline)
|
||||
.textCase(.none)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Progress Tracking Chart
|
||||
Section(header: Text("Progress Tracking")) {
|
||||
WeightProgressionChartView(exerciseName: workoutLog.exerciseName)
|
||||
}
|
||||
}
|
||||
.navigationTitle(workoutLog.exerciseName)
|
||||
.sheet(isPresented: $showingPlanEdit) {
|
||||
PlanEditView(workoutLog: workoutLog)
|
||||
}
|
||||
.sheet(isPresented: $showingNotesEdit) {
|
||||
NotesEditView(workoutLog: workoutLog)
|
||||
}
|
||||
.onAppear {
|
||||
progress = Int(workoutLog.currentStateIndex)
|
||||
}
|
||||
.onChange(of: workoutLog.currentStateIndex) { _, newValue in
|
||||
// Update local state when CoreData changes (e.g., from Watch sync)
|
||||
if progress != Int(newValue) {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
progress = Int(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateLogStatus() {
|
||||
workoutLog.currentStateIndex = Int32(progress)
|
||||
if progress >= Int(workoutLog.sets) {
|
||||
workoutLog.status = .completed
|
||||
workoutLog.completed = true
|
||||
} else if progress > 0 {
|
||||
workoutLog.status = .inProgress
|
||||
workoutLog.completed = false
|
||||
} else {
|
||||
workoutLog.status = .notStarted
|
||||
workoutLog.completed = false
|
||||
}
|
||||
updateWorkoutStatus()
|
||||
saveChanges()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private func saveChanges() {
|
||||
try? viewContext.save()
|
||||
WatchConnectivityManager.shared.syncAllData()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Plan Tiles View
|
||||
|
||||
struct PlanTilesView: View {
|
||||
@ObservedObject var workoutLog: WorkoutLog
|
||||
|
||||
var body: some View {
|
||||
if workoutLog.loadTypeEnum == .duration {
|
||||
// Duration layout: Sets | Duration
|
||||
HStack(spacing: 0) {
|
||||
PlanTile(label: "Sets", value: "\(workoutLog.sets)")
|
||||
PlanTile(label: "Duration", value: formattedDuration)
|
||||
}
|
||||
} else {
|
||||
// Weight layout: Sets | Reps | Weight
|
||||
HStack(spacing: 0) {
|
||||
PlanTile(label: "Sets", value: "\(workoutLog.sets)")
|
||||
PlanTile(label: "Reps", value: "\(workoutLog.reps)")
|
||||
PlanTile(label: "Weight", value: "\(workoutLog.weight) lbs")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var formattedDuration: String {
|
||||
let mins = workoutLog.durationMinutes
|
||||
let secs = workoutLog.durationSeconds
|
||||
if mins > 0 && secs > 0 {
|
||||
return "\(mins)m \(secs)s"
|
||||
} else if mins > 0 {
|
||||
return "\(mins) min"
|
||||
} else if secs > 0 {
|
||||
return "\(secs) sec"
|
||||
} else {
|
||||
return "0 sec"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PlanTile: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(value)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
//
|
||||
// NotesEditView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct NotesEditView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@ObservedObject var workoutLog: WorkoutLog
|
||||
|
||||
@State private var notesText: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextEditor(text: $notesText)
|
||||
.frame(minHeight: 200)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Edit Notes")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
saveChanges()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
notesText = workoutLog.notes ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveChanges() {
|
||||
workoutLog.notes = notesText
|
||||
try? viewContext.save()
|
||||
WatchConnectivityManager.shared.syncAllData()
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
//
|
||||
// PlanEditView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct PlanEditView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@ObservedObject var workoutLog: WorkoutLog
|
||||
|
||||
@State private var sets: Int = 3
|
||||
@State private var reps: Int = 12
|
||||
@State private var weight: Int = 0
|
||||
@State private var durationMinutes: Int = 0
|
||||
@State private var durationSeconds: Int = 0
|
||||
@State private var selectedLoadType: LoadType = .weight
|
||||
|
||||
// Find the corresponding exercise in the split for syncing changes
|
||||
private var correspondingExercise: Exercise? {
|
||||
workoutLog.workout?.split?.exercisesArray.first { $0.name == workoutLog.exerciseName }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Sets and Reps side by side
|
||||
Section {
|
||||
HStack(spacing: 20) {
|
||||
VStack {
|
||||
Text("Sets")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
Picker("Sets", selection: $sets) {
|
||||
ForEach(1...7, id: \.self) { num in
|
||||
Text("\(num)").tag(num)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
.frame(height: 120)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
VStack {
|
||||
Text("Reps")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
Picker("Reps", selection: $reps) {
|
||||
ForEach(1...40, id: \.self) { num in
|
||||
Text("\(num)").tag(num)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
.frame(height: 120)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// Load Type Picker
|
||||
Section {
|
||||
Picker("Load Type", selection: $selectedLoadType) {
|
||||
Text("Weight").tag(LoadType.weight)
|
||||
Text("Time").tag(LoadType.duration)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
// Weight or Time picker based on load type
|
||||
Section {
|
||||
if selectedLoadType == .weight {
|
||||
VStack {
|
||||
Text("Weight")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
Picker("Weight", selection: $weight) {
|
||||
ForEach(0...300, id: \.self) { num in
|
||||
Text("\(num) lbs").tag(num)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
.frame(height: 150)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 20) {
|
||||
VStack {
|
||||
Text("Mins")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
Picker("Minutes", selection: $durationMinutes) {
|
||||
ForEach(0...60, id: \.self) { num in
|
||||
Text("\(num)").tag(num)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
.frame(height: 120)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
VStack {
|
||||
Text("Secs")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
Picker("Seconds", selection: $durationSeconds) {
|
||||
ForEach(0...59, id: \.self) { num in
|
||||
Text("\(num)").tag(num)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
.frame(height: 120)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Edit Plan")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
saveChanges()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
sets = Int(workoutLog.sets)
|
||||
reps = Int(workoutLog.reps)
|
||||
weight = Int(workoutLog.weight)
|
||||
durationMinutes = workoutLog.durationMinutes
|
||||
durationSeconds = workoutLog.durationSeconds
|
||||
selectedLoadType = workoutLog.loadTypeEnum
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveChanges() {
|
||||
workoutLog.sets = Int32(sets)
|
||||
workoutLog.reps = Int32(reps)
|
||||
workoutLog.weight = Int32(weight)
|
||||
workoutLog.durationMinutes = durationMinutes
|
||||
workoutLog.durationSeconds = durationSeconds
|
||||
workoutLog.loadTypeEnum = selectedLoadType
|
||||
|
||||
// Sync to corresponding exercise
|
||||
if let exercise = correspondingExercise {
|
||||
exercise.sets = workoutLog.sets
|
||||
exercise.reps = workoutLog.reps
|
||||
exercise.weight = workoutLog.weight
|
||||
exercise.loadType = workoutLog.loadType
|
||||
exercise.duration = workoutLog.duration
|
||||
}
|
||||
|
||||
try? viewContext.save()
|
||||
WatchConnectivityManager.shared.syncAllData()
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
//
|
||||
// WeightProgressionChartView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created on 7/20/25.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Charts
|
||||
import CoreData
|
||||
|
||||
struct WeightProgressionChartView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
let exerciseName: String
|
||||
@State private var weightData: [WeightDataPoint] = []
|
||||
@State private var isLoading: Bool = true
|
||||
@State private var motivationalMessage: String = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
if isLoading {
|
||||
ProgressView("Loading data...")
|
||||
} else if weightData.isEmpty {
|
||||
Text("No weight history available yet.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding()
|
||||
} else {
|
||||
Text("Weight Progression")
|
||||
.font(.headline)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
Chart {
|
||||
ForEach(weightData) { dataPoint in
|
||||
LineMark(
|
||||
x: .value("Date", dataPoint.date),
|
||||
y: .value("Weight", dataPoint.weight)
|
||||
)
|
||||
.foregroundStyle(Color.blue.gradient)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", dataPoint.date),
|
||||
y: .value("Weight", dataPoint.weight)
|
||||
)
|
||||
.foregroundStyle(Color.blue)
|
||||
}
|
||||
}
|
||||
.chartYScale(domain: .automatic(includesZero: false))
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .automatic) { _ in
|
||||
AxisGridLine()
|
||||
AxisValueLabel(format: .dateTime.month().day())
|
||||
}
|
||||
}
|
||||
.frame(height: 200)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
if !motivationalMessage.isEmpty {
|
||||
Text(motivationalMessage)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
.padding()
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.onAppear {
|
||||
loadWeightData()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadWeightData() {
|
||||
isLoading = true
|
||||
|
||||
let request: NSFetchRequest<WorkoutLog> = WorkoutLog.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "exerciseName == %@ AND completed == YES", exerciseName)
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \WorkoutLog.date, ascending: true)]
|
||||
|
||||
if let logs = try? viewContext.fetch(request) {
|
||||
weightData = logs.map { log in
|
||||
WeightDataPoint(date: log.date, weight: Int(log.weight))
|
||||
}
|
||||
generateMotivationalMessage()
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func generateMotivationalMessage() {
|
||||
guard weightData.count >= 2 else {
|
||||
motivationalMessage = "Complete more workouts to track your progress!"
|
||||
return
|
||||
}
|
||||
|
||||
let firstWeight = weightData.first?.weight ?? 0
|
||||
let currentWeight = weightData.last?.weight ?? 0
|
||||
let weightDifference = currentWeight - firstWeight
|
||||
|
||||
if weightDifference > 0 {
|
||||
let percentIncrease = Int((Double(weightDifference) / Double(firstWeight)) * 100)
|
||||
if percentIncrease >= 20 {
|
||||
motivationalMessage = "Amazing progress! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
|
||||
} else if percentIncrease >= 10 {
|
||||
motivationalMessage = "Great job! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
|
||||
} else {
|
||||
motivationalMessage = "You're making progress! Weight increased by \(weightDifference) lbs. Keep it up!"
|
||||
}
|
||||
} else if weightDifference == 0 {
|
||||
motivationalMessage = "You're maintaining consistent weight. Focus on form and consider increasing when ready!"
|
||||
} else {
|
||||
motivationalMessage = "Your current weight is lower than when you started. Adjust your training as needed and keep pushing!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Data structure for chart points
|
||||
struct WeightDataPoint: Identifiable {
|
||||
let id = UUID()
|
||||
let date: Date
|
||||
let weight: Int
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
//
|
||||
// WorkoutLogListView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/13/25 at 6:58 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct WorkoutLogListView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
@ObservedObject var workout: Workout
|
||||
|
||||
@State private var showingAddSheet = false
|
||||
@State private var itemToDelete: WorkoutLog? = nil
|
||||
@State private var newlyAddedLog: WorkoutLog? = nil
|
||||
|
||||
var sortedWorkoutLogs: [WorkoutLog] {
|
||||
workout.logsArray
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if sortedWorkoutLogs.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No Exercises", systemImage: "figure.strengthtraining.traditional")
|
||||
} description: {
|
||||
Text("Add exercises to start your workout.")
|
||||
} actions: {
|
||||
Button {
|
||||
showingAddSheet = true
|
||||
} label: {
|
||||
Text("Add Exercise")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
} else {
|
||||
Form {
|
||||
Section(header: Text("\(workout.label)")) {
|
||||
ForEach(sortedWorkoutLogs, id: \.objectID) { log in
|
||||
let workoutLogStatus = log.status.checkboxStatus
|
||||
|
||||
NavigationLink {
|
||||
ExerciseView(workoutLog: log)
|
||||
} label: {
|
||||
CheckboxListItem(
|
||||
status: workoutLogStatus,
|
||||
title: log.exerciseName,
|
||||
subtitle: subtitleForLog(log)
|
||||
) {
|
||||
cycleStatus(for: log)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
Button {
|
||||
completeLog(log)
|
||||
} label: {
|
||||
Label("Complete", systemImage: "checkmark.circle.fill")
|
||||
}
|
||||
.tint(.green)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button {
|
||||
itemToDelete = log
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
.onMove(perform: moveLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $newlyAddedLog) { log in
|
||||
ExerciseView(workoutLog: log)
|
||||
}
|
||||
.navigationTitle("\(workout.split?.name ?? Split.unnamed)")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { showingAddSheet.toggle() }) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
SplitExercisePickerSheet(
|
||||
split: workout.split,
|
||||
existingExerciseNames: Set(sortedWorkoutLogs.map { $0.exerciseName })
|
||||
) { exercise in
|
||||
addExerciseFromSplit(exercise)
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Exercise?",
|
||||
isPresented: Binding<Bool>(
|
||||
get: { itemToDelete != nil },
|
||||
set: { if !$0 { itemToDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let item = itemToDelete {
|
||||
withAnimation {
|
||||
viewContext.delete(item)
|
||||
updateWorkoutStatus()
|
||||
try? viewContext.save()
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cycleStatus(for log: WorkoutLog) {
|
||||
switch log.status {
|
||||
case .notStarted:
|
||||
log.status = .inProgress
|
||||
case .inProgress:
|
||||
log.status = .completed
|
||||
case .completed:
|
||||
log.status = .notStarted
|
||||
case .skipped:
|
||||
log.status = .notStarted
|
||||
}
|
||||
updateWorkoutStatus()
|
||||
try? viewContext.save()
|
||||
WatchConnectivityManager.shared.syncAllData()
|
||||
}
|
||||
|
||||
private func completeLog(_ log: WorkoutLog) {
|
||||
log.status = .completed
|
||||
updateWorkoutStatus()
|
||||
try? viewContext.save()
|
||||
WatchConnectivityManager.shared.syncAllData()
|
||||
}
|
||||
|
||||
private func updateWorkoutStatus() {
|
||||
let logs = sortedWorkoutLogs
|
||||
let allCompleted = logs.allSatisfy { $0.status == .completed }
|
||||
let anyInProgress = logs.contains { $0.status == .inProgress }
|
||||
let allNotStarted = logs.allSatisfy { $0.status == .notStarted }
|
||||
|
||||
if allCompleted {
|
||||
workout.status = .completed
|
||||
workout.end = Date()
|
||||
} else if anyInProgress || !allNotStarted {
|
||||
workout.status = .inProgress
|
||||
} else {
|
||||
workout.status = .notStarted
|
||||
}
|
||||
}
|
||||
|
||||
private func moveLog(from source: IndexSet, to destination: Int) {
|
||||
var logs = sortedWorkoutLogs
|
||||
logs.move(fromOffsets: source, toOffset: destination)
|
||||
for (index, log) in logs.enumerated() {
|
||||
log.order = Int32(index)
|
||||
}
|
||||
try? viewContext.save()
|
||||
WatchConnectivityManager.shared.syncAllData()
|
||||
}
|
||||
|
||||
private func addExerciseFromSplit(_ exercise: Exercise) {
|
||||
let now = Date()
|
||||
|
||||
// Update workout start time if this is the first exercise
|
||||
if sortedWorkoutLogs.isEmpty {
|
||||
workout.start = now
|
||||
}
|
||||
workout.end = nil
|
||||
|
||||
let log = WorkoutLog(context: viewContext)
|
||||
log.exerciseName = exercise.name
|
||||
log.date = now
|
||||
log.order = Int32(sortedWorkoutLogs.count)
|
||||
log.sets = exercise.sets
|
||||
log.reps = exercise.reps
|
||||
log.weight = exercise.weight
|
||||
log.loadType = exercise.loadType
|
||||
log.duration = exercise.duration
|
||||
log.status = .notStarted
|
||||
log.workout = workout
|
||||
|
||||
try? viewContext.save()
|
||||
WatchConnectivityManager.shared.syncAllData()
|
||||
|
||||
// Navigate to the new exercise view
|
||||
newlyAddedLog = log
|
||||
}
|
||||
|
||||
private func subtitleForLog(_ log: WorkoutLog) -> 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) reps × \(log.weight) lbs"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Split Exercise Picker Sheet
|
||||
|
||||
struct SplitExercisePickerSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let split: Split?
|
||||
let existingExerciseNames: Set<String>
|
||||
let onExerciseSelected: (Exercise) -> Void
|
||||
|
||||
private var availableExercises: [Exercise] {
|
||||
guard let split = split else { return [] }
|
||||
return split.exercisesArray.filter { !existingExerciseNames.contains($0.name) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if !availableExercises.isEmpty {
|
||||
List {
|
||||
ForEach(availableExercises, id: \.objectID) { exercise in
|
||||
Button {
|
||||
onExerciseSelected(exercise)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(exercise.name)
|
||||
.foregroundColor(.primary)
|
||||
Text("\(exercise.sets) × \(exercise.reps) × \(exercise.weight) lbs")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "plus.circle")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if split == nil {
|
||||
ContentUnavailableView(
|
||||
"No Split Selected",
|
||||
systemImage: "dumbbell",
|
||||
description: Text("This workout has no associated split.")
|
||||
)
|
||||
} else if split?.exercisesArray.isEmpty == true {
|
||||
ContentUnavailableView(
|
||||
"No Exercises in Split",
|
||||
systemImage: "dumbbell",
|
||||
description: Text("Add exercises to your split first.")
|
||||
)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"All Exercises Added",
|
||||
systemImage: "checkmark.circle",
|
||||
description: Text("You've added all exercises from this split.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add Exercise")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
//
|
||||
// WorkoutLogsView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/13/25 at 6:52 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct WorkoutLogsView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
@FetchRequest(
|
||||
sortDescriptors: [NSSortDescriptor(keyPath: \Workout.start, ascending: false)],
|
||||
animation: .default
|
||||
)
|
||||
private var workouts: FetchedResults<Workout>
|
||||
|
||||
@State private var showingSplitPicker = false
|
||||
@State private var showingSettings = false
|
||||
@State private var itemToDelete: Workout? = nil
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(workouts, id: \.objectID) { workout in
|
||||
NavigationLink(destination: WorkoutLogListView(workout: workout)) {
|
||||
CalendarListItem(
|
||||
date: workout.start,
|
||||
title: workout.split?.name ?? Split.unnamed,
|
||||
subtitle: getSubtitle(for: workout),
|
||||
subtitle2: workout.statusName
|
||||
)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button {
|
||||
itemToDelete = workout
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if workouts.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Workouts Yet",
|
||||
systemImage: "list.bullet.clipboard",
|
||||
description: Text("Start a new workout from one of your splits.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Workout Logs")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
showingSettings = true
|
||||
} label: {
|
||||
Image(systemName: "gearshape.2")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Start New") {
|
||||
showingSplitPicker.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingSettings) {
|
||||
SettingsView()
|
||||
}
|
||||
.sheet(isPresented: $showingSplitPicker) {
|
||||
SplitPickerSheet()
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Workout?",
|
||||
isPresented: Binding<Bool>(
|
||||
get: { itemToDelete != nil },
|
||||
set: { if !$0 { itemToDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let item = itemToDelete {
|
||||
withAnimation {
|
||||
viewContext.delete(item)
|
||||
try? viewContext.save()
|
||||
WatchConnectivityManager.shared.syncAllData()
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getSubtitle(for workout: Workout) -> String {
|
||||
if workout.status == .completed, let endDate = workout.end {
|
||||
return workout.start.humanTimeInterval(to: endDate)
|
||||
} else {
|
||||
return workout.start.formattedDate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Split Picker Sheet
|
||||
|
||||
struct SplitPickerSheet: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@FetchRequest(
|
||||
sortDescriptors: [
|
||||
NSSortDescriptor(keyPath: \Split.order, ascending: true),
|
||||
NSSortDescriptor(keyPath: \Split.name, ascending: true)
|
||||
],
|
||||
animation: .default
|
||||
)
|
||||
private var splits: FetchedResults<Split>
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(splits, id: \.objectID) { split in
|
||||
Button {
|
||||
startWorkout(with: split)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: split.systemImage)
|
||||
.foregroundColor(Color.color(from: split.color))
|
||||
Text(split.name)
|
||||
Spacer()
|
||||
Text("\(split.exercisesArray.count) exercises")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select a Split")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startWorkout(with split: Split) {
|
||||
let workout = Workout(context: viewContext)
|
||||
workout.start = Date()
|
||||
workout.status = .notStarted
|
||||
workout.split = split
|
||||
|
||||
for exercise in split.exercisesArray {
|
||||
let workoutLog = WorkoutLog(context: viewContext)
|
||||
workoutLog.exerciseName = exercise.name
|
||||
workoutLog.date = Date()
|
||||
workoutLog.order = exercise.order
|
||||
workoutLog.sets = exercise.sets
|
||||
workoutLog.reps = exercise.reps
|
||||
workoutLog.weight = exercise.weight
|
||||
workoutLog.loadType = exercise.loadType
|
||||
workoutLog.duration = exercise.duration
|
||||
workoutLog.status = .notStarted
|
||||
workoutLog.workout = workout
|
||||
}
|
||||
|
||||
try? viewContext.save()
|
||||
|
||||
// Sync to Watch
|
||||
WatchConnectivityManager.shared.syncAllData()
|
||||
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
WorkoutLogsView()
|
||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.dev.rzen.indie.Workouts</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.dev.rzen.indie.Workouts</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>_XCCurrentVersionName</key>
|
||||
<string>Workouts.xcdatamodel</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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>
|
||||
@@ -1,31 +0,0 @@
|
||||
//
|
||||
// WorkoutsApp.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 8/13/25 at 11:10 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
@main
|
||||
struct WorkoutsApp: App {
|
||||
let persistenceController = PersistenceController.shared
|
||||
let connectivityManager = WatchConnectivityManager.shared
|
||||
|
||||
init() {
|
||||
// Set up Watch connectivity with Core Data context
|
||||
connectivityManager.setViewContext(persistenceController.viewContext)
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(\.managedObjectContext, persistenceController.viewContext)
|
||||
.environmentObject(connectivityManager)
|
||||
}
|
||||
}
|
||||
}
|
||||