diff --git a/.gitignore b/.gitignore index 53ca701..8afedac 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ ## User settings xcuserdata/ +*.local.md \ No newline at end of file diff --git a/Artwork/DumbBellIcon.pxd b/Artwork/DumbBellIcon.pxd index be0b937..850a3d8 100644 Binary files a/Artwork/DumbBellIcon.pxd and b/Artwork/DumbBellIcon.pxd differ diff --git a/Artwork/DumbBellIcon2.png b/Artwork/DumbBellIcon2.png new file mode 100644 index 0000000..a6a6661 Binary files /dev/null and b/Artwork/DumbBellIcon2.png differ diff --git a/Artwork/DumbBellIcon2.pxd b/Artwork/DumbBellIcon2.pxd new file mode 100644 index 0000000..19f03cb Binary files /dev/null and b/Artwork/DumbBellIcon2.pxd differ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5229d48 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,81 @@ +# CLAUDE.md + + + +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 \ No newline at end of file diff --git a/Scripts/update_build_number.sh b/Scripts/update_build_number.sh new file mode 100755 index 0000000..e118391 --- /dev/null +++ b/Scripts/update_build_number.sh @@ -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 diff --git a/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/1024.png b/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000..d07db5f Binary files /dev/null and b/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json index 65a5581..113d821 100644 --- a/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "DumbBellIcon-light.png", + "filename" : "1024.png", "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024" diff --git a/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/DumbBellIcon-light.png b/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/DumbBellIcon-light.png deleted file mode 100644 index 7e8a0d9..0000000 Binary files a/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/DumbBellIcon-light.png and /dev/null differ diff --git a/Workouts Watch App/ContentView.swift b/Workouts Watch App/ContentView.swift index aaada46..68912d2 100644 --- a/Workouts Watch App/ContentView.swift +++ b/Workouts Watch App/ContentView.swift @@ -13,49 +13,45 @@ import SwiftData struct ContentView: View { @Environment(\.modelContext) private var modelContext - @State var activeWorkouts: [Workout] = [] + @State var workouts: [Workout] = [] var body: some View { NavigationStack { - if activeWorkouts.isEmpty { - NoActiveWorkoutView() + if workouts.isEmpty { + NoWorkoutView() } else { - ActiveWorkoutListView(workouts: activeWorkouts) + ActiveWorkoutListView(workouts: workouts) } } .onAppear { - loadActiveWorkouts() + loadWorkouts() } } - func loadActiveWorkouts () { - let completedStatus = WorkoutStatus.completed.rawValue + func loadWorkouts () { do { - self.activeWorkouts = try modelContext.fetch(FetchDescriptor( - predicate: #Predicate { workout in - workout.status != completedStatus - }, + self.workouts = try modelContext.fetch(FetchDescriptor( sortBy: [ SortDescriptor(\Workout.start, order: .reverse) ] )) } 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 { VStack(spacing: 16) { Image(systemName: "dumbbell.fill") .font(.system(size: 40)) .foregroundStyle(.gray) - Text("No Active Workout") + Text("No Workouts") .font(.headline) - Text("Start a workout in the main app") + Text("Create a workout in the main app") .font(.caption) .foregroundStyle(.gray) .multilineTextAlignment(.center) diff --git a/Workouts Watch App/Utils/HapticFeedback.swift b/Workouts Watch App/Utils/HapticFeedback.swift index 6e21e66..c603b0a 100644 --- a/Workouts Watch App/Utils/HapticFeedback.swift +++ b/Workouts Watch App/Utils/HapticFeedback.swift @@ -30,4 +30,8 @@ struct HapticFeedback { WKInterfaceDevice.current().play(.notification) } } + + static func longTap() { + WKInterfaceDevice.current().play(.start) + } } diff --git a/Workouts Watch App/Views/Exercises/Cards/ExerciseDoneCard.swift b/Workouts Watch App/Views/Exercises/Cards/ExerciseDoneCard.swift index e8aae94..b98919f 100644 --- a/Workouts Watch App/Views/Exercises/Cards/ExerciseDoneCard.swift +++ b/Workouts Watch App/Views/Exercises/Cards/ExerciseDoneCard.swift @@ -12,17 +12,31 @@ import SwiftUI struct ExerciseDoneCard: View { let elapsedSeconds: Int let onComplete: () -> Void + let onOneMoreSet: () -> Void var body: some View { - VStack(spacing: 20) { + VStack(spacing: 12) { + Text("Exercise Complete!") + .font(.headline) + .foregroundColor(.green) + Button(action: onComplete) { Text("Done in \(10 - elapsedSeconds)s") - .font(.headline) + .font(.subheadline) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .tint(.green) .padding(.horizontal) + + Button(action: onOneMoreSet) { + Text("One More Set") + .font(.subheadline) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.blue) + .padding(.horizontal) } .padding() } diff --git a/Workouts Watch App/Views/Exercises/Cards/ExerciseRestCard.swift b/Workouts Watch App/Views/Exercises/Cards/ExerciseRestCard.swift index 7849cd4..85c6f77 100644 --- a/Workouts Watch App/Views/Exercises/Cards/ExerciseRestCard.swift +++ b/Workouts Watch App/Views/Exercises/Cards/ExerciseRestCard.swift @@ -11,12 +11,13 @@ import SwiftUI struct ExerciseRestCard: View { let elapsedSeconds: Int + let afterSet: Int var body: some View { VStack(spacing: 20) { - Text("Resting for") - .font(.title) - .lineLimit(1) + Text("Resting after set #\(afterSet)") + .font(.title3) + .lineLimit(2) .minimumScaleFactor(0.5) .layoutPriority(1) diff --git a/Workouts Watch App/Views/Exercises/ExerciseProgressControlView.swift b/Workouts Watch App/Views/Exercises/ExerciseProgressControlView.swift index 6d29e52..43d9803 100644 --- a/Workouts Watch App/Views/Exercises/ExerciseProgressControlView.swift +++ b/Workouts Watch App/Views/Exercises/ExerciseProgressControlView.swift @@ -19,6 +19,7 @@ struct ExerciseProgressControlView: View { @State private var elapsedSeconds: Int = 0 @State private var timer: Timer? = nil @State private var previousStateIndex: Int = 0 + @State private var hapticCounter: Int = 0 var body: some View { TabView(selection: $currentStateIndex) { @@ -32,21 +33,40 @@ struct ExerciseProgressControlView: View { .tag(index) } else if state.isRest { - ExerciseRestCard(elapsedSeconds: elapsedSeconds) + ExerciseRestCard(elapsedSeconds: elapsedSeconds, afterSet: state.afterSet ?? 0) .tag(index) } else if state.isDone { - ExerciseDoneCard(elapsedSeconds: elapsedSeconds, onComplete: completeExercise) + ExerciseDoneCard(elapsedSeconds: elapsedSeconds, onComplete: completeExercise, onOneMoreSet: addOneMoreSet) .tag(index) } } } .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 if oldValue != newValue { 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 { @@ -54,6 +74,12 @@ struct ExerciseProgressControlView: View { currentStateIndex = log.currentStateIndex ?? 0 startTimer() } + .onChange(of: log.sets) { oldValue, newValue in + // Reconstruct exercise states if sets count changed + if oldValue != newValue { + setupExerciseStates() + } + } .onDisappear { stopTimer() } @@ -76,11 +102,11 @@ struct ExerciseProgressControlView: View { timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in 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 { let currentState = exerciseStates[currentStateIndex] - if currentState.isRest { - provideRestHapticFeedback() + if currentState.isRest || currentState.isSet { + provideHapticFeedback() } else if currentState.isDone && elapsedSeconds >= 10 { // Auto-complete after 10 seconds on the DONE state completeExercise() @@ -94,34 +120,53 @@ struct ExerciseProgressControlView: View { timer = nil } - private func moveToNextState() { - if currentStateIndex < exerciseStates.count - 1 { - elapsedSeconds = 0 - withAnimation { - currentStateIndex += 1 - log.currentStateIndex = currentStateIndex - log.elapsedSeconds = elapsedSeconds - log.status = .inProgress - try? modelContext.save() + + private func provideHapticFeedback() { + // Provide haptic feedback every 15 seconds in a cycling pattern: 1 → 2 → 3 → long + if elapsedSeconds % 15 == 0 && elapsedSeconds > 0 { + hapticCounter += 1 + + switch hapticCounter % 4 { + case 1: + // 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() { - // Provide haptic feedback based on elapsed time - if elapsedSeconds % 60 == 0 && elapsedSeconds > 0 { - // Triple tap every 60 seconds - HapticFeedback.tripleTap() - } else if elapsedSeconds % 30 == 0 && elapsedSeconds > 0 { - // Double tap every 30 seconds - HapticFeedback.doubleTap() - } else if elapsedSeconds % 10 == 0 && elapsedSeconds > 0 { - // Single tap every 10 seconds - HapticFeedback.success() - } + private func addOneMoreSet() { + // Increment total sets + log.sets += 1 + + // Reconstruct exercise states (will trigger onChange) + setupExerciseStates() + + // 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 which is at index 2N-1 + 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() { diff --git a/Workouts Watch App/Views/Workouts/WorkoutCardView.swift b/Workouts Watch App/Views/Workouts/WorkoutCardView.swift index 616152d..1dd31de 100644 --- a/Workouts Watch App/Views/Workouts/WorkoutCardView.swift +++ b/Workouts Watch App/Views/Workouts/WorkoutCardView.swift @@ -28,7 +28,7 @@ struct WorkoutCardView: View { .font(.headline) .foregroundStyle(.white) - Text(workout.statusName) + Text("\(workout.statusName)~") .font(.caption) .foregroundStyle(Color.accentColor) } diff --git a/Workouts Watch App/Views/Workouts/WorkoutDetailView.swift b/Workouts Watch App/Views/Workouts/WorkoutDetailView.swift index d18bf7c..d9832d9 100644 --- a/Workouts Watch App/Views/Workouts/WorkoutDetailView.swift +++ b/Workouts Watch App/Views/Workouts/WorkoutDetailView.swift @@ -8,17 +8,24 @@ // import SwiftUI +import SwiftData struct WorkoutDetailView: View { 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 { VStack(alignment: .center, spacing: 8) { if let logs = workout.logs?.sorted(by: { $0.order < $1.order }), !logs.isEmpty { List { ForEach(logs) { log in - NavigationLink { - ExerciseProgressControlView(log: log) + Button { + handleExerciseTap(log) } label: { WorkoutLogCardView(log: log) } @@ -46,5 +53,88 @@ struct WorkoutDetailView: View { 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 } } diff --git a/Workouts Watch App/Views/Workouts/WorkoutLogCardView.swift b/Workouts Watch App/Views/Workouts/WorkoutLogCardView.swift index 03b7128..b38d547 100644 --- a/Workouts Watch App/Views/Workouts/WorkoutLogCardView.swift +++ b/Workouts Watch App/Views/Workouts/WorkoutLogCardView.swift @@ -18,7 +18,7 @@ struct WorkoutLogCardView: View { .font(.headline) .lineLimit(1) - Text(log.status?.name ?? "Not Started") + Text(getStatusText(for: log)) .font(.caption) .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) + } + } } diff --git a/Workouts.xcodeproj/project.pbxproj b/Workouts.xcodeproj/project.pbxproj index f93d5c5..c155404 100644 --- a/Workouts.xcodeproj/project.pbxproj +++ b/Workouts.xcodeproj/project.pbxproj @@ -9,9 +9,6 @@ /* Begin PBXBuildFile section */ A45FA1FE2E27171B00581607 /* Workouts Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A45FA1F12E27171A00581607 /* Workouts Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; A45FA2732E29B12500581607 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2722E29B12500581607 /* Yams */; }; - A45FA27F2E2A930900581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */; }; - A45FA2822E2A933B00581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */; }; - A45FA2872E2AC66B00581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2862E2AC66B00581607 /* SwiftUIReorderableForEach */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -41,6 +38,7 @@ /* Begin PBXFileReference section */ A45FA0912E21B3DD00581607 /* Workouts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Workouts.app; sourceTree = BUILT_PRODUCTS_DIR; }; A45FA1F12E27171A00581607 /* Workouts Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Workouts Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + A473BB712E46441B003EAD6F /* CLAUDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CLAUDE.md; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -59,6 +57,7 @@ _ATTIC_/ContentView_backup.swift, _ATTIC_/ExerciseProgressView_backup.swift, Models/Exercise.swift, + Models/ExerciseType.swift, Models/Split.swift, Models/Workout.swift, Models/WorkoutLog.swift, @@ -92,10 +91,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - A45FA2822E2A933B00581607 /* SwiftUIReorderableForEach in Frameworks */, A45FA2732E29B12500581607 /* Yams in Frameworks */, - A45FA27F2E2A930900581607 /* SwiftUIReorderableForEach in Frameworks */, - A45FA2872E2AC66B00581607 /* SwiftUIReorderableForEach in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -115,6 +111,7 @@ A45FA0932E21B3DD00581607 /* Workouts */, A45FA1F22E27171A00581607 /* Workouts Watch App */, A45FA0922E21B3DD00581607 /* Products */, + A473BB712E46441B003EAD6F /* CLAUDE.md */, ); sourceTree = ""; }; @@ -138,6 +135,7 @@ A45FA08E2E21B3DD00581607 /* Frameworks */, A45FA08F2E21B3DD00581607 /* Resources */, A45FA2022E27171B00581607 /* Embed Watch Content */, + A473BBC52E46D68C003EAD6F /* Update Build Number */, ); buildRules = ( ); @@ -150,9 +148,6 @@ name = Workouts; packageProductDependencies = ( A45FA2722E29B12500581607 /* Yams */, - A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */, - A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */, - A45FA2862E2AC66B00581607 /* SwiftUIReorderableForEach */, ); productName = Workouts; productReference = A45FA0912E21B3DD00581607 /* Workouts.app */; @@ -209,7 +204,6 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */, - A45FA2852E2AC66B00581607 /* XCLocalSwiftPackageReference "../swiftui-reorderable-foreach" */, ); preferredProjectObjectVersion = 77; productRefGroup = A45FA0922E21B3DD00581607 /* Products */; @@ -239,6 +233,30 @@ }; /* 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 */ A45FA08D2E21B3DD00581607 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -463,6 +481,7 @@ INFOPLIST_KEY_CFBundleDisplayName = Workouts; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -475,7 +494,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 11.2; + WATCHOS_DEPLOYMENT_TARGET = 11.0; }; name = Debug; }; @@ -494,6 +513,7 @@ INFOPLIST_KEY_CFBundleDisplayName = Workouts; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -506,7 +526,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 11.2; + WATCHOS_DEPLOYMENT_TARGET = 11.0; }; name = Release; }; @@ -542,13 +562,6 @@ }; /* End XCConfigurationList section */ -/* Begin XCLocalSwiftPackageReference section */ - A45FA2852E2AC66B00581607 /* XCLocalSwiftPackageReference "../swiftui-reorderable-foreach" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = "../swiftui-reorderable-foreach"; - }; -/* End XCLocalSwiftPackageReference section */ - /* Begin XCRemoteSwiftPackageReference section */ A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */ = { isa = XCRemoteSwiftPackageReference; @@ -566,18 +579,6 @@ package = A45FA26B2E297F5B00581607 /* XCRemoteSwiftPackageReference "Yams" */; productName = Yams; }; - A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */ = { - isa = XCSwiftPackageProductDependency; - productName = SwiftUIReorderableForEach; - }; - A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */ = { - isa = XCSwiftPackageProductDependency; - productName = SwiftUIReorderableForEach; - }; - A45FA2862E2AC66B00581607 /* SwiftUIReorderableForEach */ = { - isa = XCSwiftPackageProductDependency; - productName = SwiftUIReorderableForEach; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = A45FA0892E21B3DC00581607 /* Project object */; diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/1024 1.png b/Workouts/Assets.xcassets/AppIcon.appiconset/1024 1.png new file mode 100644 index 0000000..d07db5f Binary files /dev/null and b/Workouts/Assets.xcassets/AppIcon.appiconset/1024 1.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/1024 2.png b/Workouts/Assets.xcassets/AppIcon.appiconset/1024 2.png new file mode 100644 index 0000000..d07db5f Binary files /dev/null and b/Workouts/Assets.xcassets/AppIcon.appiconset/1024 2.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/1024.png b/Workouts/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000..d07db5f Binary files /dev/null and b/Workouts/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/Contents.json b/Workouts/Assets.xcassets/AppIcon.appiconset/Contents.json index 69e2dac..a595294 100644 --- a/Workouts/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Workouts/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "DumbBellIcon.png", + "filename" : "1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -13,7 +13,7 @@ "value" : "dark" } ], - "filename" : "DumbBellIcon 1.png", + "filename" : "1024 1.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -25,7 +25,7 @@ "value" : "tinted" } ], - "filename" : "DumbBellIcon 2.png", + "filename" : "1024 2.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 1.png b/Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 1.png deleted file mode 100644 index 0e0c190..0000000 Binary files a/Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 1.png and /dev/null differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 2.png b/Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 2.png deleted file mode 100644 index 0e0c190..0000000 Binary files a/Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 2.png and /dev/null differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon.png b/Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon.png deleted file mode 100644 index 0e0c190..0000000 Binary files a/Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon.png and /dev/null differ diff --git a/Workouts/ContentView.swift b/Workouts/ContentView.swift index a8336be..11ca1f6 100644 --- a/Workouts/ContentView.swift +++ b/Workouts/ContentView.swift @@ -36,11 +36,11 @@ struct ContentView: View { } NavigationStack { - Text("Achivements") + Text("Achievements") .navigationTitle("Achievements") } .tabItem { - Label("Achivements", systemImage: "star.fill") + Label("Achievements", systemImage: "star.fill") } // SettingsView() diff --git a/Workouts/Info.plist b/Workouts/Info.plist index ca9a074..666cc76 100644 --- a/Workouts/Info.plist +++ b/Workouts/Info.plist @@ -3,8 +3,6 @@ UIBackgroundModes - - remote-notification - + diff --git a/Workouts/Models/Exercise.swift b/Workouts/Models/Exercise.swift index 8c49397..d17da32 100644 --- a/Workouts/Models/Exercise.swift +++ b/Workouts/Models/Exercise.swift @@ -4,10 +4,12 @@ import SwiftData @Model final class Exercise { var name: String = "" + var loadType: Int = LoadType.weight.rawValue var order: Int = 0 var sets: Int = 0 var reps: Int = 0 var weight: Int = 0 + var duration: Date = Date.distantPast var weightLastUpdated: Date = Date() var weightReminderTimeIntervalWeeks: Int = 2 @@ -24,3 +26,18 @@ final class Exercise { 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" + } + } +} diff --git a/Workouts/Models/ExerciseType.swift b/Workouts/Models/ExerciseType.swift new file mode 100644 index 0000000..08d4bc4 --- /dev/null +++ b/Workouts/Models/ExerciseType.swift @@ -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. +// + + diff --git a/Workouts/Resources/bodyweight-starter.exercises.yaml b/Workouts/Resources/bodyweight-starter.exercises.yaml new file mode 100644 index 0000000..1f22cb7 --- /dev/null +++ b/Workouts/Resources/bodyweight-starter.exercises.yaml @@ -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 diff --git a/Workouts/Utils/Constants.swift b/Workouts/Utils/Constants.swift new file mode 100644 index 0000000..49ff473 --- /dev/null +++ b/Workouts/Utils/Constants.swift @@ -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" + } +} \ No newline at end of file diff --git a/Workouts/Views/Common/ListItem.swift b/Workouts/Views/Common/ListItem.swift index 71393db..de5d748 100644 --- a/Workouts/Views/Common/ListItem.swift +++ b/Workouts/Views/Common/ListItem.swift @@ -10,6 +10,7 @@ import SwiftUI struct ListItem: View { + var systemName: String? var title: String? var text: String? var subtitle: String? @@ -18,6 +19,9 @@ struct ListItem: View { var body: some View { HStack { + if let systemName = systemName { + Image(systemName: systemName) + } VStack (alignment: .leading) { if let title = title { Text("\(title)") diff --git a/Workouts/Views/Exercises/ExerciseAddEditView.swift b/Workouts/Views/Exercises/ExerciseAddEditView.swift index 74efe0e..fb817a5 100644 --- a/Workouts/Views/Exercises/ExerciseAddEditView.swift +++ b/Workouts/Views/Exercises/ExerciseAddEditView.swift @@ -17,7 +17,17 @@ struct ExerciseAddEditView: View { @State var model: Exercise @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 { NavigationStack { Form { @@ -40,23 +50,88 @@ struct ExerciseAddEditView: View { } Section (header: Text("Sets/Reps")) { - Stepper("Sets: \(model.sets)", value: $model.sets, in: 1...10) - Stepper("Reps: \(model.reps)", value: $model.reps, in: 1...50) + HStack (alignment: .bottom) { + 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("Weight")) { - HStack { - VStack(alignment: .center) { - Text("\(model.weight) lbs") - .font(.headline) + 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.")) { + Picker("", selection: $loadType) { + ForEach (LoadType.allCases, id: \.self) { load in + Text(load.name) + .tag(load) } - Spacer() - VStack(alignment: .trailing) { - Stepper("±1", value: $model.weight, in: 0...1000) - Stepper("±5", value: $model.weight, in: 0...1000, step: 5) + } + .pickerStyle(.segmented) + } + + 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 { 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) { ExercisePickerView { exerciseNames in @@ -94,6 +173,10 @@ struct ExerciseAddEditView: View { 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() dismiss() } diff --git a/Workouts/Views/Splits/SplitDetailView.swift b/Workouts/Views/Splits/SplitDetailView.swift index 8bdd2fb..63ee9f8 100644 --- a/Workouts/Views/Splits/SplitDetailView.swift +++ b/Workouts/Views/Splits/SplitDetailView.swift @@ -16,7 +16,8 @@ struct SplitDetailView: View { @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 itemToDelete: Exercise? = nil @State private var createdWorkout: Workout? = nil @@ -72,24 +73,29 @@ struct SplitDetailView: View { }) Button { - showingAddSheet = true + showingExerciseAddSheet = true } label: { - ListItem(title: "Add Exercise") + ListItem(systemName: "plus.circle", title: "Add Exercise") } } else { Text("No exercises added yet.") - Button(action: { showingAddSheet.toggle() }) { + Button(action: { showingExerciseAddSheet.toggle() }) { ListItem(title: "Add Exercise") } } } } - Button ("Delete This Split", role: .destructive) { - showingDeleteConfirmation = true + Section { + Button ("Edit") { + showingSplitEditSheet = true + } + + Button ("Delete", role: .destructive) { + showingDeleteConfirmation = true + } } - .tint(.red) } .navigationTitle("\(split.name)") } @@ -122,7 +128,7 @@ struct SplitDetailView: View { .navigationDestination(item: $createdWorkout, destination: { workout in WorkoutLogListView(workout: workout) }) - .sheet (isPresented: $showingAddSheet) { + .sheet (isPresented: $showingExerciseAddSheet) { ExercisePickerView(onExerciseSelected: { exerciseNames in let splitId = split.persistentModelID print("exerciseNames: \(exerciseNames)") @@ -164,7 +170,10 @@ struct SplitDetailView: View { try? modelContext.save() }, allowMultiSelect: true) } - .sheet(item: $itemToEdit) { item in + .sheet (isPresented: $showingSplitEditSheet) { + SplitAddEditView(model: split) + } + .sheet (item: $itemToEdit) { item in ExerciseAddEditView(model: item) } .confirmationDialog( diff --git a/Workouts/Views/Splits/SplitListView.swift b/Workouts/Views/Splits/SplitListView.swift new file mode 100644 index 0000000..4a883b9 --- /dev/null +++ b/Workouts/Views/Splits/SplitListView.swift @@ -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( + sortBy: [ + SortDescriptor(\Split.order), + SortDescriptor(\Split.name) + ] + )) + print("Loaded \(splits.count) splits") + } catch { + print("ERROR: failed to load splits \(error)") + } + } +} diff --git a/Workouts/Views/Splits/SplitsView.swift b/Workouts/Views/Splits/SplitsView.swift index 6e33c65..3893305 100644 --- a/Workouts/Views/Splits/SplitsView.swift +++ b/Workouts/Views/Splits/SplitsView.swift @@ -17,54 +17,22 @@ struct SplitsView: View { @State var splits: [Split] = [] @State private var showingAddSheet: Bool = false - @State private var allowSorting: Bool = true var body: some View { NavigationStack { - ScrollView { - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) { - SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in - NavigationLink { - SplitDetailView(split: split) - } label: { - SplitItem( - name: split.name, - color: Color.color(from: split.color), - systemImageName: split.systemImage, - exerciseCount: split.exercises?.count ?? 0 - ) - .overlay(dragging ? Color.white.opacity(0.8) : Color.clear) + SplitListView(splits: splits) + .navigationTitle("Splits") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { showingAddSheet.toggle() }) { + Image(systemName: "plus") } } } - .padding() - } - .navigationTitle("Splits") - .onAppear(perform: loadSplits) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { showingAddSheet.toggle() }) { - Image(systemName: "plus") - } - } - } } .sheet (isPresented: $showingAddSheet) { SplitAddEditView(model: Split(name: "New Split")) } } - - func loadSplits () { - do { - self.splits = try modelContext.fetch(FetchDescriptor( - sortBy: [ - SortDescriptor(\Split.order), - SortDescriptor(\Split.name) - ] - )) - } catch { - print("ERROR: failed to load splits \(error)") - } - } } diff --git a/Workouts/Views/WorkoutLog/WorkoutLogListView.swift b/Workouts/Views/WorkoutLog/WorkoutLogListView.swift index bac8df8..ca9f54a 100644 --- a/Workouts/Views/WorkoutLog/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLog/WorkoutLogListView.swift @@ -40,7 +40,7 @@ struct WorkoutLogListView: View { CheckboxListItem( status: workoutLogStatus, title: log.exerciseName, - subtitle: "\(log.sets) × \(log.reps) reps × \(log.weight) lbs" + subtitle: getSubtitleText(for: log) ) .swipeActions(edge: .leading, allowsFullSwipe: false) { 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 { // Use a single expression predicate that works with SwiftData print("Searching for exercise name: \(exerciseName)") diff --git a/Workouts/Views/Workouts/WorkoutListView.swift b/Workouts/Views/Workouts/WorkoutListView.swift index ac915e0..cf89c18 100644 --- a/Workouts/Views/Workouts/WorkoutListView.swift +++ b/Workouts/Views/Workouts/WorkoutListView.swift @@ -20,11 +20,13 @@ struct WorkoutListView: View { @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 itemToEdit: Workout? = nil + @State private var splits: [Split] = [] + var body: some View { NavigationStack { Form { @@ -60,6 +62,14 @@ struct WorkoutListView: View { } } .navigationTitle("Workouts") + .onAppear (perform: loadSplits) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button ("Start New Split") { + showingSplitPicker.toggle() + } + } + } .sheet(item: $itemToEdit) { item in WorkoutEditView(workout: item) } @@ -86,8 +96,14 @@ struct WorkoutListView: View { } message: { Text("Are you sure you want to delete this workout?") } -// .sheet(isPresented: $showingSplitPicker) { -// SplitPickerView { split in + .sheet(isPresented: $showingSplitPicker) { + + NavigationStack { + SplitListView(splits: splits) + .navigationTitle("Select a Split") + } + +// { split in // let workout = Workout(start: Date(), end: Date(), split: split) // modelContext.insert(workout) // if let exercises = split.exercises { @@ -106,7 +122,20 @@ struct WorkoutListView: View { // } // try? modelContext.save() // } -// } + } + } + } + + func loadSplits () { + do { + self.splits = try modelContext.fetch(FetchDescriptor( + sortBy: [ + SortDescriptor(\Split.order), + SortDescriptor(\Split.name) + ] + )) + } catch { + print("ERROR: failed to load splits \(error)") } } }