Compare commits
1 Commits
2f044c3d9c
...
main
Author | SHA1 | Date | |
---|---|---|---|
7bcc5d656c |
1
.gitignore
vendored
@ -2,3 +2,4 @@
|
|||||||
## User settings
|
## User settings
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
|
|
||||||
|
*.local.md
|
BIN
Artwork/DumbBellIcon2.png
Normal file
After Width: | Height: | Size: 93 KiB |
BIN
Artwork/DumbBellIcon2.pxd
Normal file
81
CLAUDE.md
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
<!-- rgb(86, 20, 150); -->
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
This is a workout tracking iOS app built with Swift/SwiftUI, featuring both iPhone and Apple Watch companions. The app helps users manage workout splits, track exercise logs, and sync data across devices using CloudKit.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Building and Running
|
||||||
|
- **Build the project**: Open `Workouts.xcodeproj` in Xcode and build (Cmd+B)
|
||||||
|
- **Run on iOS Simulator**: Select the Workouts scheme and run (Cmd+R)
|
||||||
|
- **Run on Apple Watch Simulator**: Select the "Workouts Watch App" scheme and run
|
||||||
|
- **Dependencies**: The project uses Swift Package Manager. Dependencies are resolved automatically via `Package.resolved`
|
||||||
|
|
||||||
|
### Key Dependencies
|
||||||
|
- **Yams**: For parsing YAML exercise definition files in `Resources/`
|
||||||
|
- **SwiftUIReorderableForEach**: For reorderable lists in UI components
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Data Layer
|
||||||
|
- **SwiftData**: Core persistence framework with CloudKit sync
|
||||||
|
- **Schema Management**: Versioned schemas in `Schema/` directory with migration support
|
||||||
|
- **Models**: Located in `Models/` directory, following single-file-per-model convention
|
||||||
|
|
||||||
|
### Core Models
|
||||||
|
- **Split**: Workout routine templates with exercises, colors, and system images
|
||||||
|
- **Exercise**: Individual exercises within splits (sets, reps, weight, load type)
|
||||||
|
- **Workout**: Active workout sessions linked to splits
|
||||||
|
- **WorkoutLog**: Historical exercise completion records
|
||||||
|
|
||||||
|
### App Structure
|
||||||
|
- **Dual targets**: Main iOS app (`Workouts/`) and Watch companion (`Workouts Watch App/`)
|
||||||
|
- **Shared components**: Both apps share similar structure but have platform-specific implementations
|
||||||
|
- **TabView navigation**: Main app uses tabs (Workouts, Logs, Reports, Achievements)
|
||||||
|
|
||||||
|
### Exercise Data Management
|
||||||
|
- **YAML-based exercise definitions**: Located in `Resources/*.exercises.yaml`
|
||||||
|
- **ExerciseListLoader**: Parses YAML files into structured exercise data
|
||||||
|
- **Preset exercise libraries**: Starter sets for bodyweight and Planet Fitness routines
|
||||||
|
|
||||||
|
### CloudKit Integration
|
||||||
|
- **Automatic sync**: Configured via `ModelConfiguration(cloudKitDatabase: .automatic)`
|
||||||
|
- **Change observation**: App observes CloudKit sync events for UI refresh
|
||||||
|
- **Cross-device sync**: Data syncs between iPhone and Apple Watch
|
||||||
|
|
||||||
|
### UI Patterns
|
||||||
|
- **SwiftUI-first**: Modern declarative UI throughout
|
||||||
|
- **Navigation**: Uses NavigationStack for hierarchical navigation
|
||||||
|
- **Form-based editing**: Consistent form patterns for data entry
|
||||||
|
- **Reorderable lists**: Custom sortable components for exercise ordering
|
||||||
|
|
||||||
|
### Key Directories
|
||||||
|
- `Models/`: SwiftData model definitions
|
||||||
|
- `Views/`: UI components organized by feature (Splits, Exercises, Workouts, etc.)
|
||||||
|
- `Schema/`: Database schema management and versioning
|
||||||
|
- `Utils/`: Shared utilities (logging, date formatting, colors)
|
||||||
|
- `Resources/`: YAML exercise definitions and static assets
|
||||||
|
|
||||||
|
### Schema Guidelines
|
||||||
|
- Each model gets its own file in `Models/`
|
||||||
|
- Schema versions managed in `Schema/` directory
|
||||||
|
- Migration plans handle schema evolution
|
||||||
|
- No circular relationships in data model
|
||||||
|
- Use appropriate delete rules for relationships
|
||||||
|
|
||||||
|
### UI Guidelines (from UI.md)
|
||||||
|
- Tab-based root navigation with independent navigation stacks
|
||||||
|
- Consistent form patterns for add/edit operations
|
||||||
|
- Sheet presentations for modal operations
|
||||||
|
- Swipe gestures for common actions (edit, complete, navigate)
|
||||||
|
|
||||||
|
### Development Notes
|
||||||
|
- **Auto-lock disabled**: App prevents device sleep during workouts
|
||||||
|
- **Preview support**: ModelContainer.preview for SwiftUI previews
|
||||||
|
- **Logging**: Structured logging via AppLogger throughout the app
|
||||||
|
- **Color system**: Custom color extensions for consistent theming
|
33
Scripts/update_build_number.sh
Executable file
@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
## IMPORTANT ##
|
||||||
|
# Add the following files to Input Files configuraiton of the build phase
|
||||||
|
# $(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)
|
||||||
|
# $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist
|
||||||
|
|
||||||
|
update_plist_key() {
|
||||||
|
local plist=$1
|
||||||
|
local key=$2
|
||||||
|
local value=$3
|
||||||
|
|
||||||
|
if /usr/libexec/PlistBuddy -c "Print :$key" "$plist" > /dev/null 2>&1; then
|
||||||
|
/usr/libexec/PlistBuddy -c "Set :$key string $value" "$plist"
|
||||||
|
else
|
||||||
|
/usr/libexec/PlistBuddy -c "Add :$key string $value" "$plist"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
git=$(sh /etc/profile; which git)
|
||||||
|
commit_hash=$("$git" rev-parse --short HEAD)
|
||||||
|
build_time=$(date "+%-d/%b/%Y")
|
||||||
|
|
||||||
|
target_plist="$TARGET_BUILD_DIR/$INFOPLIST_PATH"
|
||||||
|
dsym_plist="$DWARF_DSYM_FOLDER_PATH/$DWARF_DSYM_FILE_NAME/Contents/Info.plist"
|
||||||
|
|
||||||
|
for plist in "$target_plist" "$dsym_plist"; do
|
||||||
|
if [ -f "$plist" ]; then
|
||||||
|
echo "updating $plist"
|
||||||
|
update_plist_key "$plist" "BuildTime" "$build_time"
|
||||||
|
update_plist_key "$plist" "CommitHash" "$commit_hash"
|
||||||
|
fi
|
||||||
|
done
|
BIN
Workouts Watch App/Assets.xcassets/AppIcon.appiconset/1024.png
Normal file
After Width: | Height: | Size: 49 KiB |
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "DumbBellIcon-light.png",
|
"filename" : "1024.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "watchos",
|
"platform" : "watchos",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
Before Width: | Height: | Size: 97 KiB |
@ -13,49 +13,45 @@ import SwiftData
|
|||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
|
||||||
@State var activeWorkouts: [Workout] = []
|
@State var workouts: [Workout] = []
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
if activeWorkouts.isEmpty {
|
if workouts.isEmpty {
|
||||||
NoActiveWorkoutView()
|
NoWorkoutView()
|
||||||
} else {
|
} else {
|
||||||
ActiveWorkoutListView(workouts: activeWorkouts)
|
ActiveWorkoutListView(workouts: workouts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
loadActiveWorkouts()
|
loadWorkouts()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadActiveWorkouts () {
|
func loadWorkouts () {
|
||||||
let completedStatus = WorkoutStatus.completed.rawValue
|
|
||||||
do {
|
do {
|
||||||
self.activeWorkouts = try modelContext.fetch(FetchDescriptor<Workout>(
|
self.workouts = try modelContext.fetch(FetchDescriptor<Workout>(
|
||||||
predicate: #Predicate<Workout> { workout in
|
|
||||||
workout.status != completedStatus
|
|
||||||
},
|
|
||||||
sortBy: [
|
sortBy: [
|
||||||
SortDescriptor(\Workout.start, order: .reverse)
|
SortDescriptor(\Workout.start, order: .reverse)
|
||||||
]
|
]
|
||||||
))
|
))
|
||||||
} catch {
|
} catch {
|
||||||
print("ERROR: failed to load active workouts \(error)")
|
print("ERROR: failed to load workouts \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NoActiveWorkoutView: View {
|
struct NoWorkoutView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Image(systemName: "dumbbell.fill")
|
Image(systemName: "dumbbell.fill")
|
||||||
.font(.system(size: 40))
|
.font(.system(size: 40))
|
||||||
.foregroundStyle(.gray)
|
.foregroundStyle(.gray)
|
||||||
|
|
||||||
Text("No Active Workout")
|
Text("No Workouts")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
Text("Start a workout in the main app")
|
Text("Create a workout in the main app")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.gray)
|
.foregroundStyle(.gray)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
@ -30,4 +30,8 @@ struct HapticFeedback {
|
|||||||
WKInterfaceDevice.current().play(.notification)
|
WKInterfaceDevice.current().play(.notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func longTap() {
|
||||||
|
WKInterfaceDevice.current().play(.start)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,17 +12,31 @@ import SwiftUI
|
|||||||
struct ExerciseDoneCard: View {
|
struct ExerciseDoneCard: View {
|
||||||
let elapsedSeconds: Int
|
let elapsedSeconds: Int
|
||||||
let onComplete: () -> Void
|
let onComplete: () -> Void
|
||||||
|
let onOneMoreSet: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 12) {
|
||||||
|
Text("Exercise Complete!")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.green)
|
||||||
|
|
||||||
Button(action: onComplete) {
|
Button(action: onComplete) {
|
||||||
Text("Done in \(10 - elapsedSeconds)s")
|
Text("Done in \(10 - elapsedSeconds)s")
|
||||||
.font(.headline)
|
.font(.subheadline)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(.green)
|
.tint(.green)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Button(action: onOneMoreSet) {
|
||||||
|
Text("One More Set")
|
||||||
|
.font(.subheadline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.blue)
|
||||||
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
@ -11,12 +11,13 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ExerciseRestCard: View {
|
struct ExerciseRestCard: View {
|
||||||
let elapsedSeconds: Int
|
let elapsedSeconds: Int
|
||||||
|
let afterSet: Int
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
Text("Resting for")
|
Text("Resting after set #\(afterSet)")
|
||||||
.font(.title)
|
.font(.title3)
|
||||||
.lineLimit(1)
|
.lineLimit(2)
|
||||||
.minimumScaleFactor(0.5)
|
.minimumScaleFactor(0.5)
|
||||||
.layoutPriority(1)
|
.layoutPriority(1)
|
||||||
|
|
||||||
|
@ -19,6 +19,7 @@ struct ExerciseProgressControlView: View {
|
|||||||
@State private var elapsedSeconds: Int = 0
|
@State private var elapsedSeconds: Int = 0
|
||||||
@State private var timer: Timer? = nil
|
@State private var timer: Timer? = nil
|
||||||
@State private var previousStateIndex: Int = 0
|
@State private var previousStateIndex: Int = 0
|
||||||
|
@State private var hapticCounter: Int = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $currentStateIndex) {
|
TabView(selection: $currentStateIndex) {
|
||||||
@ -32,21 +33,40 @@ struct ExerciseProgressControlView: View {
|
|||||||
.tag(index)
|
.tag(index)
|
||||||
|
|
||||||
} else if state.isRest {
|
} else if state.isRest {
|
||||||
ExerciseRestCard(elapsedSeconds: elapsedSeconds)
|
ExerciseRestCard(elapsedSeconds: elapsedSeconds, afterSet: state.afterSet ?? 0)
|
||||||
.tag(index)
|
.tag(index)
|
||||||
|
|
||||||
} else if state.isDone {
|
} else if state.isDone {
|
||||||
ExerciseDoneCard(elapsedSeconds: elapsedSeconds, onComplete: completeExercise)
|
ExerciseDoneCard(elapsedSeconds: elapsedSeconds, onComplete: completeExercise, onOneMoreSet: addOneMoreSet)
|
||||||
.tag(index)
|
.tag(index)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
|
.gesture(
|
||||||
|
DragGesture()
|
||||||
|
.onEnded { value in
|
||||||
|
// Detect swipe left when on the Done card (last index)
|
||||||
|
if currentStateIndex == exerciseStates.count - 1 && value.translation.width < -50 {
|
||||||
|
// User swiped left from Done card - add one more set
|
||||||
|
addOneMoreSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
.onChange(of: currentStateIndex) { oldValue, newValue in
|
.onChange(of: currentStateIndex) { oldValue, newValue in
|
||||||
if oldValue != newValue {
|
if oldValue != newValue {
|
||||||
elapsedSeconds = 0
|
elapsedSeconds = 0
|
||||||
moveToNextState()
|
hapticCounter = 0 // Reset haptic pattern when changing phases
|
||||||
|
// Update the log's current state but don't auto-advance
|
||||||
|
log.currentStateIndex = currentStateIndex
|
||||||
|
log.elapsedSeconds = elapsedSeconds
|
||||||
|
try? modelContext.save()
|
||||||
|
|
||||||
|
// Update status based on current state
|
||||||
|
if currentStateIndex > 0 && currentStateIndex < exerciseStates.count - 1 {
|
||||||
|
log.status = .inProgress
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@ -54,6 +74,12 @@ struct ExerciseProgressControlView: View {
|
|||||||
currentStateIndex = log.currentStateIndex ?? 0
|
currentStateIndex = log.currentStateIndex ?? 0
|
||||||
startTimer()
|
startTimer()
|
||||||
}
|
}
|
||||||
|
.onChange(of: log.sets) { oldValue, newValue in
|
||||||
|
// Reconstruct exercise states if sets count changed
|
||||||
|
if oldValue != newValue {
|
||||||
|
setupExerciseStates()
|
||||||
|
}
|
||||||
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
stopTimer()
|
stopTimer()
|
||||||
}
|
}
|
||||||
@ -76,11 +102,11 @@ struct ExerciseProgressControlView: View {
|
|||||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||||
elapsedSeconds += 1
|
elapsedSeconds += 1
|
||||||
|
|
||||||
// Check if we need to provide haptic feedback during rest periods
|
// Check if we need to provide haptic feedback
|
||||||
if currentStateIndex >= 0 && currentStateIndex < exerciseStates.count {
|
if currentStateIndex >= 0 && currentStateIndex < exerciseStates.count {
|
||||||
let currentState = exerciseStates[currentStateIndex]
|
let currentState = exerciseStates[currentStateIndex]
|
||||||
if currentState.isRest {
|
if currentState.isRest || currentState.isSet {
|
||||||
provideRestHapticFeedback()
|
provideHapticFeedback()
|
||||||
} else if currentState.isDone && elapsedSeconds >= 10 {
|
} else if currentState.isDone && elapsedSeconds >= 10 {
|
||||||
// Auto-complete after 10 seconds on the DONE state
|
// Auto-complete after 10 seconds on the DONE state
|
||||||
completeExercise()
|
completeExercise()
|
||||||
@ -94,34 +120,53 @@ struct ExerciseProgressControlView: View {
|
|||||||
timer = nil
|
timer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func moveToNextState() {
|
|
||||||
if currentStateIndex < exerciseStates.count - 1 {
|
private func provideHapticFeedback() {
|
||||||
elapsedSeconds = 0
|
// Provide haptic feedback every 15 seconds in a cycling pattern: 1 → 2 → 3 → long
|
||||||
withAnimation {
|
if elapsedSeconds % 15 == 0 && elapsedSeconds > 0 {
|
||||||
currentStateIndex += 1
|
hapticCounter += 1
|
||||||
log.currentStateIndex = currentStateIndex
|
|
||||||
log.elapsedSeconds = elapsedSeconds
|
switch hapticCounter % 4 {
|
||||||
log.status = .inProgress
|
case 1:
|
||||||
try? modelContext.save()
|
// First 15 seconds: single tap
|
||||||
|
HapticFeedback.click()
|
||||||
|
case 2:
|
||||||
|
// Second 15 seconds: double tap
|
||||||
|
HapticFeedback.doubleTap()
|
||||||
|
case 3:
|
||||||
|
// Third 15 seconds: triple tap
|
||||||
|
HapticFeedback.tripleTap()
|
||||||
|
case 0:
|
||||||
|
// Fourth 15 seconds: long tap, then reset pattern
|
||||||
|
HapticFeedback.longTap()
|
||||||
|
default:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// We've reached the end (DONE state)
|
|
||||||
completeExercise()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func provideRestHapticFeedback() {
|
private func addOneMoreSet() {
|
||||||
// Provide haptic feedback based on elapsed time
|
// Increment total sets
|
||||||
if elapsedSeconds % 60 == 0 && elapsedSeconds > 0 {
|
log.sets += 1
|
||||||
// Triple tap every 60 seconds
|
|
||||||
HapticFeedback.tripleTap()
|
// Reconstruct exercise states (will trigger onChange)
|
||||||
} else if elapsedSeconds % 30 == 0 && elapsedSeconds > 0 {
|
setupExerciseStates()
|
||||||
// Double tap every 30 seconds
|
|
||||||
HapticFeedback.doubleTap()
|
// Calculate the state index for the additional set
|
||||||
} else if elapsedSeconds % 10 == 0 && elapsedSeconds > 0 {
|
// States: intro(0) → set1(1) → rest1(2) → ... → setN(2N-1) → done(2N)
|
||||||
// Single tap every 10 seconds
|
// For the additional set, we want to go to setN which is at index 2N-1
|
||||||
HapticFeedback.success()
|
let additionalSetStateIndex = (log.sets * 2) - 1
|
||||||
}
|
|
||||||
|
log.status = .inProgress
|
||||||
|
log.currentStateIndex = additionalSetStateIndex
|
||||||
|
log.elapsedSeconds = 0
|
||||||
|
elapsedSeconds = 0
|
||||||
|
hapticCounter = 0
|
||||||
|
|
||||||
|
// Update the current state index for the TabView
|
||||||
|
currentStateIndex = additionalSetStateIndex
|
||||||
|
|
||||||
|
try? modelContext.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func completeExercise() {
|
private func completeExercise() {
|
||||||
|
@ -28,7 +28,7 @@ struct WorkoutCardView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
Text(workout.statusName)
|
Text("\(workout.statusName)~")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(Color.accentColor)
|
.foregroundStyle(Color.accentColor)
|
||||||
}
|
}
|
||||||
|
@ -8,17 +8,24 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
struct WorkoutDetailView: View {
|
struct WorkoutDetailView: View {
|
||||||
let workout: Workout
|
let workout: Workout
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var showingCompletedDialog = false
|
||||||
|
@State private var selectedLog: WorkoutLog? = nil
|
||||||
|
@State private var navigateToExercise = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .center, spacing: 8) {
|
VStack(alignment: .center, spacing: 8) {
|
||||||
if let logs = workout.logs?.sorted(by: { $0.order < $1.order }), !logs.isEmpty {
|
if let logs = workout.logs?.sorted(by: { $0.order < $1.order }), !logs.isEmpty {
|
||||||
List {
|
List {
|
||||||
ForEach(logs) { log in
|
ForEach(logs) { log in
|
||||||
NavigationLink {
|
Button {
|
||||||
ExerciseProgressControlView(log: log)
|
handleExerciseTap(log)
|
||||||
} label: {
|
} label: {
|
||||||
WorkoutLogCardView(log: log)
|
WorkoutLogCardView(log: log)
|
||||||
}
|
}
|
||||||
@ -46,5 +53,88 @@ struct WorkoutDetailView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationDestination(isPresented: $navigateToExercise) {
|
||||||
|
if let selectedLog = selectedLog {
|
||||||
|
ExerciseProgressControlView(log: selectedLog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Exercise Completed", isPresented: $showingCompletedDialog) {
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
// Do nothing, just dismiss
|
||||||
|
}
|
||||||
|
Button("Restart") {
|
||||||
|
if let log = selectedLog {
|
||||||
|
restartExercise(log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("One More Set") {
|
||||||
|
if let log = selectedLog {
|
||||||
|
addOneMoreSet(log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("This exercise is already completed. What would you like to do?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleExerciseTap(_ log: WorkoutLog) {
|
||||||
|
selectedLog = log
|
||||||
|
|
||||||
|
switch log.status {
|
||||||
|
case .notStarted:
|
||||||
|
// Start from beginning
|
||||||
|
log.currentStateIndex = 0
|
||||||
|
try? modelContext.save()
|
||||||
|
navigateToExercise = true
|
||||||
|
|
||||||
|
case .inProgress:
|
||||||
|
// If we're in a rest state, advance to the next set
|
||||||
|
if let currentStateIndex = log.currentStateIndex, isRestState(currentStateIndex) {
|
||||||
|
log.currentStateIndex = currentStateIndex + 1
|
||||||
|
try? modelContext.save()
|
||||||
|
}
|
||||||
|
// Continue from current (possibly updated) position
|
||||||
|
navigateToExercise = true
|
||||||
|
|
||||||
|
case .completed:
|
||||||
|
// Show dialog for completed exercise
|
||||||
|
showingCompletedDialog = true
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Default to not started behavior
|
||||||
|
log.currentStateIndex = 0
|
||||||
|
try? modelContext.save()
|
||||||
|
navigateToExercise = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restartExercise(_ log: WorkoutLog) {
|
||||||
|
log.status = .notStarted
|
||||||
|
log.currentStateIndex = 0
|
||||||
|
log.elapsedSeconds = 0
|
||||||
|
try? modelContext.save()
|
||||||
|
navigateToExercise = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addOneMoreSet(_ log: WorkoutLog) {
|
||||||
|
// Increment total sets
|
||||||
|
log.sets += 1
|
||||||
|
|
||||||
|
// Calculate the state index for the additional set
|
||||||
|
// States: intro(0) → set1(1) → rest1(2) → ... → setN(2N-1) → done(2N)
|
||||||
|
// For the additional set, we want to go to setN+1 which is at index 2N+1
|
||||||
|
let additionalSetStateIndex = (log.sets * 2) - 1
|
||||||
|
|
||||||
|
log.status = .inProgress
|
||||||
|
log.currentStateIndex = additionalSetStateIndex
|
||||||
|
log.elapsedSeconds = 0
|
||||||
|
|
||||||
|
try? modelContext.save()
|
||||||
|
navigateToExercise = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isRestState(_ stateIndex: Int) -> Bool {
|
||||||
|
// Rest states are at even indices > 0
|
||||||
|
return stateIndex > 0 && stateIndex % 2 == 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,7 @@ struct WorkoutLogCardView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
Text(log.status?.name ?? "Not Started")
|
Text(getStatusText(for: log))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(Color.accentColor)
|
.foregroundStyle(Color.accentColor)
|
||||||
|
|
||||||
@ -31,4 +31,41 @@ struct WorkoutLogCardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func getStatusText(for log: WorkoutLog) -> String {
|
||||||
|
guard let status = log.status else {
|
||||||
|
return "Not Started"
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == .inProgress, let currentStateIndex = log.currentStateIndex {
|
||||||
|
let currentSet = getCurrentSetNumber(stateIndex: currentStateIndex, totalSets: log.sets)
|
||||||
|
if currentSet > 0 {
|
||||||
|
return "In Progress, Set #\(currentSet)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return status.name
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getCurrentSetNumber(stateIndex: Int, totalSets: Int) -> Int {
|
||||||
|
// Exercise states are structured as: intro(0) → set1(1) → rest1(2) → set2(3) → rest2(4) → ... → done
|
||||||
|
// For each set number n, set state index = 2n-1, rest state index = 2n
|
||||||
|
|
||||||
|
if stateIndex <= 0 {
|
||||||
|
return 0 // intro or invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're in a rest state (even indices > 0)
|
||||||
|
let isRestState = stateIndex > 0 && stateIndex % 2 == 0
|
||||||
|
|
||||||
|
if isRestState {
|
||||||
|
// During rest, show the next set number
|
||||||
|
let nextSetNumber = (stateIndex / 2) + 1
|
||||||
|
return min(nextSetNumber, totalSets)
|
||||||
|
} else {
|
||||||
|
// During set, show current set number
|
||||||
|
let currentSetNumber = (stateIndex + 1) / 2
|
||||||
|
return min(currentSetNumber, totalSets)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,9 +9,6 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
A45FA1FE2E27171B00581607 /* Workouts Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A45FA1F12E27171A00581607 /* Workouts Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
A45FA1FE2E27171B00581607 /* Workouts Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A45FA1F12E27171A00581607 /* Workouts Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
A45FA2732E29B12500581607 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2722E29B12500581607 /* Yams */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@ -41,6 +38,7 @@
|
|||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
A45FA0912E21B3DD00581607 /* Workouts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Workouts.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
A45FA0912E21B3DD00581607 /* Workouts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Workouts.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
A45FA1F12E27171A00581607 /* Workouts Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Workouts Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
A45FA1F12E27171A00581607 /* Workouts Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Workouts Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
A473BB712E46441B003EAD6F /* CLAUDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CLAUDE.md; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@ -59,6 +57,7 @@
|
|||||||
_ATTIC_/ContentView_backup.swift,
|
_ATTIC_/ContentView_backup.swift,
|
||||||
_ATTIC_/ExerciseProgressView_backup.swift,
|
_ATTIC_/ExerciseProgressView_backup.swift,
|
||||||
Models/Exercise.swift,
|
Models/Exercise.swift,
|
||||||
|
Models/ExerciseType.swift,
|
||||||
Models/Split.swift,
|
Models/Split.swift,
|
||||||
Models/Workout.swift,
|
Models/Workout.swift,
|
||||||
Models/WorkoutLog.swift,
|
Models/WorkoutLog.swift,
|
||||||
@ -92,10 +91,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
A45FA2822E2A933B00581607 /* SwiftUIReorderableForEach in Frameworks */,
|
|
||||||
A45FA2732E29B12500581607 /* Yams in Frameworks */,
|
A45FA2732E29B12500581607 /* Yams in Frameworks */,
|
||||||
A45FA27F2E2A930900581607 /* SwiftUIReorderableForEach in Frameworks */,
|
|
||||||
A45FA2872E2AC66B00581607 /* SwiftUIReorderableForEach in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -115,6 +111,7 @@
|
|||||||
A45FA0932E21B3DD00581607 /* Workouts */,
|
A45FA0932E21B3DD00581607 /* Workouts */,
|
||||||
A45FA1F22E27171A00581607 /* Workouts Watch App */,
|
A45FA1F22E27171A00581607 /* Workouts Watch App */,
|
||||||
A45FA0922E21B3DD00581607 /* Products */,
|
A45FA0922E21B3DD00581607 /* Products */,
|
||||||
|
A473BB712E46441B003EAD6F /* CLAUDE.md */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@ -138,6 +135,7 @@
|
|||||||
A45FA08E2E21B3DD00581607 /* Frameworks */,
|
A45FA08E2E21B3DD00581607 /* Frameworks */,
|
||||||
A45FA08F2E21B3DD00581607 /* Resources */,
|
A45FA08F2E21B3DD00581607 /* Resources */,
|
||||||
A45FA2022E27171B00581607 /* Embed Watch Content */,
|
A45FA2022E27171B00581607 /* Embed Watch Content */,
|
||||||
|
A473BBC52E46D68C003EAD6F /* Update Build Number */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@ -150,9 +148,6 @@
|
|||||||
name = Workouts;
|
name = Workouts;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
A45FA2722E29B12500581607 /* Yams */,
|
A45FA2722E29B12500581607 /* Yams */,
|
||||||
A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */,
|
|
||||||
A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */,
|
|
||||||
A45FA2862E2AC66B00581607 /* SwiftUIReorderableForEach */,
|
|
||||||
);
|
);
|
||||||
productName = Workouts;
|
productName = Workouts;
|
||||||
productReference = A45FA0912E21B3DD00581607 /* Workouts.app */;
|
productReference = A45FA0912E21B3DD00581607 /* Workouts.app */;
|
||||||
@ -209,7 +204,6 @@
|
|||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */,
|
A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */,
|
||||||
A45FA2852E2AC66B00581607 /* XCLocalSwiftPackageReference "../swiftui-reorderable-foreach" */,
|
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = A45FA0922E21B3DD00581607 /* Products */;
|
productRefGroup = A45FA0922E21B3DD00581607 /* Products */;
|
||||||
@ -239,6 +233,30 @@
|
|||||||
};
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
A473BBC52E46D68C003EAD6F /* Update Build Number */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"$(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)",
|
||||||
|
"$(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist",
|
||||||
|
);
|
||||||
|
name = "Update Build Number";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "${PROJECT_DIR}/Scripts/update_build_number.sh\n";
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
A45FA08D2E21B3DD00581607 /* Sources */ = {
|
A45FA08D2E21B3DD00581607 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
@ -463,6 +481,7 @@
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName = Workouts;
|
INFOPLIST_KEY_CFBundleDisplayName = Workouts;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts;
|
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts;
|
||||||
|
INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -475,7 +494,7 @@
|
|||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 4;
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
WATCHOS_DEPLOYMENT_TARGET = 11.2;
|
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@ -494,6 +513,7 @@
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName = Workouts;
|
INFOPLIST_KEY_CFBundleDisplayName = Workouts;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts;
|
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts;
|
||||||
|
INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -506,7 +526,7 @@
|
|||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 4;
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
WATCHOS_DEPLOYMENT_TARGET = 11.2;
|
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@ -542,13 +562,6 @@
|
|||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
|
||||||
A45FA2852E2AC66B00581607 /* XCLocalSwiftPackageReference "../swiftui-reorderable-foreach" */ = {
|
|
||||||
isa = XCLocalSwiftPackageReference;
|
|
||||||
relativePath = "../swiftui-reorderable-foreach";
|
|
||||||
};
|
|
||||||
/* End XCLocalSwiftPackageReference section */
|
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */ = {
|
A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
@ -566,18 +579,6 @@
|
|||||||
package = A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */;
|
package = A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */;
|
||||||
productName = Yams;
|
productName = Yams;
|
||||||
};
|
};
|
||||||
A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
productName = SwiftUIReorderableForEach;
|
|
||||||
};
|
|
||||||
A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
productName = SwiftUIReorderableForEach;
|
|
||||||
};
|
|
||||||
A45FA2862E2AC66B00581607 /* SwiftUIReorderableForEach */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
productName = SwiftUIReorderableForEach;
|
|
||||||
};
|
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = A45FA0892E21B3DC00581607 /* Project object */;
|
rootObject = A45FA0892E21B3DC00581607 /* Project object */;
|
||||||
|
BIN
Workouts/Assets.xcassets/AppIcon.appiconset/1024 1.png
Normal file
After Width: | Height: | Size: 49 KiB |
BIN
Workouts/Assets.xcassets/AppIcon.appiconset/1024 2.png
Normal file
After Width: | Height: | Size: 49 KiB |
BIN
Workouts/Assets.xcassets/AppIcon.appiconset/1024.png
Normal file
After Width: | Height: | Size: 49 KiB |
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "DumbBellIcon.png",
|
"filename" : "1024.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
@ -13,7 +13,7 @@
|
|||||||
"value" : "dark"
|
"value" : "dark"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"filename" : "DumbBellIcon 1.png",
|
"filename" : "1024 1.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
@ -25,7 +25,7 @@
|
|||||||
"value" : "tinted"
|
"value" : "tinted"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"filename" : "DumbBellIcon 2.png",
|
"filename" : "1024 2.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
Before Width: | Height: | Size: 90 KiB |
Before Width: | Height: | Size: 90 KiB |
Before Width: | Height: | Size: 90 KiB |
@ -36,11 +36,11 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Text("Achivements")
|
Text("Achievements")
|
||||||
.navigationTitle("Achievements")
|
.navigationTitle("Achievements")
|
||||||
}
|
}
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Achivements", systemImage: "star.fill")
|
Label("Achievements", systemImage: "star.fill")
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingsView()
|
// SettingsView()
|
||||||
|
@ -3,8 +3,6 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array/>
|
||||||
<string>remote-notification</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
@ -4,10 +4,12 @@ import SwiftData
|
|||||||
@Model
|
@Model
|
||||||
final class Exercise {
|
final class Exercise {
|
||||||
var name: String = ""
|
var name: String = ""
|
||||||
|
var loadType: Int = LoadType.weight.rawValue
|
||||||
var order: Int = 0
|
var order: Int = 0
|
||||||
var sets: Int = 0
|
var sets: Int = 0
|
||||||
var reps: Int = 0
|
var reps: Int = 0
|
||||||
var weight: Int = 0
|
var weight: Int = 0
|
||||||
|
var duration: Date = Date.distantPast
|
||||||
var weightLastUpdated: Date = Date()
|
var weightLastUpdated: Date = Date()
|
||||||
var weightReminderTimeIntervalWeeks: Int = 2
|
var weightReminderTimeIntervalWeeks: Int = 2
|
||||||
|
|
||||||
@ -24,3 +26,18 @@ final class Exercise {
|
|||||||
self.weightReminderTimeIntervalWeeks = weightReminderTimeIntervalWeeks
|
self.weightReminderTimeIntervalWeeks = weightReminderTimeIntervalWeeks
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
enum LoadType: Int, CaseIterable {
|
||||||
|
case none = 0
|
||||||
|
case weight = 1
|
||||||
|
case duration = 2
|
||||||
|
|
||||||
|
var name: String {
|
||||||
|
switch self {
|
||||||
|
case .none: "None"
|
||||||
|
case .weight: "Weight"
|
||||||
|
case .duration: "Duration"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
10
Workouts/Models/ExerciseType.swift
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
//
|
||||||
|
// ExerciseType.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created by rzen on 7/26/25 at 9:16 AM.
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
77
Workouts/Resources/bodyweight-starter.exercises.yaml
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
name: Starter Set - Bodyweight
|
||||||
|
source: Home or Minimal Equipment
|
||||||
|
exercises:
|
||||||
|
- name: Pull-Up
|
||||||
|
descr: Grip a bar with hands shoulder-width or wider. Pull your chin above the bar, engaging your back and arms. Lower with control.
|
||||||
|
type: Bodyweight
|
||||||
|
split: Upper Body
|
||||||
|
|
||||||
|
- name: Inverted Row
|
||||||
|
descr: Lie underneath a bar or sturdy edge, pull your chest toward the bar while keeping your body straight. Squeeze shoulder blades together.
|
||||||
|
type: Bodyweight
|
||||||
|
split: Upper Body
|
||||||
|
|
||||||
|
- name: Pike Push-Up
|
||||||
|
descr: Begin in downward dog position. Lower your head toward the ground by bending your elbows, then press back up. Focus on shoulder engagement.
|
||||||
|
type: Bodyweight
|
||||||
|
split: Upper Body
|
||||||
|
|
||||||
|
- name: Push-Up
|
||||||
|
descr: With hands just outside shoulder width, lower your body until elbows are at 90°, then push back up. Keep your body in a straight line.
|
||||||
|
type: Bodyweight
|
||||||
|
split: Upper Body
|
||||||
|
|
||||||
|
- name: Tricep Dip
|
||||||
|
descr: Using a chair or bench, lower your body by bending your elbows behind you, then press back up. Keep elbows tight to your body.
|
||||||
|
type: Bodyweight
|
||||||
|
split: Upper Body
|
||||||
|
|
||||||
|
- name: Towel Curl
|
||||||
|
descr: Sit on the floor with a towel looped under your feet. Pull against the towel using biceps, optionally resisting with your legs.
|
||||||
|
type: Bodyweight
|
||||||
|
split: Upper Body
|
||||||
|
|
||||||
|
- name: Crunch
|
||||||
|
descr: Lie on your back with knees bent. Curl your upper back off the floor using your abdominal muscles, then return slowly.
|
||||||
|
type: Bodyweight
|
||||||
|
split: Core
|
||||||
|
|
||||||
|
- name: Russian Twist
|
||||||
|
descr: Sit with knees bent and feet off the ground. Twist your torso side to side while keeping your abs engaged.
|
||||||
|
type: Bodyweight
|
||||||
|
split: Core
|
||||||
|
|
||||||
|
- name: Bodyweight Squat
|
||||||
|
descr: Stand with feet shoulder-width apart. Lower your hips back and down, keeping your heels on the floor. Rise back to standing.
|
||||||
|
type: Bodyweight
|
||||||
|
split: Lower Body
|
||||||
|
|
||||||
|
- name: Wall Sit
|
||||||
|
descr: Lean your back against a wall and lower into a seated position. Hold as long as possible while maintaining good form.
|
||||||
|
type: Bodyweight
|
||||||
|
split: Lower Body
|
||||||
|
|
||||||
|
- name: Glute Bridge
|
||||||
|
descr: Lie on your back with knees bent. Push through your heels to lift your hips, then lower slowly. Focus on hamstrings and glutes.
|
||||||
|
type: Bodyweight
|
||||||
|
split: Lower Body
|
||||||
|
|
||||||
|
- name: Hamstring Walkout
|
||||||
|
descr: Start in a glute bridge, then slowly walk your heels outward and back in, maintaining control and tension in the hamstrings.
|
||||||
|
type: Bodyweight
|
||||||
|
split: Lower Body
|
||||||
|
|
||||||
|
- name: Side-Lying Leg Raise (Inner)
|
||||||
|
descr: Lie on your side with bottom leg straight. Raise the bottom leg upward, engaging the inner thigh.
|
||||||
|
type: Bodyweight
|
||||||
|
split: Lower Body
|
||||||
|
|
||||||
|
- name: Side-Lying Leg Raise (Outer)
|
||||||
|
descr: Lie on your side with top leg straight. Raise the top leg upward to engage the outer thigh and glute.
|
||||||
|
type: Bodyweight
|
||||||
|
split: Lower Body
|
||||||
|
|
||||||
|
- name: Calf Raise
|
||||||
|
descr: Stand on the balls of your feet (on flat ground or a step). Raise your heels, then lower slowly for a full stretch.
|
||||||
|
type: Bodyweight
|
||||||
|
split: Lower Body
|
28
Workouts/Utils/Constants.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// Constants.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created by Claude on 8/8/25.
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Application-wide constants
|
||||||
|
struct Constants {
|
||||||
|
|
||||||
|
/// Default values for new exercises
|
||||||
|
struct ExerciseDefaults {
|
||||||
|
static let sets = 3
|
||||||
|
static let reps = 10
|
||||||
|
static let weight = 40
|
||||||
|
static let weightReminderTimeIntervalWeeks = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI Constants
|
||||||
|
struct UI {
|
||||||
|
static let defaultSplitColor = "indigo"
|
||||||
|
static let defaultSplitSystemImage = "dumbbell.fill"
|
||||||
|
}
|
||||||
|
}
|
@ -10,6 +10,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ListItem: View {
|
struct ListItem: View {
|
||||||
|
var systemName: String?
|
||||||
var title: String?
|
var title: String?
|
||||||
var text: String?
|
var text: String?
|
||||||
var subtitle: String?
|
var subtitle: String?
|
||||||
@ -18,6 +19,9 @@ struct ListItem: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
|
if let systemName = systemName {
|
||||||
|
Image(systemName: systemName)
|
||||||
|
}
|
||||||
VStack (alignment: .leading) {
|
VStack (alignment: .leading) {
|
||||||
if let title = title {
|
if let title = title {
|
||||||
Text("\(title)")
|
Text("\(title)")
|
||||||
|
@ -17,7 +17,17 @@ struct ExerciseAddEditView: View {
|
|||||||
@State var model: Exercise
|
@State var model: Exercise
|
||||||
|
|
||||||
@State var originalWeight: Int? = nil
|
@State var originalWeight: Int? = nil
|
||||||
|
@State var loadType: LoadType = .none
|
||||||
|
|
||||||
|
@State private var minutes = 0
|
||||||
|
@State private var seconds = 0
|
||||||
|
|
||||||
|
@State private var weight_tens = 0
|
||||||
|
@State private var weight = 0
|
||||||
|
|
||||||
|
@State private var reps = 0
|
||||||
|
@State private var sets = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
@ -40,23 +50,88 @@ struct ExerciseAddEditView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section (header: Text("Sets/Reps")) {
|
Section (header: Text("Sets/Reps")) {
|
||||||
Stepper("Sets: \(model.sets)", value: $model.sets, in: 1...10)
|
HStack (alignment: .bottom) {
|
||||||
Stepper("Reps: \(model.reps)", value: $model.reps, in: 1...50)
|
VStack (alignment: .center) {
|
||||||
|
Text("Sets")
|
||||||
|
Picker("", selection: $sets) {
|
||||||
|
ForEach(0..<20) { sets in
|
||||||
|
Text("\(sets)").tag(sets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 100)
|
||||||
|
.pickerStyle(.wheel)
|
||||||
|
}
|
||||||
|
VStack (alignment: .center) {
|
||||||
|
Text("Reps")
|
||||||
|
Picker("", selection: $reps) {
|
||||||
|
ForEach(0..<100) { reps in
|
||||||
|
Text("\(reps)").tag(reps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 100)
|
||||||
|
.pickerStyle(.wheel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
sets = model.sets
|
||||||
|
reps = model.reps
|
||||||
}
|
}
|
||||||
|
|
||||||
// Weight section
|
Section (header: Text("Load Type"), footer: Text("For bodyweight exercises choose None. For resistance or weight training selected Weight. For exercises that are time oriented (like plank or meditation select Time.")) {
|
||||||
Section (header: Text("Weight")) {
|
Picker("", selection: $loadType) {
|
||||||
HStack {
|
ForEach (LoadType.allCases, id: \.self) { load in
|
||||||
VStack(alignment: .center) {
|
Text(load.name)
|
||||||
Text("\(model.weight) lbs")
|
.tag(load)
|
||||||
.font(.headline)
|
|
||||||
}
|
}
|
||||||
Spacer()
|
}
|
||||||
VStack(alignment: .trailing) {
|
.pickerStyle(.segmented)
|
||||||
Stepper("±1", value: $model.weight, in: 0...1000)
|
}
|
||||||
Stepper("±5", value: $model.weight, in: 0...1000, step: 5)
|
|
||||||
|
if loadType == .weight {
|
||||||
|
Section (header: Text("Weight")) {
|
||||||
|
HStack {
|
||||||
|
HStack {
|
||||||
|
Picker("", selection: $weight_tens) {
|
||||||
|
ForEach(0..<100) { lbs in
|
||||||
|
Text("\(lbs*10)").tag(lbs*10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 100)
|
||||||
|
.pickerStyle(.wheel)
|
||||||
|
|
||||||
|
Picker("", selection: $weight) {
|
||||||
|
ForEach(0..<10) { lbs in
|
||||||
|
Text("\(lbs)").tag(lbs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 100)
|
||||||
|
.pickerStyle(.wheel)
|
||||||
|
|
||||||
|
Text("lbs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if loadType == .duration {
|
||||||
|
Section (header: Text("Duration")) {
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Picker("Minutes", selection: $minutes) {
|
||||||
|
ForEach(0..<60) { minute in
|
||||||
|
Text("\(minute) min").tag(minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.wheel)
|
||||||
|
|
||||||
|
Picker("Seconds", selection: $seconds) {
|
||||||
|
ForEach(0..<60) { second in
|
||||||
|
Text("\(second) sec").tag(second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.wheel)
|
||||||
}
|
}
|
||||||
.frame(width: 130)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,6 +148,10 @@ struct ExerciseAddEditView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
originalWeight = model.weight
|
originalWeight = model.weight
|
||||||
|
weight_tens = model.weight / 10
|
||||||
|
weight = model.weight - weight_tens * 10
|
||||||
|
minutes = Int(model.duration.timeIntervalSince1970) / 60
|
||||||
|
seconds = Int(model.duration.timeIntervalSince1970) - minutes * 60
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingExercisePicker) {
|
.sheet(isPresented: $showingExercisePicker) {
|
||||||
ExercisePickerView { exerciseNames in
|
ExercisePickerView { exerciseNames in
|
||||||
@ -94,6 +173,10 @@ struct ExerciseAddEditView: View {
|
|||||||
model.weightLastUpdated = Date()
|
model.weightLastUpdated = Date()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
model.duration = Date(timeIntervalSince1970: Double(minutes * 60 + seconds))
|
||||||
|
model.weight = weight_tens + weight
|
||||||
|
model.sets = sets
|
||||||
|
model.reps = reps
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,8 @@ struct SplitDetailView: View {
|
|||||||
|
|
||||||
@State var split: Split
|
@State var split: Split
|
||||||
|
|
||||||
@State private var showingAddSheet: Bool = false
|
@State private var showingExerciseAddSheet: Bool = false
|
||||||
|
@State private var showingSplitEditSheet: Bool = false
|
||||||
@State private var itemToEdit: Exercise? = nil
|
@State private var itemToEdit: Exercise? = nil
|
||||||
@State private var itemToDelete: Exercise? = nil
|
@State private var itemToDelete: Exercise? = nil
|
||||||
@State private var createdWorkout: Workout? = nil
|
@State private var createdWorkout: Workout? = nil
|
||||||
@ -72,24 +73,29 @@ struct SplitDetailView: View {
|
|||||||
})
|
})
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
showingAddSheet = true
|
showingExerciseAddSheet = true
|
||||||
} label: {
|
} label: {
|
||||||
ListItem(title: "Add Exercise")
|
ListItem(systemName: "plus.circle", title: "Add Exercise")
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
Text("No exercises added yet.")
|
Text("No exercises added yet.")
|
||||||
Button(action: { showingAddSheet.toggle() }) {
|
Button(action: { showingExerciseAddSheet.toggle() }) {
|
||||||
ListItem(title: "Add Exercise")
|
ListItem(title: "Add Exercise")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button ("Delete This Split", role: .destructive) {
|
Section {
|
||||||
showingDeleteConfirmation = true
|
Button ("Edit") {
|
||||||
|
showingSplitEditSheet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Button ("Delete", role: .destructive) {
|
||||||
|
showingDeleteConfirmation = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.tint(.red)
|
|
||||||
}
|
}
|
||||||
.navigationTitle("\(split.name)")
|
.navigationTitle("\(split.name)")
|
||||||
}
|
}
|
||||||
@ -122,7 +128,7 @@ struct SplitDetailView: View {
|
|||||||
.navigationDestination(item: $createdWorkout, destination: { workout in
|
.navigationDestination(item: $createdWorkout, destination: { workout in
|
||||||
WorkoutLogListView(workout: workout)
|
WorkoutLogListView(workout: workout)
|
||||||
})
|
})
|
||||||
.sheet (isPresented: $showingAddSheet) {
|
.sheet (isPresented: $showingExerciseAddSheet) {
|
||||||
ExercisePickerView(onExerciseSelected: { exerciseNames in
|
ExercisePickerView(onExerciseSelected: { exerciseNames in
|
||||||
let splitId = split.persistentModelID
|
let splitId = split.persistentModelID
|
||||||
print("exerciseNames: \(exerciseNames)")
|
print("exerciseNames: \(exerciseNames)")
|
||||||
@ -164,7 +170,10 @@ struct SplitDetailView: View {
|
|||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
}, allowMultiSelect: true)
|
}, allowMultiSelect: true)
|
||||||
}
|
}
|
||||||
.sheet(item: $itemToEdit) { item in
|
.sheet (isPresented: $showingSplitEditSheet) {
|
||||||
|
SplitAddEditView(model: split)
|
||||||
|
}
|
||||||
|
.sheet (item: $itemToEdit) { item in
|
||||||
ExerciseAddEditView(model: item)
|
ExerciseAddEditView(model: item)
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
|
56
Workouts/Views/Splits/SplitListView.swift
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
//
|
||||||
|
// SplitPickerView.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created by rzen on 7/25/25 at 6:24 PM.
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct SplitListView: View {
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
|
||||||
|
@State var splits: [Split]
|
||||||
|
|
||||||
|
@State private var allowSorting: Bool = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
|
||||||
|
SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in
|
||||||
|
NavigationLink {
|
||||||
|
SplitDetailView(split: split)
|
||||||
|
} label: {
|
||||||
|
SplitItem(
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
.onAppear(perform: loadSplits)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSplits () {
|
||||||
|
print("Loading splits")
|
||||||
|
do {
|
||||||
|
self.splits = try modelContext.fetch(FetchDescriptor<Split>(
|
||||||
|
sortBy: [
|
||||||
|
SortDescriptor(\Split.order),
|
||||||
|
SortDescriptor(\Split.name)
|
||||||
|
]
|
||||||
|
))
|
||||||
|
print("Loaded \(splits.count) splits")
|
||||||
|
} catch {
|
||||||
|
print("ERROR: failed to load splits \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -17,54 +17,22 @@ struct SplitsView: View {
|
|||||||
@State var splits: [Split] = []
|
@State var splits: [Split] = []
|
||||||
|
|
||||||
@State private var showingAddSheet: Bool = false
|
@State private var showingAddSheet: Bool = false
|
||||||
@State private var allowSorting: Bool = true
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
SplitListView(splits: splits)
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
|
.navigationTitle("Splits")
|
||||||
SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in
|
.toolbar {
|
||||||
NavigationLink {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
SplitDetailView(split: split)
|
Button(action: { showingAddSheet.toggle() }) {
|
||||||
} label: {
|
Image(systemName: "plus")
|
||||||
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) {
|
.sheet (isPresented: $showingAddSheet) {
|
||||||
SplitAddEditView(model: Split(name: "New Split"))
|
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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -40,7 +40,7 @@ struct WorkoutLogListView: View {
|
|||||||
CheckboxListItem(
|
CheckboxListItem(
|
||||||
status: workoutLogStatus,
|
status: workoutLogStatus,
|
||||||
title: log.exerciseName,
|
title: log.exerciseName,
|
||||||
subtitle: "\(log.sets) × \(log.reps) reps × \(log.weight) lbs"
|
subtitle: getSubtitleText(for: log)
|
||||||
)
|
)
|
||||||
.swipeActions(edge: .leading, allowsFullSwipe: false) {
|
.swipeActions(edge: .leading, allowsFullSwipe: false) {
|
||||||
let status = log.status ?? WorkoutStatus.notStarted
|
let status = log.status ?? WorkoutStatus.notStarted
|
||||||
@ -200,6 +200,41 @@ struct WorkoutLogListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getSubtitleText(for log: WorkoutLog) -> String {
|
||||||
|
let baseText = "\(log.sets) × \(log.reps) reps × \(log.weight) lbs"
|
||||||
|
|
||||||
|
if log.status == .inProgress, let currentStateIndex = log.currentStateIndex {
|
||||||
|
let currentSet = getCurrentSetNumber(stateIndex: currentStateIndex, totalSets: log.sets)
|
||||||
|
if currentSet > 0 {
|
||||||
|
return "In Progress, Set #\(currentSet) • \(baseText)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseText
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCurrentSetNumber(stateIndex: Int, totalSets: Int) -> Int {
|
||||||
|
// Exercise states are structured as: intro(0) → set1(1) → rest1(2) → set2(3) → rest2(4) → ... → done
|
||||||
|
// For each set number n, set state index = 2n-1, rest state index = 2n
|
||||||
|
|
||||||
|
if stateIndex <= 0 {
|
||||||
|
return 0 // intro or invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're in a rest state (even indices > 0)
|
||||||
|
let isRestState = stateIndex > 0 && stateIndex % 2 == 0
|
||||||
|
|
||||||
|
if isRestState {
|
||||||
|
// During rest, show the next set number
|
||||||
|
let nextSetNumber = (stateIndex / 2) + 1
|
||||||
|
return min(nextSetNumber, totalSets)
|
||||||
|
} else {
|
||||||
|
// During set, show current set number
|
||||||
|
let currentSetNumber = (stateIndex + 1) / 2
|
||||||
|
return min(currentSetNumber, totalSets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getSetsRepsWeight(_ exerciseName: String, in modelContext: ModelContext) -> SetsRepsWeight {
|
func getSetsRepsWeight(_ exerciseName: String, in modelContext: ModelContext) -> SetsRepsWeight {
|
||||||
// Use a single expression predicate that works with SwiftData
|
// Use a single expression predicate that works with SwiftData
|
||||||
print("Searching for exercise name: \(exerciseName)")
|
print("Searching for exercise name: \(exerciseName)")
|
||||||
|
@ -20,11 +20,13 @@ struct WorkoutListView: View {
|
|||||||
|
|
||||||
@Query(sort: [SortDescriptor(\Workout.start, order: .reverse)]) var workouts: [Workout]
|
@Query(sort: [SortDescriptor(\Workout.start, order: .reverse)]) var workouts: [Workout]
|
||||||
|
|
||||||
// @State private var showingSplitPicker = false
|
@State private var showingSplitPicker = false
|
||||||
|
|
||||||
@State private var itemToDelete: Workout? = nil
|
@State private var itemToDelete: Workout? = nil
|
||||||
@State private var itemToEdit: Workout? = nil
|
@State private var itemToEdit: Workout? = nil
|
||||||
|
|
||||||
|
@State private var splits: [Split] = []
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
@ -60,6 +62,14 @@ struct WorkoutListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Workouts")
|
.navigationTitle("Workouts")
|
||||||
|
.onAppear (perform: loadSplits)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button ("Start New Split") {
|
||||||
|
showingSplitPicker.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(item: $itemToEdit) { item in
|
.sheet(item: $itemToEdit) { item in
|
||||||
WorkoutEditView(workout: item)
|
WorkoutEditView(workout: item)
|
||||||
}
|
}
|
||||||
@ -86,8 +96,14 @@ struct WorkoutListView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("Are you sure you want to delete this workout?")
|
Text("Are you sure you want to delete this workout?")
|
||||||
}
|
}
|
||||||
// .sheet(isPresented: $showingSplitPicker) {
|
.sheet(isPresented: $showingSplitPicker) {
|
||||||
// SplitPickerView { split in
|
|
||||||
|
NavigationStack {
|
||||||
|
SplitListView(splits: splits)
|
||||||
|
.navigationTitle("Select a Split")
|
||||||
|
}
|
||||||
|
|
||||||
|
// { split in
|
||||||
// let workout = Workout(start: Date(), end: Date(), split: split)
|
// let workout = Workout(start: Date(), end: Date(), split: split)
|
||||||
// modelContext.insert(workout)
|
// modelContext.insert(workout)
|
||||||
// if let exercises = split.exercises {
|
// if let exercises = split.exercises {
|
||||||
@ -106,7 +122,20 @@ struct WorkoutListView: View {
|
|||||||
// }
|
// }
|
||||||
// try? modelContext.save()
|
// try? modelContext.save()
|
||||||
// }
|
// }
|
||||||
// }
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|