Compare commits
22 Commits
e7166bacca
...
2f044c3d9c
Author | SHA1 | Date | |
---|---|---|---|
2f044c3d9c | |||
3fd6887ce7 | |||
310c120ca3 | |||
d2b8625f2e | |||
a0c8b21bf4 | |||
68d90160c6 | |||
33b88cb8f0 | |||
2ef340e5c8 | |||
e3c3f2c6f0 | |||
6e46775f58 | |||
34942bfc48 | |||
66f257609f | |||
4f01a6445f | |||
f63bb0ba41 | |||
2d0e327334 | |||
48bbbbf692 | |||
39fd45e03f | |||
bdaa406876 | |||
0545f5dbc7 | |||
d4514805e9 | |||
6cd44579e2 | |||
ba81f06c56 |
108
.windsurf/prompt.md
Normal file
108
.windsurf/prompt.md
Normal file
@ -0,0 +1,108 @@
|
||||
# General Guidelines
|
||||
|
||||
## Technology Stack
|
||||
|
||||
Technology stack is described in TECH.md file.
|
||||
|
||||
## Data Model
|
||||
|
||||
Data model is defined in MODEL.md file.
|
||||
|
||||
## Persistence
|
||||
|
||||
When implementing an edit form for a SwiftData model, ensure proper loading:
|
||||
|
||||
1. BEFORE PRESENTING THE FORM:
|
||||
- Ensure the model is fully loaded with all its relationships
|
||||
- Use a deep fetch pattern to eagerly load all relationships
|
||||
- Access each property to force SwiftData to materialize lazy-loaded relationships
|
||||
- Consider creating a deep copy of the model if relationships are complex
|
||||
|
||||
2. IN THE EDIT VIEW:
|
||||
- Accept the model as a @State parameter (e.g., @State var modelObject: ModelType)
|
||||
- Create separate @State variables for each editable field
|
||||
- Initialize each state variable in the init() method using State(initialValue:)
|
||||
- Use the modelContext for persisting changes back to the data store
|
||||
|
||||
3. SAVING CHANGES:
|
||||
- When saving, update the model object properties from state variables
|
||||
- Use modelContext.save() to persist changes
|
||||
- Handle errors appropriately
|
||||
|
||||
|
||||
## User Interface and User Experience
|
||||
|
||||
Each view struct should be placed in its own file under "Views" directory.
|
||||
|
||||
The user interface and user experience should follow Apple's Human Interface Guidelines (HIG) and best practices for iOS development.
|
||||
|
||||
Avoid custom UI components, instead rely on available SwiftUI views and modifiers.
|
||||
|
||||
When creating a add/edit functionality for a model, unless otherwise instructed use a single Add/Edit View for both add and edit functionality.
|
||||
|
||||
Unless otherwise instructed, use a sheet to present both add and edit views.
|
||||
|
||||
Whenever a list view has no entries, show a placeholder view with text "No [ListName] yet." and a button "Add [ListName]".
|
||||
|
||||
Before presenting an add/edit view, ensure the model is fully loaded with all its relationships. Make use of async/await mechanism to load the model. Show an overlay with a loading indicator while the model is being loaded.
|
||||
|
||||
## Logger
|
||||
|
||||
Use custom logger instead of print statements.
|
||||
|
||||
Make a custom logger as follows:
|
||||
|
||||
```swift
|
||||
import OSLog
|
||||
|
||||
struct AppLogger {
|
||||
private let logger: Logger
|
||||
private let subsystem: String
|
||||
private let category: String
|
||||
|
||||
init(subsystem: String, category: String) {
|
||||
self.subsystem = subsystem
|
||||
self.category = category
|
||||
self.logger = Logger(subsystem: subsystem, category: category)
|
||||
}
|
||||
|
||||
func timestamp () -> String {
|
||||
Date.now.formatDateET(format: "yyyy-MM-dd HH:mm:ss")
|
||||
}
|
||||
|
||||
func formattedMessage (_ message: String) -> String {
|
||||
"\(timestamp()) [\(subsystem):\(category)] \(message)"
|
||||
}
|
||||
|
||||
func debug(_ message: String) {
|
||||
logger.debug("\(formattedMessage(message))")
|
||||
}
|
||||
|
||||
func info(_ message: String) {
|
||||
logger.info("\(formattedMessage(message))")
|
||||
}
|
||||
|
||||
func warning(_ message: String) {
|
||||
logger.warning("\(formattedMessage(message))")
|
||||
}
|
||||
|
||||
func error(_ message: String) {
|
||||
logger.error("\(formattedMessage(message))")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```swift
|
||||
extension Date {
|
||||
func formatDateET(format: String = "MMMM, d yyyy @ h:mm a z") -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeZone = TimeZone(identifier: "America/New_York")
|
||||
formatter.dateFormat = format
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
static var ISO8601: String {
|
||||
"yyyy-MM-dd'T'HH:mm:ssZ"
|
||||
}
|
||||
}
|
||||
```
|
BIN
Artwork/App Icon Template.psd
Normal file
BIN
Artwork/App Icon Template.psd
Normal file
Binary file not shown.
BIN
Artwork/DumbBellIcon-light.png
Normal file
BIN
Artwork/DumbBellIcon-light.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 97 KiB |
BIN
Artwork/DumbBellIcon.png
Normal file
BIN
Artwork/DumbBellIcon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 90 KiB |
BIN
Artwork/DumbBellIcon.pxd
Normal file
BIN
Artwork/DumbBellIcon.pxd
Normal file
Binary file not shown.
81
MODEL.md
Normal file
81
MODEL.md
Normal file
@ -0,0 +1,81 @@
|
||||
# Data Model
|
||||
|
||||
## Guidelines
|
||||
|
||||
### General
|
||||
|
||||
- When implementing a SwiftData model, allocate each model into its own file under "Models" directory.
|
||||
|
||||
### Schema Versioning
|
||||
|
||||
- Keep schema configurations in a separate folder called "Schema"
|
||||
- Setup an enum for schema versioning
|
||||
- Create SchemaMigrationPlan struct in Schema/SchemaMigrationPlan.swift for managing schema migrations
|
||||
- Use schema versioning to manage changes to the data model
|
||||
- When adding a new model, increment the schema version
|
||||
- When removing a model, increment the schema version
|
||||
- When modifying a model, increment the schema version
|
||||
|
||||
### SwiftData Relationship Rules
|
||||
|
||||
- DO NOT create any relationships that are not explicitly defined in the data model
|
||||
- AVOID circular references in all cases
|
||||
- Infer type of relationship from the property name (to many for plural, to one for singular)
|
||||
|
||||
## Models
|
||||
|
||||
ExerciseType
|
||||
- name (String)
|
||||
- descr (String)
|
||||
- exercises (Set<Exercise>?) // deleteRule: nullify
|
||||
|
||||
MuscleGroup
|
||||
- name (String)
|
||||
- descr (String)
|
||||
- muscles (Set<Muscle>?) // deleteRule: nullify
|
||||
|
||||
Muscle
|
||||
- name (String)
|
||||
- descr (String)
|
||||
- muscleGroup (MuscleGroup?) // deleteRule: nullify, inverse: MuscleGroup.muscles
|
||||
- exercises (Set<Exercise>?) // deleteRule: nullify
|
||||
|
||||
Exercise
|
||||
- type (ExerciseType?) // deleteRule: .nullify, inverse: ExerciseType.exercises
|
||||
- name (String)
|
||||
- setup (String)
|
||||
- descr (String)
|
||||
- muscles (Set<Muscle>?) // deleteRule: .nullify, inverse: Muscle.exercises
|
||||
- sets (Int)
|
||||
- reps (Int)
|
||||
- weight (Int)
|
||||
- splits (Set<SplitExerciseAssignment>?) // deleteRule: .nullify, inverse: SplitExerciseAssignment.exercise
|
||||
- logs (Set<WorkoutLog>?) // deleteRule: .nullify, inverse: WorkoutLog.exercise
|
||||
|
||||
SplitExerciseAssignment
|
||||
- split (Split?) // deleteRule: .nullify
|
||||
- exercise (Exercise?) // deleteRule: .nullify
|
||||
- order (Int)
|
||||
- sets (Int)
|
||||
- reps (Int)
|
||||
- weight (Int)
|
||||
|
||||
Split
|
||||
- name (String)
|
||||
- intro (String)
|
||||
- exercises (Set<SplitExerciseAssignment>?) // deleteRule: .cascade, inverse: SplitExerciseAssignment.split
|
||||
|
||||
WorkoutLog
|
||||
- workout (Workout?) // deleteRule: .nullify
|
||||
- exercise (Exercise?) // deleteRule: .nullify
|
||||
- date (Date)
|
||||
- sets (Int)
|
||||
- reps (Int)
|
||||
- weight (Int)
|
||||
- completed (Bool)
|
||||
|
||||
Workout
|
||||
- split (Split?) // deleteRule: .nullify
|
||||
- start (Date)
|
||||
- end (Date?)
|
||||
- logs (Set<WorkoutLog>?) // deleteRule: .cascade, inverse: WorkoutLog.workout
|
157
UI.md
Normal file
157
UI.md
Normal file
@ -0,0 +1,157 @@
|
||||
# User Interface
|
||||
|
||||
## Tabs
|
||||
|
||||
Each tab is a root of its own navigation stack.
|
||||
|
||||
- Workouts
|
||||
- Reports
|
||||
- Settings
|
||||
|
||||
## Workout Log
|
||||
|
||||
- toolbar
|
||||
- left bar button: none
|
||||
- title: Workout Log
|
||||
- right bar button: Add Workout
|
||||
- main view:
|
||||
- List of WorkoutLog objects
|
||||
- Grouped by date, in descending order
|
||||
- Group header shows date and split name
|
||||
- Each row shows:
|
||||
- Time
|
||||
- Exercise
|
||||
- Sets
|
||||
- Reps
|
||||
- Weight
|
||||
|
||||
- When main view list has no entries, show a placeholder view with text "No workouts yet." and a button "Start Split"
|
||||
|
||||
### Start Split View
|
||||
|
||||
Start Split View slides on top of Workout Log View navigation stack.
|
||||
|
||||
- left bar button: default (back to Workout Log)
|
||||
- title: Start Split
|
||||
- right bar button: none
|
||||
|
||||
- main view:
|
||||
- list of available splits
|
||||
- button "Start [SplitName] Split" -> Workout Split
|
||||
|
||||
### Workout Split View
|
||||
|
||||
Workout Split View slides on top of Workout Log View navigation stack.
|
||||
|
||||
- left bar button: default (back to Start Split)
|
||||
- title: [SplitName] Split
|
||||
- right bar button: Add Exercise
|
||||
|
||||
- main view:
|
||||
- list of exercises in the split
|
||||
- actions:
|
||||
- on row slide to left
|
||||
- button "Edit"
|
||||
- on row slide to right
|
||||
- button "Completed"
|
||||
- on row tap
|
||||
- open Exercise View
|
||||
|
||||
### Exercise View
|
||||
|
||||
Exercise View slides on top of Workout Split View navigation stack.
|
||||
|
||||
- left bar button: default (back to Workout Split)
|
||||
- title: [ExerciseName]
|
||||
- right bar button: Edit (pencil icon)
|
||||
- action: open Add/Edit Exercise Assignment View
|
||||
|
||||
- main view:
|
||||
- form
|
||||
sections:
|
||||
- split
|
||||
- split name
|
||||
- exercise assignment (titled "Planned")
|
||||
- exercise
|
||||
- name
|
||||
- setup
|
||||
- description
|
||||
- muscles
|
||||
- weight
|
||||
- sets (read only)
|
||||
- reps (read only)
|
||||
- weight (read only)
|
||||
- workout log (titled "Actual")
|
||||
- date (date/time picker)
|
||||
- sets (integer picker)
|
||||
- reps (integer picker)
|
||||
- weight (integer picker)
|
||||
- status
|
||||
- Completed (checkbox)
|
||||
|
||||
- actions:
|
||||
- swipe left
|
||||
- open Exercise View for the previous ExerciseAssignment
|
||||
- swipe right
|
||||
- open Exercise View for the next ExerciseAssignment
|
||||
|
||||
### Add/Edit Exercise to Split View
|
||||
|
||||
This view should be opened as a sheet.
|
||||
|
||||
- left bar button: default (back to Workout Split)
|
||||
- title: Add/Edit Exercise to Split
|
||||
- right bar button: Save
|
||||
|
||||
- main view:
|
||||
- before an exercise is selected
|
||||
- list of available exercises
|
||||
- button "Add [ExerciseName]"
|
||||
- after an exercise is selected
|
||||
- a form for model SplitExerciseAssignment
|
||||
- sets
|
||||
- reps
|
||||
- weight
|
||||
|
||||
## Settings
|
||||
|
||||
- left bar button: none
|
||||
- title: Settings
|
||||
- right bar button: none
|
||||
|
||||
- main view:
|
||||
- list
|
||||
- Splits
|
||||
- Exercises
|
||||
- Muscle Groups
|
||||
- Muscles
|
||||
- Exercise Types
|
||||
|
||||
- actions:
|
||||
- on row tap
|
||||
- open corresponding list view (e.g. for "Splits" open "Splits List View")
|
||||
|
||||
### (Splits|Exercises|Muscle Groups|Muscles|Exercise Types) List View
|
||||
|
||||
- left bar button: default (back to Settings)
|
||||
- title: [ListName]
|
||||
- right bar button: Add (plus icon)
|
||||
- action: open corresponding add/edit view (e.g. for "Splits" open "Splits Add/Edit View")
|
||||
|
||||
- main view:
|
||||
- list of [ListName]
|
||||
- actions:
|
||||
- on row tap
|
||||
- open corresponding add/edit view (e.g. for "Splits" open "Splits Add/Edit View")
|
||||
|
||||
### (Splits|Exercises|Muscle Groups|Muscles|Exercise Types) Add/Edit View
|
||||
|
||||
Add/Edit views typically open as sheets.
|
||||
|
||||
- left bar button: default (back to Settings)
|
||||
- title: [Add/Edit] [ListName]
|
||||
- right bar button: Save
|
||||
|
||||
- main view:
|
||||
- form with editable fields
|
||||
|
@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "DumbBellIcon-light.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "watchos",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 97 KiB |
6
Workouts Watch App/Assets.xcassets/Contents.json
Normal file
6
Workouts Watch App/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
70
Workouts Watch App/ContentView.swift
Normal file
70
Workouts Watch App/ContentView.swift
Normal file
@ -0,0 +1,70 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/15/25 at 7:09 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@State var activeWorkouts: [Workout] = []
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
if activeWorkouts.isEmpty {
|
||||
NoActiveWorkoutView()
|
||||
} else {
|
||||
ActiveWorkoutListView(workouts: activeWorkouts)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadActiveWorkouts()
|
||||
}
|
||||
}
|
||||
|
||||
func loadActiveWorkouts () {
|
||||
let completedStatus = WorkoutStatus.completed.rawValue
|
||||
do {
|
||||
self.activeWorkouts = try modelContext.fetch(FetchDescriptor<Workout>(
|
||||
predicate: #Predicate<Workout> { workout in
|
||||
workout.status != completedStatus
|
||||
},
|
||||
sortBy: [
|
||||
SortDescriptor(\Workout.start, order: .reverse)
|
||||
]
|
||||
))
|
||||
} catch {
|
||||
print("ERROR: failed to load active workouts \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NoActiveWorkoutView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "dumbbell.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.gray)
|
||||
|
||||
Text("No Active Workout")
|
||||
.font(.headline)
|
||||
|
||||
Text("Start a workout in the main app")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.gray)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.modelContainer(AppContainer.preview)
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
205
Workouts Watch App/Schema/AppContainer.swift
Normal file
205
Workouts Watch App/Schema/AppContainer.swift
Normal file
@ -0,0 +1,205 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
final class AppContainer {
|
||||
static let logger = AppLogger(
|
||||
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Workouts.watchkitapp",
|
||||
category: "AppContainer"
|
||||
)
|
||||
|
||||
static func create() -> ModelContainer {
|
||||
// Using the current models directly without migration plan to avoid reference errors
|
||||
let schema = Schema(SchemaVersion.models)
|
||||
|
||||
#if targetEnvironment(simulator) && os(watchOS)
|
||||
// Use local-only storage for watchOS simulator
|
||||
let configuration = ModelConfiguration(isStoredInMemoryOnly: false)
|
||||
logger.info("Creating local-only database for watchOS simulator")
|
||||
|
||||
do {
|
||||
let container = try ModelContainer(for: schema, configurations: configuration)
|
||||
|
||||
// Populate with test data if needed
|
||||
Task { @MainActor in
|
||||
await populateSimulatorData(container: container)
|
||||
}
|
||||
|
||||
return container
|
||||
} catch {
|
||||
logger.error("Failed to create simulator ModelContainer: \(error.localizedDescription)")
|
||||
fatalError("Failed to create simulator ModelContainer: \(error.localizedDescription)")
|
||||
}
|
||||
#else
|
||||
// Use CloudKit for real devices
|
||||
let configuration = ModelConfiguration(cloudKitDatabase: .automatic)
|
||||
logger.info("Creating CloudKit database for real device")
|
||||
|
||||
let container = try! ModelContainer(for: schema, configurations: configuration)
|
||||
return container
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static var preview: ModelContainer {
|
||||
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
|
||||
do {
|
||||
let schema = Schema(SchemaVersion.models)
|
||||
let container = try ModelContainer(for: schema, configurations: configuration)
|
||||
return container
|
||||
} catch {
|
||||
fatalError("Failed to create preview ModelContainer: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func populateSimulatorData(container: ModelContainer) async {
|
||||
let context = container.mainContext
|
||||
|
||||
// Check if data already exists
|
||||
let fetchDescriptor = FetchDescriptor<Split>()
|
||||
guard (try? context.fetch(fetchDescriptor))?.isEmpty ?? true else {
|
||||
logger.info("Simulator database already has data, skipping population")
|
||||
return // Data already exists
|
||||
}
|
||||
|
||||
logger.info("Populating simulator database with test data from pf-starter-exercises.yaml")
|
||||
|
||||
// Create splits
|
||||
let upperBodySplit = Split(name: "Upper Body", color: "blue", systemImage: "figure.strengthtraining.traditional", order: 0)
|
||||
let lowerBodySplit = Split(name: "Lower Body", color: "green", systemImage: "figure.run", order: 1)
|
||||
let fullBodySplit = Split(name: "Full Body", color: "purple", systemImage: "figure.mixed.cardio", order: 2)
|
||||
let coreSplit = Split(name: "Core", color: "red", systemImage: "figure.core.training", order: 3)
|
||||
|
||||
context.insert(upperBodySplit)
|
||||
context.insert(lowerBodySplit)
|
||||
context.insert(fullBodySplit)
|
||||
context.insert(coreSplit)
|
||||
|
||||
// Create exercises based on pf-starter-exercises.yaml
|
||||
|
||||
// Upper Body Exercises
|
||||
let latPullDown = Exercise(split: upperBodySplit, exerciseName: "Lat Pull Down", order: 0, sets: 3, reps: 12, weight: 120)
|
||||
let seatedRow = Exercise(split: upperBodySplit, exerciseName: "Seated Row", order: 1, sets: 3, reps: 12, weight: 110)
|
||||
let shoulderPress = Exercise(split: upperBodySplit, exerciseName: "Shoulder Press", order: 2, sets: 3, reps: 10, weight: 90)
|
||||
let chestPress = Exercise(split: upperBodySplit, exerciseName: "Chest Press", order: 3, sets: 3, reps: 10, weight: 130)
|
||||
let tricepPress = Exercise(split: upperBodySplit, exerciseName: "Tricep Press", order: 4, sets: 3, reps: 12, weight: 70)
|
||||
let armCurl = Exercise(split: upperBodySplit, exerciseName: "Arm Curl", order: 5, sets: 3, reps: 12, weight: 60)
|
||||
|
||||
context.insert(latPullDown)
|
||||
context.insert(seatedRow)
|
||||
context.insert(shoulderPress)
|
||||
context.insert(chestPress)
|
||||
context.insert(tricepPress)
|
||||
context.insert(armCurl)
|
||||
|
||||
// Core Exercises
|
||||
let abdominal = Exercise(split: coreSplit, exerciseName: "Abdominal", order: 0, sets: 3, reps: 15, weight: 80)
|
||||
let rotary = Exercise(split: coreSplit, exerciseName: "Rotary", order: 1, sets: 3, reps: 15, weight: 70)
|
||||
let plank = Exercise(split: coreSplit, exerciseName: "Plank", order: 2, sets: 3, reps: 1, weight: 0) // Reps as time in minutes
|
||||
let russianTwists = Exercise(split: coreSplit, exerciseName: "Russian Twists", order: 3, sets: 3, reps: 20, weight: 25)
|
||||
|
||||
context.insert(abdominal)
|
||||
context.insert(rotary)
|
||||
context.insert(plank)
|
||||
context.insert(russianTwists)
|
||||
|
||||
// Lower Body Exercises
|
||||
let legPress = Exercise(split: lowerBodySplit, exerciseName: "Leg Press", order: 0, sets: 3, reps: 12, weight: 200)
|
||||
let legExtension = Exercise(split: lowerBodySplit, exerciseName: "Leg Extension", order: 1, sets: 3, reps: 12, weight: 110)
|
||||
let legCurl = Exercise(split: lowerBodySplit, exerciseName: "Leg Curl", order: 2, sets: 3, reps: 12, weight: 90)
|
||||
let adductor = Exercise(split: lowerBodySplit, exerciseName: "Adductor", order: 3, sets: 3, reps: 15, weight: 100)
|
||||
let abductor = Exercise(split: lowerBodySplit, exerciseName: "Abductor", order: 4, sets: 3, reps: 15, weight: 90)
|
||||
let calfs = Exercise(split: lowerBodySplit, exerciseName: "Calfs", order: 5, sets: 3, reps: 15, weight: 120)
|
||||
|
||||
context.insert(legPress)
|
||||
context.insert(legExtension)
|
||||
context.insert(legCurl)
|
||||
context.insert(adductor)
|
||||
context.insert(abductor)
|
||||
context.insert(calfs)
|
||||
|
||||
// Full Body Exercises (selected from both upper and lower)
|
||||
let fullBodyChestPress = Exercise(split: fullBodySplit, exerciseName: "Chest Press", order: 0, sets: 3, reps: 10, weight: 130)
|
||||
let fullBodyLatPullDown = Exercise(split: fullBodySplit, exerciseName: "Lat Pull Down", order: 1, sets: 3, reps: 12, weight: 120)
|
||||
let fullBodyLegPress = Exercise(split: fullBodySplit, exerciseName: "Leg Press", order: 2, sets: 3, reps: 12, weight: 200)
|
||||
let fullBodyAbdominal = Exercise(split: fullBodySplit, exerciseName: "Abdominal", order: 3, sets: 3, reps: 15, weight: 80)
|
||||
|
||||
context.insert(fullBodyChestPress)
|
||||
context.insert(fullBodyLatPullDown)
|
||||
context.insert(fullBodyLegPress)
|
||||
context.insert(fullBodyAbdominal)
|
||||
|
||||
// Create workouts
|
||||
let now = Date()
|
||||
|
||||
// Upper Body Workout (in progress)
|
||||
let upperBodyWorkout = Workout(start: now, end: now, split: upperBodySplit)
|
||||
upperBodyWorkout.status = 2
|
||||
// upperBodyWorkout.status = .inProgress
|
||||
upperBodyWorkout.end = nil
|
||||
context.insert(upperBodyWorkout)
|
||||
|
||||
// Lower Body Workout (scheduled for tomorrow)
|
||||
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: now) ?? now
|
||||
let lowerBodyWorkout = Workout(start: tomorrow, end: tomorrow, split: lowerBodySplit)
|
||||
lowerBodyWorkout.status = 1
|
||||
// lowerBodyWorkout.status = .notStarted
|
||||
context.insert(lowerBodyWorkout)
|
||||
|
||||
// Full Body Workout (completed yesterday)
|
||||
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now) ?? now
|
||||
let fullBodyWorkout = Workout(start: yesterday, end: yesterday, split: fullBodySplit)
|
||||
fullBodyWorkout.status = 3
|
||||
// fullBodyWorkout.status = .completed
|
||||
context.insert(fullBodyWorkout)
|
||||
|
||||
// Create workout logs for Upper Body workout (in progress)
|
||||
let chestPressLog = WorkoutLog(workout: upperBodyWorkout, exerciseName: chestPress.name, date: now, order: 0, sets: chestPress.sets, reps: chestPress.reps, weight: chestPress.weight, status: .completed, completed: true)
|
||||
let shoulderPressLog = WorkoutLog(workout: upperBodyWorkout, exerciseName: shoulderPress.name, date: now, order: 1, sets: shoulderPress.sets, reps: shoulderPress.reps, weight: shoulderPress.weight, status: .completed, completed: true)
|
||||
let latPullDownLog = WorkoutLog(workout: upperBodyWorkout, exerciseName: latPullDown.name, date: now, order: 2, sets: latPullDown.sets, reps: latPullDown.reps, weight: latPullDown.weight, status: .inProgress, completed: false)
|
||||
let seatedRowLog = WorkoutLog(workout: upperBodyWorkout, exerciseName: seatedRow.name, date: now, order: 3, sets: seatedRow.sets, reps: seatedRow.reps, weight: seatedRow.weight, status: .notStarted, completed: false)
|
||||
let tricepPressLog = WorkoutLog(workout: upperBodyWorkout, exerciseName: tricepPress.name, date: now, order: 4, sets: tricepPress.sets, reps: tricepPress.reps, weight: tricepPress.weight, status: .notStarted, completed: false)
|
||||
let armCurlLog = WorkoutLog(workout: upperBodyWorkout, exerciseName: armCurl.name, date: now, order: 5, sets: armCurl.sets, reps: armCurl.reps, weight: armCurl.weight, status: .notStarted, completed: false)
|
||||
|
||||
context.insert(chestPressLog)
|
||||
context.insert(shoulderPressLog)
|
||||
context.insert(latPullDownLog)
|
||||
context.insert(seatedRowLog)
|
||||
context.insert(tricepPressLog)
|
||||
context.insert(armCurlLog)
|
||||
|
||||
// Create workout logs for Lower Body workout (scheduled)
|
||||
let legPressLog = WorkoutLog(workout: lowerBodyWorkout, exerciseName: legPress.name, date: tomorrow, order: 0, sets: legPress.sets, reps: legPress.reps, weight: legPress.weight, status: .notStarted, completed: false)
|
||||
let legExtensionLog = WorkoutLog(workout: lowerBodyWorkout, exerciseName: legExtension.name, date: tomorrow, order: 1, sets: legExtension.sets, reps: legExtension.reps, weight: legExtension.weight, status: .notStarted, completed: false)
|
||||
let legCurlLog = WorkoutLog(workout: lowerBodyWorkout, exerciseName: legCurl.name, date: tomorrow, order: 2, sets: legCurl.sets, reps: legCurl.reps, weight: legCurl.weight, status: .notStarted, completed: false)
|
||||
let adductorLog = WorkoutLog(workout: lowerBodyWorkout, exerciseName: adductor.name, date: tomorrow, order: 3, sets: adductor.sets, reps: adductor.reps, weight: adductor.weight, status: .notStarted, completed: false)
|
||||
let abductorLog = WorkoutLog(workout: lowerBodyWorkout, exerciseName: abductor.name, date: tomorrow, order: 4, sets: abductor.sets, reps: abductor.reps, weight: abductor.weight, status: .notStarted, completed: false)
|
||||
let calfsLog = WorkoutLog(workout: lowerBodyWorkout, exerciseName: calfs.name, date: tomorrow, order: 5, sets: calfs.sets, reps: calfs.reps, weight: calfs.weight, status: .notStarted, completed: false)
|
||||
|
||||
context.insert(legPressLog)
|
||||
context.insert(legExtensionLog)
|
||||
context.insert(legCurlLog)
|
||||
context.insert(adductorLog)
|
||||
context.insert(abductorLog)
|
||||
context.insert(calfsLog)
|
||||
|
||||
// Create workout logs for Full Body workout (completed)
|
||||
let fullBodyChestPressLog = WorkoutLog(workout: fullBodyWorkout, exerciseName: fullBodyChestPress.name, date: yesterday, order: 0, sets: fullBodyChestPress.sets, reps: fullBodyChestPress.reps, weight: fullBodyChestPress.weight, status: .completed, completed: true)
|
||||
let fullBodyLatPullDownLog = WorkoutLog(workout: fullBodyWorkout, exerciseName: fullBodyLatPullDown.name, date: yesterday, order: 1, sets: fullBodyLatPullDown.sets, reps: fullBodyLatPullDown.reps, weight: fullBodyLatPullDown.weight, status: .completed, completed: true)
|
||||
let fullBodyLegPressLog = WorkoutLog(workout: fullBodyWorkout, exerciseName: fullBodyLegPress.name, date: yesterday, order: 2, sets: fullBodyLegPress.sets, reps: fullBodyLegPress.reps, weight: fullBodyLegPress.weight, status: .completed, completed: true)
|
||||
let fullBodyAbdominalLog = WorkoutLog(workout: fullBodyWorkout, exerciseName: fullBodyAbdominal.name, date: yesterday, order: 3, sets: fullBodyAbdominal.sets, reps: fullBodyAbdominal.reps, weight: fullBodyAbdominal.weight, status: .completed, completed: true)
|
||||
|
||||
context.insert(fullBodyChestPressLog)
|
||||
context.insert(fullBodyLatPullDownLog)
|
||||
context.insert(fullBodyLegPressLog)
|
||||
context.insert(fullBodyAbdominalLog)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.info("Successfully populated simulator database with test data from pf-starter-exercises.yaml")
|
||||
} catch {
|
||||
logger.error("Failed to save test data: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
10
Workouts Watch App/Schema/SchemaVersion.swift
Normal file
10
Workouts Watch App/Schema/SchemaVersion.swift
Normal file
@ -0,0 +1,10 @@
|
||||
import SwiftData
|
||||
|
||||
enum SchemaVersion {
|
||||
static var models: [any PersistentModel.Type] = [
|
||||
// Split.self,
|
||||
// Exercise.self,
|
||||
Workout.self,
|
||||
WorkoutLog.self
|
||||
]
|
||||
}
|
22
Workouts Watch App/Utils/AppLogger.swift
Normal file
22
Workouts Watch App/Utils/AppLogger.swift
Normal file
@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
struct AppLogger {
|
||||
private let logger: Logger
|
||||
|
||||
init(subsystem: String, category: String) {
|
||||
self.logger = Logger(subsystem: subsystem, category: category)
|
||||
}
|
||||
|
||||
func debug(_ message: String) {
|
||||
logger.debug("\(message)")
|
||||
}
|
||||
|
||||
func info(_ message: String) {
|
||||
logger.info("\(message)")
|
||||
}
|
||||
|
||||
func error(_ message: String) {
|
||||
logger.error("\(message)")
|
||||
}
|
||||
}
|
21
Workouts Watch App/Utils/Color+color.swift
Normal file
21
Workouts Watch App/Utils/Color+color.swift
Normal file
@ -0,0 +1,21 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
static func color(from name: String) -> Color {
|
||||
switch name {
|
||||
case "red": return .red
|
||||
case "orange": return .orange
|
||||
case "yellow": return .yellow
|
||||
case "green": return .green
|
||||
case "mint": return .mint
|
||||
case "teal": return .teal
|
||||
case "cyan": return .cyan
|
||||
case "blue": return .blue
|
||||
case "indigo": return .indigo
|
||||
case "purple": return .purple
|
||||
case "pink": return .pink
|
||||
case "brown": return .brown
|
||||
default: return .indigo
|
||||
}
|
||||
}
|
||||
}
|
9
Workouts Watch App/Utils/Date+formatDate.swift
Normal file
9
Workouts Watch App/Utils/Date+formatDate.swift
Normal file
@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
func formatDate() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
}
|
14
Workouts Watch App/Utils/Date+formatDateET.swift
Normal file
14
Workouts Watch App/Utils/Date+formatDateET.swift
Normal file
@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
func formatDateET(format: String = "MMMM, d yyyy @ h:mm a z") -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeZone = TimeZone(identifier: "America/New_York")
|
||||
formatter.dateFormat = format
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
static var ISO8601: String {
|
||||
"yyyy-MM-dd'T'HH:mm:ssZ"
|
||||
}
|
||||
}
|
9
Workouts Watch App/Utils/Date+formatedDate.swift
Normal file
9
Workouts Watch App/Utils/Date+formatedDate.swift
Normal file
@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
func formattedDate() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
}
|
33
Workouts Watch App/Utils/HapticFeedback.swift
Normal file
33
Workouts Watch App/Utils/HapticFeedback.swift
Normal file
@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
import WatchKit
|
||||
|
||||
struct HapticFeedback {
|
||||
static func success() {
|
||||
WKInterfaceDevice.current().play(.success)
|
||||
}
|
||||
|
||||
static func notification() {
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
}
|
||||
|
||||
static func click() {
|
||||
WKInterfaceDevice.current().play(.click)
|
||||
}
|
||||
|
||||
static func doubleTap() {
|
||||
WKInterfaceDevice.current().play(.click)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
WKInterfaceDevice.current().play(.click)
|
||||
}
|
||||
}
|
||||
|
||||
static func tripleTap() {
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
}
|
||||
}
|
||||
}
|
18
Workouts Watch App/Utils/TimeInterval+formatted.swift
Normal file
18
Workouts Watch App/Utils/TimeInterval+formatted.swift
Normal file
@ -0,0 +1,18 @@
|
||||
//
|
||||
// TimeInterval+minutesSecons.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/23/25 at 4:22 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Int {
|
||||
var secondsFormatted: String {
|
||||
let minutes = self / 60
|
||||
let seconds = self % 60
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
//
|
||||
// ExerciseDoneCard.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/23/25 at 4:29 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ExerciseDoneCard: View {
|
||||
let elapsedSeconds: Int
|
||||
let onComplete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Button(action: onComplete) {
|
||||
Text("Done in \(10 - elapsedSeconds)s")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.green)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var timeFormatted: String {
|
||||
let minutes = elapsedSeconds / 60
|
||||
let seconds = elapsedSeconds % 60
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,55 @@
|
||||
//
|
||||
// ExerciseIntroView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/23/25 at 4:19 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ExerciseIntroCard: View {
|
||||
let log: WorkoutLog
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
Text(log.exerciseName)
|
||||
.font(.title)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
.layoutPriority(1)
|
||||
|
||||
HStack(alignment: .bottom) {
|
||||
Text("\(log.weight)")
|
||||
Text("lbs")
|
||||
.fontWeight(.light)
|
||||
.padding([.trailing], 10)
|
||||
|
||||
Text("\(log.sets)")
|
||||
Text("×")
|
||||
.fontWeight(.light)
|
||||
Text("\(log.reps)")
|
||||
}
|
||||
.font(.title3)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
.layoutPriority(1)
|
||||
|
||||
Text(log.status?.name ?? "Not Started")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
.padding()
|
||||
|
||||
// VStack(spacing: 20) {
|
||||
// Text(title)
|
||||
// .font(.title)
|
||||
//
|
||||
// Text(elapsedSeconds.secondsFormatted)
|
||||
// .font(.system(size: 48, weight: .semibold, design: .monospaced))
|
||||
// .foregroundStyle(Color.accentColor)
|
||||
// }
|
||||
// .padding()
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,30 @@
|
||||
//
|
||||
// ExerciseRestCard.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/23/25 at 4:28 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ExerciseRestCard: View {
|
||||
let elapsedSeconds: Int
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("Resting for")
|
||||
.font(.title)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
.layoutPriority(1)
|
||||
|
||||
Text(elapsedSeconds.secondsFormatted)
|
||||
.font(.system(size: 48, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(Color.green)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,28 @@
|
||||
//
|
||||
// ExerciseSetCard.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/23/25 at 4:26 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ExerciseSetCard: View {
|
||||
let set: Int
|
||||
let elapsedSeconds: Int
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("Set \(set)")
|
||||
.font(.title)
|
||||
|
||||
Text(elapsedSeconds.secondsFormatted)
|
||||
.font(.system(size: 48, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,140 @@
|
||||
//
|
||||
// ExerciseProgressControlView 2.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/23/25 at 9:15 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ExerciseProgressControlView: View {
|
||||
let log: WorkoutLog
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@State private var exerciseStates: [ExerciseState] = []
|
||||
@State private var currentStateIndex: Int = 0
|
||||
@State private var elapsedSeconds: Int = 0
|
||||
@State private var timer: Timer? = nil
|
||||
@State private var previousStateIndex: Int = 0
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $currentStateIndex) {
|
||||
ForEach(Array(exerciseStates.enumerated()), id: \.element.id) { index, state in
|
||||
if state.isIntro {
|
||||
ExerciseIntroCard(log: log)
|
||||
.tag(index)
|
||||
|
||||
} else if state.isSet {
|
||||
ExerciseSetCard(set: state.setNumber ?? 0, elapsedSeconds: elapsedSeconds)
|
||||
.tag(index)
|
||||
|
||||
} else if state.isRest {
|
||||
ExerciseRestCard(elapsedSeconds: elapsedSeconds)
|
||||
.tag(index)
|
||||
|
||||
} else if state.isDone {
|
||||
ExerciseDoneCard(elapsedSeconds: elapsedSeconds, onComplete: completeExercise)
|
||||
.tag(index)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.onChange(of: currentStateIndex) { oldValue, newValue in
|
||||
if oldValue != newValue {
|
||||
elapsedSeconds = 0
|
||||
moveToNextState()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
setupExerciseStates()
|
||||
currentStateIndex = log.currentStateIndex ?? 0
|
||||
startTimer()
|
||||
}
|
||||
.onDisappear {
|
||||
stopTimer()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupExerciseStates() {
|
||||
var states: [ExerciseState] = []
|
||||
states.append(.intro)
|
||||
for i in 1...log.sets {
|
||||
states.append(.set(number: i))
|
||||
if i < log.sets {
|
||||
states.append(.rest(afterSet: i))
|
||||
}
|
||||
}
|
||||
states.append(.done)
|
||||
exerciseStates = states
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||
elapsedSeconds += 1
|
||||
|
||||
// Check if we need to provide haptic feedback during rest periods
|
||||
if currentStateIndex >= 0 && currentStateIndex < exerciseStates.count {
|
||||
let currentState = exerciseStates[currentStateIndex]
|
||||
if currentState.isRest {
|
||||
provideRestHapticFeedback()
|
||||
} else if currentState.isDone && elapsedSeconds >= 10 {
|
||||
// Auto-complete after 10 seconds on the DONE state
|
||||
completeExercise()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func moveToNextState() {
|
||||
if currentStateIndex < exerciseStates.count - 1 {
|
||||
elapsedSeconds = 0
|
||||
withAnimation {
|
||||
currentStateIndex += 1
|
||||
log.currentStateIndex = currentStateIndex
|
||||
log.elapsedSeconds = elapsedSeconds
|
||||
log.status = .inProgress
|
||||
try? modelContext.save()
|
||||
}
|
||||
} else {
|
||||
// We've reached the end (DONE state)
|
||||
completeExercise()
|
||||
}
|
||||
}
|
||||
|
||||
private func provideRestHapticFeedback() {
|
||||
// Provide haptic feedback based on elapsed time
|
||||
if elapsedSeconds % 60 == 0 && elapsedSeconds > 0 {
|
||||
// Triple tap every 60 seconds
|
||||
HapticFeedback.tripleTap()
|
||||
} else if elapsedSeconds % 30 == 0 && elapsedSeconds > 0 {
|
||||
// Double tap every 30 seconds
|
||||
HapticFeedback.doubleTap()
|
||||
} else if elapsedSeconds % 10 == 0 && elapsedSeconds > 0 {
|
||||
// Single tap every 10 seconds
|
||||
HapticFeedback.success()
|
||||
}
|
||||
}
|
||||
|
||||
private func completeExercise() {
|
||||
// Update the workout log status to completed
|
||||
log.status = .completed
|
||||
|
||||
// reset index in case we wish to re-run the exercise
|
||||
log.currentStateIndex = 0
|
||||
|
||||
// Provide "tada" haptic feedback
|
||||
HapticFeedback.tripleTap()
|
||||
|
||||
// Dismiss this view to return to WorkoutDetailView
|
||||
dismiss()
|
||||
}
|
||||
}
|
317
Workouts Watch App/Views/Exercises/ExerciseProgressView.swift
Normal file
317
Workouts Watch App/Views/Exercises/ExerciseProgressView.swift
Normal file
@ -0,0 +1,317 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import WatchKit
|
||||
|
||||
// Enum to track the current phase of the exercise
|
||||
enum ExercisePhase {
|
||||
case notStarted
|
||||
case exercising(setNumber: Int)
|
||||
case resting(setNumber: Int, elapsedSeconds: Int)
|
||||
case completed
|
||||
}
|
||||
|
||||
struct ExerciseProgressView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let log: WorkoutLog
|
||||
|
||||
@State private var phase: ExercisePhase = .notStarted
|
||||
@State private var hapticSeconds: Int = 0
|
||||
@State private var restSeconds: Int = 0
|
||||
@State private var hapticTimer: Timer? = nil
|
||||
@State private var restTimer: Timer? = nil
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 15) {
|
||||
exerciseHeader
|
||||
|
||||
switch phase {
|
||||
case .notStarted:
|
||||
startPhaseView
|
||||
case .exercising(let setNumber):
|
||||
exercisingPhaseView(setNumber: setNumber)
|
||||
case .resting(let setNumber, let elapsedSeconds):
|
||||
restingPhaseView(setNumber: setNumber, elapsedSeconds: elapsedSeconds)
|
||||
case .completed:
|
||||
completedPhaseView
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle(log.exerciseName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onDisappear {
|
||||
stopTimers()
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 50)
|
||||
.onEnded { gesture in
|
||||
if gesture.translation.width < 0 {
|
||||
// Swipe left - progress to next phase
|
||||
handleSwipeLeft()
|
||||
} else if gesture.translation.height < 0 && gesture.translation.height < -50 {
|
||||
// Swipe up - cancel current set
|
||||
handleSwipeUp()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - View Components
|
||||
|
||||
private var exerciseHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(log.exerciseName)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("\(log.sets) sets × \(log.reps) reps")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("\(log.weight) lbs")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.bottom, 5)
|
||||
}
|
||||
|
||||
private var startPhaseView: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("Ready to start?")
|
||||
.font(.headline)
|
||||
|
||||
Button(action: startFirstSet) {
|
||||
Text("Start First Set")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
private func exercisingPhaseView(setNumber: Int) -> some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("Set \(setNumber) of \(log.sets)")
|
||||
.font(.headline)
|
||||
|
||||
Text("Exercising...")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack(spacing: 20) {
|
||||
Button(action: completeSet) {
|
||||
Text("Complete")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.green)
|
||||
|
||||
Button(action: cancelSet) {
|
||||
Text("Cancel")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.red)
|
||||
}
|
||||
|
||||
Text("Or swipe left to complete")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Swipe up to cancel")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func restingPhaseView(setNumber: Int, elapsedSeconds: Int) -> some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("Rest after Set \(setNumber)")
|
||||
.font(.headline)
|
||||
|
||||
Text("Rest time: \(formatSeconds(elapsedSeconds))")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if setNumber < (log.sets) {
|
||||
Button(action: { startNextSet(after: setNumber) }) {
|
||||
Text("Start Set \(setNumber + 1)")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.blue)
|
||||
|
||||
Text("Or swipe left to start next set")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Button(action: completeExercise) {
|
||||
Text("Complete Exercise")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.green)
|
||||
|
||||
Text("Or swipe left to complete")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var completedPhaseView: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("Exercise Completed!")
|
||||
.font(.headline)
|
||||
.foregroundColor(.green)
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(.green)
|
||||
|
||||
Button(action: { dismiss() }) {
|
||||
Text("Return to Workout")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Action Handlers
|
||||
|
||||
private func handleSwipeLeft() {
|
||||
switch phase {
|
||||
case .notStarted:
|
||||
startFirstSet()
|
||||
case .exercising:
|
||||
completeSet()
|
||||
case .resting(let setNumber, _):
|
||||
if setNumber < (log.sets) {
|
||||
startNextSet(after: setNumber)
|
||||
} else {
|
||||
completeExercise()
|
||||
}
|
||||
case .completed:
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSwipeUp() {
|
||||
if case .exercising = phase {
|
||||
cancelSet()
|
||||
}
|
||||
}
|
||||
|
||||
private func startFirstSet() {
|
||||
phase = .exercising(setNumber: 1)
|
||||
startHapticTimer()
|
||||
}
|
||||
|
||||
private func startNextSet(after completedSetNumber: Int) {
|
||||
stopTimers()
|
||||
let nextSetNumber = completedSetNumber + 1
|
||||
phase = .exercising(setNumber: nextSetNumber)
|
||||
startHapticTimer()
|
||||
}
|
||||
|
||||
private func completeSet() {
|
||||
stopTimers()
|
||||
|
||||
if case .exercising(let setNumber) = phase {
|
||||
// Start rest timer
|
||||
phase = .resting(setNumber: setNumber, elapsedSeconds: 0)
|
||||
startRestTimer()
|
||||
startHapticTimer()
|
||||
|
||||
// Play completion haptic
|
||||
HapticFeedback.success()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelSet() {
|
||||
// Just go back to the previous state
|
||||
stopTimers()
|
||||
phase = .notStarted
|
||||
}
|
||||
|
||||
private func completeExercise() {
|
||||
stopTimers()
|
||||
|
||||
// Update workout log
|
||||
log.completed = true
|
||||
log.status = .completed
|
||||
try? modelContext.save()
|
||||
|
||||
// Show completion screen
|
||||
phase = .completed
|
||||
|
||||
// Play completion haptic
|
||||
HapticFeedback.success()
|
||||
}
|
||||
|
||||
// MARK: - Timer Management
|
||||
|
||||
private func startHapticTimer() {
|
||||
hapticSeconds = 0
|
||||
hapticTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||
hapticSeconds += 1
|
||||
|
||||
// Provide haptic feedback based on time intervals
|
||||
if hapticSeconds % 60 == 0 {
|
||||
// Triple tap every 60 seconds
|
||||
HapticFeedback.tripleTap()
|
||||
} else if hapticSeconds % 30 == 0 {
|
||||
// Double tap every 30 seconds
|
||||
HapticFeedback.doubleTap()
|
||||
} else if hapticSeconds % 10 == 0 {
|
||||
// Light tap every 10 seconds
|
||||
HapticFeedback.click()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startRestTimer() {
|
||||
restSeconds = 0
|
||||
restTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||
restSeconds += 1
|
||||
|
||||
if case .resting(let setNumber, _) = phase {
|
||||
phase = .resting(setNumber: setNumber, elapsedSeconds: restSeconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimers() {
|
||||
hapticTimer?.invalidate()
|
||||
hapticTimer = nil
|
||||
|
||||
restTimer?.invalidate()
|
||||
restTimer = nil
|
||||
}
|
||||
|
||||
// MARK: - Helper Functions
|
||||
|
||||
private func formatSeconds(_ seconds: Int) -> String {
|
||||
let minutes = seconds / 60
|
||||
let remainingSeconds = seconds % 60
|
||||
return String(format: "%d:%02d", minutes, remainingSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// let config = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
// let container = try! ModelContainer(for: SchemaV1.models, configurations: config)
|
||||
//
|
||||
// // Create sample data
|
||||
// let exercise = Exercise(name: "Bench Press", sets: 3, reps: 8, weight: 60.0)
|
||||
// let workout = Workout(name: "Chest Day", date: Date())
|
||||
// let log = WorkoutLog(exercise: exercise, workout: workout)
|
||||
//
|
||||
// NavigationStack {
|
||||
// ExerciseProgressView(log: log)
|
||||
// .modelContainer(container)
|
||||
// }
|
||||
//}
|
72
Workouts Watch App/Views/Exercises/ExerciseState.swift
Normal file
72
Workouts Watch App/Views/Exercises/ExerciseState.swift
Normal file
@ -0,0 +1,72 @@
|
||||
//
|
||||
// ExerciseState.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/23/25 at 9:14 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
|
||||
|
||||
enum ExerciseState: Identifiable {
|
||||
case intro
|
||||
case set(number: Int)
|
||||
case rest(afterSet: Int)
|
||||
case done
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .intro:
|
||||
return "detail"
|
||||
case .set(let number):
|
||||
return "set_\(number)"
|
||||
case .rest(let afterSet):
|
||||
return "rest_\(afterSet)"
|
||||
case .done:
|
||||
return "done"
|
||||
}
|
||||
}
|
||||
|
||||
var setNumber: Int? {
|
||||
switch self {
|
||||
case .intro, .rest, .done: return nil
|
||||
case .set (let number): return number
|
||||
}
|
||||
}
|
||||
|
||||
var afterSet: Int? {
|
||||
switch self {
|
||||
case .intro, .set, .done: return nil
|
||||
case .rest (let afterSet): return afterSet
|
||||
}
|
||||
}
|
||||
|
||||
var isIntro: Bool {
|
||||
if case .intro = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var isSet: Bool {
|
||||
if case .set = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var isRest: Bool {
|
||||
if case .rest = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var isDone: Bool {
|
||||
if case .done = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
47
Workouts Watch App/Views/Exercises/ExerciseStateView.swift
Normal file
47
Workouts Watch App/Views/Exercises/ExerciseStateView.swift
Normal file
@ -0,0 +1,47 @@
|
||||
//
|
||||
// ExerciseStateView 2.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/23/25 at 9:15 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ExerciseStateView: View {
|
||||
let title: String
|
||||
let isRest: Bool
|
||||
let isDone: Bool
|
||||
let elapsedSeconds: Int
|
||||
let onComplete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text(title)
|
||||
.font(.title)
|
||||
|
||||
Text(timeFormatted)
|
||||
.font(.system(size: 48, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(isRest ? .orange : .accentColor)
|
||||
|
||||
if isDone {
|
||||
Button(action: onComplete) {
|
||||
Text("Done in \(10 - elapsedSeconds)s")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.green)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var timeFormatted: String {
|
||||
let minutes = elapsedSeconds / 60
|
||||
let seconds = elapsedSeconds % 60
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
//
|
||||
// ActiveWorkoutListView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/20/25 at 6:35 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ActiveWorkoutListView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
let workouts: [Workout]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(workouts) { workout in
|
||||
NavigationLink {
|
||||
WorkoutDetailView(workout: workout)
|
||||
} label: {
|
||||
WorkoutCardView(workout: workout)
|
||||
}
|
||||
.listRowBackground(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.secondary.opacity(0.2))
|
||||
.padding(
|
||||
EdgeInsets(
|
||||
top: 4,
|
||||
leading: 8,
|
||||
bottom: 4,
|
||||
trailing: 8
|
||||
)
|
||||
)
|
||||
)
|
||||
// .swipeActions (edge: .trailing, allowsFullSwipe: false) {
|
||||
// Button {
|
||||
// //
|
||||
// } label: {
|
||||
// Label("Delete", systemImage: "trash")
|
||||
// .frame(height: 40)
|
||||
// }
|
||||
// .tint(.red)
|
||||
// }
|
||||
}
|
||||
}
|
||||
.listStyle(.carousel)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let container = AppContainer.preview
|
||||
let split = Split(name: "Upper Body", color: "blue", systemImage: "figure.strengthtraining.traditional")
|
||||
let workout1 = Workout(start: Date(), end: Date(), split: split)
|
||||
|
||||
let split2 = Split(name: "Lower Body", color: "red", systemImage: "figure.run")
|
||||
let workout2 = Workout(start: Date().addingTimeInterval(-3600), end: Date(), split: split2)
|
||||
|
||||
ActiveWorkoutListView(workouts: [workout1, workout2])
|
||||
.modelContainer(container)
|
||||
}
|
36
Workouts Watch App/Views/Workouts/WorkoutCardView.swift
Normal file
36
Workouts Watch App/Views/Workouts/WorkoutCardView.swift
Normal file
@ -0,0 +1,36 @@
|
||||
//
|
||||
// WorkoutCardView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/22/25 at 9:54 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct WorkoutCardView: View {
|
||||
let workout: Workout
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
if let split = workout.split {
|
||||
Image(systemName: split.systemImage)
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(split.getColor())
|
||||
} else {
|
||||
Image(systemName: "dumbbell.fill")
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
|
||||
Text(workout.split?.name ?? "Workout")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(workout.statusName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
50
Workouts Watch App/Views/Workouts/WorkoutDetailView.swift
Normal file
50
Workouts Watch App/Views/Workouts/WorkoutDetailView.swift
Normal file
@ -0,0 +1,50 @@
|
||||
//
|
||||
// WorkoutDetailView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/22/25 at 9:54 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct WorkoutDetailView: View {
|
||||
let workout: Workout
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center, spacing: 8) {
|
||||
if let logs = workout.logs?.sorted(by: { $0.order < $1.order }), !logs.isEmpty {
|
||||
List {
|
||||
ForEach(logs) { log in
|
||||
NavigationLink {
|
||||
ExerciseProgressControlView(log: log)
|
||||
} label: {
|
||||
WorkoutLogCardView(log: log)
|
||||
}
|
||||
.listRowBackground(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.secondary.opacity(0.2))
|
||||
.padding(
|
||||
EdgeInsets(
|
||||
top: 4,
|
||||
leading: 8,
|
||||
bottom: 4,
|
||||
trailing: 8
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.listStyle(.carousel)
|
||||
} else {
|
||||
Text("No exercises in this workout")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
34
Workouts Watch App/Views/Workouts/WorkoutLogCardView.swift
Normal file
34
Workouts Watch App/Views/Workouts/WorkoutLogCardView.swift
Normal file
@ -0,0 +1,34 @@
|
||||
//
|
||||
// WorkoutLogCardView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/22/25 at 9:56 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct WorkoutLogCardView: View {
|
||||
let log: WorkoutLog
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(log.exerciseName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(log.status?.name ?? "Not Started")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Text("\(log.weight) lbs")
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(log.sets) × \(log.reps)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
69
Workouts Watch App/Views/Workouts/WorkoutLogDetailView.swift
Normal file
69
Workouts Watch App/Views/Workouts/WorkoutLogDetailView.swift
Normal file
@ -0,0 +1,69 @@
|
||||
//
|
||||
// WorkoutLogDetailView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/22/25 at 9:57 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct WorkoutLogDetailView: View {
|
||||
let log: WorkoutLog
|
||||
|
||||
var body: some View {
|
||||
NavigationLink {
|
||||
ExerciseProgressControlView(log: log)
|
||||
} label: {
|
||||
VStack(alignment: .center) {
|
||||
Text(log.exerciseName)
|
||||
.font(.title)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
.layoutPriority(1)
|
||||
|
||||
HStack (alignment: .bottom) {
|
||||
Text("\(log.weight)")
|
||||
Text( "lbs")
|
||||
.fontWeight(.light)
|
||||
.padding([.trailing], 10)
|
||||
|
||||
Text("\(log.sets)")
|
||||
Text("×")
|
||||
.fontWeight(.light)
|
||||
Text("\(log.reps)")
|
||||
}
|
||||
.font(.title3)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
.layoutPriority(1)
|
||||
|
||||
Text(log.status?.name ?? "Not Started")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
|
||||
Text("Tap to start")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
|
||||
}
|
||||
.padding()
|
||||
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func statusColor(for status: WorkoutStatus?) -> Color {
|
||||
guard let status = status else { return .secondary }
|
||||
|
||||
switch status {
|
||||
case .notStarted:
|
||||
return .secondary
|
||||
case .inProgress:
|
||||
return .blue
|
||||
case .completed:
|
||||
return .green
|
||||
case .skipped:
|
||||
return .red
|
||||
}
|
||||
}
|
||||
}
|
117
Workouts Watch App/Views/Workouts/WorkoutLogListView.swift
Normal file
117
Workouts Watch App/Views/Workouts/WorkoutLogListView.swift
Normal file
@ -0,0 +1,117 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct WorkoutLogListView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
let workout: Workout
|
||||
|
||||
@State private var selectedLogIndex: Int = 0
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if let logs = workout.logs?.sorted(by: { $0.order < $1.order }), !logs.isEmpty {
|
||||
TabView(selection: $selectedLogIndex) {
|
||||
ForEach(Array(logs.enumerated()), id: \.element.id) { index, log in
|
||||
WorkoutLogCard(log: log, index: index)
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page)
|
||||
// .indexViewStyle(.page(backgroundDisplayMode: .always))
|
||||
} else {
|
||||
Text("No exercises in this workout")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle(workout.split?.name ?? "Workout")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkoutLogCard: View {
|
||||
let log: WorkoutLog
|
||||
let index: Int
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text(log.exerciseName)
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack {
|
||||
VStack {
|
||||
Text("\(log.sets)")
|
||||
.font(.title2)
|
||||
Text("Sets")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
VStack {
|
||||
Text("\(log.reps)")
|
||||
.font(.title2)
|
||||
Text("Reps")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
VStack {
|
||||
Text("\(log.weight)")
|
||||
.font(.title2)
|
||||
Text("Weight")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
ExerciseProgressView(log: log)
|
||||
} label: {
|
||||
Text("Start")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
Text(log.status?.name ?? "Not Started")
|
||||
.font(.caption)
|
||||
.foregroundStyle(statusColor(for: log.status))
|
||||
}
|
||||
.padding()
|
||||
.background(Color.secondary.opacity(0.2))
|
||||
.cornerRadius(12)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private func statusColor(for status: WorkoutStatus?) -> Color {
|
||||
guard let status = status else { return .secondary }
|
||||
|
||||
switch status {
|
||||
case .notStarted:
|
||||
return .secondary
|
||||
case .inProgress:
|
||||
return .blue
|
||||
case .completed:
|
||||
return .green
|
||||
case .skipped:
|
||||
return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let container = AppContainer.preview
|
||||
let workout = Workout(start: Date(), end: Date(), split: nil)
|
||||
let log1 = WorkoutLog(workout: workout, exerciseName: "Bench Press", date: Date(), sets: 3, reps: 10, weight: 135)
|
||||
let log2 = WorkoutLog(workout: workout, exerciseName: "Squats", date: Date(), order: 1, sets: 3, reps: 8, weight: 225)
|
||||
|
||||
return WorkoutLogListView(workout: workout)
|
||||
.modelContainer(container)
|
||||
}
|
16
Workouts Watch App/Workouts Watch App.entitlements
Normal file
16
Workouts Watch App/Workouts Watch App.entitlements
Normal file
@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.com.dev.rzen.indie.Workouts</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
23
Workouts Watch App/WorkoutsApp.swift
Normal file
23
Workouts Watch App/WorkoutsApp.swift
Normal file
@ -0,0 +1,23 @@
|
||||
//
|
||||
// WorksoutsApp.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/15/25 at 7:09 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@main
|
||||
struct Workouts_Watch_AppApp: App {
|
||||
let container = AppContainer.create()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.modelContainer(container)
|
||||
}
|
||||
}
|
||||
}
|
45
Workouts Watch App/__ATTIC__/ExerciseDetailView.swift
Normal file
45
Workouts Watch App/__ATTIC__/ExerciseDetailView.swift
Normal file
@ -0,0 +1,45 @@
|
||||
////
|
||||
//// ExerciseDetailView.swift
|
||||
//// Workouts
|
||||
////
|
||||
//// Created by rzen on 7/23/25 at 9:17 AM.
|
||||
////
|
||||
//// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
////
|
||||
//
|
||||
//import SwiftUI
|
||||
//
|
||||
//struct ExerciseDetailView: View {
|
||||
// let log: WorkoutLog
|
||||
// let onStart: () -> Void
|
||||
//
|
||||
// var body: some View {
|
||||
// VStack(alignment: .center, spacing: 16) {
|
||||
// Text(log.exerciseName)
|
||||
// .font(.title)
|
||||
// .lineLimit(1)
|
||||
// .minimumScaleFactor(0.5)
|
||||
// .layoutPriority(1)
|
||||
//
|
||||
// HStack(alignment: .bottom) {
|
||||
// Text("\(log.weight)")
|
||||
// Text("lbs")
|
||||
// .fontWeight(.light)
|
||||
// .padding([.trailing], 10)
|
||||
//
|
||||
// Text("\(log.sets)")
|
||||
// Text("×")
|
||||
// .fontWeight(.light)
|
||||
// Text("\(log.reps)")
|
||||
// }
|
||||
// .font(.title3)
|
||||
// .lineLimit(1)
|
||||
// .minimumScaleFactor(0.5)
|
||||
// .layoutPriority(1)
|
||||
//
|
||||
// Text(log.status?.name ?? "Not Started")
|
||||
// .foregroundStyle(Color.accentColor)
|
||||
// }
|
||||
// .padding()
|
||||
// }
|
||||
//}
|
@ -0,0 +1,35 @@
|
||||
////
|
||||
//// ExerciseProgressControlView.swift
|
||||
//// Workouts
|
||||
////
|
||||
//// Created by rzen on 7/20/25 at 7:19 PM.
|
||||
////
|
||||
//// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
////
|
||||
//
|
||||
//import SwiftUI
|
||||
//import SwiftData
|
||||
//
|
||||
//
|
||||
//
|
||||
//
|
||||
//
|
||||
//
|
||||
//// Detail view shown as the first item in the exercise progress carousel
|
||||
//
|
||||
//
|
||||
//// Helper extension to safely access array elements
|
||||
////extension Array {
|
||||
//// subscript(safe index: Index) -> Element? {
|
||||
//// return indices.contains(index) ? self[index] : nil
|
||||
//// }
|
||||
////}
|
||||
//
|
||||
////#Preview {
|
||||
//// let container = AppContainer.preview
|
||||
//// let workout = Workout(start: Date(), end: nil, split: nil)
|
||||
//// let log = WorkoutLog(workout: workout, exerciseName: "Bench Press", date: Date(), sets: 3, reps: 10, weight: 135)
|
||||
////
|
||||
//// ExerciseProgressControlView(log: log)
|
||||
//// .modelContainer(container)
|
||||
////}
|
584
Workouts.xcodeproj/project.pbxproj
Normal file
584
Workouts.xcodeproj/project.pbxproj
Normal file
@ -0,0 +1,584 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
A45FA1FE2E27171B00581607 /* Workouts Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A45FA1F12E27171A00581607 /* Workouts Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
A45FA2732E29B12500581607 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2722E29B12500581607 /* Yams */; };
|
||||
A45FA27F2E2A930900581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */; };
|
||||
A45FA2822E2A933B00581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */; };
|
||||
A45FA2872E2AC66B00581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2862E2AC66B00581607 /* SwiftUIReorderableForEach */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
A45FA1FC2E27171B00581607 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = A45FA0892E21B3DC00581607 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = A45FA1F02E27171A00581607;
|
||||
remoteInfo = "Workouts Watch App";
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
A45FA2022E27171B00581607 /* Embed Watch Content */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
|
||||
dstSubfolderSpec = 16;
|
||||
files = (
|
||||
A45FA1FE2E27171B00581607 /* Workouts Watch App.app in Embed Watch Content */,
|
||||
);
|
||||
name = "Embed Watch Content";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
A45FA0912E21B3DD00581607 /* Workouts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Workouts.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A45FA1F12E27171A00581607 /* Workouts Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Workouts Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
A45FA0A12E21B3DE00581607 /* Exceptions for "Workouts" folder in "Workouts" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
_ATTIC_/ContentView_backup.swift,
|
||||
_ATTIC_/ExerciseProgressView_backup.swift,
|
||||
Info.plist,
|
||||
);
|
||||
target = A45FA0902E21B3DD00581607 /* Workouts */;
|
||||
};
|
||||
A45FA2C52E2D3CBD00581607 /* Exceptions for "Workouts" folder in "Workouts Watch App" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
_ATTIC_/ContentView_backup.swift,
|
||||
_ATTIC_/ExerciseProgressView_backup.swift,
|
||||
Models/Exercise.swift,
|
||||
Models/Split.swift,
|
||||
Models/Workout.swift,
|
||||
Models/WorkoutLog.swift,
|
||||
Schema/SchemaV1.swift,
|
||||
Views/Common/CheckboxStatus.swift,
|
||||
Views/WorkoutLog/WorkoutStatus.swift,
|
||||
);
|
||||
target = A45FA1F02E27171A00581607 /* Workouts Watch App */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
A45FA0932E21B3DD00581607 /* Workouts */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
A45FA0A12E21B3DE00581607 /* Exceptions for "Workouts" folder in "Workouts" target */,
|
||||
A45FA2C52E2D3CBD00581607 /* Exceptions for "Workouts" folder in "Workouts Watch App" target */,
|
||||
);
|
||||
path = Workouts;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A45FA1F22E27171A00581607 /* Workouts Watch App */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = "Workouts Watch App";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
A45FA08E2E21B3DD00581607 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A45FA2822E2A933B00581607 /* SwiftUIReorderableForEach in Frameworks */,
|
||||
A45FA2732E29B12500581607 /* Yams in Frameworks */,
|
||||
A45FA27F2E2A930900581607 /* SwiftUIReorderableForEach in Frameworks */,
|
||||
A45FA2872E2AC66B00581607 /* SwiftUIReorderableForEach in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A45FA1EE2E27171A00581607 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
A45FA0882E21B3DC00581607 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A45FA0932E21B3DD00581607 /* Workouts */,
|
||||
A45FA1F22E27171A00581607 /* Workouts Watch App */,
|
||||
A45FA0922E21B3DD00581607 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A45FA0922E21B3DD00581607 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A45FA0912E21B3DD00581607 /* Workouts.app */,
|
||||
A45FA1F12E27171A00581607 /* Workouts Watch App.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
A45FA0902E21B3DD00581607 /* Workouts */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A45FA0A22E21B3DE00581607 /* Build configuration list for PBXNativeTarget "Workouts" */;
|
||||
buildPhases = (
|
||||
A45FA08D2E21B3DD00581607 /* Sources */,
|
||||
A45FA08E2E21B3DD00581607 /* Frameworks */,
|
||||
A45FA08F2E21B3DD00581607 /* Resources */,
|
||||
A45FA2022E27171B00581607 /* Embed Watch Content */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
A45FA1FD2E27171B00581607 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A45FA0932E21B3DD00581607 /* Workouts */,
|
||||
);
|
||||
name = Workouts;
|
||||
packageProductDependencies = (
|
||||
A45FA2722E29B12500581607 /* Yams */,
|
||||
A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */,
|
||||
A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */,
|
||||
A45FA2862E2AC66B00581607 /* SwiftUIReorderableForEach */,
|
||||
);
|
||||
productName = Workouts;
|
||||
productReference = A45FA0912E21B3DD00581607 /* Workouts.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
A45FA1F02E27171A00581607 /* Workouts Watch App */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A45FA1FF2E27171B00581607 /* Build configuration list for PBXNativeTarget "Workouts Watch App" */;
|
||||
buildPhases = (
|
||||
A45FA1ED2E27171A00581607 /* Sources */,
|
||||
A45FA1EE2E27171A00581607 /* Frameworks */,
|
||||
A45FA1EF2E27171A00581607 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A45FA1F22E27171A00581607 /* Workouts Watch App */,
|
||||
);
|
||||
name = "Workouts Watch App";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "Workouts Watch App";
|
||||
productReference = A45FA1F12E27171A00581607 /* Workouts Watch App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
A45FA0892E21B3DC00581607 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1620;
|
||||
LastUpgradeCheck = 1620;
|
||||
TargetAttributes = {
|
||||
A45FA0902E21B3DD00581607 = {
|
||||
CreatedOnToolsVersion = 16.2;
|
||||
};
|
||||
A45FA1F02E27171A00581607 = {
|
||||
CreatedOnToolsVersion = 16.2;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = A45FA08C2E21B3DC00581607 /* Build configuration list for PBXProject "Workouts" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = A45FA0882E21B3DC00581607;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */,
|
||||
A45FA2852E2AC66B00581607 /* XCLocalSwiftPackageReference "../swiftui-reorderable-foreach" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = A45FA0922E21B3DD00581607 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
A45FA0902E21B3DD00581607 /* Workouts */,
|
||||
A45FA1F02E27171A00581607 /* Workouts Watch App */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
A45FA08F2E21B3DD00581607 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A45FA1EF2E27171A00581607 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
A45FA08D2E21B3DD00581607 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A45FA1ED2E27171A00581607 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
A45FA1FD2E27171B00581607 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = A45FA1F02E27171A00581607 /* Workouts Watch App */;
|
||||
targetProxy = A45FA1FC2E27171B00581607 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
A45FA0A32E21B3DE00581607 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Workouts/Workouts.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Workouts/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = C32Z8JNLG6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Workouts/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.rzen.indie.Workouts;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A45FA0A42E21B3DE00581607 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Workouts/Workouts.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Workouts/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = C32Z8JNLG6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Workouts/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.rzen.indie.Workouts;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A45FA0A52E21B3DE00581607 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
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;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
EXCLUDED_SOURCE_FILE_NAMES = "_ATTIC_/*";
|
||||
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;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A45FA0A62E21B3DE00581607 /* 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;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
EXCLUDED_SOURCE_FILE_NAMES = "_ATTIC_/*";
|
||||
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;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A45FA2002E27171B00581607 /* 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\"";
|
||||
DEVELOPMENT_TEAM = C32Z8JNLG6;
|
||||
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 = 11.2;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A45FA2012E27171B00581607 /* 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\"";
|
||||
DEVELOPMENT_TEAM = C32Z8JNLG6;
|
||||
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 = 11.2;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
A45FA08C2E21B3DC00581607 /* Build configuration list for PBXProject "Workouts" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A45FA0A52E21B3DE00581607 /* Debug */,
|
||||
A45FA0A62E21B3DE00581607 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
A45FA0A22E21B3DE00581607 /* Build configuration list for PBXNativeTarget "Workouts" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A45FA0A32E21B3DE00581607 /* Debug */,
|
||||
A45FA0A42E21B3DE00581607 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
A45FA1FF2E27171B00581607 /* Build configuration list for PBXNativeTarget "Workouts Watch App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A45FA2002E27171B00581607 /* Debug */,
|
||||
A45FA2012E27171B00581607 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
A45FA2852E2AC66B00581607 /* XCLocalSwiftPackageReference "../swiftui-reorderable-foreach" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = "../swiftui-reorderable-foreach";
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/jpsim/Yams";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 6.0.2;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
A45FA2722E29B12500581607 /* Yams */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */;
|
||||
productName = Yams;
|
||||
};
|
||||
A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = SwiftUIReorderableForEach;
|
||||
};
|
||||
A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = SwiftUIReorderableForEach;
|
||||
};
|
||||
A45FA2862E2AC66B00581607 /* SwiftUIReorderableForEach */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = SwiftUIReorderableForEach;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = A45FA0892E21B3DC00581607 /* Project object */;
|
||||
}
|
7
Workouts.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
Workouts.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
@ -0,0 +1,15 @@
|
||||
{
|
||||
"originHash" : "81702b8f7fa6fc73458821cd4dfa7dde6684a9d1d4b6d63ecde0b6b832d8d75e",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "yams",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/jpsim/Yams",
|
||||
"state" : {
|
||||
"revision" : "f4d4d6827d36092d151ad7f6fef1991c1b7192f6",
|
||||
"version" : "6.0.2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Bucket
|
||||
uuid = "EEDBFE9D-0066-4E41-BFBC-8B95DBCF47E3"
|
||||
type = "1"
|
||||
version = "2.0">
|
||||
</Bucket>
|
@ -0,0 +1,24 @@
|
||||
<?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>1</integer>
|
||||
</dict>
|
||||
<key>Workouts.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
<key>Worksouts Watch App.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
11
Workouts/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
11
Workouts/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
38
Workouts/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
38
Workouts/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "DumbBellIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "DumbBellIcon 1.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "DumbBellIcon 2.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 1.png
Normal file
BIN
Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 90 KiB |
BIN
Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 2.png
Normal file
BIN
Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 90 KiB |
BIN
Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon.png
Normal file
BIN
Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 90 KiB |
6
Workouts/Assets.xcassets/Contents.json
Normal file
6
Workouts/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
57
Workouts/ContentView.swift
Normal file
57
Workouts/ContentView.swift
Normal file
@ -0,0 +1,57 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/11/25 at 5:04 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
SplitsView()
|
||||
.tabItem {
|
||||
Label("Workouts", systemImage: "figure.strengthtraining.traditional")
|
||||
}
|
||||
|
||||
WorkoutListView()
|
||||
.tabItem {
|
||||
Label("Logs", systemImage: "list.bullet.clipboard.fill")
|
||||
}
|
||||
|
||||
|
||||
NavigationStack {
|
||||
Text("Reports Placeholder")
|
||||
.navigationTitle("Reports")
|
||||
}
|
||||
.tabItem {
|
||||
Label("Reports", systemImage: "chart.bar")
|
||||
}
|
||||
|
||||
NavigationStack {
|
||||
Text("Achivements")
|
||||
.navigationTitle("Achievements")
|
||||
}
|
||||
.tabItem {
|
||||
Label("Achivements", systemImage: "star.fill")
|
||||
}
|
||||
|
||||
// SettingsView()
|
||||
// .tabItem {
|
||||
// Label("Settings", systemImage: "gear")
|
||||
// }
|
||||
}
|
||||
.observeCloudKitChanges()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
10
Workouts/Info.plist
Normal file
10
Workouts/Info.plist
Normal file
@ -0,0 +1,10 @@
|
||||
<?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>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
26
Workouts/Models/Exercise.swift
Normal file
26
Workouts/Models/Exercise.swift
Normal file
@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class Exercise {
|
||||
var name: String = ""
|
||||
var order: Int = 0
|
||||
var sets: Int = 0
|
||||
var reps: Int = 0
|
||||
var weight: Int = 0
|
||||
var weightLastUpdated: Date = Date()
|
||||
var weightReminderTimeIntervalWeeks: Int = 2
|
||||
|
||||
@Relationship(deleteRule: .nullify)
|
||||
var split: Split?
|
||||
|
||||
init(split: Split, exerciseName: String, order: Int, sets: Int, reps: Int, weight: Int, weightReminderTimeIntervalWeeks: Int = 2) {
|
||||
self.split = split
|
||||
self.name = exerciseName
|
||||
self.order = order
|
||||
self.sets = sets
|
||||
self.reps = reps
|
||||
self.weight = weight
|
||||
self.weightReminderTimeIntervalWeeks = weightReminderTimeIntervalWeeks
|
||||
}
|
||||
}
|
98
Workouts/Models/Split.swift
Normal file
98
Workouts/Models/Split.swift
Normal file
@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@Model
|
||||
final class Split {
|
||||
var name: String = ""
|
||||
var color: String = "indigo"
|
||||
var systemImage: String = "dumbbell.fill"
|
||||
var order: Int = 0
|
||||
|
||||
// Returns the SwiftUI Color for the stored color name
|
||||
func getColor () -> Color {
|
||||
return Color.color(from: self.color)
|
||||
}
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \Exercise.split)
|
||||
var exercises: [Exercise]? = []
|
||||
|
||||
@Relationship(deleteRule: .nullify, inverse: \Workout.split)
|
||||
var workouts: [Workout]? = []
|
||||
|
||||
init(name: String, color: String = "indigo", systemImage: String = "dumbbell.fill", order: Int = 0) {
|
||||
self.name = name
|
||||
self.color = color
|
||||
self.systemImage = systemImage
|
||||
self.order = order
|
||||
}
|
||||
|
||||
static let unnamed = "Unnamed Split"
|
||||
}
|
||||
|
||||
// MARK: - Identifiable Conformance
|
||||
|
||||
extension Split: Identifiable {
|
||||
public var id: String {
|
||||
// Use the name as a unique identifier for the split
|
||||
// This is sufficient for UI purposes
|
||||
return self.name
|
||||
}
|
||||
}
|
||||
|
||||
//// MARK: - Private Form View
|
||||
//
|
||||
//fileprivate struct SplitFormView: View {
|
||||
// @Binding var model: Split
|
||||
//
|
||||
// // Available colors for splits
|
||||
// private let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
|
||||
//
|
||||
// // Available system images for splits
|
||||
// private let availableIcons = ["dumbbell.fill", "figure.strengthtraining.traditional", "figure.run", "figure.hiking", "figure.cooldown", "figure.boxing", "figure.wrestling", "figure.gymnastics", "figure.handball", "figure.core.training", "heart.fill", "bolt.fill"]
|
||||
//
|
||||
// var body: some View {
|
||||
// Section(header: Text("Name")) {
|
||||
// TextField("Name", text: $model.name)
|
||||
// .bold()
|
||||
// }
|
||||
//
|
||||
// Section(header: Text("Appearance")) {
|
||||
// Picker("Color", selection: $model.color) {
|
||||
// ForEach(availableColors, id: \.self) { colorName in
|
||||
// let tempSplit = Split(name: "", color: colorName)
|
||||
// HStack {
|
||||
// Circle()
|
||||
// .fill(tempSplit.getColor())
|
||||
// .frame(width: 20, height: 20)
|
||||
// Text(colorName.capitalized)
|
||||
// }
|
||||
// .tag(colorName)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Picker("Icon", selection: $model.systemImage) {
|
||||
// ForEach(availableIcons, id: \.self) { iconName in
|
||||
// HStack {
|
||||
// Image(systemName: iconName)
|
||||
// .frame(width: 24, height: 24)
|
||||
// Text(iconName.replacingOccurrences(of: ".fill", with: "").replacingOccurrences(of: "figure.", with: "").capitalized)
|
||||
// }
|
||||
// .tag(iconName)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Section(header: Text("Exercises")) {
|
||||
// NavigationLink {
|
||||
// ExerciseListView(split: model)
|
||||
// } label: {
|
||||
// ListItem(
|
||||
// text: "Exercises",
|
||||
// count: model.exercises?.count ?? 0
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
51
Workouts/Models/Workout.swift
Normal file
51
Workouts/Models/Workout.swift
Normal file
@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class Workout {
|
||||
var start: Date = Date()
|
||||
var end: Date?
|
||||
var status: Int = 1
|
||||
// var status: WorkoutStatus = WorkoutStatus.notStarted
|
||||
|
||||
//case notStarted = 1
|
||||
//case inProgress = 2
|
||||
//case completed = 3
|
||||
//case skipped = 4
|
||||
|
||||
|
||||
@Relationship(deleteRule: .nullify)
|
||||
var split: Split?
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout)
|
||||
var logs: [WorkoutLog]? = []
|
||||
|
||||
init(start: Date, end: Date, split: Split?) {
|
||||
self.start = start
|
||||
self.end = end
|
||||
self.split = split
|
||||
}
|
||||
|
||||
var label: String {
|
||||
if status == 3, let endDate = end {
|
||||
// if status == .completed, let endDate = end {
|
||||
return "\(start.formattedDate())—\(endDate.formattedDate())"
|
||||
} else {
|
||||
return start.formattedDate()
|
||||
}
|
||||
}
|
||||
|
||||
var statusName: String {
|
||||
if status == 1 {
|
||||
return "Not Started"
|
||||
} else if status == 2 {
|
||||
return "In Progress"
|
||||
} else if status == 3 {
|
||||
return "Completed"
|
||||
} else if status == 4 {
|
||||
return "Skipped"
|
||||
} else {
|
||||
return "In progress"
|
||||
}
|
||||
}
|
||||
}
|
33
Workouts/Models/WorkoutLog.swift
Normal file
33
Workouts/Models/WorkoutLog.swift
Normal file
@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class WorkoutLog {
|
||||
var date: Date = Date()
|
||||
var sets: Int = 0
|
||||
var reps: Int = 0
|
||||
var weight: Int = 0
|
||||
var status: WorkoutStatus? = WorkoutStatus.notStarted
|
||||
var order: Int = 0
|
||||
var exerciseName: String = ""
|
||||
|
||||
var currentStateIndex: Int? = nil
|
||||
var elapsedSeconds: Int? = nil
|
||||
|
||||
var completed: Bool = false
|
||||
|
||||
@Relationship(deleteRule: .nullify)
|
||||
var workout: Workout?
|
||||
|
||||
init(workout: Workout, exerciseName: String, date: Date, order: Int = 0, sets: Int, reps: Int, weight: Int, status: WorkoutStatus = .notStarted, completed: Bool = false) {
|
||||
self.date = date
|
||||
self.order = order
|
||||
self.sets = sets
|
||||
self.reps = reps
|
||||
self.weight = weight
|
||||
self.status = status
|
||||
self.workout = workout
|
||||
self.exerciseName = exerciseName
|
||||
self.completed = completed
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
42
Workouts/Resources/_attic_/exercise-types.json
Normal file
42
Workouts/Resources/_attic_/exercise-types.json
Normal file
@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"name": "Bodyweight",
|
||||
"descr": "Exercises that use your own body as resistance, requiring no equipment."
|
||||
},
|
||||
{
|
||||
"name": "Free Weight",
|
||||
"descr": "Exercises using handheld weights such as dumbbells, barbells, or kettlebells."
|
||||
},
|
||||
{
|
||||
"name": "Resistance Band",
|
||||
"descr": "Exercises performed with elastic bands that provide variable resistance."
|
||||
},
|
||||
{
|
||||
"name": "Machine-Based",
|
||||
"descr": "Exercises performed on gym equipment with guided movement paths."
|
||||
},
|
||||
{
|
||||
"name": "Suspension",
|
||||
"descr": "Exercises using bodyweight and adjustable straps anchored to a point."
|
||||
},
|
||||
{
|
||||
"name": "Calisthenics",
|
||||
"descr": "Advanced bodyweight movements focusing on control, balance, and strength."
|
||||
},
|
||||
{
|
||||
"name": "Stretching - Static",
|
||||
"descr": "Stretching muscles by holding a position for a prolonged time."
|
||||
},
|
||||
{
|
||||
"name": "Stretching - Dynamic",
|
||||
"descr": "Active movements that stretch muscles and joints through full range of motion."
|
||||
},
|
||||
{
|
||||
"name": "Plyometric",
|
||||
"descr": "Explosive movements designed to build power and speed."
|
||||
},
|
||||
{
|
||||
"name": "Isometric",
|
||||
"descr": "Exercises that involve holding a position under tension without movement."
|
||||
}
|
||||
]
|
151
Workouts/Resources/_attic_/exercises.json
Normal file
151
Workouts/Resources/_attic_/exercises.json
Normal file
@ -0,0 +1,151 @@
|
||||
[
|
||||
{
|
||||
"name": "Lat Pull Down",
|
||||
"setup": "Seat: 3, Thigh Pad: 4",
|
||||
"descr": "Sit upright with your knees secured under the pad. Grip the bar wider than shoulder-width. Pull the bar down to your chest, squeezing your shoulder blades together. Avoid leaning back excessively or using momentum.",
|
||||
"sets": 3,
|
||||
"reps": 10,
|
||||
"weight": 60,
|
||||
"type": "Machine-Based",
|
||||
"muscles": ["Latissimus Dorsi", "Trapezius", "Rhomboids", "Biceps Brachii"]
|
||||
},
|
||||
{
|
||||
"name": "Seated Row",
|
||||
"setup": "Seat: 2, Chest Pad: 3",
|
||||
"descr": "With your chest firmly against the pad, grip the handles and pull straight back while keeping your elbows close to your body. Focus on retracting your shoulder blades and avoid rounding your back.",
|
||||
"sets": 3,
|
||||
"reps": 10,
|
||||
"weight": 60,
|
||||
"type": "Machine-Based",
|
||||
"muscles": ["Latissimus Dorsi", "Rhomboids", "Trapezius", "Biceps Brachii"]
|
||||
},
|
||||
{
|
||||
"name": "Shoulder Press",
|
||||
"setup": "Seat: 4",
|
||||
"descr": "Sit with your back against the pad, grip the handles just outside shoulder-width. Press upward without locking out your elbows. Keep your neck relaxed and avoid shrugging your shoulders.",
|
||||
"sets": 3,
|
||||
"reps": 10,
|
||||
"weight": 30,
|
||||
"type": "Machine-Based",
|
||||
"muscles": ["Deltoid (Anterior)", "Deltoid (Lateral)", "Triceps Brachii"]
|
||||
},
|
||||
{
|
||||
"name": "Chest Press",
|
||||
"setup": "Seat: 3",
|
||||
"descr": "Adjust the seat so the handles are at mid-chest height. Push forward until arms are nearly extended, then return slowly. Keep wrists straight and don’t let your elbows drop too low.",
|
||||
"sets": 3,
|
||||
"reps": 10,
|
||||
"weight": 40,
|
||||
"type": "Machine-Based",
|
||||
"muscles": ["Pectoralis Major", "Triceps Brachii", "Deltoid (Anterior)"]
|
||||
},
|
||||
{
|
||||
"name": "Tricep Press",
|
||||
"setup": "Seat: 2",
|
||||
"descr": "With elbows close to your sides, press the handles downward in a controlled motion. Avoid flaring your elbows or using your shoulders to assist the motion.",
|
||||
"sets": 3,
|
||||
"reps": 10,
|
||||
"weight": 60,
|
||||
"type": "Machine-Based",
|
||||
"muscles": ["Triceps Brachii"]
|
||||
},
|
||||
{
|
||||
"name": "Arm Curl",
|
||||
"setup": "Seat: 3",
|
||||
"descr": "Position your arms over the pad and grip the handles. Curl the weight upward while keeping your upper arms stationary. Avoid using momentum and fully control the lowering phase.",
|
||||
"sets": 3,
|
||||
"reps": 10,
|
||||
"weight": 30,
|
||||
"type": "Machine-Based",
|
||||
"muscles": ["Biceps Brachii", "Brachialis"]
|
||||
},
|
||||
{
|
||||
"name": "Abdominal",
|
||||
"setup": "Seat: 2, Back Pad: 3",
|
||||
"descr": "Sit with the pads resting against your chest. Contract your abs to curl forward, keeping your lower back in contact with the pad. Avoid pulling with your arms or hips.",
|
||||
"sets": 3,
|
||||
"reps": 10,
|
||||
"weight": 60,
|
||||
"type": "Machine-Based",
|
||||
"muscles": ["Rectus Abdominis", "Internal Obliques"]
|
||||
},
|
||||
{
|
||||
"name": "Rotary",
|
||||
"setup": "Seat: 3, Start Angle: Centered",
|
||||
"descr": "Rotate your torso from side to side in a controlled motion, keeping your hips still. Focus on using your obliques to generate the twist, not momentum or the arms.",
|
||||
"sets": 3,
|
||||
"reps": 10,
|
||||
"weight": 60,
|
||||
"type": "Machine-Based",
|
||||
"muscles": ["Internal Obliques", "External Obliques"]
|
||||
},
|
||||
{
|
||||
"name": "Leg Press",
|
||||
"setup": "Seat Angle: 4, Platform: Middle",
|
||||
"descr": "Place your feet shoulder-width on the platform. Press upward through your heels without locking your knees. Keep your back flat against the pad throughout the motion.",
|
||||
"sets": 3,
|
||||
"reps": 10,
|
||||
"weight": 130,
|
||||
"type": "Machine-Based",
|
||||
"muscles": [
|
||||
"Gluteus Maximus",
|
||||
"Rectus Femoris",
|
||||
"Vastus Lateralis",
|
||||
"Vastus Medialis",
|
||||
"Vastus Intermedius",
|
||||
"Biceps Femoris",
|
||||
"Semitendinosus",
|
||||
"Semimembranosus"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Leg Extension",
|
||||
"setup": "Seat: 3, Pad: Above ankle",
|
||||
"descr": "Sit upright and align your knees with the pivot point. Extend your legs to a straightened position, then lower with control. Avoid jerky movements or lifting your hips off the seat.",
|
||||
"sets": 3,
|
||||
"reps": 10,
|
||||
"weight": 70,
|
||||
"type": "Machine-Based",
|
||||
"muscles": ["Rectus Femoris", "Vastus Lateralis", "Vastus Medialis", "Vastus Intermedius"]
|
||||
},
|
||||
{
|
||||
"name": "Leg Curl",
|
||||
"setup": "Seat: 2, Pad: Above ankle",
|
||||
"descr": "Lie face down or sit depending on the version. Curl your legs toward your glutes, focusing on hamstring engagement. Avoid arching your back or using momentum.",
|
||||
"sets": 3,
|
||||
"reps": 10,
|
||||
"weight": 60,
|
||||
"type": "Machine-Based",
|
||||
"muscles": ["Biceps Femoris", "Semitendinosus", "Semimembranosus"]
|
||||
},
|
||||
{
|
||||
"name": "Adductor",
|
||||
"setup": "Seat: 3, Start Position: Wide",
|
||||
"descr": "Sit with legs placed outside the pads. Bring your legs together using inner thigh muscles. Control the motion both in and out, avoiding fast swings.",
|
||||
"sets": 3,
|
||||
"reps": 10,
|
||||
"weight": 110,
|
||||
"type": "Machine-Based",
|
||||
"muscles": ["Adductor Longus", "Adductor Brevis", "Adductor Magnus", "Gracilis"]
|
||||
},
|
||||
{
|
||||
"name": "Abductor",
|
||||
"setup": "Seat: 3, Start Position: Narrow",
|
||||
"descr": "Sit with legs inside the pads and push outward to engage outer thighs and glutes. Avoid leaning forward and keep the motion controlled throughout.",
|
||||
"sets": 3,
|
||||
"reps": 10,
|
||||
"weight": 110,
|
||||
"type": "Machine-Based",
|
||||
"muscles": ["Gluteus Medius", "Tensor Fasciae Latae"]
|
||||
},
|
||||
{
|
||||
"name": "Calfs",
|
||||
"setup": "Seat: 3, Toe Bar: Midfoot",
|
||||
"descr": "Place the balls of your feet on the platform with heels hanging off. Raise your heels by contracting your calves, then slowly lower them below the platform level for a full stretch.",
|
||||
"sets": 3,
|
||||
"reps": 10,
|
||||
"weight": 60,
|
||||
"type": "Machine-Based",
|
||||
"muscles": ["Gastrocnemius", "Soleus"]
|
||||
}
|
||||
]
|
183
Workouts/Resources/_attic_/exercises.yaml
Normal file
183
Workouts/Resources/_attic_/exercises.yaml
Normal file
@ -0,0 +1,183 @@
|
||||
name: Beginner
|
||||
source: Planet Fitness
|
||||
exercises:
|
||||
- name: Lat Pull Down
|
||||
setup: 'Seat: 3, Thigh Pad: 4'
|
||||
descr: Sit upright with your knees secured under the pad. Grip the bar wider than
|
||||
shoulder-width. Pull the bar down to your chest, squeezing your shoulder blades
|
||||
together. Avoid leaning back excessively or using momentum.
|
||||
sets: 3
|
||||
reps: 10
|
||||
weight: 60
|
||||
type: Machine-Based
|
||||
muscles:
|
||||
- Latissimus Dorsi
|
||||
- Trapezius
|
||||
- Rhomboids
|
||||
- Biceps Brachii
|
||||
- name: Seated Row
|
||||
setup: 'Seat: 2, Chest Pad: 3'
|
||||
descr: With your chest firmly against the pad, grip the handles and pull straight
|
||||
back while keeping your elbows close to your body. Focus on retracting your shoulder
|
||||
blades and avoid rounding your back.
|
||||
sets: 3
|
||||
reps: 10
|
||||
weight: 60
|
||||
type: Machine-Based
|
||||
muscles:
|
||||
- Latissimus Dorsi
|
||||
- Rhomboids
|
||||
- Trapezius
|
||||
- Biceps Brachii
|
||||
- name: Shoulder Press
|
||||
setup: 'Seat: 4'
|
||||
descr: Sit with your back against the pad, grip the handles just outside shoulder-width.
|
||||
Press upward without locking out your elbows. Keep your neck relaxed and avoid
|
||||
shrugging your shoulders.
|
||||
sets: 3
|
||||
reps: 10
|
||||
weight: 30
|
||||
type: Machine-Based
|
||||
muscles:
|
||||
- Deltoid (Anterior)
|
||||
- Deltoid (Lateral)
|
||||
- Triceps Brachii
|
||||
- name: Chest Press
|
||||
setup: 'Seat: 3'
|
||||
descr: Adjust the seat so the handles are at mid-chest height. Push forward until
|
||||
arms are nearly extended, then return slowly. Keep wrists straight and don’t let
|
||||
your elbows drop too low.
|
||||
sets: 3
|
||||
reps: 10
|
||||
weight: 40
|
||||
type: Machine-Based
|
||||
muscles:
|
||||
- Pectoralis Major
|
||||
- Triceps Brachii
|
||||
- Deltoid (Anterior)
|
||||
- name: Tricep Press
|
||||
setup: 'Seat: 2'
|
||||
descr: With elbows close to your sides, press the handles downward in a controlled
|
||||
motion. Avoid flaring your elbows or using your shoulders to assist the motion.
|
||||
sets: 3
|
||||
reps: 10
|
||||
weight: 60
|
||||
type: Machine-Based
|
||||
muscles:
|
||||
- Triceps Brachii
|
||||
- name: Arm Curl
|
||||
setup: 'Seat: 3'
|
||||
descr: Position your arms over the pad and grip the handles. Curl the weight upward
|
||||
while keeping your upper arms stationary. Avoid using momentum and fully control
|
||||
the lowering phase.
|
||||
sets: 3
|
||||
reps: 10
|
||||
weight: 30
|
||||
type: Machine-Based
|
||||
muscles:
|
||||
- Biceps Brachii
|
||||
- Brachialis
|
||||
- name: Abdominal
|
||||
setup: 'Seat: 2, Back Pad: 3'
|
||||
descr: Sit with the pads resting against your chest. Contract your abs to curl forward,
|
||||
keeping your lower back in contact with the pad. Avoid pulling with your arms
|
||||
or hips.
|
||||
sets: 3
|
||||
reps: 10
|
||||
weight: 60
|
||||
type: Machine-Based
|
||||
muscles:
|
||||
- Rectus Abdominis
|
||||
- Internal Obliques
|
||||
- name: Rotary
|
||||
setup: 'Seat: 3, Start Angle: Centered'
|
||||
descr: Rotate your torso from side to side in a controlled motion, keeping your
|
||||
hips still. Focus on using your obliques to generate the twist, not momentum or
|
||||
the arms.
|
||||
sets: 3
|
||||
reps: 10
|
||||
weight: 60
|
||||
type: Machine-Based
|
||||
muscles:
|
||||
- Internal Obliques
|
||||
- External Obliques
|
||||
- name: Leg Press
|
||||
setup: 'Seat Angle: 4, Platform: Middle'
|
||||
descr: Place your feet shoulder-width on the platform. Press upward through your
|
||||
heels without locking your knees. Keep your back flat against the pad throughout
|
||||
the motion.
|
||||
sets: 3
|
||||
reps: 10
|
||||
weight: 130
|
||||
type: Machine-Based
|
||||
muscles:
|
||||
- Gluteus Maximus
|
||||
- Rectus Femoris
|
||||
- Vastus Lateralis
|
||||
- Vastus Medialis
|
||||
- Vastus Intermedius
|
||||
- Biceps Femoris
|
||||
- Semitendinosus
|
||||
- Semimembranosus
|
||||
- name: Leg Extension
|
||||
setup: 'Seat: 3, Pad: Above ankle'
|
||||
descr: Sit upright and align your knees with the pivot point. Extend your legs to
|
||||
a straightened position, then lower with control. Avoid jerky movements or lifting
|
||||
your hips off the seat.
|
||||
sets: 3
|
||||
reps: 10
|
||||
weight: 70
|
||||
type: Machine-Based
|
||||
muscles:
|
||||
- Rectus Femoris
|
||||
- Vastus Lateralis
|
||||
- Vastus Medialis
|
||||
- Vastus Intermedius
|
||||
- name: Leg Curl
|
||||
setup: 'Seat: 2, Pad: Above ankle'
|
||||
descr: Lie face down or sit depending on the version. Curl your legs toward your
|
||||
glutes, focusing on hamstring engagement. Avoid arching your back or using momentum.
|
||||
sets: 3
|
||||
reps: 10
|
||||
weight: 60
|
||||
type: Machine-Based
|
||||
muscles:
|
||||
- Biceps Femoris
|
||||
- Semitendinosus
|
||||
- Semimembranosus
|
||||
- name: Adductor
|
||||
setup: 'Seat: 3, Start Position: Wide'
|
||||
descr: Sit with legs placed outside the pads. Bring your legs together using inner
|
||||
thigh muscles. Control the motion both in and out, avoiding fast swings.
|
||||
sets: 3
|
||||
reps: 10
|
||||
weight: 110
|
||||
type: Machine-Based
|
||||
muscles:
|
||||
- Adductor Longus
|
||||
- Adductor Brevis
|
||||
- Adductor Magnus
|
||||
- Gracilis
|
||||
- name: Abductor
|
||||
setup: 'Seat: 3, Start Position: Narrow'
|
||||
descr: Sit with legs inside the pads and push outward to engage outer thighs and
|
||||
glutes. Avoid leaning forward and keep the motion controlled throughout.
|
||||
sets: 3
|
||||
reps: 10
|
||||
weight: 110
|
||||
type: Machine-Based
|
||||
muscles:
|
||||
- Gluteus Medius
|
||||
- Tensor Fasciae Latae
|
||||
- name: Calfs
|
||||
setup: 'Seat: 3, Toe Bar: Midfoot'
|
||||
descr: Place the balls of your feet on the platform with heels hanging off. Raise
|
||||
your heels by contracting your calves, then slowly lower them below the platform
|
||||
level for a full stretch.
|
||||
sets: 3
|
||||
reps: 10
|
||||
weight: 60
|
||||
type: Machine-Based
|
||||
muscles:
|
||||
- Gastrocnemius
|
||||
- Soleus
|
58
Workouts/Resources/_attic_/muscle-groups.json
Normal file
58
Workouts/Resources/_attic_/muscle-groups.json
Normal file
@ -0,0 +1,58 @@
|
||||
[
|
||||
{
|
||||
"name": "Chest",
|
||||
"descr": "Muscles at the front of the upper torso responsible for pushing movements."
|
||||
},
|
||||
{
|
||||
"name": "Back",
|
||||
"descr": "Muscles along the spine and upper back involved in pulling, posture, and lifting."
|
||||
},
|
||||
{
|
||||
"name": "Shoulders",
|
||||
"descr": "Muscles surrounding the shoulder joint, enabling arm rotation and elevation."
|
||||
},
|
||||
{
|
||||
"name": "Arms",
|
||||
"descr": "Upper limb muscles responsible for flexion, extension, and grip strength."
|
||||
},
|
||||
{
|
||||
"name": "Abdominals",
|
||||
"descr": "Core muscles on the front and sides of the torso, key for stability and trunk movement."
|
||||
},
|
||||
{
|
||||
"name": "Glutes",
|
||||
"descr": "Powerful hip muscles that drive hip extension, rotation, and posture."
|
||||
},
|
||||
{
|
||||
"name": "Quadriceps",
|
||||
"descr": "Four muscles at the front of the thigh responsible for knee extension."
|
||||
},
|
||||
{
|
||||
"name": "Hamstrings",
|
||||
"descr": "Muscles at the back of the thigh that bend the knee and extend the hip."
|
||||
},
|
||||
{
|
||||
"name": "Calves",
|
||||
"descr": "Muscles of the lower leg that control ankle movement and propulsion."
|
||||
},
|
||||
{
|
||||
"name": "Forearms",
|
||||
"descr": "Muscles between the elbow and wrist that manage wrist and finger motion."
|
||||
},
|
||||
{
|
||||
"name": "Neck",
|
||||
"descr": "Muscles supporting the head and enabling neck movement and posture."
|
||||
},
|
||||
{
|
||||
"name": "Hip Flexors",
|
||||
"descr": "Muscles at the front of the hip that lift the thigh toward the torso."
|
||||
},
|
||||
{
|
||||
"name": "Adductors",
|
||||
"descr": "Inner thigh muscles that pull the legs toward the midline of the body."
|
||||
},
|
||||
{
|
||||
"name": "Abductors",
|
||||
"descr": "Outer hip muscles that move the legs away from the body’s midline."
|
||||
}
|
||||
]
|
60
Workouts/Resources/_attic_/muscles.json
Normal file
60
Workouts/Resources/_attic_/muscles.json
Normal file
@ -0,0 +1,60 @@
|
||||
[
|
||||
{ "name": "Pectoralis Major", "muscleGroup": "Chest", "descr": "Large chest muscle located on the front of the upper torso, responsible for arm flexion and pushing." },
|
||||
{ "name": "Pectoralis Minor", "muscleGroup": "Chest", "descr": "Thin, triangular muscle beneath the pectoralis major, stabilizes the scapula." },
|
||||
|
||||
{ "name": "Latissimus Dorsi", "muscleGroup": "Back", "descr": "Broad muscle on the mid to lower back, responsible for pulling and shoulder extension." },
|
||||
{ "name": "Trapezius", "muscleGroup": "Back", "descr": "Large muscle covering the upper back and neck, involved in shoulder movement and posture." },
|
||||
{ "name": "Rhomboids", "muscleGroup": "Back", "descr": "Muscles between the shoulder blades, responsible for scapular retraction." },
|
||||
{ "name": "Erector Spinae", "muscleGroup": "Back", "descr": "Long vertical muscles along the spine that maintain posture and extend the back." },
|
||||
|
||||
{ "name": "Deltoid (Anterior)", "muscleGroup": "Shoulders", "descr": "Front portion of the shoulder muscle, raises the arm forward." },
|
||||
{ "name": "Deltoid (Lateral)", "muscleGroup": "Shoulders", "descr": "Middle portion of the shoulder muscle, raises the arm to the side." },
|
||||
{ "name": "Deltoid (Posterior)", "muscleGroup": "Shoulders", "descr": "Rear portion of the shoulder muscle, moves the arm backward." },
|
||||
{ "name": "Rotator Cuff Muscles", "muscleGroup": "Shoulders", "descr": "Group of four muscles surrounding the shoulder joint, stabilizing and rotating the arm." },
|
||||
|
||||
{ "name": "Biceps Brachii", "muscleGroup": "Arms", "descr": "Front upper arm muscle, responsible for elbow flexion and forearm rotation." },
|
||||
{ "name": "Triceps Brachii", "muscleGroup": "Arms", "descr": "Back upper arm muscle, responsible for elbow extension." },
|
||||
{ "name": "Brachialis", "muscleGroup": "Arms", "descr": "Muscle beneath the biceps, assists in elbow flexion." },
|
||||
{ "name": "Brachioradialis", "muscleGroup": "Arms", "descr": "Forearm muscle on the thumb side, aids in elbow flexion." },
|
||||
|
||||
{ "name": "Rectus Abdominis", "muscleGroup": "Abdominals", "descr": "Vertical muscle on the front of the abdomen, responsible for trunk flexion (the 'six-pack')." },
|
||||
{ "name": "Transverse Abdominis", "muscleGroup": "Abdominals", "descr": "Deepest abdominal muscle, wraps around the torso to stabilize the core." },
|
||||
{ "name": "Internal Obliques", "muscleGroup": "Abdominals", "descr": "Muscles located beneath the external obliques, involved in trunk rotation and lateral flexion." },
|
||||
{ "name": "External Obliques", "muscleGroup": "Abdominals", "descr": "Muscles on the sides of the abdomen, responsible for trunk twisting and side bending." },
|
||||
|
||||
{ "name": "Gluteus Maximus", "muscleGroup": "Glutes", "descr": "Largest glute muscle located in the buttocks, responsible for hip extension and rotation." },
|
||||
{ "name": "Gluteus Medius", "muscleGroup": "Glutes", "descr": "Muscle on the outer surface of the pelvis, important for hip abduction and stability." },
|
||||
{ "name": "Gluteus Minimus", "muscleGroup": "Glutes", "descr": "Smallest glute muscle, located beneath the medius, assists in hip abduction." },
|
||||
|
||||
{ "name": "Rectus Femoris", "muscleGroup": "Quadriceps", "descr": "Front thigh muscle, part of the quadriceps group, helps extend the knee and flex the hip." },
|
||||
{ "name": "Vastus Lateralis", "muscleGroup": "Quadriceps", "descr": "Outer thigh muscle, part of the quadriceps, involved in knee extension." },
|
||||
{ "name": "Vastus Medialis", "muscleGroup": "Quadriceps", "descr": "Inner thigh muscle, part of the quadriceps, assists in knee extension and stabilization." },
|
||||
{ "name": "Vastus Intermedius", "muscleGroup": "Quadriceps", "descr": "Deep thigh muscle beneath rectus femoris, assists in knee extension." },
|
||||
|
||||
{ "name": "Biceps Femoris", "muscleGroup": "Hamstrings", "descr": "Muscle on the back of the thigh, responsible for knee flexion and hip extension." },
|
||||
{ "name": "Semitendinosus", "muscleGroup": "Hamstrings", "descr": "Medial hamstring muscle, assists in knee flexion and internal rotation." },
|
||||
{ "name": "Semimembranosus", "muscleGroup": "Hamstrings", "descr": "Deep medial hamstring muscle, also assists in knee flexion and hip extension." },
|
||||
|
||||
{ "name": "Gastrocnemius", "muscleGroup": "Calves", "descr": "Large calf muscle visible on the back of the lower leg, responsible for plantar flexion of the foot." },
|
||||
{ "name": "Soleus", "muscleGroup": "Calves", "descr": "Flat muscle beneath the gastrocnemius, aids in plantar flexion when the knee is bent." },
|
||||
|
||||
{ "name": "Flexor Carpi Radialis", "muscleGroup": "Forearms", "descr": "Muscle on the front of the forearm, flexes and abducts the wrist." },
|
||||
{ "name": "Flexor Carpi Ulnaris", "muscleGroup": "Forearms", "descr": "Forearm muscle that flexes and adducts the wrist." },
|
||||
{ "name": "Extensor Carpi Radialis", "muscleGroup": "Forearms", "descr": "Posterior forearm muscle that extends and abducts the wrist." },
|
||||
{ "name": "Pronator Teres", "muscleGroup": "Forearms", "descr": "Muscle running across the forearm that pronates the forearm (palm down)." },
|
||||
|
||||
{ "name": "Sternocleidomastoid", "muscleGroup": "Neck", "descr": "Prominent neck muscle responsible for rotating and flexing the head." },
|
||||
{ "name": "Splenius Capitis", "muscleGroup": "Neck", "descr": "Back of neck muscle that extends and rotates the head." },
|
||||
{ "name": "Scalenes", "muscleGroup": "Neck", "descr": "Lateral neck muscles that assist in neck flexion and elevate the ribs during breathing." },
|
||||
|
||||
{ "name": "Iliopsoas", "muscleGroup": "Hip Flexors", "descr": "Deep muscle group connecting the lower spine to the femur, main hip flexor." },
|
||||
{ "name": "Rectus Femoris", "muscleGroup": "Hip Flexors", "descr": "Also part of the quadriceps, helps flex the hip and extend the knee." },
|
||||
{ "name": "Sartorius", "muscleGroup": "Hip Flexors", "descr": "Longest muscle in the body, crosses the thigh to flex and rotate the hip and knee." },
|
||||
|
||||
{ "name": "Adductor Longus", "muscleGroup": "Adductors", "descr": "Medial thigh muscle that adducts the leg and assists with hip flexion." },
|
||||
{ "name": "Adductor Brevis", "muscleGroup": "Adductors", "descr": "Short adductor muscle that helps pull the thigh inward." },
|
||||
{ "name": "Adductor Magnus", "muscleGroup": "Adductors", "descr": "Large, deep inner thigh muscle that performs hip adduction and extension." },
|
||||
{ "name": "Gracilis", "muscleGroup": "Adductors", "descr": "Thin inner thigh muscle that assists in adduction and knee flexion." },
|
||||
|
||||
{ "name": "Tensor Fasciae Latae", "muscleGroup": "Abductors", "descr": "Lateral hip muscle that abducts and medially rotates the thigh." }
|
||||
]
|
135
Workouts/Resources/_attic_/muscles.yaml
Normal file
135
Workouts/Resources/_attic_/muscles.yaml
Normal file
@ -0,0 +1,135 @@
|
||||
- name: Pectoralis Major
|
||||
muscleGroup: Chest
|
||||
descr: Large chest muscle located on the front of the upper torso, responsible for arm flexion and pushing.
|
||||
- name: Pectoralis Minor
|
||||
muscleGroup: Chest
|
||||
descr: Thin, triangular muscle beneath the pectoralis major, stabilizes the scapula.
|
||||
- name: Latissimus Dorsi
|
||||
muscleGroup: Back
|
||||
descr: Broad muscle on the mid to lower back, responsible for pulling and shoulder extension.
|
||||
- name: Trapezius
|
||||
muscleGroup: Back
|
||||
descr: Large muscle covering the upper back and neck, involved in shoulder movement and posture.
|
||||
- name: Rhomboids
|
||||
muscleGroup: Back
|
||||
descr: Muscles between the shoulder blades, responsible for scapular retraction.
|
||||
- name: Erector Spinae
|
||||
muscleGroup: Back
|
||||
descr: Long vertical muscles along the spine that maintain posture and extend the back.
|
||||
- name: Deltoid (Anterior)
|
||||
muscleGroup: Shoulders
|
||||
descr: Front portion of the shoulder muscle, raises the arm forward.
|
||||
- name: Deltoid (Lateral)
|
||||
muscleGroup: Shoulders
|
||||
descr: Middle portion of the shoulder muscle, raises the arm to the side.
|
||||
- name: Deltoid (Posterior)
|
||||
muscleGroup: Shoulders
|
||||
descr: Rear portion of the shoulder muscle, moves the arm backward.
|
||||
- name: Rotator Cuff Muscles
|
||||
muscleGroup: Shoulders
|
||||
descr: Group of four muscles surrounding the shoulder joint, stabilizing and rotating the arm.
|
||||
- name: Biceps Brachii
|
||||
muscleGroup: Arms
|
||||
descr: Front upper arm muscle, responsible for elbow flexion and forearm rotation.
|
||||
- name: Triceps Brachii
|
||||
muscleGroup: Arms
|
||||
descr: Back upper arm muscle, responsible for elbow extension.
|
||||
- name: Brachialis
|
||||
muscleGroup: Arms
|
||||
descr: Muscle beneath the biceps, assists in elbow flexion.
|
||||
- name: Brachioradialis
|
||||
muscleGroup: Arms
|
||||
descr: Forearm muscle on the thumb side, aids in elbow flexion.
|
||||
- name: Rectus Abdominis
|
||||
muscleGroup: Abdominals
|
||||
descr: Vertical muscle on the front of the abdomen, responsible for trunk flexion (the 'six-pack').
|
||||
- name: Transverse Abdominis
|
||||
muscleGroup: Abdominals
|
||||
descr: Deepest abdominal muscle, wraps around the torso to stabilize the core.
|
||||
- name: Internal Obliques
|
||||
muscleGroup: Abdominals
|
||||
descr: Muscles located beneath the external obliques, involved in trunk rotation and lateral flexion.
|
||||
- name: External Obliques
|
||||
muscleGroup: Abdominals
|
||||
descr: Muscles on the sides of the abdomen, responsible for trunk twisting and side bending.
|
||||
- name: Gluteus Maximus
|
||||
muscleGroup: Glutes
|
||||
descr: Largest glute muscle located in the buttocks, responsible for hip extension and rotation.
|
||||
- name: Gluteus Medius
|
||||
muscleGroup: Glutes
|
||||
descr: Muscle on the outer surface of the pelvis, important for hip abduction and stability.
|
||||
- name: Gluteus Minimus
|
||||
muscleGroup: Glutes
|
||||
descr: Smallest glute muscle, located beneath the medius, assists in hip abduction.
|
||||
- name: Rectus Femoris
|
||||
muscleGroup: Quadriceps
|
||||
descr: Front thigh muscle, part of the quadriceps group, helps extend the knee and flex the hip.
|
||||
- name: Vastus Lateralis
|
||||
muscleGroup: Quadriceps
|
||||
descr: Outer thigh muscle, part of the quadriceps, involved in knee extension.
|
||||
- name: Vastus Medialis
|
||||
muscleGroup: Quadriceps
|
||||
descr: Inner thigh muscle, part of the quadriceps, assists in knee extension and stabilization.
|
||||
- name: Vastus Intermedius
|
||||
muscleGroup: Quadriceps
|
||||
descr: Deep thigh muscle beneath rectus femoris, assists in knee extension.
|
||||
- name: Biceps Femoris
|
||||
muscleGroup: Hamstrings
|
||||
descr: Muscle on the back of the thigh, responsible for knee flexion and hip extension.
|
||||
- name: Semitendinosus
|
||||
muscleGroup: Hamstrings
|
||||
descr: Medial hamstring muscle, assists in knee flexion and internal rotation.
|
||||
- name: Semimembranosus
|
||||
muscleGroup: Hamstrings
|
||||
descr: Deep medial hamstring muscle, also assists in knee flexion and hip extension.
|
||||
- name: Gastrocnemius
|
||||
muscleGroup: Calves
|
||||
descr: Large calf muscle visible on the back of the lower leg, responsible for plantar flexion of the foot.
|
||||
- name: Soleus
|
||||
muscleGroup: Calves
|
||||
descr: Flat muscle beneath the gastrocnemius, aids in plantar flexion when the knee is bent.
|
||||
- name: Flexor Carpi Radialis
|
||||
muscleGroup: Forearms
|
||||
descr: Muscle on the front of the forearm, flexes and abducts the wrist.
|
||||
- name: Flexor Carpi Ulnaris
|
||||
muscleGroup: Forearms
|
||||
descr: Forearm muscle that flexes and adducts the wrist.
|
||||
- name: Extensor Carpi Radialis
|
||||
muscleGroup: Forearms
|
||||
descr: Posterior forearm muscle that extends and abducts the wrist.
|
||||
- name: Pronator Teres
|
||||
muscleGroup: Forearms
|
||||
descr: Muscle running across the forearm that pronates the forearm (palm down).
|
||||
- name: Sternocleidomastoid
|
||||
muscleGroup: Neck
|
||||
descr: Prominent neck muscle responsible for rotating and flexing the head.
|
||||
- name: Splenius Capitis
|
||||
muscleGroup: Neck
|
||||
descr: Back of neck muscle that extends and rotates the head.
|
||||
- name: Scalenes
|
||||
muscleGroup: Neck
|
||||
descr: Lateral neck muscles that assist in neck flexion and elevate the ribs during breathing.
|
||||
- name: Iliopsoas
|
||||
muscleGroup: Hip Flexors
|
||||
descr: Deep muscle group connecting the lower spine to the femur, main hip flexor.
|
||||
- name: Rectus Femoris
|
||||
muscleGroup: Hip Flexors
|
||||
descr: Also part of the quadriceps, helps flex the hip and extend the knee.
|
||||
- name: Sartorius
|
||||
muscleGroup: Hip Flexors
|
||||
descr: Longest muscle in the body, crosses the thigh to flex and rotate the hip and knee.
|
||||
- name: Adductor Longus
|
||||
muscleGroup: Adductors
|
||||
descr: Medial thigh muscle that adducts the leg and assists with hip flexion.
|
||||
- name: Adductor Brevis
|
||||
muscleGroup: Adductors
|
||||
descr: Short adductor muscle that helps pull the thigh inward.
|
||||
- name: Adductor Magnus
|
||||
muscleGroup: Adductors
|
||||
descr: Large, deep inner thigh muscle that performs hip adduction and extension.
|
||||
- name: Gracilis
|
||||
muscleGroup: Adductors
|
||||
descr: Thin inner thigh muscle that assists in adduction and knee flexion.
|
||||
- name: Tensor Fasciae Latae
|
||||
muscleGroup: Abductors
|
||||
descr: Lateral hip muscle that abducts and medially rotates the thigh.
|
50
Workouts/Resources/_attic_/splits.json
Normal file
50
Workouts/Resources/_attic_/splits.json
Normal file
@ -0,0 +1,50 @@
|
||||
[
|
||||
{
|
||||
"name": "Upper Body",
|
||||
"intro": "Focuses on muscles of the chest, back, shoulders, arms, and core. Ideal for developing strength and symmetry above the waist.",
|
||||
"splitExerciseAssignments": [
|
||||
{ "exercise": "Lat Pull Down", "weight": 70, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Seated Row", "weight": 80, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Shoulder Press", "weight": 40, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Chest Press", "weight": 50, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Tricep Press", "weight": 40, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Arm Curl", "weight": 35, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Abdominal", "weight": 50, "sets": 3, "reps": 15 },
|
||||
{ "exercise": "Rotary", "weight": 40, "sets": 3, "reps": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Lower Body",
|
||||
"intro": "Targets the legs, glutes, calves, and lower core. Essential for building power, stability, and balanced physique.",
|
||||
"splitExerciseAssignments": [
|
||||
{ "exercise": "Leg Press", "weight": 120, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Leg Extension", "weight": 60, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Leg Curl", "weight": 55, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Adductor", "weight": 50, "sets": 3, "reps": 12 },
|
||||
{ "exercise": "Abductor", "weight": 50, "sets": 3, "reps": 12 },
|
||||
{ "exercise": "Calfs", "weight": 70, "sets": 3, "reps": 15 },
|
||||
{ "exercise": "Abdominal", "weight": 50, "sets": 3, "reps": 15 },
|
||||
{ "exercise": "Rotary", "weight": 40, "sets": 3, "reps": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Full Body",
|
||||
"intro": "Combines upper and lower body exercises to target all major muscle groups in a single session. Ideal for general fitness and efficient training.",
|
||||
"splitExerciseAssignments": [
|
||||
{ "exercise": "Lat Pull Down", "weight": 70, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Seated Row", "weight": 80, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Shoulder Press", "weight": 40, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Chest Press", "weight": 50, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Tricep Press", "weight": 40, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Arm Curl", "weight": 35, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Lat Pull Down", "weight": 70, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Shoulder Press", "weight": 40, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Chest Press", "weight": 50, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Leg Press", "weight": 120, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Leg Curl", "weight": 55, "sets": 3, "reps": 10 },
|
||||
{ "exercise": "Calfs", "weight": 70, "sets": 3, "reps": 15 },
|
||||
{ "exercise": "Abdominal", "weight": 50, "sets": 3, "reps": 15 },
|
||||
{ "exercise": "Rotary", "weight": 40, "sets": 3, "reps": 12 }
|
||||
]
|
||||
}
|
||||
]
|
83
Workouts/Resources/pf-starter.exercises.yaml
Normal file
83
Workouts/Resources/pf-starter.exercises.yaml
Normal file
@ -0,0 +1,83 @@
|
||||
name: Starter Set
|
||||
source: Planet Fitness
|
||||
exercises:
|
||||
- name: Lat Pull Down
|
||||
descr: Sit upright with your knees secured under the pad. Grip the bar wider than
|
||||
shoulder-width. Pull the bar down to your chest, squeezing your shoulder blades
|
||||
together. Avoid leaning back excessively or using momentum.
|
||||
type: Machine-Based
|
||||
split: Upper Body
|
||||
- name: Seated Row
|
||||
descr: With your chest firmly against the pad, grip the handles and pull straight
|
||||
back while keeping your elbows close to your body. Focus on retracting your shoulder
|
||||
blades and avoid rounding your back.
|
||||
type: Machine-Based
|
||||
split: Upper Body
|
||||
- name: Shoulder Press
|
||||
descr: Sit with your back against the pad, grip the handles just outside shoulder-width.
|
||||
Press upward without locking out your elbows. Keep your neck relaxed and avoid
|
||||
shrugging your shoulders.
|
||||
type: Machine-Based
|
||||
split: Upper Body
|
||||
- name: Chest Press
|
||||
descr: Adjust the seat so the handles are at mid-chest height. Push forward until arms
|
||||
are nearly extended, then return slowly. Keep wrists straight and dont let your elbows
|
||||
drop too low.
|
||||
type: Machine-Based
|
||||
split: Upper Body
|
||||
- name: Tricep Press
|
||||
descr: With elbows close to your sides, press the handles downward in a controlled
|
||||
motion. Avoid flaring your elbows or using your shoulders to assist the motion.
|
||||
type: Machine-Based
|
||||
split: Upper Body
|
||||
- name: Arm Curl
|
||||
descr: Position your arms over the pad and grip the handles. Curl the weight upward
|
||||
while keeping your upper arms stationary. Avoid using momentum and fully control
|
||||
the lowering phase.
|
||||
type: Machine-Based
|
||||
split: Upper Body
|
||||
- name: Abdominal
|
||||
descr: Sit with the pads resting against your chest. Contract your abs to curl forward,
|
||||
keeping your lower back in contact with the pad. Avoid pulling with your arms
|
||||
or hips.
|
||||
type: Machine-Based
|
||||
split: Core
|
||||
- name: Rotary
|
||||
descr: Rotate your torso from side to side in a controlled motion, keeping your
|
||||
hips still. Focus on using your obliques to generate the twist, not momentum or
|
||||
the arms.
|
||||
type: Machine-Based
|
||||
split: Core
|
||||
- name: Leg Press
|
||||
descr: Place your feet shoulder-width on the platform. Press upward through your
|
||||
heels without locking your knees. Keep your back flat against the pad throughout
|
||||
the motion.
|
||||
type: Machine-Based
|
||||
split: Lower Body
|
||||
- name: Leg Extension
|
||||
descr: Sit upright and align your knees with the pivot point. Extend your legs to
|
||||
a straightened position, then lower with control. Avoid jerky movements or lifting
|
||||
your hips off the seat.
|
||||
type: Machine-Based
|
||||
split: Lower Body
|
||||
- name: Leg Curl
|
||||
descr: Lie face down or sit depending on the version. Curl your legs toward your
|
||||
glutes, focusing on hamstring engagement. Avoid arching your back or using momentum.
|
||||
type: Machine-Based
|
||||
split: Lower Body
|
||||
- name: Adductor
|
||||
descr: Sit with legs placed outside the pads. Bring your legs together using inner
|
||||
thigh muscles. Control the motion both in and out, avoiding fast swings.
|
||||
type: Machine-Based
|
||||
split: Lower Body
|
||||
- name: Abductor
|
||||
descr: Sit with legs inside the pads and push outward to engage outer thighs and
|
||||
glutes. Avoid leaning forward and keep the motion controlled throughout.
|
||||
type: Machine-Based
|
||||
split: Lower Body
|
||||
- name: Calfs
|
||||
descr: Place the balls of your feet on the platform with heels hanging off. Raise
|
||||
your heels by contracting your calves, then slowly lower them below the platform
|
||||
level for a full stretch.
|
||||
type: Machine-Based
|
||||
split: Lower Body
|
30
Workouts/Schema/AppContainer.swift
Normal file
30
Workouts/Schema/AppContainer.swift
Normal file
@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
final class AppContainer {
|
||||
static let logger = AppLogger(
|
||||
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Workouts",
|
||||
category: "AppContainer"
|
||||
)
|
||||
|
||||
static func create() -> ModelContainer {
|
||||
// Using the current models directly without migration plan to avoid reference errors
|
||||
let schema = Schema(SchemaVersion.models)
|
||||
let configuration = ModelConfiguration(cloudKitDatabase: .automatic)
|
||||
let container = try! ModelContainer(for: schema, configurations: configuration)
|
||||
return container
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static var preview: ModelContainer {
|
||||
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
|
||||
do {
|
||||
let schema = Schema(SchemaVersion.models)
|
||||
let container = try ModelContainer(for: schema, configurations: configuration)
|
||||
return container
|
||||
} catch {
|
||||
fatalError("Failed to create preview ModelContainer: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
38
Workouts/Schema/CloudKitSyncObserver.swift
Normal file
38
Workouts/Schema/CloudKitSyncObserver.swift
Normal file
@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// A view modifier that refreshes the view when CloudKit data changes
|
||||
struct CloudKitSyncObserver: ViewModifier {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@State private var refreshID = UUID()
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.id(refreshID) // Force view refresh when this changes
|
||||
.onReceive(NotificationCenter.default.publisher(for: .cloudKitDataDidChange)) { _ in
|
||||
refreshID = UUID()
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let _ = try await fetchAll(of: Exercise.self, from: modelContext)
|
||||
let _ = try await fetchAll(of: Split.self, from: modelContext)
|
||||
let _ = try await fetchAll(of: Workout.self, from: modelContext)
|
||||
let _ = try await fetchAll(of: WorkoutLog.self, from: modelContext)
|
||||
} catch {
|
||||
print("ERROR: failed to fetch \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchAll<T: PersistentModel>(of type: T.Type,from modelContext: ModelContext) async throws -> [T]? {
|
||||
try modelContext.fetch(FetchDescriptor<T>())
|
||||
}
|
||||
}
|
||||
|
||||
// Extension to make it easier to use the modifier
|
||||
extension View {
|
||||
/// Adds observation for CloudKit sync changes and refreshes the view when changes occur
|
||||
func observeCloudKitChanges() -> some View {
|
||||
self.modifier(CloudKitSyncObserver())
|
||||
}
|
||||
}
|
12
Workouts/Schema/SchemaV1.swift
Normal file
12
Workouts/Schema/SchemaV1.swift
Normal file
@ -0,0 +1,12 @@
|
||||
import SwiftData
|
||||
|
||||
enum SchemaV1: VersionedSchema {
|
||||
static var versionIdentifier: Schema.Version = .init(1, 0, 0)
|
||||
|
||||
static var models: [any PersistentModel.Type] = [
|
||||
Split.self,
|
||||
Exercise.self,
|
||||
Workout.self,
|
||||
WorkoutLog.self
|
||||
]
|
||||
}
|
19
Workouts/Schema/SchemaVersion.swift
Normal file
19
Workouts/Schema/SchemaVersion.swift
Normal file
@ -0,0 +1,19 @@
|
||||
import SwiftData
|
||||
|
||||
enum SchemaVersion: Int {
|
||||
case v1
|
||||
|
||||
static var current: SchemaVersion { .v1 }
|
||||
|
||||
static var schemas: [VersionedSchema.Type] {
|
||||
[
|
||||
SchemaV1.self
|
||||
]
|
||||
}
|
||||
|
||||
static var models: [any PersistentModel.Type] {
|
||||
switch (Self.current) {
|
||||
case .v1: SchemaV1.models
|
||||
}
|
||||
}
|
||||
}
|
19
Workouts/Schema/WorkoutsMigrationPlan.swift
Normal file
19
Workouts/Schema/WorkoutsMigrationPlan.swift
Normal file
@ -0,0 +1,19 @@
|
||||
import SwiftData
|
||||
import Foundation
|
||||
|
||||
struct WorkoutsMigrationPlan: SchemaMigrationPlan {
|
||||
static var schemas: [VersionedSchema.Type] = SchemaVersion.schemas
|
||||
|
||||
static var stages: [MigrationStage] = [
|
||||
MigrationStage.custom(
|
||||
fromVersion: SchemaV1.self,
|
||||
toVersion: SchemaV1.self,
|
||||
willMigrate: { context in
|
||||
print("migrating from v1 to v1")
|
||||
},
|
||||
didMigrate: { _ in
|
||||
// No additional actions needed after migration
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
58
Workouts/Utils/AppLogger.swift
Normal file
58
Workouts/Utils/AppLogger.swift
Normal file
@ -0,0 +1,58 @@
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
struct AppLogger {
|
||||
private let logger: Logger
|
||||
private let subsystem: String
|
||||
private let category: String
|
||||
|
||||
init(subsystem: String, category: String) {
|
||||
self.subsystem = subsystem
|
||||
self.category = category
|
||||
self.logger = Logger(subsystem: subsystem, category: category)
|
||||
}
|
||||
|
||||
func timestamp () -> String {
|
||||
Date.now.formatDateET(format: "yyyy-MM-dd HH:mm:ss")
|
||||
}
|
||||
|
||||
func formattedMessage (_ message: String) -> String {
|
||||
"\(timestamp()) [\(subsystem):\(category)] \(message)"
|
||||
}
|
||||
|
||||
func debug(_ message: String) {
|
||||
logger.debug("\(formattedMessage(message))")
|
||||
}
|
||||
|
||||
func info(_ message: String) {
|
||||
logger.info("\(formattedMessage(message))")
|
||||
}
|
||||
|
||||
func warning(_ message: String) {
|
||||
logger.warning("\(formattedMessage(message))")
|
||||
}
|
||||
|
||||
func error(_ message: String) {
|
||||
logger.error("\(formattedMessage(message))")
|
||||
}
|
||||
|
||||
func vdebug(_ message: String) -> any View {
|
||||
logger.debug("\(formattedMessage(message))")
|
||||
return EmptyView()
|
||||
}
|
||||
|
||||
func vinfo(_ message: String) -> any View {
|
||||
logger.info("\(formattedMessage(message))")
|
||||
return EmptyView()
|
||||
}
|
||||
|
||||
func vwarning(_ message: String) -> any View {
|
||||
logger.warning("\(formattedMessage(message))")
|
||||
return EmptyView()
|
||||
}
|
||||
|
||||
func verror(_ message: String) -> any View {
|
||||
logger.error("\(formattedMessage(message))")
|
||||
return EmptyView()
|
||||
}
|
||||
}
|
30
Workouts/Utils/Color+color.swift
Normal file
30
Workouts/Utils/Color+color.swift
Normal file
@ -0,0 +1,30 @@
|
||||
//
|
||||
// Color+color.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/17/25 at 10:41 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUICore
|
||||
|
||||
extension Color {
|
||||
static func color (from: String) -> Color {
|
||||
switch from {
|
||||
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 .black
|
||||
}
|
||||
}
|
||||
}
|
30
Workouts/Utils/Color+darker.swift
Normal file
30
Workouts/Utils/Color+darker.swift
Normal file
@ -0,0 +1,30 @@
|
||||
//
|
||||
// Color+darker.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/17/25 at 9:20 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
extension Color {
|
||||
func darker(by percentage: CGFloat) -> Color {
|
||||
let uiColor = UIColor(self)
|
||||
var hue: CGFloat = 0
|
||||
var saturation: CGFloat = 0
|
||||
var brightness: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
|
||||
if uiColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) {
|
||||
let newBrightness = max(brightness * (1 - percentage), 0)
|
||||
let darkerUIColor = UIColor(hue: hue, saturation: saturation, brightness: newBrightness, alpha: alpha)
|
||||
return Color(darkerUIColor)
|
||||
}
|
||||
|
||||
return self // Fallback if color can't be converted
|
||||
}
|
||||
}
|
||||
|
9
Workouts/Utils/Date+formatDate.swift
Normal file
9
Workouts/Utils/Date+formatDate.swift
Normal file
@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
func formatDate() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
}
|
14
Workouts/Utils/Date+formatDateET.swift
Normal file
14
Workouts/Utils/Date+formatDateET.swift
Normal file
@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
func formatDateET(format: String = "MMMM, d yyyy @ h:mm a z") -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeZone = TimeZone(identifier: "America/New_York")
|
||||
formatter.dateFormat = format
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
static var ISO8601: String {
|
||||
"yyyy-MM-dd'T'HH:mm:ssZ"
|
||||
}
|
||||
}
|
9
Workouts/Utils/Date+formatedDate.swift
Normal file
9
Workouts/Utils/Date+formatedDate.swift
Normal file
@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
func formattedDate() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
}
|
50
Workouts/Utils/Date+humanTimeInterval.swift
Normal file
50
Workouts/Utils/Date+humanTimeInterval.swift
Normal file
@ -0,0 +1,50 @@
|
||||
//
|
||||
// Date+humanTimeInterval.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/19/25 at 1:06 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
func humanTimeInterval(to referenceDate: Date = Date()) -> String {
|
||||
let seconds = Int(referenceDate.timeIntervalSince(self))
|
||||
let absSeconds = abs(seconds)
|
||||
|
||||
let minute = 60
|
||||
let hour = 3600
|
||||
let day = 86400
|
||||
let week = 604800
|
||||
let month = 2592000
|
||||
let year = 31536000
|
||||
|
||||
switch absSeconds {
|
||||
case 0..<5:
|
||||
return "just now"
|
||||
case 5..<minute:
|
||||
return "\(absSeconds) seconds"
|
||||
case minute..<hour:
|
||||
let minutes = absSeconds / minute
|
||||
return "\(minutes) minute\(minutes == 1 ? "" : "s")"
|
||||
case hour..<day:
|
||||
let hours = absSeconds / hour
|
||||
return "\(hours) hour\(hours == 1 ? "" : "s")"
|
||||
case day..<week:
|
||||
let days = absSeconds / day
|
||||
return "\(days) day\(days == 1 ? "" : "s")"
|
||||
case week..<month:
|
||||
let weeks = absSeconds / week
|
||||
return "\(weeks) week\(weeks == 1 ? "" : "s")"
|
||||
case month..<year:
|
||||
let months = absSeconds / month
|
||||
return "\(months) month\(months == 1 ? "" : "s")"
|
||||
default:
|
||||
let years = absSeconds / year
|
||||
return "\(years) year\(years == 1 ? "" : "s")"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
94
Workouts/Views/Common/CalendarListItem.swift
Normal file
94
Workouts/Views/Common/CalendarListItem.swift
Normal file
@ -0,0 +1,94 @@
|
||||
//
|
||||
// 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 subtitle = subtitle2 {
|
||||
Text("\(subtitle)")
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
if let count = count {
|
||||
Spacer()
|
||||
Text("\(count)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
private static let monthFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale.current
|
||||
formatter.dateFormat = "MMM"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let dayFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale.current
|
||||
formatter.dateFormat = "d"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let weekdayFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale.current
|
||||
formatter.dateFormat = "E"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
var abbreviatedMonth: String {
|
||||
Date.monthFormatter.string(from: self)
|
||||
}
|
||||
|
||||
var dayOfMonth: String {
|
||||
Date.dayFormatter.string(from: self)
|
||||
}
|
||||
|
||||
var abbreviatedWeekday: String {
|
||||
Date.weekdayFormatter.string(from: self)
|
||||
}
|
||||
}
|
47
Workouts/Views/Common/CheckboxListItem.swift
Normal file
47
Workouts/Views/Common/CheckboxListItem.swift
Normal file
@ -0,0 +1,47 @@
|
||||
//
|
||||
// ListItem.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/13/25 at 10:42 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
||||
struct CheckboxListItem: View {
|
||||
var status: CheckboxStatus
|
||||
var title: String
|
||||
var subtitle: String?
|
||||
var count: Int?
|
||||
|
||||
var body: some View {
|
||||
HStack (alignment: .top) {
|
||||
Image(systemName: status.systemName)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 30)
|
||||
.foregroundStyle(status.color)
|
||||
VStack (alignment: .leading) {
|
||||
Text("\(title)")
|
||||
.font(.headline)
|
||||
HStack (alignment: .bottom) {
|
||||
if let subtitle = subtitle {
|
||||
Text("\(subtitle)")
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let count = count {
|
||||
Spacer()
|
||||
Text("\(count)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
35
Workouts/Views/Common/CheckboxStatus.swift
Normal file
35
Workouts/Views/Common/CheckboxStatus.swift
Normal file
@ -0,0 +1,35 @@
|
||||
//
|
||||
// CheckboxStatus.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/20/25 at 11:07 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUICore
|
||||
|
||||
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: "cross.circle"
|
||||
}
|
||||
}
|
||||
}
|
57
Workouts/Views/Common/ListItem.swift
Normal file
57
Workouts/Views/Common/ListItem.swift
Normal file
@ -0,0 +1,57 @@
|
||||
//
|
||||
// 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 title: String?
|
||||
var text: String?
|
||||
var subtitle: String?
|
||||
var count: Int?
|
||||
// var badges: [Badge]? = []
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
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 badges = badges {
|
||||
// ForEach (badges, id: \.self) { badge in
|
||||
// BadgeView(badge: badge)
|
||||
// }
|
||||
// }
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
104
Workouts/Views/Exercises/ExerciseAddEditView.swift
Normal file
104
Workouts/Views/Exercises/ExerciseAddEditView.swift
Normal file
@ -0,0 +1,104 @@
|
||||
//
|
||||
// SplitExerciseAssignment.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/15/25 at 7:12 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ExerciseAddEditView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showingExercisePicker = false
|
||||
|
||||
@State var model: Exercise
|
||||
|
||||
@State var originalWeight: Int? = nil
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(header: Text("Exercise")) {
|
||||
let exerciseName = model.name
|
||||
if exerciseName.isEmpty {
|
||||
Button(action: {
|
||||
showingExercisePicker = true
|
||||
}) {
|
||||
HStack {
|
||||
Text(model.name.isEmpty ? "Select Exercise" : model.name)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ListItem(title: exerciseName)
|
||||
}
|
||||
}
|
||||
|
||||
Section (header: Text("Sets/Reps")) {
|
||||
Stepper("Sets: \(model.sets)", value: $model.sets, in: 1...10)
|
||||
Stepper("Reps: \(model.reps)", value: $model.reps, in: 1...50)
|
||||
}
|
||||
|
||||
// Weight section
|
||||
Section (header: Text("Weight")) {
|
||||
HStack {
|
||||
VStack(alignment: .center) {
|
||||
Text("\(model.weight) lbs")
|
||||
.font(.headline)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing) {
|
||||
Stepper("±1", value: $model.weight, in: 0...1000)
|
||||
Stepper("±5", value: $model.weight, in: 0...1000, step: 5)
|
||||
}
|
||||
.frame(width: 130)
|
||||
}
|
||||
}
|
||||
|
||||
Section (header: Text("Weight Increase")) {
|
||||
HStack {
|
||||
Text("Remind every \(model.weightReminderTimeIntervalWeeks) weeks")
|
||||
Spacer()
|
||||
Stepper("", value: $model.weightReminderTimeIntervalWeeks, in: 0...366)
|
||||
}
|
||||
HStack {
|
||||
Text("Last weight change \(Date().humanTimeInterval(to: model.weightLastUpdated)) ago")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
originalWeight = model.weight
|
||||
}
|
||||
.sheet(isPresented: $showingExercisePicker) {
|
||||
ExercisePickerView { exerciseNames in
|
||||
model.name = exerciseNames.first ?? "Exercise.unnamed"
|
||||
}
|
||||
}
|
||||
.navigationTitle(model.name.isEmpty ? "New Exercise" : model.name)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
if let originalWeight = originalWeight {
|
||||
if originalWeight != model.weight {
|
||||
model.weightLastUpdated = Date()
|
||||
}
|
||||
}
|
||||
try? modelContext.save()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
68
Workouts/Views/Exercises/ExerciseListLoader.swift
Normal file
68
Workouts/Views/Exercises/ExerciseListLoader.swift
Normal file
@ -0,0 +1,68 @@
|
||||
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
|
||||
}
|
||||
}
|
196
Workouts/Views/Exercises/ExerciseListView.swift
Normal file
196
Workouts/Views/Exercises/ExerciseListView.swift
Normal file
@ -0,0 +1,196 @@
|
||||
//
|
||||
// SplitExercisesListView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 8:38 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ExerciseListView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// Use a @Query to observe the Split and its exercises
|
||||
@Query private var splits: [Split]
|
||||
private 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
|
||||
|
||||
// Initialize with a Split and set up a query to observe it
|
||||
init(split: Split) {
|
||||
self.split = split
|
||||
// Create a predicate to fetch only this specific split
|
||||
let splitId = split.persistentModelID
|
||||
self._splits = Query(filter: #Predicate<Split> { s in
|
||||
s.persistentModelID == splitId
|
||||
})
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Use the first Split from our query if available, otherwise fall back to the original split
|
||||
let currentSplit = splits.first ?? split
|
||||
|
||||
NavigationStack {
|
||||
Form {
|
||||
List {
|
||||
if let assignments = currentSplit.exercises, !assignments.isEmpty {
|
||||
let sortedAssignments = assignments.sorted(by: { $0.order == $1.order ? $0.name < $1.name : $0.order < $1.order })
|
||||
|
||||
ForEach(sortedAssignments) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs \(item.order)"
|
||||
)
|
||||
.swipeActions {
|
||||
Button {
|
||||
itemToDelete = item
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
Button {
|
||||
itemToEdit = item
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
}
|
||||
}
|
||||
.onMove(perform: { indices, destination in
|
||||
var exerciseArray = Array(sortedAssignments)
|
||||
exerciseArray.move(fromOffsets: indices, toOffset: destination)
|
||||
for (index, exercise) in exerciseArray.enumerated() {
|
||||
exercise.order = index
|
||||
}
|
||||
if let modelContext = exerciseArray.first?.modelContext {
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
print("Error saving after reordering: \(error)")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Button {
|
||||
showingAddSheet = true
|
||||
} label: {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
|
||||
} else {
|
||||
Text("No exercises added yet.")
|
||||
Button(action: { showingAddSheet.toggle() }) {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("\(currentSplit.name)")
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Start This Split") {
|
||||
let split = currentSplit
|
||||
let workout = Workout(start: Date(), end: Date(), split: split)
|
||||
modelContext.insert(workout)
|
||||
if let exercises = split.exercises {
|
||||
for assignment in exercises {
|
||||
let workoutLog = WorkoutLog(
|
||||
workout: workout,
|
||||
exerciseName: assignment.name,
|
||||
date: Date(),
|
||||
order: assignment.order,
|
||||
sets: assignment.sets,
|
||||
reps: assignment.reps,
|
||||
weight: assignment.weight
|
||||
)
|
||||
modelContext.insert(workoutLog)
|
||||
}
|
||||
}
|
||||
try? modelContext.save()
|
||||
|
||||
// Set the created workout to trigger navigation
|
||||
createdWorkout = workout
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $createdWorkout, destination: { workout in
|
||||
WorkoutLogListView(workout: workout)
|
||||
})
|
||||
// .toolbar {
|
||||
// ToolbarItem(placement: .navigationBarTrailing) {
|
||||
// Button(action: { showingAddSheet.toggle() }) {
|
||||
// Image(systemName: "plus")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
.sheet (isPresented: $showingAddSheet) {
|
||||
ExercisePickerView(onExerciseSelected: { exerciseNames in
|
||||
let splitId = currentSplit.persistentModelID
|
||||
print("exerciseNames: \(exerciseNames)")
|
||||
if exerciseNames.count == 1 {
|
||||
itemToEdit = Exercise(
|
||||
split: split,
|
||||
exerciseName: exerciseNames.first ?? "Exercise.unnamed",
|
||||
order: 0,
|
||||
sets: 3,
|
||||
reps: 10,
|
||||
weight: 40
|
||||
)
|
||||
} else {
|
||||
for exerciseName in exerciseNames {
|
||||
var duplicateExercise: [Exercise]? = nil
|
||||
do {
|
||||
duplicateExercise = try modelContext.fetch(FetchDescriptor<Exercise>(predicate: #Predicate{ exercise in
|
||||
exerciseName == exercise.name && splitId == exercise.split?.persistentModelID
|
||||
}))
|
||||
} catch {
|
||||
print("ERROR: failed to fetch \(exerciseName)")
|
||||
}
|
||||
|
||||
if let dup = duplicateExercise, dup.count > 0 {
|
||||
print("Skipping duplicate \(exerciseName) found \(dup.count) duplicate(s)")
|
||||
} else {
|
||||
print("Creating \(exerciseName) for \(split.name)")
|
||||
modelContext.insert(Exercise(
|
||||
split: split,
|
||||
exerciseName: exerciseName,
|
||||
order: 0,
|
||||
sets: 3,
|
||||
reps: 10,
|
||||
weight: 40
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
try? modelContext.save()
|
||||
}, allowMultiSelect: true)
|
||||
}
|
||||
.sheet(item: $itemToEdit) { item in
|
||||
ExerciseAddEditView(model: item)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Exercise?",
|
||||
isPresented: .constant(itemToDelete != nil),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let item = itemToDelete {
|
||||
withAnimation {
|
||||
modelContext.delete(item)
|
||||
try? modelContext.save()
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
147
Workouts/Views/Exercises/ExercisePickerView.swift
Normal file
147
Workouts/Views/Exercises/ExercisePickerView.swift
Normal file
@ -0,0 +1,147 @@
|
||||
//
|
||||
// ExercisePickerView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/13/25 at 7:17 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
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 {
|
||||
// Group exercises by split
|
||||
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()
|
||||
}
|
||||
}
|
134
Workouts/Views/Exercises/ExerciseView.swift
Normal file
134
Workouts/Views/Exercises/ExerciseView.swift
Normal file
@ -0,0 +1,134 @@
|
||||
//
|
||||
// ExerciseView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 5:44 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import Charts
|
||||
|
||||
struct ExerciseView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@Bindable var workoutLog: WorkoutLog
|
||||
|
||||
@State var allLogs: [WorkoutLog]
|
||||
var currentIndex: Int = 0
|
||||
|
||||
@State private var progress: Int = 0
|
||||
@State private var navigateTo: WorkoutLog? = nil
|
||||
|
||||
let notStartedColor = Color.white
|
||||
let completedColor = Color.green
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(header: Text("Navigation")) {
|
||||
HStack {
|
||||
Button(action: navigateToPrevious) {
|
||||
HStack {
|
||||
Image(systemName: "chevron.left")
|
||||
Text("Previous")
|
||||
}
|
||||
}
|
||||
.disabled(currentIndex <= 0)
|
||||
|
||||
Spacer()
|
||||
Text("\(currentIndex)")
|
||||
Spacer()
|
||||
|
||||
Button(action: navigateToNext) {
|
||||
HStack {
|
||||
Text("Next")
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
}
|
||||
.disabled(currentIndex >= allLogs.count - 1)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
Section (header: Text("Progress")) {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: workoutLog.sets), spacing: 2) {
|
||||
ForEach (1...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)")
|
||||
.foregroundColor(.primary)
|
||||
.colorInvert()
|
||||
}
|
||||
.onTapGesture {
|
||||
if progress == index {
|
||||
progress = 0
|
||||
} else {
|
||||
progress = index
|
||||
}
|
||||
let _ = print("progress set to \(progress)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section (header: Text("Plan")) {
|
||||
Stepper("\(workoutLog.sets) sets", value: $workoutLog.sets, in: 1...10)
|
||||
.font(.title)
|
||||
Stepper("\(workoutLog.reps) reps", value: $workoutLog.reps, in: 1...25)
|
||||
.font(.title)
|
||||
HStack {
|
||||
Text("\(workoutLog.weight) lbs")
|
||||
VStack (alignment: .trailing) {
|
||||
Stepper("", value: $workoutLog.weight, in: 1...200)
|
||||
Stepper("", value: $workoutLog.weight, in: 1...200, step: 5)
|
||||
}
|
||||
}
|
||||
.font(.title)
|
||||
}
|
||||
|
||||
Section(header: Text("Progress Tracking")) {
|
||||
WeightProgressionChartView(exerciseName: workoutLog.exerciseName)
|
||||
}
|
||||
}
|
||||
.navigationTitle("\(workoutLog.exerciseName)")
|
||||
.navigationDestination(item: $navigateTo) { nextLog in
|
||||
ExerciseView(
|
||||
workoutLog: nextLog,
|
||||
allLogs: allLogs,
|
||||
currentIndex: allLogs.firstIndex(of: nextLog) ?? 0
|
||||
)
|
||||
}
|
||||
// .onAppear {
|
||||
// allLogs = modelContext.fetch(FetchDescriptor(sortBy: [
|
||||
// SortDescriptor(\WorkoutLog.order),
|
||||
// SortDescriptor(\WorkoutLog.name)
|
||||
// ]))
|
||||
// }
|
||||
}
|
||||
|
||||
private func navigateToPrevious() {
|
||||
guard currentIndex > 0 else { return }
|
||||
let previousIndex = currentIndex - 1
|
||||
navigateTo = allLogs[previousIndex]
|
||||
}
|
||||
|
||||
private func navigateToNext() {
|
||||
guard currentIndex < allLogs.count - 1 else { return }
|
||||
let nextIndex = currentIndex + 1
|
||||
navigateTo = allLogs[nextIndex]
|
||||
}
|
||||
}
|
142
Workouts/Views/Exercises/WeightProgressionChartView.swift
Normal file
142
Workouts/Views/Exercises/WeightProgressionChartView.swift
Normal file
@ -0,0 +1,142 @@
|
||||
//
|
||||
// WeightProgressionChartView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created on 7/20/25.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Charts
|
||||
import SwiftData
|
||||
|
||||
struct WeightProgressionChartView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
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) { value 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
|
||||
|
||||
// Create a fetch descriptor to get workout logs for this exercise
|
||||
let descriptor = FetchDescriptor<WorkoutLog>(
|
||||
predicate: #Predicate<WorkoutLog> { log in
|
||||
log.exerciseName == exerciseName && log.completed == true
|
||||
},
|
||||
sortBy: [SortDescriptor(\WorkoutLog.date)]
|
||||
)
|
||||
|
||||
// Fetch the data
|
||||
if let logs = try? modelContext.fetch(descriptor) {
|
||||
// Convert to data points
|
||||
weightData = logs.map { log in
|
||||
WeightDataPoint(date: log.date, weight: log.weight)
|
||||
}
|
||||
|
||||
// Generate motivational message based on progress
|
||||
generateMotivationalMessage()
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func generateMotivationalMessage() {
|
||||
guard weightData.count >= 2 else {
|
||||
motivationalMessage = "Complete more workouts to track your progress!"
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate progress metrics
|
||||
let firstWeight = weightData.first?.weight ?? 0
|
||||
let currentWeight = weightData.last?.weight ?? 0
|
||||
let weightDifference = currentWeight - firstWeight
|
||||
|
||||
// Generate appropriate message based on progress
|
||||
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
|
||||
}
|
||||
|
||||
#Preview {
|
||||
WeightProgressionChartView(exerciseName: "Bench Press")
|
||||
.modelContainer(for: [WorkoutLog.self], inMemory: true)
|
||||
}
|
24
Workouts/Views/Settings/SettingsView.swift
Normal file
24
Workouts/Views/Settings/SettingsView.swift
Normal file
@ -0,0 +1,24 @@
|
||||
//
|
||||
// SettingsView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/20/25 at 8:14 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section (header: Text("Options")) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
30
Workouts/Views/Splits/OrderableItem.swift
Normal file
30
Workouts/Views/Splits/OrderableItem.swift
Normal file
@ -0,0 +1,30 @@
|
||||
//
|
||||
// OrderableItem.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 5:19 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Protocol for items that can be ordered in a sequence
|
||||
protocol OrderableItem {
|
||||
/// Updates the order of the item to the specified index
|
||||
func updateOrder(to index: Int)
|
||||
}
|
||||
|
||||
/// Extension to make Split conform to OrderableItem
|
||||
extension Split: OrderableItem {
|
||||
func updateOrder(to index: Int) {
|
||||
self.order = index
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension to make SplitExerciseAssignment conform to OrderableItem
|
||||
extension Exercise: OrderableItem {
|
||||
func updateOrder(to index: Int) {
|
||||
self.order = index
|
||||
}
|
||||
}
|
99
Workouts/Views/Splits/SortableForEach.swift
Normal file
99
Workouts/Views/Splits/SortableForEach.swift
Normal file
@ -0,0 +1,99 @@
|
||||
//
|
||||
// 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() {
|
||||
// Only update orders if we're working with items that have an 'order' property
|
||||
for (index, item) in data.enumerated() {
|
||||
// Use key path and dynamic member lookup to set the order if available
|
||||
if let orderableItem = item as? any OrderableItem {
|
||||
orderableItem.updateOrder(to: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
83
Workouts/Views/Splits/SplitAddEditView.swift
Normal file
83
Workouts/Views/Splits/SplitAddEditView.swift
Normal file
@ -0,0 +1,83 @@
|
||||
//
|
||||
// SplitAddEditView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 9:42 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SplitAddEditView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State var model: Split
|
||||
|
||||
private let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
|
||||
|
||||
private let availableIcons = ["dumbbell.fill", "figure.strengthtraining.traditional", "figure.run", "figure.hiking", "figure.cooldown", "figure.boxing", "figure.wrestling", "figure.gymnastics", "figure.handball", "figure.core.training", "heart.fill", "bolt.fill"]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(header: Text("Name")) {
|
||||
TextField("Name", text: $model.name)
|
||||
.bold()
|
||||
}
|
||||
|
||||
Section(header: Text("Appearance")) {
|
||||
Picker("Color", selection: $model.color) {
|
||||
ForEach(availableColors, id: \.self) { colorName in
|
||||
let tempSplit = Split(name: "", color: colorName)
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(tempSplit.getColor())
|
||||
.frame(width: 20, height: 20)
|
||||
Text(colorName.capitalized)
|
||||
}
|
||||
.tag(colorName)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Icon", selection: $model.systemImage) {
|
||||
ForEach(availableIcons, id: \.self) { iconName in
|
||||
HStack {
|
||||
Image(systemName: iconName)
|
||||
.frame(width: 24, height: 24)
|
||||
Text(iconName.replacingOccurrences(of: ".fill", with: "").replacingOccurrences(of: "figure.", with: "").capitalized)
|
||||
}
|
||||
.tag(iconName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Exercises")) {
|
||||
NavigationLink {
|
||||
ExerciseListView(split: model)
|
||||
} label: {
|
||||
ListItem(
|
||||
text: "Exercises",
|
||||
count: model.exercises?.count ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
try? modelContext.save()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
198
Workouts/Views/Splits/SplitDetailView.swift
Normal file
198
Workouts/Views/Splits/SplitDetailView.swift
Normal file
@ -0,0 +1,198 @@
|
||||
//
|
||||
// SplitDetailView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/25/25 at 3:27 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct SplitDetailView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State 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
|
||||
@State private var showingDeleteConfirmation: Bool = false
|
||||
|
||||
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")) {
|
||||
List {
|
||||
if let assignments = split.exercises, !assignments.isEmpty {
|
||||
let sortedAssignments = assignments.sorted(by: { $0.order == $1.order ? $0.name < $1.name : $0.order < $1.order })
|
||||
|
||||
ForEach(sortedAssignments) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs \(item.order)"
|
||||
)
|
||||
.swipeActions {
|
||||
Button {
|
||||
itemToDelete = item
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
Button {
|
||||
itemToEdit = item
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
}
|
||||
}
|
||||
.onMove(perform: { indices, destination in
|
||||
var exerciseArray = Array(sortedAssignments)
|
||||
exerciseArray.move(fromOffsets: indices, toOffset: destination)
|
||||
for (index, exercise) in exerciseArray.enumerated() {
|
||||
exercise.order = index
|
||||
}
|
||||
if let modelContext = exerciseArray.first?.modelContext {
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
print("Error saving after reordering: \(error)")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Button {
|
||||
showingAddSheet = true
|
||||
} label: {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
|
||||
} else {
|
||||
Text("No exercises added yet.")
|
||||
Button(action: { showingAddSheet.toggle() }) {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button ("Delete This Split", role: .destructive) {
|
||||
showingDeleteConfirmation = true
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.navigationTitle("\(split.name)")
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Start This Split") {
|
||||
let workout = Workout(start: Date(), end: Date(), split: split)
|
||||
modelContext.insert(workout)
|
||||
if let exercises = split.exercises {
|
||||
for assignment in exercises {
|
||||
let workoutLog = WorkoutLog(
|
||||
workout: workout,
|
||||
exerciseName: assignment.name,
|
||||
date: Date(),
|
||||
order: assignment.order,
|
||||
sets: assignment.sets,
|
||||
reps: assignment.reps,
|
||||
weight: assignment.weight
|
||||
)
|
||||
modelContext.insert(workoutLog)
|
||||
}
|
||||
}
|
||||
try? modelContext.save()
|
||||
|
||||
// Set the created workout to trigger navigation
|
||||
createdWorkout = workout
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $createdWorkout, destination: { workout in
|
||||
WorkoutLogListView(workout: workout)
|
||||
})
|
||||
.sheet (isPresented: $showingAddSheet) {
|
||||
ExercisePickerView(onExerciseSelected: { exerciseNames in
|
||||
let splitId = split.persistentModelID
|
||||
print("exerciseNames: \(exerciseNames)")
|
||||
if exerciseNames.count == 1 {
|
||||
itemToEdit = Exercise(
|
||||
split: split,
|
||||
exerciseName: exerciseNames.first ?? "Exercise.unnamed",
|
||||
order: 0,
|
||||
sets: 3,
|
||||
reps: 10,
|
||||
weight: 40
|
||||
)
|
||||
} else {
|
||||
for exerciseName in exerciseNames {
|
||||
var duplicateExercise: [Exercise]? = nil
|
||||
do {
|
||||
duplicateExercise = try modelContext.fetch(FetchDescriptor<Exercise>(predicate: #Predicate{ exercise in
|
||||
exerciseName == exercise.name && splitId == exercise.split?.persistentModelID
|
||||
}))
|
||||
} catch {
|
||||
print("ERROR: failed to fetch \(exerciseName)")
|
||||
}
|
||||
|
||||
if let dup = duplicateExercise, dup.count > 0 {
|
||||
print("Skipping duplicate \(exerciseName) found \(dup.count) duplicate(s)")
|
||||
} else {
|
||||
print("Creating \(exerciseName) for \(split.name)")
|
||||
modelContext.insert(Exercise(
|
||||
split: split,
|
||||
exerciseName: exerciseName,
|
||||
order: 0,
|
||||
sets: 3,
|
||||
reps: 10,
|
||||
weight: 40
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
try? modelContext.save()
|
||||
}, allowMultiSelect: true)
|
||||
}
|
||||
.sheet(item: $itemToEdit) { item in
|
||||
ExerciseAddEditView(model: item)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Exercise?",
|
||||
isPresented: .constant(itemToDelete != nil),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let item = itemToDelete {
|
||||
withAnimation {
|
||||
modelContext.delete(item)
|
||||
try? modelContext.save()
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete This Split?",
|
||||
isPresented: $showingDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
modelContext.delete(split)
|
||||
try? modelContext.save()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
61
Workouts/Views/Splits/SplitItem.swift
Normal file
61
Workouts/Views/Splits/SplitItem.swift
Normal file
@ -0,0 +1,61 @@
|
||||
//
|
||||
// DraggableSplitItem.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 {
|
||||
|
||||
var name: String
|
||||
var color: Color
|
||||
var systemImageName: String
|
||||
var exerciseCount: Int
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ZStack(alignment: .bottom) {
|
||||
// Golden ratio rectangle (1:1.618)
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [color, color.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 - now using dynamic sizing
|
||||
Image(systemName: systemImageName)
|
||||
.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(name)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
Text("\(exerciseCount) exercises")
|
||||
.font(.caption)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
70
Workouts/Views/Splits/SplitsView.swift
Normal file
70
Workouts/Views/Splits/SplitsView.swift
Normal file
@ -0,0 +1,70 @@
|
||||
//
|
||||
// SplitsView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/17/25 at 6:55 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct SplitsView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State var splits: [Split] = []
|
||||
|
||||
@State private var showingAddSheet: Bool = false
|
||||
@State private var allowSorting: Bool = true
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
|
||||
SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in
|
||||
NavigationLink {
|
||||
SplitDetailView(split: split)
|
||||
} label: {
|
||||
SplitItem(
|
||||
name: split.name,
|
||||
color: Color.color(from: split.color),
|
||||
systemImageName: split.systemImage,
|
||||
exerciseCount: split.exercises?.count ?? 0
|
||||
)
|
||||
.overlay(dragging ? Color.white.opacity(0.8) : Color.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Splits")
|
||||
.onAppear(perform: loadSplits)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { showingAddSheet.toggle() }) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet (isPresented: $showingAddSheet) {
|
||||
SplitAddEditView(model: Split(name: "New Split"))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func loadSplits () {
|
||||
do {
|
||||
self.splits = try modelContext.fetch(FetchDescriptor<Split>(
|
||||
sortBy: [
|
||||
SortDescriptor(\Split.order),
|
||||
SortDescriptor(\Split.name)
|
||||
]
|
||||
))
|
||||
} catch {
|
||||
print("ERROR: failed to load splits \(error)")
|
||||
}
|
||||
}
|
||||
}
|
105
Workouts/Views/WorkoutLog/WorkoutLogEditView.swift
Normal file
105
Workouts/Views/WorkoutLog/WorkoutLogEditView.swift
Normal file
@ -0,0 +1,105 @@
|
||||
//
|
||||
// WorkoutAddEditView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/13/25 at 9:13 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct WorkoutLogEditView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State var workoutLog: WorkoutLog
|
||||
@State private var showingSaveConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section (header: Text("Exercise")) {
|
||||
Text(workoutLog.exerciseName)
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
Section(header: Text("Sets/Reps")) {
|
||||
Stepper("Sets: \(workoutLog.sets)", value: $workoutLog.sets, in: 1...10)
|
||||
Stepper("Reps: \(workoutLog.reps)", value: $workoutLog.reps, in: 1...50)
|
||||
}
|
||||
|
||||
Section(header: Text("Weight")) {
|
||||
HStack {
|
||||
VStack(alignment: .center) {
|
||||
Text("\(workoutLog.weight) lbs")
|
||||
.font(.headline)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing) {
|
||||
Stepper("±1", value: $workoutLog.weight, in: 0...1000)
|
||||
Stepper("±5", value: $workoutLog.weight, in: 0...1000, step: 5)
|
||||
}
|
||||
.frame(width: 130)
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
showingSaveConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Save Options", isPresented: $showingSaveConfirmation) {
|
||||
Button("Save Workout Log Only") {
|
||||
try? modelContext.save()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
Button("Save Workout Log and Update Split") {
|
||||
// Save the workout log
|
||||
try? modelContext.save()
|
||||
|
||||
// Update the split with this workout log's data
|
||||
// Note: Implementation depends on how splits are updated in your app
|
||||
updateSplit(from: workoutLog)
|
||||
|
||||
dismiss()
|
||||
}
|
||||
|
||||
Button("Cancel", role: .cancel) {
|
||||
// Do nothing, dialog will dismiss
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSplit(from workoutLog: WorkoutLog) {
|
||||
let split = workoutLog.workout?.split
|
||||
|
||||
// Find the matching exercise in split.exercises by name
|
||||
if let exercises = split?.exercises {
|
||||
for exercise in exercises {
|
||||
if exercise.name == workoutLog.exerciseName {
|
||||
// Update the sets, reps, and weight in the split exercise assignment
|
||||
exercise.sets = workoutLog.sets
|
||||
exercise.reps = workoutLog.reps
|
||||
exercise.weight = workoutLog.weight
|
||||
|
||||
// Save the changes to the split
|
||||
try? modelContext.save()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
230
Workouts/Views/WorkoutLog/WorkoutLogListView.swift
Normal file
230
Workouts/Views/WorkoutLog/WorkoutLogListView.swift
Normal file
@ -0,0 +1,230 @@
|
||||
//
|
||||
// WorkoutLogView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/13/25 at 6:58 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct WorkoutLogListView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@State var workout: Workout
|
||||
|
||||
@State private var showingAddSheet = false
|
||||
@State private var itemToEdit: WorkoutLog? = nil
|
||||
@State private var itemToDelete: WorkoutLog? = nil
|
||||
|
||||
var sortedWorkoutLogs: [WorkoutLog] {
|
||||
if let logs = workout.logs {
|
||||
logs.sorted(by: {
|
||||
$0.order == $1.order ? $0.exerciseName < $1.exerciseName : $0.order < $1.order
|
||||
})
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section (header: Text("\(workout.label)")) {
|
||||
List {
|
||||
ForEach (sortedWorkoutLogs) { log in
|
||||
let workoutLogStatus = log.status?.checkboxStatus ?? (log.completed ? CheckboxStatus.checked : CheckboxStatus.unchecked)
|
||||
|
||||
NavigationLink(destination: ExerciseView(workoutLog: log, allLogs: sortedWorkoutLogs)) {
|
||||
CheckboxListItem(
|
||||
status: workoutLogStatus,
|
||||
title: log.exerciseName,
|
||||
subtitle: "\(log.sets) × \(log.reps) reps × \(log.weight) lbs"
|
||||
)
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: false) {
|
||||
let status = log.status ?? WorkoutStatus.notStarted
|
||||
|
||||
if [.inProgress,.completed].contains(status) {
|
||||
Button {
|
||||
resetWorkout(log)
|
||||
} label: {
|
||||
Label("Not Started", systemImage: WorkoutStatus.notStarted.checkboxStatus.systemName)
|
||||
}
|
||||
.tint(WorkoutStatus.notStarted.checkboxStatus.color)
|
||||
}
|
||||
|
||||
if [.notStarted,.completed].contains(status) {
|
||||
Button {
|
||||
startWorkout(log)
|
||||
} label: {
|
||||
Label("In Progress", systemImage: WorkoutStatus.inProgress.checkboxStatus.systemName)
|
||||
}
|
||||
.tint(WorkoutStatus.inProgress.checkboxStatus.color)
|
||||
}
|
||||
|
||||
if [.notStarted,.inProgress].contains(status) {
|
||||
Button {
|
||||
completeWorkout(log)
|
||||
} label: {
|
||||
Label("Complete", systemImage: WorkoutStatus.completed.checkboxStatus.systemName)
|
||||
}
|
||||
.tint(WorkoutStatus.completed.checkboxStatus.color)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button {
|
||||
itemToDelete = log
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.secondary)
|
||||
Button {
|
||||
itemToEdit = log
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.onMove(perform: { indices, destination in
|
||||
var workoutLogArray = Array(sortedWorkoutLogs)
|
||||
workoutLogArray.move(fromOffsets: indices, toOffset: destination)
|
||||
for (index, log) in workoutLogArray.enumerated() {
|
||||
log.order = index
|
||||
}
|
||||
if let modelContext = workoutLogArray.first?.modelContext {
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
print("Error saving after reordering: \(error)")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("\(workout.split?.name ?? Split.unnamed) Split")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { showingAddSheet.toggle() }) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
ExercisePickerView { exerciseNames in
|
||||
let setsRepsWeight = getSetsRepsWeight(exerciseNames.first ?? "Exercise.unnamed", in: modelContext)
|
||||
let workoutLog = WorkoutLog(
|
||||
workout: workout,
|
||||
exerciseName: exerciseNames.first ?? "Exercise.unnamed",
|
||||
date: Date(),
|
||||
sets: setsRepsWeight.sets,
|
||||
reps: setsRepsWeight.reps,
|
||||
weight: setsRepsWeight.weight,
|
||||
completed: false
|
||||
)
|
||||
workout.logs?.append(workoutLog)
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
.sheet(item: $itemToEdit) { item in
|
||||
WorkoutLogEditView(workoutLog: item)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete?",
|
||||
isPresented: Binding<Bool>(
|
||||
get: { itemToDelete != nil },
|
||||
set: { if !$0 { itemToDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let item = itemToDelete {
|
||||
withAnimation {
|
||||
modelContext.delete(item)
|
||||
try? modelContext.save()
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
itemToDelete = nil
|
||||
}
|
||||
} message: {
|
||||
Text("Are you sure you want to delete workout started \(itemToDelete?.exerciseName ?? "this item")?")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func startWorkout (_ log: WorkoutLog) {
|
||||
withAnimation {
|
||||
log.status = .inProgress
|
||||
updateWorkout(log)
|
||||
}
|
||||
}
|
||||
|
||||
func resetWorkout (_ log: WorkoutLog) {
|
||||
withAnimation {
|
||||
log.status = .notStarted
|
||||
updateWorkout(log)
|
||||
}
|
||||
}
|
||||
|
||||
func completeWorkout (_ log: WorkoutLog) {
|
||||
withAnimation {
|
||||
log.status = .completed
|
||||
updateWorkout(log)
|
||||
}
|
||||
}
|
||||
|
||||
func updateWorkout (_ log: WorkoutLog) {
|
||||
if let workout = log.workout {
|
||||
if let _ = workout.logs?.first(where: { $0.status != .completed }) {
|
||||
if let notStartedLogs = workout.logs?.filter({ $0.status == .notStarted }) {
|
||||
if notStartedLogs.count == workout.logs?.count ?? 0 {
|
||||
workout.status = WorkoutStatus.notStarted.rawValue
|
||||
}
|
||||
}
|
||||
if let _ = workout.logs?.first(where: { $0.status == .inProgress }) {
|
||||
workout.status = WorkoutStatus.inProgress.rawValue
|
||||
}
|
||||
} else {
|
||||
workout.status = WorkoutStatus.completed.rawValue
|
||||
workout.end = Date()
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
|
||||
func getSetsRepsWeight(_ exerciseName: String, in modelContext: ModelContext) -> SetsRepsWeight {
|
||||
// Use a single expression predicate that works with SwiftData
|
||||
print("Searching for exercise name: \(exerciseName)")
|
||||
|
||||
var descriptor = FetchDescriptor<WorkoutLog>(
|
||||
predicate: #Predicate<WorkoutLog> { log in
|
||||
log.exerciseName == exerciseName
|
||||
},
|
||||
sortBy: [SortDescriptor(\WorkoutLog.date, order: .reverse)]
|
||||
)
|
||||
|
||||
descriptor.fetchLimit = 1
|
||||
|
||||
let results = try? modelContext.fetch(descriptor)
|
||||
|
||||
if let log = results?.first {
|
||||
return SetsRepsWeight(sets: log.sets, reps: log.reps, weight: log.weight)
|
||||
} else {
|
||||
return SetsRepsWeight(sets: 3, reps: 10, weight: 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SetsRepsWeight {
|
||||
let sets: Int
|
||||
let reps: Int
|
||||
let weight: Int
|
||||
}
|
37
Workouts/Views/WorkoutLog/WorkoutStatus.swift
Normal file
37
Workouts/Views/WorkoutLog/WorkoutStatus.swift
Normal file
@ -0,0 +1,37 @@
|
||||
//
|
||||
// WorkoutStatus.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/16/25 at 7:03 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum WorkoutStatus: Int, Codable {
|
||||
case notStarted = 1
|
||||
case inProgress = 2
|
||||
case completed = 3
|
||||
case skipped = 4
|
||||
|
||||
static var unnamed = "Undetermined"
|
||||
|
||||
var name: String {
|
||||
switch (self) {
|
||||
case .notStarted: "Not Started"
|
||||
case .inProgress: "In Progress"
|
||||
case .completed: "Completed"
|
||||
case .skipped: "Skipped"
|
||||
}
|
||||
}
|
||||
|
||||
var checkboxStatus: CheckboxStatus {
|
||||
switch (self) {
|
||||
case .notStarted: .unchecked
|
||||
case .inProgress: .intermediate
|
||||
case .completed: .checked
|
||||
case .skipped: .cancelled
|
||||
}
|
||||
}
|
||||
}
|
59
Workouts/Views/Workouts/WorkoutEditView.swift
Normal file
59
Workouts/Views/Workouts/WorkoutEditView.swift
Normal file
@ -0,0 +1,59 @@
|
||||
//
|
||||
// WorkoutEditView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/14/25 at 7:35 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct WorkoutEditView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State var workout: Workout
|
||||
@State var workoutEndDate: Date = Date()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section (header: Text("Split")) {
|
||||
Text("\(workout.split?.name ?? Split.unnamed)")
|
||||
}
|
||||
|
||||
Section (header: Text("Status")) {
|
||||
Text("\(workout.statusName)")
|
||||
}
|
||||
|
||||
Section (header: Text("Start/End")) {
|
||||
DatePicker("Started", selection: $workout.start)
|
||||
if workout.status == WorkoutStatus.completed.rawValue {
|
||||
DatePicker("Ended", selection: $workoutEndDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("\(workout.split?.name ?? Split.unnamed) Split")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
try? modelContext.save()
|
||||
if workout.status == WorkoutStatus.completed.rawValue {
|
||||
workout.end = workoutEndDate
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user