Compare commits

...

1 Commits

Author SHA1 Message Date
7bcc5d656c wip 2025-08-08 21:09:11 -04:00
38 changed files with 776 additions and 159 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
## User settings ## User settings
xcuserdata/ xcuserdata/
*.local.md

Binary file not shown.

BIN
Artwork/DumbBellIcon2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
Artwork/DumbBellIcon2.pxd Normal file

Binary file not shown.

81
CLAUDE.md Normal file
View File

@ -0,0 +1,81 @@
# CLAUDE.md
<!-- rgb(86, 20, 150); -->
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a workout tracking iOS app built with Swift/SwiftUI, featuring both iPhone and Apple Watch companions. The app helps users manage workout splits, track exercise logs, and sync data across devices using CloudKit.
## Development Commands
### Building and Running
- **Build the project**: Open `Workouts.xcodeproj` in Xcode and build (Cmd+B)
- **Run on iOS Simulator**: Select the Workouts scheme and run (Cmd+R)
- **Run on Apple Watch Simulator**: Select the "Workouts Watch App" scheme and run
- **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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

View File

@ -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)

View File

@ -30,4 +30,8 @@ struct HapticFeedback {
WKInterfaceDevice.current().play(.notification) WKInterfaceDevice.current().play(.notification)
} }
} }
static func longTap() {
WKInterfaceDevice.current().play(.start)
}
} }

View File

@ -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()
} }

View File

@ -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)

View File

@ -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() {

View File

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

View File

@ -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
} }
} }

View File

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

View File

@ -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 */;

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

View File

@ -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()

View File

@ -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>

View File

@ -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"
}
}
}

View File

@ -0,0 +1,10 @@
//
// ExerciseType.swift
// Workouts
//
// Created by rzen on 7/26/25 at 9:16AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//

View File

@ -0,0 +1,77 @@
name: Starter Set - Bodyweight
source: Home or Minimal Equipment
exercises:
- name: Pull-Up
descr: Grip a bar with hands shoulder-width or wider. Pull your chin above the bar, engaging your back and arms. Lower with control.
type: Bodyweight
split: Upper Body
- name: Inverted Row
descr: Lie underneath a bar or sturdy edge, pull your chest toward the bar while keeping your body straight. Squeeze shoulder blades together.
type: Bodyweight
split: Upper Body
- name: Pike Push-Up
descr: Begin in downward dog position. Lower your head toward the ground by bending your elbows, then press back up. Focus on shoulder engagement.
type: Bodyweight
split: Upper Body
- name: Push-Up
descr: With hands just outside shoulder width, lower your body until elbows are at 90°, then push back up. Keep your body in a straight line.
type: Bodyweight
split: Upper Body
- name: Tricep Dip
descr: Using a chair or bench, lower your body by bending your elbows behind you, then press back up. Keep elbows tight to your body.
type: Bodyweight
split: Upper Body
- name: Towel Curl
descr: Sit on the floor with a towel looped under your feet. Pull against the towel using biceps, optionally resisting with your legs.
type: Bodyweight
split: Upper Body
- name: Crunch
descr: Lie on your back with knees bent. Curl your upper back off the floor using your abdominal muscles, then return slowly.
type: Bodyweight
split: Core
- name: Russian Twist
descr: Sit with knees bent and feet off the ground. Twist your torso side to side while keeping your abs engaged.
type: Bodyweight
split: Core
- name: Bodyweight Squat
descr: Stand with feet shoulder-width apart. Lower your hips back and down, keeping your heels on the floor. Rise back to standing.
type: Bodyweight
split: Lower Body
- name: Wall Sit
descr: Lean your back against a wall and lower into a seated position. Hold as long as possible while maintaining good form.
type: Bodyweight
split: Lower Body
- name: Glute Bridge
descr: Lie on your back with knees bent. Push through your heels to lift your hips, then lower slowly. Focus on hamstrings and glutes.
type: Bodyweight
split: Lower Body
- name: Hamstring Walkout
descr: Start in a glute bridge, then slowly walk your heels outward and back in, maintaining control and tension in the hamstrings.
type: Bodyweight
split: Lower Body
- name: Side-Lying Leg Raise (Inner)
descr: Lie on your side with bottom leg straight. Raise the bottom leg upward, engaging the inner thigh.
type: Bodyweight
split: Lower Body
- name: Side-Lying Leg Raise (Outer)
descr: Lie on your side with top leg straight. Raise the top leg upward to engage the outer thigh and glute.
type: Bodyweight
split: Lower Body
- name: Calf Raise
descr: Stand on the balls of your feet (on flat ground or a step). Raise your heels, then lower slowly for a full stretch.
type: Bodyweight
split: Lower Body

View File

@ -0,0 +1,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"
}
}

View File

@ -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)")

View File

@ -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()
} }

View File

@ -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(

View File

@ -0,0 +1,56 @@
//
// SplitPickerView.swift
// Workouts
//
// Created by rzen on 7/25/25 at 6:24PM.
//
// 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)")
}
}
}

View File

@ -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)")
}
}
} }

View File

@ -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)")

View File

@ -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)")
} }
} }
} }