From 02ec114c53f15e2dbec74f2ff014345d54762519 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 25 Jan 2026 06:22:56 +0000 Subject: [PATCH 1/8] Draft design document for Adding Custom Exercises --- docs/design/add_custom_exercise.md | 104 +++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/design/add_custom_exercise.md diff --git a/docs/design/add_custom_exercise.md b/docs/design/add_custom_exercise.md new file mode 100644 index 0000000..bfb6c12 --- /dev/null +++ b/docs/design/add_custom_exercise.md @@ -0,0 +1,104 @@ +# Design Document: Personalized Exercise Library Feature + +## 1. Overview +This document outlines the design and implementation plan for adding a "Personalized Exercise" feature to the Workout Logger application. This feature allows users to create and add their own custom exercises to the library, expanding beyond the built-in database. + +## 2. Feature Requirements + +### 2.1 User Stories +- **As a user**, I want to add a new exercise that is not in the default list, so I can track my specific workout routines. +- **As a user**, I want to specify the name, category (Compound/Isolation), and primary muscle group for my custom exercise. +- **As a user**, I want to see my custom exercises integrated seamlessly with the built-in exercises in the library. +- **As a user**, I want to be able to delete custom exercises I no longer need (Optional for v1, but good to consider). + +### 2.2 Functional Requirements +- **Add Exercise Form**: A dedicated screen for inputting exercise details. +- **Validation**: Ensure exercise name is not empty and muscle group/category are selected. +- **Persistence**: Save custom exercises locally using the existing Hive-based storage. +- **Integration**: Display custom exercises in the `ExerciseLibraryScreen` alongside built-in ones. + +## 3. Technical Architecture + +### 3.1 Data Layer (`lib/data`, `lib/models`) +- **Model**: The existing `Exercise` model is sufficient. It already has an `isCustom` flag. + ```dart + class Exercise { + final String id; + final String name; + final List muscleActivations; // Derived from selected muscles + final String category; // 'compound' or 'isolation' + final bool isCustom; // Set to true for new exercises + // ... + } + ``` +- **Storage**: `StorageService` (`lib/services/storage_service.dart`) already implements methods for custom exercises (`saveCustomExercise`, `getCustomExercises`, `deleteCustomExercise`). No changes required here. + +### 3.2 State Management (`lib/services/workout_provider.dart`) +The `WorkoutProvider` manages the app state. We need to expose a method to add a custom exercise. + +**Proposed Changes:** +- Add `addCustomExercise(String name, String category, String muscleGroupId)` method. + - Generate a unique ID (using `Uuid`). + - Create `Exercise` object with `isCustom: true`. + - Create `MuscleActivation` list (Simplified for v1: 100% activation for the selected primary muscle). + - Call `_storage.saveCustomExercise()`. + - Add to `_allExercises` list. + - `notifyListeners()` to update UI. + +### 3.3 UI Layer (`lib/screens`) + +#### A. `ExerciseLibraryScreen` Update +- **Data Source**: Change `ExerciseDatabase.getAll()` to `context.watch().allExercises`. This ensures the list updates when a new exercise is added. +- **Action**: Add a `FloatingActionButton` (or an action button in AppBar) to navigate to the new `AddCustomExerciseScreen`. + +#### B. New Screen: `AddCustomExerciseScreen` +- **Widgets**: + - `TextFormField` for Exercise Name. + - `DropdownButtonFormField` or `SegmentedButton` for Category (Compound/Isolation). + - `DropdownButtonFormField` for Primary Muscle Group (using `MuscleGroups.names`). + - `ElevatedButton` for "Save Exercise". +- **Validation**: Use `GlobalKey` to validate inputs before submission. +- **Feedback**: Show a `SnackBar` (Toast) upon successful creation or error. + +## 4. Implementation Steps + +1. **Update `WorkoutProvider`**: + - Implement `addCustomExercise` method. + - Ensure `_allExercises` is correctly populated on `init()` by merging built-in and custom exercises (already partially implemented in `loadAllData` calling `_storage.getAllExercises`). + +2. **Create `AddCustomExerciseScreen`**: + - Create `lib/screens/add_custom_exercise_screen.dart`. + - Implement the form with validation. + - Connect to `WorkoutProvider`. + +3. **Update `ExerciseLibraryScreen`**: + - Replace static data fetch with Provider listener. + - Add navigation to `AddCustomExerciseScreen`. + +## 5. Flutter Best Practices Adherence + +- **State Management**: Use `Provider` for business logic and state. UI components should only react to state changes. +- **Immutability**: Ensure `Exercise` objects are immutable. Use `List.from()` when modifying lists to avoid reference issues. +- **Asynchronous Operations**: Handle storage operations asynchronously. Show loading indicators if necessary (though strictly local storage is fast). +- **Form Validation**: Use standard Flutter `Form` and `TextFormField` validation logic. +- **Theming**: Use `AppTheme` constants (colors, spacing, typography) to maintain consistency with the "Samsung Health-style" minimal interface. +- **User Feedback**: Provide immediate feedback (SnackBar) for user actions. + +## 6. Testing Strategy + +- **Unit Tests**: + - Test `WorkoutProvider.addCustomExercise`: + - Verify exercise is added to the list. + - Verify `saveCustomExercise` is called on storage. + - Verify `isCustom` flag is set. +- **Widget Tests**: + - Test `AddCustomExerciseScreen`: + - Verify validation errors show up for empty name. + - Verify submitting form calls the provider method. + - Test `ExerciseLibraryScreen`: + - Verify custom exercises appear in the list. + +## 7. Future Considerations (v2) +- **Advanced Muscle Activation**: Allow users to select multiple muscle groups and specify activation percentages (e.g., Chest 70%, Triceps 30%). +- **Icon Selection**: Allow users to pick an icon for their custom exercise. +- **Edit/Delete**: Implement functionality to edit or remove custom exercises. From 9f7e1e7cfc9533556d3b0249bf31f250c0f5f762 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sun, 25 Jan 2026 23:06:38 +0530 Subject: [PATCH 2/8] feat: Add core workout logging functionality including exercise library, routines, workout flow, and analytics screens, along with a workout data provider. --- workout-logger/devtools_options.yaml | 3 + .../screens/add_custom_exercise_screen.dart | 359 +++++++++++++++ .../lib/screens/analytics_screen.dart | 8 +- .../lib/screens/exercise_library_screen.dart | 408 ++++++++++++++---- .../lib/screens/routines_screen.dart | 4 +- .../lib/screens/workout_flow_screen.dart | 4 +- .../lib/services/workout_provider.dart | 58 ++- workout-logger/pubspec.yaml | 2 +- workout-logger/test/widget_test.dart | 4 +- 9 files changed, 751 insertions(+), 99 deletions(-) create mode 100644 workout-logger/devtools_options.yaml create mode 100644 workout-logger/lib/screens/add_custom_exercise_screen.dart diff --git a/workout-logger/devtools_options.yaml b/workout-logger/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/workout-logger/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/workout-logger/lib/screens/add_custom_exercise_screen.dart b/workout-logger/lib/screens/add_custom_exercise_screen.dart new file mode 100644 index 0000000..3e11928 --- /dev/null +++ b/workout-logger/lib/screens/add_custom_exercise_screen.dart @@ -0,0 +1,359 @@ +// Add Custom Exercise Screen - Form for creating user-defined exercises + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../services/workout_provider.dart'; +import '../data/exercise_database.dart'; +import '../theme/app_theme.dart'; + +class AddCustomExerciseScreen extends StatefulWidget { + const AddCustomExerciseScreen({super.key}); + + @override + State createState() => _AddCustomExerciseScreenState(); +} + +class _AddCustomExerciseScreenState extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + + String _selectedCategory = 'compound'; + String? _selectedMuscleGroup; + bool _isSubmitting = false; + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + Future _saveExercise() async { + if (!_formKey.currentState!.validate()) return; + if (_selectedMuscleGroup == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please select a primary muscle group'), + backgroundColor: AppTheme.error, + ), + ); + return; + } + + setState(() => _isSubmitting = true); + + try { + final provider = context.read(); + await provider.addCustomExercise( + name: _nameController.text.trim(), + category: _selectedCategory, + primaryMuscleGroupId: _selectedMuscleGroup!, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle, color: AppTheme.success), + const SizedBox(width: 8), + Text('${_nameController.text.trim()} added successfully!'), + ], + ), + backgroundColor: AppTheme.cardColor, + ), + ); + Navigator.of(context).pop(true); // Return success + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to save exercise: $e'), + backgroundColor: AppTheme.error, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isSubmitting = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Add Custom Exercise'), + actions: [ + TextButton( + onPressed: _isSubmitting ? null : _saveExercise, + child: _isSubmitting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.md), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Info Banner + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: AppTheme.primaryColor.withOpacity(0.3), + ), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + color: AppTheme.primaryColor, + size: 24, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + 'Create a custom exercise to track workouts not in the built-in library.', + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: AppSpacing.lg), + + // Exercise Name + Text( + 'Exercise Name', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.sm), + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + hintText: 'e.g., Cable Lateral Raise', + prefixIcon: Icon(Icons.fitness_center), + ), + textCapitalization: TextCapitalization.words, + inputFormatters: [ + LengthLimitingTextInputFormatter(50), + ], + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter an exercise name'; + } + if (value.trim().length < 3) { + return 'Name must be at least 3 characters'; + } + return null; + }, + ), + + const SizedBox(height: AppSpacing.lg), + + // Category Selection + Text( + 'Exercise Type', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.sm), + SegmentedButton( + segments: const [ + ButtonSegment( + value: 'compound', + label: Text('Compound'), + icon: Icon(Icons.fitness_center), + ), + ButtonSegment( + value: 'isolation', + label: Text('Isolation'), + icon: Icon(Icons.accessibility_new), + ), + ], + selected: {_selectedCategory}, + onSelectionChanged: (Set selection) { + setState(() => _selectedCategory = selection.first); + }, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return AppTheme.primaryColor.withOpacity(0.2); + } + return AppTheme.surfaceColor; + }), + ), + ), + + const SizedBox(height: AppSpacing.xs), + Text( + _selectedCategory == 'compound' + ? 'Works multiple muscle groups (e.g., squats, bench press)' + : 'Targets a single muscle group (e.g., bicep curls)', + style: TextStyle( + color: AppTheme.textMuted, + fontSize: 12, + ), + ), + + const SizedBox(height: AppSpacing.lg), + + // Primary Muscle Group + Text( + 'Primary Muscle Group', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.sm), + + // Muscle Group Grid + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 2.2, + crossAxisSpacing: AppSpacing.sm, + mainAxisSpacing: AppSpacing.sm, + ), + itemCount: MuscleGroups.names.length, + itemBuilder: (context, index) { + final muscleId = MuscleGroups.names.keys.elementAt(index); + final muscleName = MuscleGroups.names[muscleId]!; + final muscleColor = AppTheme.getMuscleColor(muscleId); + final isSelected = _selectedMuscleGroup == muscleId; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => setState(() => _selectedMuscleGroup = muscleId), + borderRadius: BorderRadius.circular(AppRadius.md), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: isSelected + ? muscleColor.withOpacity(0.3) + : AppTheme.surfaceColor, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: isSelected + ? muscleColor + : AppTheme.cardColor, + width: 2, + ), + ), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isSelected) ...[ + Icon( + Icons.check_circle, + size: 14, + color: muscleColor, + ), + const SizedBox(width: 4), + ], + Flexible( + child: Text( + muscleName, + style: TextStyle( + color: isSelected + ? muscleColor + : AppTheme.textSecondary, + fontSize: 11, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + + if (_selectedMuscleGroup != null) ...[ + const SizedBox(height: AppSpacing.md), + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppTheme.getMuscleColor(_selectedMuscleGroup!).withOpacity(0.1), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: AppTheme.getMuscleColor(_selectedMuscleGroup!), + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(width: AppSpacing.sm), + Text( + 'Primary: ${MuscleGroups.names[_selectedMuscleGroup]}', + style: TextStyle( + color: AppTheme.getMuscleColor(_selectedMuscleGroup!), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + + const SizedBox(height: AppSpacing.xxl), + + // Save Button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isSubmitting ? null : _saveExercise, + icon: _isSubmitting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.add), + label: Text(_isSubmitting ? 'Saving...' : 'Add Exercise'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ), + + const SizedBox(height: AppSpacing.lg), + ], + ), + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/analytics_screen.dart b/workout-logger/lib/screens/analytics_screen.dart index e0e4054..53d3902 100644 --- a/workout-logger/lib/screens/analytics_screen.dart +++ b/workout-logger/lib/screens/analytics_screen.dart @@ -446,7 +446,7 @@ class _ExercisesTabState extends State<_ExercisesTab> { Container( padding: const EdgeInsets.all(AppSpacing.md), child: DropdownButtonFormField( - value: _selectedExerciseId, + initialValue: _selectedExerciseId, decoration: const InputDecoration( labelText: 'Select Exercise', prefixIcon: Icon(Icons.fitness_center), @@ -639,7 +639,7 @@ class _ExerciseProgressView extends StatelessWidget { showTitles: true, reservedSize: 40, getTitlesWidget: (value, meta) => Text( - '${(value * 100).toStringAsFixed(0)}', + (value * 100).toStringAsFixed(0), style: const TextStyle( color: AppTheme.textMuted, fontSize: 10, @@ -933,7 +933,7 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { const SizedBox(height: AppSpacing.lg), DropdownButtonFormField( - value: _selectedExerciseId, + initialValue: _selectedExerciseId, decoration: const InputDecoration( labelText: 'Exercise', ), @@ -947,7 +947,7 @@ class _CreateTargetSheetState extends State<_CreateTargetSheet> { const SizedBox(height: AppSpacing.md), DropdownButtonFormField( - value: _targetType, + initialValue: _targetType, decoration: const InputDecoration( labelText: 'Target Type', ), diff --git a/workout-logger/lib/screens/exercise_library_screen.dart b/workout-logger/lib/screens/exercise_library_screen.dart index 93d3a21..b804e14 100644 --- a/workout-logger/lib/screens/exercise_library_screen.dart +++ b/workout-logger/lib/screens/exercise_library_screen.dart @@ -7,6 +7,7 @@ import '../models/models.dart'; import '../services/workout_provider.dart'; import '../theme/app_theme.dart'; import '../data/exercise_database.dart'; +import 'add_custom_exercise_screen.dart'; class ExerciseLibraryScreen extends StatefulWidget { const ExerciseLibraryScreen({super.key}); @@ -21,17 +22,31 @@ class _ExerciseLibraryScreenState extends State { @override Widget build(BuildContext context) { - final allExercises = ExerciseDatabase.getAll(); - + // Use Provider's exercise list (includes custom exercises) + final allExercises = context.watch().allExercises; + // Filter exercises var filteredExercises = allExercises.where((e) { - final matchesSearch = _searchQuery.isEmpty || + final matchesSearch = + _searchQuery.isEmpty || e.name.toLowerCase().contains(_searchQuery.toLowerCase()); - final matchesMuscle = _selectedMuscleGroup == null || - e.muscleActivations.any((m) => m.muscleGroupId == _selectedMuscleGroup); + final matchesMuscle = + _selectedMuscleGroup == null || + e.muscleActivations.any( + (m) => m.muscleGroupId == _selectedMuscleGroup, + ); return matchesSearch && matchesMuscle; }).toList(); + // Sort: custom exercises first within each group for visibility + filteredExercises.sort((a, b) { + // First by custom status (custom first) + if (a.isCustom && !b.isCustom) return -1; + if (!a.isCustom && b.isCustom) return 1; + // Then alphabetically + return a.name.compareTo(b.name); + }); + // Group by primary muscle final grouped = >{}; for (var exercise in filteredExercises) { @@ -39,9 +54,53 @@ class _ExerciseLibraryScreenState extends State { grouped.putIfAbsent(primary, () => []).add(exercise); } + // Count custom exercises for display + final customCount = allExercises.where((e) => e.isCustom).length; + return Scaffold( appBar: AppBar( title: const Text('Exercise Library'), + actions: [ + if (customCount > 0) + Padding( + padding: const EdgeInsets.only(right: AppSpacing.md), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$customCount custom', + style: const TextStyle( + color: AppTheme.primaryColor, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const AddCustomExerciseScreen(), + ), + ); + // No need to manually refresh - Provider will notify listeners + if (result == true && mounted) { + // Optional: Show a subtle confirmation + } + }, + icon: const Icon(Icons.add), + label: const Text('Add Exercise'), ), body: Column( children: [ @@ -62,7 +121,7 @@ class _ExerciseLibraryScreenState extends State { onChanged: (val) => setState(() => _searchQuery = val), ), ), - + // Muscle group filter chips SizedBox( height: 48, @@ -73,44 +132,57 @@ class _ExerciseLibraryScreenState extends State { FilterChip( label: const Text('All'), selected: _selectedMuscleGroup == null, - onSelected: (_) => setState(() => _selectedMuscleGroup = null), + onSelected: (_) => + setState(() => _selectedMuscleGroup = null), ), const SizedBox(width: 8), - ...MuscleGroups.names.entries.map((entry) => Padding( - padding: const EdgeInsets.only(right: 8), - child: FilterChip( - label: Text(entry.value), - selected: _selectedMuscleGroup == entry.key, - selectedColor: AppTheme.getMuscleColor(entry.key).withOpacity(0.3), - onSelected: (selected) => setState(() { - _selectedMuscleGroup = selected ? entry.key : null; - }), + ...MuscleGroups.names.entries.map( + (entry) => Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: Text(entry.value), + selected: _selectedMuscleGroup == entry.key, + selectedColor: AppTheme.getMuscleColor( + entry.key, + ).withOpacity(0.3), + onSelected: (selected) => setState(() { + _selectedMuscleGroup = selected ? entry.key : null; + }), + ), ), - )), + ), ], ), ), - + const SizedBox(height: AppSpacing.sm), - + // Exercise list Expanded( child: grouped.isEmpty ? _buildEmptyState() : ListView.builder( - padding: const EdgeInsets.all(AppSpacing.md), + padding: const EdgeInsets.only( + left: AppSpacing.md, + right: AppSpacing.md, + top: AppSpacing.md, + bottom: 80, // Space for FAB + ), itemCount: grouped.length, itemBuilder: (context, index) { final muscleId = grouped.keys.elementAt(index); final exercises = grouped[muscleId]!; - final muscleName = MuscleGroups.names[muscleId] ?? muscleId; + final muscleName = + MuscleGroups.names[muscleId] ?? muscleId; final muscleColor = AppTheme.getMuscleColor(muscleId); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.sm, + ), child: Row( children: [ Container( @@ -141,9 +213,9 @@ class _ExerciseLibraryScreenState extends State { ], ), ), - ...exercises.map((exercise) => _ExerciseCard( - exercise: exercise, - )), + ...exercises.map( + (exercise) => _ExerciseCard(exercise: exercise), + ), const SizedBox(height: AppSpacing.md), ], ); @@ -160,11 +232,7 @@ class _ExerciseLibraryScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.search_off, - size: 64, - color: AppTheme.textMuted, - ), + Icon(Icons.search_off, size: 64, color: AppTheme.textMuted), const SizedBox(height: 16), const Text( 'No exercises found', @@ -192,37 +260,72 @@ class _ExerciseCard extends StatelessWidget { padding: const EdgeInsets.all(AppSpacing.md), child: Row( children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - exercise.category == 'compound' - ? Icons.fitness_center - : Icons.accessibility_new, - color: AppTheme.primaryColor, - ), + // Icon with custom badge + Stack( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: exercise.isCustom + ? AppTheme.warning.withOpacity(0.2) + : AppTheme.primaryColor.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + exercise.category == 'compound' + ? Icons.fitness_center + : Icons.accessibility_new, + color: exercise.isCustom + ? AppTheme.warning + : AppTheme.primaryColor, + ), + ), + if (exercise.isCustom) + Positioned( + right: -2, + top: -2, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: AppTheme.warning, + borderRadius: BorderRadius.circular(6), + ), + child: const Icon( + Icons.star, + size: 10, + color: Colors.black, + ), + ), + ), + ], ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - exercise.name, - style: const TextStyle( - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), + Row( + children: [ + Expanded( + child: Text( + exercise.name, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ), + ], ), const SizedBox(height: 4), Row( children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( color: exercise.category == 'compound' ? AppTheme.primaryColor.withOpacity(0.2) @@ -240,9 +343,30 @@ class _ExerciseCard extends StatelessWidget { ), ), ), + if (exercise.isCustom) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppTheme.warning.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'CUSTOM', + style: TextStyle( + color: AppTheme.warning, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], const SizedBox(width: 8), Text( - '${exercise.muscleActivations.length} muscles', + '${exercise.muscleActivations.length} muscle${exercise.muscleActivations.length != 1 ? 's' : ''}', style: TextStyle( color: AppTheme.textMuted, fontSize: 12, @@ -253,10 +377,7 @@ class _ExerciseCard extends StatelessWidget { ], ), ), - const Icon( - Icons.chevron_right, - color: AppTheme.textMuted, - ), + const Icon(Icons.chevron_right, color: AppTheme.textMuted), ], ), ), @@ -305,22 +426,47 @@ class _ExerciseDetailsSheet extends StatelessWidget { ), ), const SizedBox(height: AppSpacing.lg), - + // Header Row( children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - exercise.category == 'compound' - ? Icons.fitness_center - : Icons.accessibility_new, - color: AppTheme.primaryColor, - ), + Stack( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: exercise.isCustom + ? AppTheme.warning.withOpacity(0.2) + : AppTheme.primaryColor.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + exercise.category == 'compound' + ? Icons.fitness_center + : Icons.accessibility_new, + color: exercise.isCustom + ? AppTheme.warning + : AppTheme.primaryColor, + ), + ), + if (exercise.isCustom) + Positioned( + right: -2, + top: -2, + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppTheme.warning, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.star, + size: 10, + color: Colors.black, + ), + ), + ), + ], ), const SizedBox(width: 12), Expanded( @@ -331,18 +477,53 @@ class _ExerciseDetailsSheet extends StatelessWidget { exercise.name, style: Theme.of(context).textTheme.titleLarge, ), - Text( - exercise.category == 'compound' ? 'Compound Exercise' : 'Isolation Exercise', - style: const TextStyle(color: AppTheme.textSecondary), + Row( + children: [ + Text( + exercise.category == 'compound' + ? 'Compound Exercise' + : 'Isolation Exercise', + style: const TextStyle(color: AppTheme.textSecondary), + ), + if (exercise.isCustom) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppTheme.warning.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'CUSTOM', + style: TextStyle( + color: AppTheme.warning, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], ), ], ), ), + // Delete button for custom exercises + if (exercise.isCustom) + IconButton( + onPressed: () => _confirmDelete(context, provider), + icon: const Icon(Icons.delete_outline), + color: AppTheme.error, + tooltip: 'Delete custom exercise', + ), ], ), - + const SizedBox(height: AppSpacing.lg), - + // Muscle activations Text( 'Muscle Activation', @@ -350,9 +531,11 @@ class _ExerciseDetailsSheet extends StatelessWidget { ), const SizedBox(height: AppSpacing.sm), ...exercise.muscleActivations.map((activation) { - final muscleName = MuscleGroups.names[activation.muscleGroupId] ?? activation.muscleGroupId; + final muscleName = + MuscleGroups.names[activation.muscleGroupId] ?? + activation.muscleGroupId; final color = AppTheme.getMuscleColor(activation.muscleGroupId); - + return Padding( padding: const EdgeInsets.only(bottom: AppSpacing.sm), child: Row( @@ -369,16 +552,13 @@ class _ExerciseDetailsSheet extends StatelessWidget { Expanded(child: Text(muscleName)), Text( '${activation.activationPercentage}%', - style: TextStyle( - color: color, - fontWeight: FontWeight.bold, - ), + style: TextStyle(color: color, fontWeight: FontWeight.bold), ), ], ), ); }), - + if (lastSession != null) ...[ const SizedBox(height: AppSpacing.lg), Text( @@ -398,7 +578,7 @@ class _ExerciseDetailsSheet extends StatelessWidget { }).toList(), ), ], - + if (growthModel != null && growthModel.r2 > 0.2) ...[ const SizedBox(height: AppSpacing.md), Container( @@ -421,12 +601,58 @@ class _ExerciseDetailsSheet extends StatelessWidget { ), ), ], - + const SizedBox(height: AppSpacing.lg), ], ), ); } + + Future _confirmDelete( + BuildContext context, + WorkoutProvider provider, + ) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppTheme.cardColor, + title: const Text('Delete Custom Exercise?'), + content: Text( + 'Are you sure you want to delete "${exercise.name}"? This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: AppTheme.error), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + final success = await provider.deleteCustomExercise(exercise.id); + if (success && context.mounted) { + Navigator.of(context).pop(); // Close the bottom sheet + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle, color: AppTheme.success), + const SizedBox(width: 8), + Text('"${exercise.name}" deleted'), + ], + ), + backgroundColor: AppTheme.cardColor, + ), + ); + } + } + } } // ==================== Exercise Selector Screen ==================== @@ -451,13 +677,17 @@ class _ExerciseSelectorScreenState extends State { @override Widget build(BuildContext context) { - final allExercises = ExerciseDatabase.getAll(); - + // Use Provider's exercise list (includes custom exercises) + final allExercises = context.watch().allExercises; + var filteredExercises = allExercises.where((e) { return _searchQuery.isEmpty || e.name.toLowerCase().contains(_searchQuery.toLowerCase()); }).toList(); + // Sort alphabetically within groups + filteredExercises.sort((a, b) => a.name.compareTo(b.name)); + // Group by primary muscle final grouped = >{}; for (var exercise in filteredExercises) { @@ -477,7 +707,7 @@ class _ExerciseSelectorScreenState extends State { onChanged: (val) => setState(() => _searchQuery = val), ), ), - + if (widget.selectionMode && _selectedIds.isNotEmpty) Container( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), @@ -498,7 +728,7 @@ class _ExerciseSelectorScreenState extends State { ], ), ), - + Expanded( child: ListView.builder( padding: const EdgeInsets.all(AppSpacing.md), @@ -512,7 +742,9 @@ class _ExerciseSelectorScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.sm, + ), child: Text( muscleName, style: TextStyle( @@ -567,7 +799,7 @@ class _ExerciseSelectorScreenState extends State { }, ), ), - + if (widget.selectionMode) Container( padding: const EdgeInsets.all(AppSpacing.md), diff --git a/workout-logger/lib/screens/routines_screen.dart b/workout-logger/lib/screens/routines_screen.dart index a5d3b2e..03726f4 100644 --- a/workout-logger/lib/screens/routines_screen.dart +++ b/workout-logger/lib/screens/routines_screen.dart @@ -350,7 +350,9 @@ class _CreateRoutineScreenState extends State { itemCount: _selectedExerciseIds.length + 1, onReorder: (oldIndex, newIndex) { if (oldIndex >= _selectedExerciseIds.length || - newIndex >= _selectedExerciseIds.length + 1) return; + newIndex >= _selectedExerciseIds.length + 1) { + return; + } setState(() { if (newIndex > oldIndex) newIndex--; diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 772bdf3..d95668a 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -35,7 +35,7 @@ class _WorkoutFlowScreenState extends State { double _currentWeight = 20; int _currentReps = 10; bool _isDropset = false; - List _drops = []; + final List _drops = []; // TextEditingControllers for dropset fields (following Flutter best practices) final TextEditingController _mainWeightController = TextEditingController(); @@ -504,7 +504,7 @@ class _WorkoutFlowScreenState extends State { } }); }, - activeColor: AppTheme.warning, + activeThumbColor: AppTheme.warning, ), ], ), diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 6fbb966..9f34f7c 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -17,7 +17,7 @@ class WorkoutProvider extends ChangeNotifier { List _targets = []; List _muscleGroups = []; List _allExercises = []; - Map _growthModels = {}; // exerciseId -> GrowthModel + final Map _growthModels = {}; // exerciseId -> GrowthModel // Active workout state WorkoutSession? _activeSession; @@ -99,6 +99,62 @@ class WorkoutProvider extends ChangeNotifier { return MuscleGroups.names[id] ?? 'Unknown'; } + // ==================== CUSTOM EXERCISES ==================== + + /// Add a custom exercise created by the user + Future addCustomExercise({ + required String name, + required String category, + required String primaryMuscleGroupId, + }) async { + // Generate unique ID + final id = 'custom_${_uuid.v4()}'; + + // Create muscle activation (100% for primary muscle in v1) + final muscleActivations = [ + MuscleActivation( + muscleGroupId: primaryMuscleGroupId, + activationPercentage: 100, + ), + ]; + + // Create exercise with isCustom flag + final exercise = Exercise( + id: id, + name: name, + muscleActivations: muscleActivations, + category: category, + isCustom: true, + ); + + // Persist to storage + await _storage.saveCustomExercise(exercise); + + // Add to local list (use List.from for immutability) + _allExercises = List.from(_allExercises)..add(exercise); + + notifyListeners(); + } + + /// Delete a custom exercise + Future deleteCustomExercise(String exerciseId) async { + // Only allow deleting custom exercises + final exercise = getExercise(exerciseId); + if (exercise == null || !exercise.isCustom) { + return false; + } + + // Remove from storage + await _storage.deleteCustomExercise(exerciseId); + + // Remove from local list + _allExercises = List.from(_allExercises) + ..removeWhere((e) => e.id == exerciseId); + + notifyListeners(); + return true; + } + // ==================== WORKOUT FLOW ==================== /// Start a new workout with a routine diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index 788964c..45a9649 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.1+2 +version: 1.0.2+3 environment: sdk: ^3.9.2 diff --git a/workout-logger/test/widget_test.dart b/workout-logger/test/widget_test.dart index b7a3054..814cc29 100644 --- a/workout-logger/test/widget_test.dart +++ b/workout-logger/test/widget_test.dart @@ -1,13 +1,13 @@ // This is a basic Flutter widget test. import 'package:flutter_test/flutter_test.dart'; -import 'package:workout_logger/main.dart'; +import 'package:repforge/main.dart'; void main() { testWidgets('App smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. await tester.pumpWidget(const WorkoutLoggerApp()); - + // Verify that our app shows the loading screen expect(find.text('Workout Logger'), findsOneWidget); }); From df4f2c33c21d8678016c3dbbbad3fd691e2ff58e Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sun, 25 Jan 2026 23:35:37 +0530 Subject: [PATCH 3/8] Adds new feature for editing and fixes some review comments --- workout-logger/lib/models/models.dart | 53 +- .../screens/add_custom_exercise_screen.dart | 181 ++-- .../screens/edit_workout_session_screen.dart | 846 ++++++++++++++++++ .../lib/screens/exercise_library_screen.dart | 4 +- .../lib/screens/history_screen.dart | 171 ++-- .../lib/services/workout_provider.dart | 77 +- 6 files changed, 1182 insertions(+), 150 deletions(-) create mode 100644 workout-logger/lib/screens/edit_workout_session_screen.dart diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index 1aaddef..e261d56 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -72,8 +72,9 @@ class Exercise { String get primaryMuscle { if (muscleActivations.isEmpty) return 'Unknown'; - final sorted = List.from(muscleActivations) - ..sort((a, b) => b.activationPercentage.compareTo(a.activationPercentage)); + final sorted = List.from( + muscleActivations, + )..sort((a, b) => b.activationPercentage.compareTo(a.activationPercentage)); return sorted.first.muscleGroupId; } @@ -144,6 +145,22 @@ class WorkoutSet { timeTaken: json['timeTaken'], timestamp: DateTime.parse(json['timestamp']), ); + + WorkoutSet copyWith({ + double? weight, + int? reps, + bool? isDropset, + List? drops, + int? timeTaken, + DateTime? timestamp, + }) => WorkoutSet( + weight: weight ?? this.weight, + reps: reps ?? this.reps, + isDropset: isDropset ?? this.isDropset, + drops: drops ?? this.drops, + timeTaken: timeTaken ?? this.timeTaken, + timestamp: timestamp ?? this.timestamp, + ); } class DropsetEntry { @@ -167,11 +184,7 @@ class ExerciseLog { final List sets; final String? notes; - ExerciseLog({ - required this.exerciseId, - required this.sets, - this.notes, - }); + ExerciseLog({required this.exerciseId, required this.sets, this.notes}); double get totalVolume => sets.fold(0.0, (sum, set) => sum + set.volume); @@ -186,6 +199,16 @@ class ExerciseLog { sets: (json['sets'] as List).map((s) => WorkoutSet.fromJson(s)).toList(), notes: json['notes'], ); + + ExerciseLog copyWith({ + String? exerciseId, + List? sets, + String? notes, + }) => ExerciseLog( + exerciseId: exerciseId ?? this.exerciseId, + sets: sets ?? this.sets, + notes: notes ?? this.notes, + ); } // ==================== Workout Session ==================== @@ -229,6 +252,22 @@ class WorkoutSession { duration: json['duration'], notes: json['notes'], ); + + WorkoutSession copyWith({ + String? id, + DateTime? date, + String? routineId, + List? exercises, + int? duration, + String? notes, + }) => WorkoutSession( + id: id ?? this.id, + date: date ?? this.date, + routineId: routineId ?? this.routineId, + exercises: exercises ?? this.exercises, + duration: duration ?? this.duration, + notes: notes ?? this.notes, + ); } // ==================== Routine ==================== diff --git a/workout-logger/lib/screens/add_custom_exercise_screen.dart b/workout-logger/lib/screens/add_custom_exercise_screen.dart index 3e11928..32fe958 100644 --- a/workout-logger/lib/screens/add_custom_exercise_screen.dart +++ b/workout-logger/lib/screens/add_custom_exercise_screen.dart @@ -1,5 +1,6 @@ // Add Custom Exercise Screen - Form for creating user-defined exercises +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -12,13 +13,14 @@ class AddCustomExerciseScreen extends StatefulWidget { const AddCustomExerciseScreen({super.key}); @override - State createState() => _AddCustomExerciseScreenState(); + State createState() => + _AddCustomExerciseScreenState(); } class _AddCustomExerciseScreenState extends State { final _formKey = GlobalKey(); final _nameController = TextEditingController(); - + String _selectedCategory = 'compound'; String? _selectedMuscleGroup; bool _isSubmitting = false; @@ -67,10 +69,11 @@ class _AddCustomExerciseScreenState extends State { Navigator.of(context).pop(true); // Return success } } catch (e) { + debugPrint('Failed to save custom exercise: $e'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to save exercise: $e'), + const SnackBar( + content: Text('Failed to save exercise. Please try again.'), backgroundColor: AppTheme.error, ), ); @@ -137,9 +140,9 @@ class _AddCustomExerciseScreenState extends State { ], ), ), - + const SizedBox(height: AppSpacing.lg), - + // Exercise Name Text( 'Exercise Name', @@ -153,9 +156,7 @@ class _AddCustomExerciseScreenState extends State { prefixIcon: Icon(Icons.fitness_center), ), textCapitalization: TextCapitalization.words, - inputFormatters: [ - LengthLimitingTextInputFormatter(50), - ], + inputFormatters: [LengthLimitingTextInputFormatter(50)], validator: (value) { if (value == null || value.trim().isEmpty) { return 'Please enter an exercise name'; @@ -166,9 +167,9 @@ class _AddCustomExerciseScreenState extends State { return null; }, ), - + const SizedBox(height: AppSpacing.lg), - + // Category Selection Text( 'Exercise Type', @@ -201,105 +202,113 @@ class _AddCustomExerciseScreenState extends State { }), ), ), - + const SizedBox(height: AppSpacing.xs), Text( _selectedCategory == 'compound' ? 'Works multiple muscle groups (e.g., squats, bench press)' : 'Targets a single muscle group (e.g., bicep curls)', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 12, - ), + style: TextStyle(color: AppTheme.textMuted, fontSize: 12), ), - + const SizedBox(height: AppSpacing.lg), - + // Primary Muscle Group Text( 'Primary Muscle Group', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: AppSpacing.sm), - + // Muscle Group Grid - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 2.2, - crossAxisSpacing: AppSpacing.sm, - mainAxisSpacing: AppSpacing.sm, - ), - itemCount: MuscleGroups.names.length, - itemBuilder: (context, index) { - final muscleId = MuscleGroups.names.keys.elementAt(index); - final muscleName = MuscleGroups.names[muscleId]!; - final muscleColor = AppTheme.getMuscleColor(muscleId); - final isSelected = _selectedMuscleGroup == muscleId; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => setState(() => _selectedMuscleGroup = muscleId), - borderRadius: BorderRadius.circular(AppRadius.md), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - decoration: BoxDecoration( - color: isSelected - ? muscleColor.withOpacity(0.3) - : AppTheme.surfaceColor, - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all( - color: isSelected - ? muscleColor - : AppTheme.cardColor, - width: 2, - ), + Builder( + builder: (context) { + // Materialize keys once to avoid O(n²) lookup + final muscleKeys = MuscleGroups.names.keys.toList(); + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 2.2, + crossAxisSpacing: AppSpacing.sm, + mainAxisSpacing: AppSpacing.sm, ), - child: Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (isSelected) ...[ - Icon( - Icons.check_circle, - size: 14, - color: muscleColor, - ), - const SizedBox(width: 4), - ], - Flexible( - child: Text( - muscleName, - style: TextStyle( - color: isSelected - ? muscleColor - : AppTheme.textSecondary, - fontSize: 11, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.normal, + itemCount: muscleKeys.length, + itemBuilder: (context, index) { + final muscleId = muscleKeys[index]; + final muscleName = MuscleGroups.names[muscleId]!; + final muscleColor = AppTheme.getMuscleColor(muscleId); + final isSelected = _selectedMuscleGroup == muscleId; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => + setState(() => _selectedMuscleGroup = muscleId), + borderRadius: BorderRadius.circular(AppRadius.md), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: isSelected + ? muscleColor.withOpacity(0.3) + : AppTheme.surfaceColor, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: isSelected + ? muscleColor + : AppTheme.cardColor, + width: 2, + ), + ), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isSelected) ...[ + Icon( + Icons.check_circle, + size: 14, + color: muscleColor, + ), + const SizedBox(width: 4), + ], + Flexible( + child: Text( + muscleName, + style: TextStyle( + color: isSelected + ? muscleColor + : AppTheme.textSecondary, + fontSize: 11, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + ), + overflow: TextOverflow.ellipsis, + ), ), - overflow: TextOverflow.ellipsis, - ), + ], ), - ], + ), ), ), - ), - ), + ); + }, ); }, ), - + if (_selectedMuscleGroup != null) ...[ const SizedBox(height: AppSpacing.md), Container( padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( - color: AppTheme.getMuscleColor(_selectedMuscleGroup!).withOpacity(0.1), + color: AppTheme.getMuscleColor( + _selectedMuscleGroup!, + ).withOpacity(0.1), borderRadius: BorderRadius.circular(AppRadius.md), ), child: Row( @@ -324,9 +333,9 @@ class _AddCustomExerciseScreenState extends State { ), ), ], - + const SizedBox(height: AppSpacing.xxl), - + // Save Button SizedBox( width: double.infinity, @@ -348,7 +357,7 @@ class _AddCustomExerciseScreenState extends State { ), ), ), - + const SizedBox(height: AppSpacing.lg), ], ), diff --git a/workout-logger/lib/screens/edit_workout_session_screen.dart b/workout-logger/lib/screens/edit_workout_session_screen.dart new file mode 100644 index 0000000..b2662f5 --- /dev/null +++ b/workout-logger/lib/screens/edit_workout_session_screen.dart @@ -0,0 +1,846 @@ +// Edit Workout Session Screen - Modify recorded workout sessions + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; + +import '../models/models.dart'; +import '../services/workout_provider.dart'; +import '../theme/app_theme.dart'; + +class EditWorkoutSessionScreen extends StatefulWidget { + final WorkoutSession session; + + const EditWorkoutSessionScreen({super.key, required this.session}); + + @override + State createState() => + _EditWorkoutSessionScreenState(); +} + +class _EditWorkoutSessionScreenState extends State { + late DateTime _selectedDate; + late TimeOfDay _selectedTime; + late TextEditingController _notesController; + late TextEditingController _durationController; + late List<_EditableExerciseLog> _editableExercises; + bool _isSubmitting = false; + bool _hasChanges = false; + + @override + void initState() { + super.initState(); + _selectedDate = widget.session.date; + _selectedTime = TimeOfDay.fromDateTime(widget.session.date); + _notesController = TextEditingController(text: widget.session.notes ?? ''); + _durationController = TextEditingController( + text: widget.session.duration.toString(), + ); + + // Convert to editable structure + _editableExercises = widget.session.exercises.map((log) { + return _EditableExerciseLog( + exerciseId: log.exerciseId, + sets: log.sets + .map( + (set) => _EditableSet( + weight: set.weight, + reps: set.reps, + isDropset: set.isDropset, + ), + ) + .toList(), + notes: log.notes, + ); + }).toList(); + } + + @override + void dispose() { + _notesController.dispose(); + _durationController.dispose(); + super.dispose(); + } + + void _markChanged() { + if (!_hasChanges) { + setState(() => _hasChanges = true); + } + } + + Future _selectDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedDate, + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 1)), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.dark( + primary: AppTheme.primaryColor, + surface: AppTheme.cardColor, + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + setState(() { + _selectedDate = DateTime( + picked.year, + picked.month, + picked.day, + _selectedTime.hour, + _selectedTime.minute, + ); + }); + _markChanged(); + } + } + + Future _selectTime() async { + final picked = await showTimePicker( + context: context, + initialTime: _selectedTime, + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.dark( + primary: AppTheme.primaryColor, + surface: AppTheme.cardColor, + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + setState(() { + _selectedTime = picked; + _selectedDate = DateTime( + _selectedDate.year, + _selectedDate.month, + _selectedDate.day, + picked.hour, + picked.minute, + ); + }); + _markChanged(); + } + } + + void _addSet(int exerciseIndex) { + setState(() { + // Copy last set values or use defaults + final lastSet = _editableExercises[exerciseIndex].sets.isNotEmpty + ? _editableExercises[exerciseIndex].sets.last + : null; + _editableExercises[exerciseIndex].sets.add( + _EditableSet( + weight: lastSet?.weight ?? 0, + reps: lastSet?.reps ?? 0, + isDropset: false, + ), + ); + }); + _markChanged(); + } + + void _deleteSet(int exerciseIndex, int setIndex) { + setState(() { + _editableExercises[exerciseIndex].sets.removeAt(setIndex); + }); + _markChanged(); + } + + void _deleteExercise(int exerciseIndex) { + setState(() { + _editableExercises.removeAt(exerciseIndex); + }); + _markChanged(); + } + + Future _saveChanges() async { + // Validate duration + final duration = int.tryParse(_durationController.text); + if (duration == null || duration < 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please enter a valid duration'), + backgroundColor: AppTheme.error, + ), + ); + return; + } + + // Validate that there's at least one exercise with sets + final exercisesWithSets = _editableExercises + .where((e) => e.sets.isNotEmpty) + .toList(); + if (exercisesWithSets.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Workout must have at least one exercise with sets'), + backgroundColor: AppTheme.error, + ), + ); + return; + } + + setState(() => _isSubmitting = true); + + try { + // Convert editable exercises back to ExerciseLog + final updatedExercises = exercisesWithSets.map((e) { + return ExerciseLog( + exerciseId: e.exerciseId, + sets: e.sets + .map( + (s) => WorkoutSet( + weight: s.weight, + reps: s.reps, + isDropset: s.isDropset, + ), + ) + .toList(), + notes: e.notes, + ); + }).toList(); + + // Create updated session + final updatedSession = widget.session.copyWith( + date: _selectedDate, + duration: duration, + notes: _notesController.text.isEmpty ? null : _notesController.text, + exercises: updatedExercises, + ); + + // Save via provider + final provider = context.read(); + await provider.updateWorkoutSession(updatedSession); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: const [ + Icon(Icons.check_circle, color: AppTheme.success), + SizedBox(width: 8), + Text('Workout updated successfully'), + ], + ), + backgroundColor: AppTheme.cardColor, + ), + ); + Navigator.of(context).pop(true); // Return success + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to save: $e'), + backgroundColor: AppTheme.error, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isSubmitting = false); + } + } + } + + Future _onWillPop() async { + if (!_hasChanges) return true; + + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppTheme.cardColor, + title: const Text('Discard Changes?'), + content: const Text( + 'You have unsaved changes. Are you sure you want to discard them?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: AppTheme.error), + child: const Text('Discard'), + ), + ], + ), + ); + return result ?? false; + } + + @override + Widget build(BuildContext context) { + final provider = context.read(); + final dateFormat = DateFormat('EEEE, MMMM d, yyyy'); + final timeFormat = DateFormat('h:mm a'); + + return PopScope( + canPop: !_hasChanges, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final shouldPop = await _onWillPop(); + if (shouldPop && context.mounted) { + Navigator.of(context).pop(); + } + }, + child: Scaffold( + appBar: AppBar( + title: const Text('Edit Workout'), + actions: [ + TextButton( + onPressed: _isSubmitting ? null : _saveChanges, + child: _isSubmitting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(AppSpacing.md), + children: [ + // Date & Time Section + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.calendar_today, + size: 20, + color: AppTheme.primaryColor, + ), + const SizedBox(width: 8), + Text( + 'Date & Time', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + Expanded( + child: InkWell( + onTap: _selectDate, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppTheme.surfaceColor, + borderRadius: BorderRadius.circular( + AppRadius.md, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Date', + style: TextStyle( + color: AppTheme.textMuted, + fontSize: 12, + ), + ), + const SizedBox(height: 4), + Text( + dateFormat.format(_selectedDate), + style: const TextStyle( + color: AppTheme.textPrimary, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + InkWell( + onTap: _selectTime, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppTheme.surfaceColor, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Time', + style: TextStyle( + color: AppTheme.textMuted, + fontSize: 12, + ), + ), + const SizedBox(height: 4), + Text( + timeFormat.format(_selectedDate), + style: const TextStyle( + color: AppTheme.textPrimary, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ), + + const SizedBox(height: AppSpacing.md), + + // Duration & Notes Section + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.timer_outlined, + size: 20, + color: AppTheme.secondaryColor, + ), + const SizedBox(width: 8), + Text( + 'Duration (minutes)', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + TextField( + controller: _durationController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + hintText: 'Duration in minutes', + ), + onChanged: (_) => _markChanged(), + ), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + const Icon( + Icons.notes, + size: 20, + color: AppTheme.warning, + ), + const SizedBox(width: 8), + Text( + 'Notes', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + TextField( + controller: _notesController, + maxLines: 3, + decoration: const InputDecoration( + hintText: 'Optional workout notes...', + ), + onChanged: (_) => _markChanged(), + ), + ], + ), + ), + ), + + const SizedBox(height: AppSpacing.lg), + + // Exercises Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Exercises', + style: Theme.of(context).textTheme.titleLarge, + ), + Text( + '${_editableExercises.length} exercises', + style: const TextStyle(color: AppTheme.textMuted), + ), + ], + ), + + const SizedBox(height: AppSpacing.md), + + // Exercise Cards + ..._editableExercises.asMap().entries.map((entry) { + final exerciseIndex = entry.key; + final editableLog = entry.value; + final exercise = provider.getExercise(editableLog.exerciseId); + + return _EditableExerciseCard( + key: ValueKey('exercise_$exerciseIndex'), + exerciseName: exercise?.name ?? 'Unknown Exercise', + editableLog: editableLog, + onSetChanged: (setIndex, weight, reps) { + setState(() { + editableLog.sets[setIndex].weight = weight; + editableLog.sets[setIndex].reps = reps; + }); + _markChanged(); + }, + onAddSet: () => _addSet(exerciseIndex), + onDeleteSet: (setIndex) => _deleteSet(exerciseIndex, setIndex), + onDeleteExercise: () => _deleteExercise(exerciseIndex), + ); + }), + + if (_editableExercises.isEmpty) + Container( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Center( + child: Column( + children: [ + Icon( + Icons.fitness_center, + size: 48, + color: AppTheme.textMuted, + ), + const SizedBox(height: AppSpacing.md), + const Text( + 'No exercises in this workout', + style: TextStyle(color: AppTheme.textMuted), + ), + ], + ), + ), + ), + + const SizedBox(height: 80), // Space for bottom + ], + ), + ), + ); + } +} + +// Helper class for editable exercise data +class _EditableExerciseLog { + final String exerciseId; + final List<_EditableSet> sets; + final String? notes; + + _EditableExerciseLog({ + required this.exerciseId, + required this.sets, + this.notes, + }); +} + +class _EditableSet { + double weight; + int reps; + bool isDropset; + + _EditableSet({ + required this.weight, + required this.reps, + this.isDropset = false, + }); +} + +// Editable Exercise Card Widget +class _EditableExerciseCard extends StatelessWidget { + final String exerciseName; + final _EditableExerciseLog editableLog; + final Function(int setIndex, double weight, int reps) onSetChanged; + final VoidCallback onAddSet; + final Function(int setIndex) onDeleteSet; + final VoidCallback onDeleteExercise; + + const _EditableExerciseCard({ + super.key, + required this.exerciseName, + required this.editableLog, + required this.onSetChanged, + required this.onAddSet, + required this.onDeleteSet, + required this.onDeleteExercise, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.md), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Expanded( + child: Text( + exerciseName, + style: const TextStyle( + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + fontSize: 16, + ), + ), + ), + IconButton( + onPressed: () => _confirmDeleteExercise(context), + icon: const Icon(Icons.delete_outline, size: 20), + color: AppTheme.error, + tooltip: 'Remove exercise', + ), + ], + ), + + const Divider(), + + // Sets + ...editableLog.sets.asMap().entries.map((entry) { + final setIndex = entry.key; + final set = entry.value; + + return _EditableSetRow( + setNumber: setIndex + 1, + weight: set.weight, + reps: set.reps, + onWeightChanged: (weight) => + onSetChanged(setIndex, weight, set.reps), + onRepsChanged: (reps) => + onSetChanged(setIndex, set.weight, reps), + onDelete: () => onDeleteSet(setIndex), + ); + }), + + // Add Set Button + Center( + child: TextButton.icon( + onPressed: onAddSet, + icon: const Icon(Icons.add, size: 18), + label: const Text('Add Set'), + ), + ), + ], + ), + ), + ); + } + + void _confirmDeleteExercise(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppTheme.cardColor, + title: const Text('Remove Exercise?'), + content: Text('Remove "$exerciseName" from this workout?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onDeleteExercise(); + }, + style: TextButton.styleFrom(foregroundColor: AppTheme.error), + child: const Text('Remove'), + ), + ], + ), + ); + } +} + +// Editable Set Row Widget +class _EditableSetRow extends StatefulWidget { + final int setNumber; + final double weight; + final int reps; + final Function(double) onWeightChanged; + final Function(int) onRepsChanged; + final VoidCallback onDelete; + + const _EditableSetRow({ + required this.setNumber, + required this.weight, + required this.reps, + required this.onWeightChanged, + required this.onRepsChanged, + required this.onDelete, + }); + + @override + State<_EditableSetRow> createState() => _EditableSetRowState(); +} + +class _EditableSetRowState extends State<_EditableSetRow> { + late TextEditingController _weightController; + late TextEditingController _repsController; + + @override + void initState() { + super.initState(); + _weightController = TextEditingController(text: widget.weight.toString()); + _repsController = TextEditingController(text: widget.reps.toString()); + } + + @override + void didUpdateWidget(covariant _EditableSetRow oldWidget) { + super.didUpdateWidget(oldWidget); + // Update controllers if parent changed values + if (widget.weight != oldWidget.weight) { + _weightController.text = widget.weight.toString(); + } + if (widget.reps != oldWidget.reps) { + _repsController.text = widget.reps.toString(); + } + } + + @override + void dispose() { + _weightController.dispose(); + _repsController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + child: Row( + children: [ + // Set number + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.2), + borderRadius: BorderRadius.circular(14), + ), + child: Center( + child: Text( + '${widget.setNumber}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + + // Weight input + SizedBox( + width: 80, + child: TextField( + controller: _weightController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + suffixText: 'kg', + suffixStyle: const TextStyle( + color: AppTheme.textMuted, + fontSize: 12, + ), + filled: true, + fillColor: AppTheme.surfaceColor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + onChanged: (value) { + final weight = double.tryParse(value) ?? 0; + widget.onWeightChanged(weight); + }, + ), + ), + const SizedBox(width: AppSpacing.sm), + + // × symbol + const Text('×', style: TextStyle(color: AppTheme.textMuted)), + const SizedBox(width: AppSpacing.sm), + + // Reps input + SizedBox( + width: 70, + child: TextField( + controller: _repsController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + suffixText: 'reps', + suffixStyle: const TextStyle( + color: AppTheme.textMuted, + fontSize: 12, + ), + filled: true, + fillColor: AppTheme.surfaceColor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + onChanged: (value) { + final reps = int.tryParse(value) ?? 0; + widget.onRepsChanged(reps); + }, + ), + ), + + const Spacer(), + + // Delete button + IconButton( + onPressed: widget.onDelete, + icon: const Icon(Icons.close, size: 18), + color: AppTheme.textMuted, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + tooltip: 'Delete set', + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/screens/exercise_library_screen.dart b/workout-logger/lib/screens/exercise_library_screen.dart index b804e14..ee80ea2 100644 --- a/workout-logger/lib/screens/exercise_library_screen.dart +++ b/workout-logger/lib/screens/exercise_library_screen.dart @@ -637,8 +637,10 @@ class _ExerciseDetailsSheet extends StatelessWidget { if (confirmed == true && context.mounted) { final success = await provider.deleteCustomExercise(exercise.id); if (success && context.mounted) { + // Capture messenger before pop to avoid deactivated context + final messenger = ScaffoldMessenger.of(context); Navigator.of(context).pop(); // Close the bottom sheet - ScaffoldMessenger.of(context).showSnackBar( + messenger.showSnackBar( SnackBar( content: Row( children: [ diff --git a/workout-logger/lib/screens/history_screen.dart b/workout-logger/lib/screens/history_screen.dart index 00931de..b76e0c7 100644 --- a/workout-logger/lib/screens/history_screen.dart +++ b/workout-logger/lib/screens/history_screen.dart @@ -7,6 +7,7 @@ import 'package:intl/intl.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; import '../theme/app_theme.dart'; +import 'edit_workout_session_screen.dart'; class HistoryScreen extends StatelessWidget { const HistoryScreen({super.key}); @@ -17,9 +18,7 @@ class HistoryScreen extends StatelessWidget { final sessions = provider.sessions; return Scaffold( - appBar: AppBar( - title: const Text('Workout History'), - ), + appBar: AppBar(title: const Text('Workout History')), body: sessions.isEmpty ? _buildEmptyState(context) : _buildSessionList(context, sessions, provider), @@ -31,11 +30,7 @@ class HistoryScreen extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.history, - size: 64, - color: AppTheme.textMuted, - ), + Icon(Icons.history, size: 64, color: AppTheme.textMuted), const SizedBox(height: 16), Text( 'No Workout History', @@ -84,10 +79,9 @@ class HistoryScreen extends StatelessWidget { ), ), ), - ...monthSessions.map((session) => _SessionCard( - session: session, - provider: provider, - )), + ...monthSessions.map( + (session) => _SessionCard(session: session, provider: provider), + ), ], ); }, @@ -99,10 +93,7 @@ class _SessionCard extends StatelessWidget { final WorkoutSession session; final WorkoutProvider provider; - const _SessionCard({ - required this.session, - required this.provider, - }); + const _SessionCard({required this.session, required this.provider}); @override Widget build(BuildContext context) { @@ -146,10 +137,7 @@ class _SessionCard extends StatelessWidget { '${session.exercises.length} exercises', ), const SizedBox(width: AppSpacing.md), - _buildStat( - Icons.timer_outlined, - '${session.duration} min', - ), + _buildStat(Icons.timer_outlined, '${session.duration} min'), const SizedBox(width: AppSpacing.md), _buildStat( Icons.trending_up, @@ -201,10 +189,7 @@ class _SessionCard extends StatelessWidget { const SizedBox(width: 4), Text( text, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), + style: const TextStyle(color: AppTheme.textSecondary, fontSize: 12), ), ], ); @@ -264,8 +249,34 @@ class _SessionDetailsSheet extends StatelessWidget { ), ), ), - const SizedBox(height: AppSpacing.lg), - + const SizedBox(height: AppSpacing.md), + + // Action Buttons Row + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Edit Button + TextButton.icon( + onPressed: () => _editSession(context), + icon: const Icon(Icons.edit_outlined, size: 18), + label: const Text('Edit'), + style: TextButton.styleFrom( + foregroundColor: AppTheme.primaryColor, + ), + ), + const SizedBox(width: 8), + // Delete Button + TextButton.icon( + onPressed: () => _confirmDelete(context), + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Delete'), + style: TextButton.styleFrom(foregroundColor: AppTheme.error), + ), + ], + ), + + const SizedBox(height: AppSpacing.sm), + // Header Text( dateFormat.format(session.date), @@ -275,9 +286,9 @@ class _SessionDetailsSheet extends StatelessWidget { '${timeFormat.format(session.date)} • ${session.duration} minutes', style: Theme.of(context).textTheme.bodyMedium, ), - + const SizedBox(height: AppSpacing.lg), - + // Stats row Row( children: [ @@ -291,7 +302,8 @@ class _SessionDetailsSheet extends StatelessWidget { const SizedBox(width: AppSpacing.md), Expanded( child: _StatBox( - value: '${session.exercises.fold(0, (sum, e) => sum + e.sets.length)}', + value: + '${session.exercises.fold(0, (sum, e) => sum + e.sets.length)}', label: 'Total Sets', color: AppTheme.secondaryColor, ), @@ -306,32 +318,88 @@ class _SessionDetailsSheet extends StatelessWidget { ), ], ), - + const SizedBox(height: AppSpacing.lg), const Divider(), const SizedBox(height: AppSpacing.md), - + // Exercises - ...session.exercises.map((log) => _ExerciseDetailCard( - log: log, - provider: provider, - )), - + ...session.exercises.map( + (log) => _ExerciseDetailCard(log: log, provider: provider), + ), + if (session.notes != null && session.notes!.isNotEmpty) ...[ const SizedBox(height: AppSpacing.lg), - Text( - 'Notes', - style: Theme.of(context).textTheme.titleMedium, - ), + Text('Notes', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: AppSpacing.sm), - Text( - session.notes!, - style: Theme.of(context).textTheme.bodyMedium, - ), + Text(session.notes!, style: Theme.of(context).textTheme.bodyMedium), ], ], ); } + + void _editSession(BuildContext context) { + Navigator.of(context).pop(); // Close the bottom sheet first + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => EditWorkoutSessionScreen(session: session), + ), + ); + } + + Future _confirmDelete(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppTheme.cardColor, + title: const Text('Delete Workout?'), + content: Text( + 'Are you sure you want to delete this workout from ${DateFormat('MMMM d, yyyy').format(session.date)}? This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: AppTheme.error), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + try { + await provider.deleteWorkoutSession(session.id); + if (context.mounted) { + Navigator.of(context).pop(); // Close the bottom sheet + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: const [ + Icon(Icons.check_circle, color: AppTheme.success), + SizedBox(width: 8), + Text('Workout deleted'), + ], + ), + backgroundColor: AppTheme.cardColor, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to delete: $e'), + backgroundColor: AppTheme.error, + ), + ); + } + } + } + } } class _StatBox extends StatelessWidget { @@ -366,10 +434,7 @@ class _StatBox extends StatelessWidget { const SizedBox(height: 4), Text( label, - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary, - ), + style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary), ), ], ), @@ -381,10 +446,7 @@ class _ExerciseDetailCard extends StatelessWidget { final ExerciseLog log; final WorkoutProvider provider; - const _ExerciseDetailCard({ - required this.log, - required this.provider, - }); + const _ExerciseDetailCard({required this.log, required this.provider}); @override Widget build(BuildContext context) { @@ -461,7 +523,10 @@ class _ExerciseDetailCard extends StatelessWidget { if (set.isDropset) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( color: AppTheme.warning.withOpacity(0.2), borderRadius: BorderRadius.circular(4), diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 9f34f7c..cac3558 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -101,12 +101,36 @@ class WorkoutProvider extends ChangeNotifier { // ==================== CUSTOM EXERCISES ==================== + /// Allowed category values for exercises + static const Set _allowedCategories = {'compound', 'isolation'}; + /// Add a custom exercise created by the user + /// + /// Throws [ArgumentError] if inputs are invalid. Future addCustomExercise({ required String name, required String category, required String primaryMuscleGroupId, }) async { + // Validate and normalize name (trim and collapse whitespace) + final normalizedName = name.trim().replaceAll(RegExp(r'\s+'), ' '); + if (normalizedName.isEmpty) { + throw ArgumentError('Exercise name cannot be empty'); + } + + // Validate primaryMuscleGroupId + if (primaryMuscleGroupId.isEmpty) { + throw ArgumentError('Primary muscle group is required'); + } + + // Normalize and validate category + final normalizedCategory = category.toLowerCase().trim(); + if (!_allowedCategories.contains(normalizedCategory)) { + throw ArgumentError( + 'Invalid category "$category". Must be one of: ${_allowedCategories.join(", ")}', + ); + } + // Generate unique ID final id = 'custom_${_uuid.v4()}'; @@ -118,12 +142,12 @@ class WorkoutProvider extends ChangeNotifier { ), ]; - // Create exercise with isCustom flag + // Create exercise with isCustom flag using normalized values final exercise = Exercise( id: id, - name: name, + name: normalizedName, muscleActivations: muscleActivations, - category: category, + category: normalizedCategory, isCustom: true, ); @@ -331,6 +355,53 @@ class WorkoutProvider extends ChangeNotifier { return null; } + // ==================== SESSION MANAGEMENT ==================== + + /// Delete a workout session + Future deleteWorkoutSession(String sessionId) async { + // Find the session to get exercise IDs for model retraining + final session = _sessions.firstWhere( + (s) => s.id == sessionId, + orElse: () => throw Exception('Session not found'), + ); + final affectedExerciseIds = session.exercises.map((e) => e.exerciseId).toSet(); + + // Remove from storage + await _storage.deleteWorkoutSession(sessionId); + + // Remove from local list + _sessions = List.from(_sessions)..removeWhere((s) => s.id == sessionId); + + // Retrain growth models for affected exercises + for (var exerciseId in affectedExerciseIds) { + await _updateGrowthModel(exerciseId); + } + + notifyListeners(); + } + + /// Update an existing workout session + Future updateWorkoutSession(WorkoutSession updatedSession) async { + // Save to storage (overwrites by ID) + await _storage.saveWorkoutSession(updatedSession); + + // Update local list + final index = _sessions.indexWhere((s) => s.id == updatedSession.id); + if (index != -1) { + _sessions = List.from(_sessions)..[index] = updatedSession; + } + + // Sort sessions by date (most recent first) + _sessions.sort((a, b) => b.date.compareTo(a.date)); + + // Retrain growth models for affected exercises + for (var log in updatedSession.exercises) { + await _updateGrowthModel(log.exerciseId); + } + + notifyListeners(); + } + // ==================== ROUTINES ==================== Future createRoutine(String name, List exerciseIds) async { From 64c712642a120ebd43323202a73a186d871a9494 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Mon, 26 Jan 2026 00:18:05 +0530 Subject: [PATCH 4/8] feat: Implement core workout tracking features including custom exercise creation, workout history, session editing, and supporting models, services, and tests. --- workout-logger/lib/models/models.dart | 67 ++-- .../screens/add_custom_exercise_screen.dart | 1 - .../screens/edit_workout_session_screen.dart | 27 +- .../lib/screens/history_screen.dart | 12 +- .../lib/services/workout_provider.dart | 95 +++-- workout-logger/pubspec.lock | 256 ++++++++++++ workout-logger/pubspec.yaml | 4 + .../test/add_custom_exercise_screen_test.dart | 275 +++++++++++++ .../test/exercise_library_screen_test.dart | 368 ++++++++++++++++++ .../test/workout_provider_test.dart | 302 ++++++++++++++ 10 files changed, 1340 insertions(+), 67 deletions(-) create mode 100644 workout-logger/test/add_custom_exercise_screen_test.dart create mode 100644 workout-logger/test/exercise_library_screen_test.dart create mode 100644 workout-logger/test/workout_provider_test.dart diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index e261d56..c1dfe1a 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -1,5 +1,8 @@ // Data Models for Workout Logger App +// Sentinel value for copyWith methods to distinguish "not provided" from "null" +const Object _sentinel = Object(); + // ==================== Muscle Groups ==================== class MuscleGroup { @@ -147,19 +150,19 @@ class WorkoutSet { ); WorkoutSet copyWith({ - double? weight, - int? reps, - bool? isDropset, - List? drops, - int? timeTaken, - DateTime? timestamp, + Object? weight = _sentinel, + Object? reps = _sentinel, + Object? isDropset = _sentinel, + Object? drops = _sentinel, + Object? timeTaken = _sentinel, + Object? timestamp = _sentinel, }) => WorkoutSet( - weight: weight ?? this.weight, - reps: reps ?? this.reps, - isDropset: isDropset ?? this.isDropset, - drops: drops ?? this.drops, - timeTaken: timeTaken ?? this.timeTaken, - timestamp: timestamp ?? this.timestamp, + weight: weight == _sentinel ? this.weight : weight as double, + reps: reps == _sentinel ? this.reps : reps as int, + isDropset: isDropset == _sentinel ? this.isDropset : isDropset as bool, + drops: drops == _sentinel ? this.drops : drops as List?, + timeTaken: timeTaken == _sentinel ? this.timeTaken : timeTaken as int?, + timestamp: timestamp == _sentinel ? this.timestamp : timestamp as DateTime?, ); } @@ -201,13 +204,15 @@ class ExerciseLog { ); ExerciseLog copyWith({ - String? exerciseId, - List? sets, - String? notes, + Object? exerciseId = _sentinel, + Object? sets = _sentinel, + Object? notes = _sentinel, }) => ExerciseLog( - exerciseId: exerciseId ?? this.exerciseId, - sets: sets ?? this.sets, - notes: notes ?? this.notes, + exerciseId: exerciseId == _sentinel + ? this.exerciseId + : exerciseId as String, + sets: sets == _sentinel ? this.sets : sets as List, + notes: notes == _sentinel ? this.notes : notes as String?, ); } @@ -254,19 +259,21 @@ class WorkoutSession { ); WorkoutSession copyWith({ - String? id, - DateTime? date, - String? routineId, - List? exercises, - int? duration, - String? notes, + Object? id = _sentinel, + Object? date = _sentinel, + Object? routineId = _sentinel, + Object? exercises = _sentinel, + Object? duration = _sentinel, + Object? notes = _sentinel, }) => WorkoutSession( - id: id ?? this.id, - date: date ?? this.date, - routineId: routineId ?? this.routineId, - exercises: exercises ?? this.exercises, - duration: duration ?? this.duration, - notes: notes ?? this.notes, + id: id == _sentinel ? this.id : id as String, + date: date == _sentinel ? this.date : date as DateTime, + routineId: routineId == _sentinel ? this.routineId : routineId as String?, + exercises: exercises == _sentinel + ? this.exercises + : exercises as List, + duration: duration == _sentinel ? this.duration : duration as int, + notes: notes == _sentinel ? this.notes : notes as String?, ); } diff --git a/workout-logger/lib/screens/add_custom_exercise_screen.dart b/workout-logger/lib/screens/add_custom_exercise_screen.dart index 32fe958..ba75d83 100644 --- a/workout-logger/lib/screens/add_custom_exercise_screen.dart +++ b/workout-logger/lib/screens/add_custom_exercise_screen.dart @@ -1,6 +1,5 @@ // Add Custom Exercise Screen - Form for creating user-defined exercises -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; diff --git a/workout-logger/lib/screens/edit_workout_session_screen.dart b/workout-logger/lib/screens/edit_workout_session_screen.dart index b2662f5..9c509ed 100644 --- a/workout-logger/lib/screens/edit_workout_session_screen.dart +++ b/workout-logger/lib/screens/edit_workout_session_screen.dart @@ -1,5 +1,6 @@ // Edit Workout Session Screen - Modify recorded workout sessions +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -38,7 +39,7 @@ class _EditWorkoutSessionScreenState extends State { text: widget.session.duration.toString(), ); - // Convert to editable structure + // Convert to editable structure, preserving all set metadata _editableExercises = widget.session.exercises.map((log) { return _EditableExerciseLog( exerciseId: log.exerciseId, @@ -48,6 +49,9 @@ class _EditWorkoutSessionScreenState extends State { weight: set.weight, reps: set.reps, isDropset: set.isDropset, + drops: set.drops, + timeTaken: set.timeTaken, + timestamp: set.timestamp, ), ) .toList(), @@ -143,6 +147,9 @@ class _EditWorkoutSessionScreenState extends State { weight: lastSet?.weight ?? 0, reps: lastSet?.reps ?? 0, isDropset: false, + drops: null, + timeTaken: null, + timestamp: DateTime.now(), ), ); }); @@ -193,7 +200,7 @@ class _EditWorkoutSessionScreenState extends State { setState(() => _isSubmitting = true); try { - // Convert editable exercises back to ExerciseLog + // Convert editable exercises back to ExerciseLog, preserving metadata final updatedExercises = exercisesWithSets.map((e) { return ExerciseLog( exerciseId: e.exerciseId, @@ -203,6 +210,9 @@ class _EditWorkoutSessionScreenState extends State { weight: s.weight, reps: s.reps, isDropset: s.isDropset, + drops: s.drops, + timeTaken: s.timeTaken, + timestamp: s.timestamp, ), ) .toList(), @@ -238,10 +248,11 @@ class _EditWorkoutSessionScreenState extends State { Navigator.of(context).pop(true); // Return success } } catch (e) { + debugPrint('Failed to save workout session: $e'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to save: $e'), + const SnackBar( + content: Text('Failed to save workout. Please try again.'), backgroundColor: AppTheme.error, ), ); @@ -560,12 +571,18 @@ class _EditableSet { double weight; int reps; bool isDropset; + List? drops; + int? timeTaken; + DateTime timestamp; _EditableSet({ required this.weight, required this.reps, this.isDropset = false, - }); + this.drops, + this.timeTaken, + DateTime? timestamp, + }) : timestamp = timestamp ?? DateTime.now(); } // Editable Exercise Card Widget diff --git a/workout-logger/lib/screens/history_screen.dart b/workout-logger/lib/screens/history_screen.dart index b76e0c7..f78477d 100644 --- a/workout-logger/lib/screens/history_screen.dart +++ b/workout-logger/lib/screens/history_screen.dart @@ -1,5 +1,6 @@ // History Screen - View past workout sessions +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; @@ -339,8 +340,10 @@ class _SessionDetailsSheet extends StatelessWidget { } void _editSession(BuildContext context) { - Navigator.of(context).pop(); // Close the bottom sheet first - Navigator.of(context).push( + // Capture navigator before pop to avoid using deactivated context + final navigator = Navigator.of(context); + navigator.pop(); // Close the bottom sheet first + navigator.push( MaterialPageRoute( builder: (context) => EditWorkoutSessionScreen(session: session), ), @@ -389,10 +392,11 @@ class _SessionDetailsSheet extends StatelessWidget { ); } } catch (e) { + debugPrint('Failed to delete workout session: $e'); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to delete: $e'), + const SnackBar( + content: Text('Failed to delete workout. Please try again.'), backgroundColor: AppTheme.error, ), ); diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index cac3558..637532e 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -17,7 +17,8 @@ class WorkoutProvider extends ChangeNotifier { List _targets = []; List _muscleGroups = []; List _allExercises = []; - final Map _growthModels = {}; // exerciseId -> GrowthModel + final Map _growthModels = + {}; // exerciseId -> GrowthModel // Active workout state WorkoutSession? _activeSession; @@ -32,8 +33,9 @@ class WorkoutProvider extends ChangeNotifier { List get targets => _targets; List get muscleGroups => _muscleGroups; List get allExercises => _allExercises; - - bool get hasActiveWorkout => _activeSession != null || _workoutStartTime != null; + + bool get hasActiveWorkout => + _activeSession != null || _workoutStartTime != null; Routine? get activeRoutine => _activeRoutine; int get currentExerciseIndex => _currentExerciseIndex; List get currentExerciseLogs => _currentExerciseLogs; @@ -60,7 +62,7 @@ class WorkoutProvider extends ChangeNotifier { Future _trainAllGrowthModels() async { final exerciseIds = {}; - + // Get all unique exercise IDs from sessions for (var session in _sessions) { for (var log in session.exercises) { @@ -75,7 +77,10 @@ class WorkoutProvider extends ChangeNotifier { } Future _updateGrowthModel(String exerciseId) async { - final dataPoints = MLService.extractExerciseDataPoints(exerciseId, _sessions); + final dataPoints = MLService.extractExerciseDataPoints( + exerciseId, + _sessions, + ); if (dataPoints.length >= 2) { _growthModels[exerciseId] = MLService.trainGrowthModel(dataPoints); } @@ -105,7 +110,7 @@ class WorkoutProvider extends ChangeNotifier { static const Set _allowedCategories = {'compound', 'isolation'}; /// Add a custom exercise created by the user - /// + /// /// Throws [ArgumentError] if inputs are invalid. Future addCustomExercise({ required String name, @@ -199,7 +204,7 @@ class WorkoutProvider extends ChangeNotifier { /// Get current exercise being performed Exercise? get currentExercise { - if (_currentExerciseLogs.isEmpty || + if (_currentExerciseLogs.isEmpty || _currentExerciseIndex >= _currentExerciseLogs.length) { return null; } @@ -209,7 +214,7 @@ class WorkoutProvider extends ChangeNotifier { /// Get current exercise log ExerciseLog? get currentExerciseLog { - if (_currentExerciseLogs.isEmpty || + if (_currentExerciseLogs.isEmpty || _currentExerciseIndex >= _currentExerciseLogs.length) { return null; } @@ -364,15 +369,19 @@ class WorkoutProvider extends ChangeNotifier { (s) => s.id == sessionId, orElse: () => throw Exception('Session not found'), ); - final affectedExerciseIds = session.exercises.map((e) => e.exerciseId).toSet(); + // All exercises in the deleted session need their growth models retrained + final affectedExerciseIds = session.exercises + .map((e) => e.exerciseId) + .toSet(); - // Remove from storage + // Remove from storage first await _storage.deleteWorkoutSession(sessionId); // Remove from local list _sessions = List.from(_sessions)..removeWhere((s) => s.id == sessionId); - // Retrain growth models for affected exercises + // Retrain growth models for all affected exercises + // (their data has changed because a session was removed) for (var exerciseId in affectedExerciseIds) { await _updateGrowthModel(exerciseId); } @@ -382,6 +391,24 @@ class WorkoutProvider extends ChangeNotifier { /// Update an existing workout session Future updateWorkoutSession(WorkoutSession updatedSession) async { + // Find the previous version of the session to compare exercises + final previousSession = _sessions.firstWhere( + (s) => s.id == updatedSession.id, + orElse: () => updatedSession, // Fallback if not found (shouldn't happen) + ); + + // Gather exercise IDs from BOTH previous and updated sessions + // so we retrain models for exercises that were added OR removed + final previousExerciseIds = previousSession.exercises + .map((e) => e.exerciseId) + .toSet(); + final updatedExerciseIds = updatedSession.exercises + .map((e) => e.exerciseId) + .toSet(); + final allAffectedExerciseIds = previousExerciseIds.union( + updatedExerciseIds, + ); + // Save to storage (overwrites by ID) await _storage.saveWorkoutSession(updatedSession); @@ -394,9 +421,10 @@ class WorkoutProvider extends ChangeNotifier { // Sort sessions by date (most recent first) _sessions.sort((a, b) => b.date.compareTo(a.date)); - // Retrain growth models for affected exercises - for (var log in updatedSession.exercises) { - await _updateGrowthModel(log.exerciseId); + // Retrain growth models for ALL affected exercises + // (both exercises that were in the old session and exercises in the new session) + for (var exerciseId in allAffectedExerciseIds) { + await _updateGrowthModel(exerciseId); } notifyListeners(); @@ -443,10 +471,15 @@ class WorkoutProvider extends ChangeNotifier { if (lastLog != null && lastLog.sets.isNotEmpty) { switch (type) { case 'reps': - currentValue = lastLog.sets.map((s) => s.reps).reduce((a, b) => a > b ? a : b).toDouble(); + currentValue = lastLog.sets + .map((s) => s.reps) + .reduce((a, b) => a > b ? a : b) + .toDouble(); break; case 'weight': - currentValue = lastLog.sets.map((s) => s.weight).reduce((a, b) => a > b ? a : b); + currentValue = lastLog.sets + .map((s) => s.weight) + .reduce((a, b) => a > b ? a : b); break; case 'volume': currentValue = lastLog.totalVolume; @@ -481,18 +514,23 @@ class WorkoutProvider extends ChangeNotifier { Future _updateTargetsFromSession(WorkoutSession session) async { for (var log in session.exercises) { - final exerciseTargets = _targets.where((t) => - t.exerciseId == log.exerciseId && !t.isCompleted - ).toList(); + final exerciseTargets = _targets + .where((t) => t.exerciseId == log.exerciseId && !t.isCompleted) + .toList(); for (var target in exerciseTargets) { double newValue = 0; switch (target.targetType) { case 'reps': - newValue = log.sets.map((s) => s.reps).reduce((a, b) => a > b ? a : b).toDouble(); + newValue = log.sets + .map((s) => s.reps) + .reduce((a, b) => a > b ? a : b) + .toDouble(); break; case 'weight': - newValue = log.sets.map((s) => s.weight).reduce((a, b) => a > b ? a : b); + newValue = log.sets + .map((s) => s.weight) + .reduce((a, b) => a > b ? a : b); break; case 'volume': newValue = log.totalVolume; @@ -527,9 +565,11 @@ class WorkoutProvider extends ChangeNotifier { // ==================== ANALYTICS ==================== /// Get volume progression for an exercise - List<({DateTime date, double volume})> getVolumeProgression(String exerciseId) { + List<({DateTime date, double volume})> getVolumeProgression( + String exerciseId, + ) { final data = <({DateTime date, double volume})>[]; - + for (var session in _sessions.reversed) { for (var log in session.exercises) { if (log.exerciseId == exerciseId) { @@ -538,7 +578,7 @@ class WorkoutProvider extends ChangeNotifier { } } } - + return data; } @@ -555,9 +595,10 @@ class WorkoutProvider extends ChangeNotifier { if (exercise == null) continue; for (var activation in exercise.muscleActivations) { - final muscleVolume = log.totalVolume * (activation.activationPercentage / 100); - volumeByMuscle[activation.muscleGroupId] = - (volumeByMuscle[activation.muscleGroupId] ?? 0) + muscleVolume; + final muscleVolume = + log.totalVolume * (activation.activationPercentage / 100); + volumeByMuscle[activation.muscleGroupId] = + (volumeByMuscle[activation.muscleGroupId] ?? 0) + muscleVolume; } } } diff --git a/workout-logger/pubspec.lock b/workout-logger/pubspec.lock index 01afb76..08d743d 100644 --- a/workout-logger/pubspec.lock +++ b/workout-logger/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" + url: "https://pub.dev" + source: hosted + version: "93.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b + url: "https://pub.dev" + source: hosted + version: "10.0.1" archive: dependency: transitive description: @@ -33,6 +49,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: b4d854962a32fd9f8efc0b76f98214790b833af8b2e9b2df6bfc927c0415a072 + url: "https://pub.dev" + source: hosted + version: "2.10.5" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8" + url: "https://pub.dev" + source: hosted + version: "8.12.3" characters: dependency: transitive description: @@ -65,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" collection: dependency: transitive description: @@ -73,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" crypto: dependency: transitive description: @@ -89,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0aa2b9bae196552b71575efc94580e447546c26c7120577bb6f81fbd33b52e" + url: "https://pub.dev" + source: hosted + version: "3.1.4" equatable: dependency: transitive description: @@ -113,6 +201,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" fixnum: dependency: transitive description: @@ -155,6 +251,22 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" hive: dependency: "direct main" description: @@ -171,6 +283,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" image: dependency: transitive description: @@ -187,6 +315,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" json_annotation: dependency: transitive description: @@ -227,6 +363,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -251,6 +395,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: a45d1aa065b796922db7b9e7e7e45f921aed17adf3a8318a1f47097e7e695566 + url: "https://pub.dev" + source: hosted + version: "5.6.3" nested: dependency: transitive description: @@ -259,6 +419,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" path: dependency: transitive description: @@ -339,6 +507,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" posix: dependency: transitive description: @@ -355,11 +531,51 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" + url: "https://pub.dev" + source: hosted + version: "4.2.0" source_span: dependency: transitive description: @@ -384,6 +600,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -440,6 +664,38 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" xdg_directories: dependency: transitive description: diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index 45a9649..8ed7292 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -60,6 +60,10 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^5.0.0 flutter_launcher_icons: ^0.13.1 + + # Testing + mockito: ^5.4.4 + build_runner: ^2.4.8 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/workout-logger/test/add_custom_exercise_screen_test.dart b/workout-logger/test/add_custom_exercise_screen_test.dart new file mode 100644 index 0000000..eb7f1ae --- /dev/null +++ b/workout-logger/test/add_custom_exercise_screen_test.dart @@ -0,0 +1,275 @@ +// Widget Tests for AddCustomExerciseScreen + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/add_custom_exercise_screen.dart'; +import 'package:repforge/services/storage_service.dart'; +import 'package:repforge/services/workout_provider.dart'; + +// Mock StorageService for testing - all methods return Future +class MockStorageService implements StorageService { + final List _customExercises = []; + bool saveCustomExerciseCalled = false; + Exercise? lastSavedExercise; + + @override + Future init() async {} + + @override + Future> getAllExercises() async => _customExercises; + + @override + Future> getCustomExercises() async => _customExercises; + + @override + Future saveCustomExercise(Exercise exercise) async { + saveCustomExerciseCalled = true; + lastSavedExercise = exercise; + _customExercises.add(exercise); + } + + @override + Future deleteCustomExercise(String id) async { + _customExercises.removeWhere((e) => e.id == id); + } + + @override + Future> getAllWorkoutSessions() async => []; + + @override + Future> getAllRoutines() async => []; + + @override + Future> getAllMuscleGroups() async => []; + + @override + Future> getAllTargets() async => []; + + @override + Future saveWorkoutSession(WorkoutSession session) async {} + @override + Future getWorkoutSession(String id) async => null; + @override + Future deleteWorkoutSession(String id) async {} + @override + Future> getSessionsForExercise( + String exerciseId, + ) async => []; + @override + Future> getSessionsInDateRange( + DateTime start, + DateTime end, + ) async => []; + @override + Future saveRoutine(Routine routine) async {} + @override + Future getRoutine(String id) async => null; + @override + Future deleteRoutine(String id) async {} + @override + Future saveTarget(Target target) async {} + @override + Future getTarget(String id) async => null; + @override + Future deleteTarget(String id) async {} + @override + Future> getTargetsForExercise(String exerciseId) async => []; + @override + Future updateMuscleGroupGrowthRate( + String muscleGroupId, + double rate, + ) async {} + @override + Future getMuscleGroup(String id) async => null; + @override + Future getExercise(String id) async => null; + @override + Future saveSetting(String key, String value) async {} + @override + Future getSetting(String key) async => null; + @override + Future exportAllData() async => '{}'; + @override + Future importData(String jsonData) async {} + @override + Future> getQuickStats() async => {}; +} + +Widget createTestWidget({ + required Widget child, + required WorkoutProvider provider, +}) { + return ChangeNotifierProvider.value( + value: provider, + child: MaterialApp(home: child), + ); +} + +void main() { + group('AddCustomExerciseScreen Widget Tests', () { + late MockStorageService mockStorage; + late WorkoutProvider provider; + + setUp(() async { + mockStorage = MockStorageService(); + provider = WorkoutProvider(mockStorage); + await provider.init(); + }); + + testWidgets('should show validation error for empty name', (tester) async { + // Arrange + await tester.pumpWidget( + createTestWidget( + child: const AddCustomExerciseScreen(), + provider: provider, + ), + ); + + // Act - Try to save without entering a name + // First select a muscle group (required) + await tester.tap(find.text('Chest')); + await tester.pump(); + + // Find and tap the Save button in AppBar + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Assert - Should show validation error + expect(find.text('Please enter an exercise name'), findsOneWidget); + }); + + testWidgets( + 'should show validation error for name less than 3 characters', + (tester) async { + // Arrange + await tester.pumpWidget( + createTestWidget( + child: const AddCustomExerciseScreen(), + provider: provider, + ), + ); + + // Act - Enter a short name + await tester.enterText(find.byType(TextFormField), 'Ab'); + await tester.tap(find.text('Chest')); + await tester.pump(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Assert + expect(find.text('Name must be at least 3 characters'), findsOneWidget); + }, + ); + + testWidgets('should show error when no muscle group selected', ( + tester, + ) async { + // Arrange + await tester.pumpWidget( + createTestWidget( + child: const AddCustomExerciseScreen(), + provider: provider, + ), + ); + + // Act - Enter valid name but don't select muscle group + await tester.enterText(find.byType(TextFormField), 'My Exercise'); + await tester.pump(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Assert - Should show snackbar error + expect(find.text('Please select a primary muscle group'), findsOneWidget); + }); + + testWidgets('should call provider method on valid form submission', ( + tester, + ) async { + // Arrange + await tester.pumpWidget( + createTestWidget( + child: const AddCustomExerciseScreen(), + provider: provider, + ), + ); + + // Act - Fill in valid form + await tester.enterText(find.byType(TextFormField), 'Cable Lateral Raise'); + await tester.pump(); + + // Select a muscle group + await tester.tap(find.text('Shoulders')); + await tester.pump(); + + // Submit + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Assert - Storage should have been called + expect(mockStorage.saveCustomExerciseCalled, isTrue); + expect( + mockStorage.lastSavedExercise?.name, + equals('Cable Lateral Raise'), + ); + }); + + testWidgets('should show compound/isolation toggle buttons', ( + tester, + ) async { + // Arrange + await tester.pumpWidget( + createTestWidget( + child: const AddCustomExerciseScreen(), + provider: provider, + ), + ); + await tester.pumpAndSettle(); + + // Assert + expect(find.text('Compound'), findsOneWidget); + expect(find.text('Isolation'), findsOneWidget); + }); + + testWidgets('should display all muscle group options', (tester) async { + // Arrange + await tester.pumpWidget( + createTestWidget( + child: const AddCustomExerciseScreen(), + provider: provider, + ), + ); + await tester.pumpAndSettle(); + + // Assert - Check for some muscle groups + expect(find.text('Chest'), findsOneWidget); + expect(find.text('Back'), findsOneWidget); + expect(find.text('Shoulders'), findsOneWidget); + expect(find.text('Biceps'), findsOneWidget); + }); + + testWidgets('should update category when toggle is tapped', (tester) async { + // Arrange + await tester.pumpWidget( + createTestWidget( + child: const AddCustomExerciseScreen(), + provider: provider, + ), + ); + await tester.pumpAndSettle(); + + // Act - Tap Isolation + await tester.tap(find.text('Isolation')); + await tester.pumpAndSettle(); + + // Assert - Description should change + expect( + find.text('Targets a single muscle group (e.g., bicep curls)'), + findsOneWidget, + ); + }); + }); +} diff --git a/workout-logger/test/exercise_library_screen_test.dart b/workout-logger/test/exercise_library_screen_test.dart new file mode 100644 index 0000000..da531bd --- /dev/null +++ b/workout-logger/test/exercise_library_screen_test.dart @@ -0,0 +1,368 @@ +// Widget Tests for ExerciseLibraryScreen + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/exercise_library_screen.dart'; +import 'package:repforge/services/storage_service.dart'; +import 'package:repforge/services/workout_provider.dart'; + +// Mock StorageService for testing - all methods return Future +class MockStorageService implements StorageService { + final List _customExercises = []; + + void addMockCustomExercise(Exercise exercise) { + _customExercises.add(exercise); + } + + @override + Future init() async {} + + @override + Future> getAllExercises() async => _customExercises; + + @override + Future> getCustomExercises() async => _customExercises; + + @override + Future saveCustomExercise(Exercise exercise) async { + _customExercises.add(exercise); + } + + @override + Future deleteCustomExercise(String id) async { + _customExercises.removeWhere((e) => e.id == id); + } + + @override + Future> getAllWorkoutSessions() async => []; + + @override + Future> getAllRoutines() async => []; + + @override + Future> getAllMuscleGroups() async => []; + + @override + Future> getAllTargets() async => []; + + @override + Future saveWorkoutSession(WorkoutSession session) async {} + @override + Future getWorkoutSession(String id) async => null; + @override + Future deleteWorkoutSession(String id) async {} + @override + Future> getSessionsForExercise( + String exerciseId, + ) async => []; + @override + Future> getSessionsInDateRange( + DateTime start, + DateTime end, + ) async => []; + @override + Future saveRoutine(Routine routine) async {} + @override + Future getRoutine(String id) async => null; + @override + Future deleteRoutine(String id) async {} + @override + Future saveTarget(Target target) async {} + @override + Future getTarget(String id) async => null; + @override + Future deleteTarget(String id) async {} + @override + Future> getTargetsForExercise(String exerciseId) async => []; + @override + Future updateMuscleGroupGrowthRate( + String muscleGroupId, + double rate, + ) async {} + @override + Future getMuscleGroup(String id) async => null; + @override + Future getExercise(String id) async => null; + @override + Future saveSetting(String key, String value) async {} + @override + Future getSetting(String key) async => null; + @override + Future exportAllData() async => '{}'; + @override + Future importData(String jsonData) async {} + @override + Future> getQuickStats() async => {}; +} + +Widget createTestWidget({ + required Widget child, + required WorkoutProvider provider, +}) { + return ChangeNotifierProvider.value( + value: provider, + child: MaterialApp(home: child), + ); +} + +void main() { + group('ExerciseLibraryScreen Widget Tests', () { + late MockStorageService mockStorage; + late WorkoutProvider provider; + + setUp(() async { + mockStorage = MockStorageService(); + provider = WorkoutProvider(mockStorage); + await provider.init(); + }); + + testWidgets('should display search bar', (tester) async { + // Arrange + await tester.pumpWidget( + createTestWidget( + child: const ExerciseLibraryScreen(), + provider: provider, + ), + ); + await tester.pumpAndSettle(); + + // Assert + expect(find.byIcon(Icons.search), findsOneWidget); + expect(find.text('Search exercises...'), findsOneWidget); + }); + + testWidgets('should display FAB to add custom exercise', (tester) async { + // Arrange + await tester.pumpWidget( + createTestWidget( + child: const ExerciseLibraryScreen(), + provider: provider, + ), + ); + await tester.pumpAndSettle(); + + // Assert + expect(find.byType(FloatingActionButton), findsOneWidget); + expect(find.text('Add Exercise'), findsOneWidget); + }); + + testWidgets('should display custom exercises in the list', (tester) async { + // Arrange - Add a custom exercise + await provider.addCustomExercise( + name: 'My Custom Curl', + category: 'isolation', + primaryMuscleGroupId: 'biceps', + ); + + await tester.pumpWidget( + createTestWidget( + child: const ExerciseLibraryScreen(), + provider: provider, + ), + ); + await tester.pumpAndSettle(); + + // Assert - Should find the custom exercise + expect(find.text('My Custom Curl'), findsOneWidget); + }); + + testWidgets('should display CUSTOM tag for custom exercises', ( + tester, + ) async { + // Arrange - Add a custom exercise + await provider.addCustomExercise( + name: 'Tagged Custom Exercise', + category: 'compound', + primaryMuscleGroupId: 'chest', + ); + + await tester.pumpWidget( + createTestWidget( + child: const ExerciseLibraryScreen(), + provider: provider, + ), + ); + await tester.pumpAndSettle(); + + // Assert - Should find the CUSTOM tag + expect(find.text('CUSTOM'), findsOneWidget); + }); + + testWidgets('should display custom exercise count in header when present', ( + tester, + ) async { + // Arrange - Add a custom exercise + await provider.addCustomExercise( + name: 'Count Test Exercise', + category: 'isolation', + primaryMuscleGroupId: 'shoulders', + ); + + await tester.pumpWidget( + createTestWidget( + child: const ExerciseLibraryScreen(), + provider: provider, + ), + ); + await tester.pumpAndSettle(); + + // Assert - Should show custom count + expect(find.text('1 custom'), findsOneWidget); + }); + + testWidgets('should filter exercises by search query', (tester) async { + // Arrange - Add custom exercises + await provider.addCustomExercise( + name: 'Bicep Curl', + category: 'isolation', + primaryMuscleGroupId: 'biceps', + ); + await provider.addCustomExercise( + name: 'Tricep Pushdown', + category: 'isolation', + primaryMuscleGroupId: 'triceps', + ); + + await tester.pumpWidget( + createTestWidget( + child: const ExerciseLibraryScreen(), + provider: provider, + ), + ); + await tester.pumpAndSettle(); + + // Act - Enter search query + await tester.enterText(find.byType(TextField), 'Bicep'); + await tester.pumpAndSettle(); + + // Assert - Should only show matching exercise + expect(find.text('Bicep Curl'), findsOneWidget); + expect(find.text('Tricep Pushdown'), findsNothing); + }); + + testWidgets('should show muscle group filter chips', (tester) async { + // Arrange + await tester.pumpWidget( + createTestWidget( + child: const ExerciseLibraryScreen(), + provider: provider, + ), + ); + await tester.pumpAndSettle(); + + // Assert - Should show All filter and some muscle groups + expect(find.text('All'), findsOneWidget); + }); + + testWidgets('should navigate to add screen when FAB is tapped', ( + tester, + ) async { + // Arrange + await tester.pumpWidget( + createTestWidget( + child: const ExerciseLibraryScreen(), + provider: provider, + ), + ); + await tester.pumpAndSettle(); + + // Act - Tap the FAB + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + // Assert - Should navigate to AddCustomExerciseScreen + expect(find.text('Add Custom Exercise'), findsOneWidget); + }); + }); + + group('ExerciseLibraryScreen - Delete Functionality', () { + late MockStorageService mockStorage; + late WorkoutProvider provider; + + setUp(() async { + mockStorage = MockStorageService(); + provider = WorkoutProvider(mockStorage); + await provider.init(); + + // Add a custom exercise for delete tests + await provider.addCustomExercise( + name: 'Exercise To Delete', + category: 'compound', + primaryMuscleGroupId: 'back', + ); + }); + + testWidgets('should show exercise details when tapped', (tester) async { + // Arrange + await tester.pumpWidget( + createTestWidget( + child: const ExerciseLibraryScreen(), + provider: provider, + ), + ); + await tester.pumpAndSettle(); + + // Act - Tap on the custom exercise + await tester.tap(find.text('Exercise To Delete')); + await tester.pumpAndSettle(); + + // Assert - Should show details sheet with delete option + expect(find.byIcon(Icons.delete_outline), findsOneWidget); + }); + + testWidgets('should show confirmation dialog when delete is tapped', ( + tester, + ) async { + // Arrange + await tester.pumpWidget( + createTestWidget( + child: const ExerciseLibraryScreen(), + provider: provider, + ), + ); + await tester.pumpAndSettle(); + + // Act - Tap on the custom exercise to open details + await tester.tap(find.text('Exercise To Delete')); + await tester.pumpAndSettle(); + + // Tap delete button + await tester.tap(find.byIcon(Icons.delete_outline)); + await tester.pumpAndSettle(); + + // Assert - Should show confirmation dialog + expect(find.text('Delete Custom Exercise?'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Delete'), findsWidgets); + }); + + testWidgets('should cancel delete when Cancel is tapped', (tester) async { + // Arrange + await tester.pumpWidget( + createTestWidget( + child: const ExerciseLibraryScreen(), + provider: provider, + ), + ); + await tester.pumpAndSettle(); + + // Act - Open details and tap delete + await tester.tap(find.text('Exercise To Delete')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.delete_outline)); + await tester.pumpAndSettle(); + + // Tap Cancel + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + // Assert - Exercise should still exist + expect( + provider.allExercises.any((e) => e.name == 'Exercise To Delete'), + isTrue, + ); + }); + }); +} diff --git a/workout-logger/test/workout_provider_test.dart b/workout-logger/test/workout_provider_test.dart new file mode 100644 index 0000000..3e327f3 --- /dev/null +++ b/workout-logger/test/workout_provider_test.dart @@ -0,0 +1,302 @@ +// Unit Tests for WorkoutProvider - Custom Exercise functionality + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/storage_service.dart'; +import 'package:repforge/services/workout_provider.dart'; + +// Mock class for StorageService +class MockStorageService extends Mock implements StorageService { + final List _customExercises = []; + final List _sessions = []; + final List _routines = []; + + @override + Future init() async {} + + @override + Future> getAllExercises() async { + return _customExercises; + } + + @override + Future> getCustomExercises() async { + return _customExercises; + } + + @override + Future saveCustomExercise(Exercise exercise) async { + _customExercises.add(exercise); + } + + @override + Future deleteCustomExercise(String id) async { + _customExercises.removeWhere((e) => e.id == id); + } + + @override + Future> getAllWorkoutSessions() async { + return _sessions; + } + + @override + Future> getAllRoutines() async { + return _routines; + } + + @override + Future> getAllMuscleGroups() async { + return []; + } + + @override + Future> getAllTargets() async { + return []; + } + + List get customExercises => _customExercises; +} + +void main() { + group('WorkoutProvider - Custom Exercise Tests', () { + late MockStorageService mockStorage; + late WorkoutProvider provider; + + setUp(() async { + mockStorage = MockStorageService(); + provider = WorkoutProvider(mockStorage); + await provider.init(); + }); + + group('addCustomExercise', () { + test('should add exercise to the list', () async { + // Arrange + const name = 'Cable Lateral Raise'; + const category = 'isolation'; + const muscleGroup = 'shoulders'; + + // Act + await provider.addCustomExercise( + name: name, + category: category, + primaryMuscleGroupId: muscleGroup, + ); + + // Assert + expect(provider.allExercises.length, greaterThan(0)); + final addedExercise = provider.allExercises.firstWhere( + (e) => e.name == name, + ); + expect(addedExercise, isNotNull); + expect(addedExercise.name, equals(name)); + }); + + test('should set isCustom flag to true', () async { + // Arrange + const name = 'My Custom Exercise'; + const category = 'compound'; + const muscleGroup = 'chest'; + + // Act + await provider.addCustomExercise( + name: name, + category: category, + primaryMuscleGroupId: muscleGroup, + ); + + // Assert + final addedExercise = provider.allExercises.firstWhere( + (e) => e.name == name, + ); + expect(addedExercise.isCustom, isTrue); + }); + + test('should call saveCustomExercise on storage', () async { + // Arrange + const name = 'Test Exercise'; + const category = 'isolation'; + const muscleGroup = 'biceps'; + + // Act + await provider.addCustomExercise( + name: name, + category: category, + primaryMuscleGroupId: muscleGroup, + ); + + // Assert - Check the mock storage was called + expect(mockStorage.customExercises.length, equals(1)); + expect(mockStorage.customExercises.first.name, equals(name)); + }); + + test('should generate unique ID prefixed with custom_', () async { + // Arrange + const name = 'Unique ID Test'; + const category = 'compound'; + const muscleGroup = 'back'; + + // Act + await provider.addCustomExercise( + name: name, + category: category, + primaryMuscleGroupId: muscleGroup, + ); + + // Assert + final addedExercise = provider.allExercises.firstWhere( + (e) => e.name == name, + ); + expect(addedExercise.id, startsWith('custom_')); + }); + + test('should normalize category to lowercase', () async { + // Arrange + const name = 'Category Test'; + const category = 'COMPOUND'; // Uppercase + const muscleGroup = 'legs'; + + // Act + await provider.addCustomExercise( + name: name, + category: category, + primaryMuscleGroupId: muscleGroup, + ); + + // Assert + final addedExercise = provider.allExercises.firstWhere( + (e) => e.name == name, + ); + expect(addedExercise.category, equals('compound')); + }); + + test('should trim and normalize whitespace in name', () async { + // Arrange + const name = ' Whitespace Test '; // Extra spaces + const category = 'isolation'; + const muscleGroup = 'triceps'; + + // Act + await provider.addCustomExercise( + name: name, + category: category, + primaryMuscleGroupId: muscleGroup, + ); + + // Assert + final addedExercise = provider.allExercises.firstWhere( + (e) => e.name == 'Whitespace Test', + ); + expect(addedExercise.name, equals('Whitespace Test')); + }); + + test('should throw ArgumentError for empty name', () async { + // Arrange & Act & Assert + expect( + () => provider.addCustomExercise( + name: '', + category: 'compound', + primaryMuscleGroupId: 'chest', + ), + throwsA(isA()), + ); + }); + + test('should throw ArgumentError for empty muscle group', () async { + // Arrange & Act & Assert + expect( + () => provider.addCustomExercise( + name: 'Valid Name', + category: 'compound', + primaryMuscleGroupId: '', + ), + throwsA(isA()), + ); + }); + + test('should throw ArgumentError for invalid category', () async { + // Arrange & Act & Assert + expect( + () => provider.addCustomExercise( + name: 'Valid Name', + category: 'invalid_category', + primaryMuscleGroupId: 'chest', + ), + throwsA(isA()), + ); + }); + }); + + group('deleteCustomExercise', () { + test('should remove custom exercise from list', () async { + // Arrange - Add an exercise first + await provider.addCustomExercise( + name: 'To Delete', + category: 'compound', + primaryMuscleGroupId: 'chest', + ); + final exercise = provider.allExercises.firstWhere( + (e) => e.name == 'To Delete', + ); + final initialCount = provider.allExercises.length; + + // Act + final result = await provider.deleteCustomExercise(exercise.id); + + // Assert + expect(result, isTrue); + expect(provider.allExercises.length, lessThan(initialCount)); + expect( + provider.allExercises.where((e) => e.name == 'To Delete'), + isEmpty, + ); + }); + + test( + 'should return false when trying to delete non-custom exercise', + () async { + // Arrange - Try to delete a built-in exercise ID + const builtInId = 'bench_press'; + + // Act + final result = await provider.deleteCustomExercise(builtInId); + + // Assert + expect(result, isFalse); + }, + ); + + test('should return false when exercise not found', () async { + // Arrange + const nonExistentId = 'custom_nonexistent'; + + // Act + final result = await provider.deleteCustomExercise(nonExistentId); + + // Assert + expect(result, isFalse); + }); + + test('should call deleteCustomExercise on storage', () async { + // Arrange - Add an exercise first + await provider.addCustomExercise( + name: 'Storage Delete Test', + category: 'isolation', + primaryMuscleGroupId: 'biceps', + ); + final exercise = provider.allExercises.firstWhere( + (e) => e.name == 'Storage Delete Test', + ); + + // Act + await provider.deleteCustomExercise(exercise.id); + + // Assert - Check the mock storage was updated + expect( + mockStorage.customExercises.where((e) => e.id == exercise.id), + isEmpty, + ); + }); + }); + }); +} From 9af7105daa9cd31cc2d2acdf44471f8ec631209b Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Mon, 26 Jan 2026 00:43:28 +0530 Subject: [PATCH 5/8] feat: Introduce workout session editing, history screen, workout provider, and comprehensive tests for exercise library and custom exercise management. --- .../screens/edit_workout_session_screen.dart | 22 ++- .../lib/screens/history_screen.dart | 13 +- .../lib/services/workout_provider.dart | 130 +++++++++++------- .../test/add_custom_exercise_screen_test.dart | 93 +------------ .../test/exercise_library_screen_test.dart | 121 +++++----------- .../test/test_utils/mock_storage_service.dart | 99 +++++++++++++ .../test/workout_provider_test.dart | 58 +------- 7 files changed, 238 insertions(+), 298 deletions(-) create mode 100644 workout-logger/test/test_utils/mock_storage_service.dart diff --git a/workout-logger/lib/screens/edit_workout_session_screen.dart b/workout-logger/lib/screens/edit_workout_session_screen.dart index 9c509ed..e9d187e 100644 --- a/workout-logger/lib/screens/edit_workout_session_screen.dart +++ b/workout-logger/lib/screens/edit_workout_session_screen.dart @@ -719,6 +719,8 @@ class _EditableSetRow extends StatefulWidget { class _EditableSetRowState extends State<_EditableSetRow> { late TextEditingController _weightController; late TextEditingController _repsController; + final FocusNode _weightFocus = FocusNode(); + final FocusNode _repsFocus = FocusNode(); @override void initState() { @@ -730,12 +732,18 @@ class _EditableSetRowState extends State<_EditableSetRow> { @override void didUpdateWidget(covariant _EditableSetRow oldWidget) { super.didUpdateWidget(oldWidget); - // Update controllers if parent changed values - if (widget.weight != oldWidget.weight) { - _weightController.text = widget.weight.toString(); + // Only update controllers if the value genuinely changed AND we don't have focus + // This prevents clobbering user input while typing + if (widget.weight != oldWidget.weight && !_weightFocus.hasFocus) { + // Also check if current text already matches to avoid cursor jumps if logic falls through + if (double.tryParse(_weightController.text) != widget.weight) { + _weightController.text = widget.weight.toString(); + } } - if (widget.reps != oldWidget.reps) { - _repsController.text = widget.reps.toString(); + if (widget.reps != oldWidget.reps && !_repsFocus.hasFocus) { + if (int.tryParse(_repsController.text) != widget.reps) { + _repsController.text = widget.reps.toString(); + } } } @@ -743,6 +751,8 @@ class _EditableSetRowState extends State<_EditableSetRow> { void dispose() { _weightController.dispose(); _repsController.dispose(); + _weightFocus.dispose(); + _repsFocus.dispose(); super.dispose(); } @@ -778,6 +788,7 @@ class _EditableSetRowState extends State<_EditableSetRow> { width: 80, child: TextField( controller: _weightController, + focusNode: _weightFocus, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), @@ -817,6 +828,7 @@ class _EditableSetRowState extends State<_EditableSetRow> { width: 70, child: TextField( controller: _repsController, + focusNode: _repsFocus, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], textAlign: TextAlign.center, diff --git a/workout-logger/lib/screens/history_screen.dart b/workout-logger/lib/screens/history_screen.dart index f78477d..312bcac 100644 --- a/workout-logger/lib/screens/history_screen.dart +++ b/workout-logger/lib/screens/history_screen.dart @@ -374,14 +374,17 @@ class _SessionDetailsSheet extends StatelessWidget { ); if (confirmed == true && context.mounted) { + final navigator = Navigator.of(context); + final messenger = ScaffoldMessenger.of(context); try { await provider.deleteWorkoutSession(session.id); if (context.mounted) { - Navigator.of(context).pop(); // Close the bottom sheet - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( + // Ideally we can trust navigator if it's still valid, but keep check for safety + navigator.pop(); // Close the bottom sheet + messenger.showSnackBar( + const SnackBar( content: Row( - children: const [ + children: [ Icon(Icons.check_circle, color: AppTheme.success), SizedBox(width: 8), Text('Workout deleted'), @@ -394,7 +397,7 @@ class _SessionDetailsSheet extends StatelessWidget { } catch (e) { debugPrint('Failed to delete workout session: $e'); if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( + messenger.showSnackBar( const SnackBar( content: Text('Failed to delete workout. Please try again.'), backgroundColor: AppTheme.error, diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 637532e..5b2ada5 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -386,6 +386,9 @@ class WorkoutProvider extends ChangeNotifier { await _updateGrowthModel(exerciseId); } + // Recalculate targets for affected exercises + await _recalculateTargets(affectedExerciseIds); + notifyListeners(); } @@ -427,6 +430,9 @@ class WorkoutProvider extends ChangeNotifier { await _updateGrowthModel(exerciseId); } + // Recalculate targets for affected exercises + await _recalculateTargets(allAffectedExerciseIds); + notifyListeners(); } @@ -465,27 +471,8 @@ class WorkoutProvider extends ChangeNotifier { required String type, required double targetValue, }) async { - // Get current value from last session - double currentValue = 0; - final lastLog = getLastSessionForExercise(exerciseId); - if (lastLog != null && lastLog.sets.isNotEmpty) { - switch (type) { - case 'reps': - currentValue = lastLog.sets - .map((s) => s.reps) - .reduce((a, b) => a > b ? a : b) - .toDouble(); - break; - case 'weight': - currentValue = lastLog.sets - .map((s) => s.weight) - .reduce((a, b) => a > b ? a : b); - break; - case 'volume': - currentValue = lastLog.totalVolume; - break; - } - } + // Get current value from history + double currentValue = _calculateCurrentTargetValue(exerciseId, type); // Predict completion date DateTime? estimatedDate; @@ -505,6 +492,7 @@ class WorkoutProvider extends ChangeNotifier { targetValue: targetValue, currentValue: currentValue, estimatedCompletionDate: estimatedDate, + isCompleted: currentValue >= targetValue, ); await _storage.saveTarget(target); @@ -512,48 +500,86 @@ class WorkoutProvider extends ChangeNotifier { notifyListeners(); } - Future _updateTargetsFromSession(WorkoutSession session) async { - for (var log in session.exercises) { - final exerciseTargets = _targets - .where((t) => t.exerciseId == log.exerciseId && !t.isCompleted) + /// Recalculate targets for a set of exercises based on full history + Future _recalculateTargets(Set exerciseIds) async { + for (var exerciseId in exerciseIds) { + final relevantTargets = _targets + .where((t) => t.exerciseId == exerciseId) .toList(); - for (var target in exerciseTargets) { - double newValue = 0; - switch (target.targetType) { - case 'reps': - newValue = log.sets - .map((s) => s.reps) - .reduce((a, b) => a > b ? a : b) - .toDouble(); - break; - case 'weight': - newValue = log.sets - .map((s) => s.weight) - .reduce((a, b) => a > b ? a : b); - break; - case 'volume': - newValue = log.totalVolume; - break; - } + for (var target in relevantTargets) { + // Recalculate current value from all sessions + final newValue = _calculateCurrentTargetValue( + exerciseId, + target.targetType, + ); target.currentValue = newValue; + + // If it was completed but now isn't (e.g. deleted PR session), uncomplete it + // If it wasn't completed but now is (unlikely on delete, but possible on edit), complete it target.isCompleted = newValue >= target.targetValue; - // Update prediction - final growthModel = _growthModels[log.exerciseId]; - if (growthModel != null && !target.isCompleted) { - target.estimatedCompletionDate = MLService.predictTargetCompletion( - currentValue: newValue, - targetValue: target.targetValue, - growthModel: growthModel, - ); + // Update prediction if not completed + if (!target.isCompleted) { + final growthModel = _growthModels[exerciseId]; + if (growthModel != null) { + target.estimatedCompletionDate = MLService.predictTargetCompletion( + currentValue: newValue, + targetValue: target.targetValue, + growthModel: growthModel, + ); + } else { + target.estimatedCompletionDate = null; + } + } else { + target.estimatedCompletionDate = null; } await _storage.saveTarget(target); } } - notifyListeners(); + } + + /// Calculate the current best value for a target type from all history + double _calculateCurrentTargetValue(String exerciseId, String targetType) { + double bestValue = 0; + + for (var session in _sessions) { + for (var log in session.exercises) { + if (log.exerciseId == exerciseId && log.sets.isNotEmpty) { + double sessionValue = 0; + switch (targetType) { + case 'reps': + sessionValue = log.sets + .map((s) => s.reps) + .reduce((a, b) => a > b ? a : b) + .toDouble(); + break; + case 'weight': + sessionValue = log.sets + .map((s) => s.weight) + .reduce((a, b) => a > b ? a : b); + break; + case 'volume': + sessionValue = log.totalVolume; + break; + } + if (sessionValue > bestValue) { + bestValue = sessionValue; + } + } + } + } + return bestValue; + } + + Future _updateTargetsFromSession(WorkoutSession session) async { + // This is optimzed for adding new sessions, but we can just use the generic recalculate + // to be safe and consistent, although it's slightly more expensive. + // Given the scale of mobile data, scanning history is acceptable. + final exerciseIds = session.exercises.map((e) => e.exerciseId).toSet(); + await _recalculateTargets(exerciseIds); } Future deleteTarget(String id) async { diff --git a/workout-logger/test/add_custom_exercise_screen_test.dart b/workout-logger/test/add_custom_exercise_screen_test.dart index eb7f1ae..bcc58b7 100644 --- a/workout-logger/test/add_custom_exercise_screen_test.dart +++ b/workout-logger/test/add_custom_exercise_screen_test.dart @@ -5,97 +5,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:repforge/models/models.dart'; import 'package:repforge/screens/add_custom_exercise_screen.dart'; -import 'package:repforge/services/storage_service.dart'; import 'package:repforge/services/workout_provider.dart'; - -// Mock StorageService for testing - all methods return Future -class MockStorageService implements StorageService { - final List _customExercises = []; - bool saveCustomExerciseCalled = false; - Exercise? lastSavedExercise; - - @override - Future init() async {} - - @override - Future> getAllExercises() async => _customExercises; - - @override - Future> getCustomExercises() async => _customExercises; - - @override - Future saveCustomExercise(Exercise exercise) async { - saveCustomExerciseCalled = true; - lastSavedExercise = exercise; - _customExercises.add(exercise); - } - - @override - Future deleteCustomExercise(String id) async { - _customExercises.removeWhere((e) => e.id == id); - } - - @override - Future> getAllWorkoutSessions() async => []; - - @override - Future> getAllRoutines() async => []; - - @override - Future> getAllMuscleGroups() async => []; - - @override - Future> getAllTargets() async => []; - - @override - Future saveWorkoutSession(WorkoutSession session) async {} - @override - Future getWorkoutSession(String id) async => null; - @override - Future deleteWorkoutSession(String id) async {} - @override - Future> getSessionsForExercise( - String exerciseId, - ) async => []; - @override - Future> getSessionsInDateRange( - DateTime start, - DateTime end, - ) async => []; - @override - Future saveRoutine(Routine routine) async {} - @override - Future getRoutine(String id) async => null; - @override - Future deleteRoutine(String id) async {} - @override - Future saveTarget(Target target) async {} - @override - Future getTarget(String id) async => null; - @override - Future deleteTarget(String id) async {} - @override - Future> getTargetsForExercise(String exerciseId) async => []; - @override - Future updateMuscleGroupGrowthRate( - String muscleGroupId, - double rate, - ) async {} - @override - Future getMuscleGroup(String id) async => null; - @override - Future getExercise(String id) async => null; - @override - Future saveSetting(String key, String value) async {} - @override - Future getSetting(String key) async => null; - @override - Future exportAllData() async => '{}'; - @override - Future importData(String jsonData) async {} - @override - Future> getQuickStats() async => {}; -} +import 'test_utils/mock_storage_service.dart'; Widget createTestWidget({ required Widget child, @@ -215,6 +126,8 @@ void main() { mockStorage.lastSavedExercise?.name, equals('Cable Lateral Raise'), ); + // Verify screen popped (AddCustomExerciseScreen no longer in tree) + expect(find.byType(AddCustomExerciseScreen), findsNothing); }); testWidgets('should show compound/isolation toggle buttons', ( diff --git a/workout-logger/test/exercise_library_screen_test.dart b/workout-logger/test/exercise_library_screen_test.dart index da531bd..3fc7086 100644 --- a/workout-logger/test/exercise_library_screen_test.dart +++ b/workout-logger/test/exercise_library_screen_test.dart @@ -5,97 +5,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:repforge/models/models.dart'; import 'package:repforge/screens/exercise_library_screen.dart'; -import 'package:repforge/services/storage_service.dart'; import 'package:repforge/services/workout_provider.dart'; - -// Mock StorageService for testing - all methods return Future -class MockStorageService implements StorageService { - final List _customExercises = []; - - void addMockCustomExercise(Exercise exercise) { - _customExercises.add(exercise); - } - - @override - Future init() async {} - - @override - Future> getAllExercises() async => _customExercises; - - @override - Future> getCustomExercises() async => _customExercises; - - @override - Future saveCustomExercise(Exercise exercise) async { - _customExercises.add(exercise); - } - - @override - Future deleteCustomExercise(String id) async { - _customExercises.removeWhere((e) => e.id == id); - } - - @override - Future> getAllWorkoutSessions() async => []; - - @override - Future> getAllRoutines() async => []; - - @override - Future> getAllMuscleGroups() async => []; - - @override - Future> getAllTargets() async => []; - - @override - Future saveWorkoutSession(WorkoutSession session) async {} - @override - Future getWorkoutSession(String id) async => null; - @override - Future deleteWorkoutSession(String id) async {} - @override - Future> getSessionsForExercise( - String exerciseId, - ) async => []; - @override - Future> getSessionsInDateRange( - DateTime start, - DateTime end, - ) async => []; - @override - Future saveRoutine(Routine routine) async {} - @override - Future getRoutine(String id) async => null; - @override - Future deleteRoutine(String id) async {} - @override - Future saveTarget(Target target) async {} - @override - Future getTarget(String id) async => null; - @override - Future deleteTarget(String id) async {} - @override - Future> getTargetsForExercise(String exerciseId) async => []; - @override - Future updateMuscleGroupGrowthRate( - String muscleGroupId, - double rate, - ) async {} - @override - Future getMuscleGroup(String id) async => null; - @override - Future getExercise(String id) async => null; - @override - Future saveSetting(String key, String value) async {} - @override - Future getSetting(String key) async => null; - @override - Future exportAllData() async => '{}'; - @override - Future importData(String jsonData) async {} - @override - Future> getQuickStats() async => {}; -} +import 'test_utils/mock_storage_service.dart'; Widget createTestWidget({ required Widget child, @@ -338,6 +249,36 @@ void main() { expect(find.text('Delete'), findsWidgets); }); + testWidgets('should delete exercise when Delete is confirmed', ( + tester, + ) async { + // Arrange + await tester.pumpWidget( + createTestWidget( + child: const ExerciseLibraryScreen(), + provider: provider, + ), + ); + await tester.pumpAndSettle(); + + // Act - Open details and tap delete + await tester.tap(find.text('Exercise To Delete')); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.delete_outline)); + await tester.pumpAndSettle(); + + // Tap Delete in dialog + await tester.tap(find.widgetWithText(TextButton, 'Delete')); + await tester.pumpAndSettle(); + + // Assert - Exercise should be removed + expect( + provider.allExercises.any((e) => e.name == 'Exercise To Delete'), + isFalse, + ); + }); + testWidgets('should cancel delete when Cancel is tapped', (tester) async { // Arrange await tester.pumpWidget( diff --git a/workout-logger/test/test_utils/mock_storage_service.dart b/workout-logger/test/test_utils/mock_storage_service.dart new file mode 100644 index 0000000..ce5d81e --- /dev/null +++ b/workout-logger/test/test_utils/mock_storage_service.dart @@ -0,0 +1,99 @@ +// Shared Mock Storage Service for testing +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/storage_service.dart'; + +// Mock StorageService that works as a manual fake/stub +class MockStorageService implements StorageService { + final List _customExercises = []; + bool saveCustomExerciseCalled = false; + Exercise? lastSavedExercise; + + // Public getter to access the hidden list in tests + List get customExercises => _customExercises; + + void addMockCustomExercise(Exercise exercise) { + _customExercises.add(exercise); + } + + @override + Future init() async {} + + @override + Future> getAllExercises() async => _customExercises; + + @override + Future> getCustomExercises() async => _customExercises; + + @override + Future saveCustomExercise(Exercise exercise) async { + saveCustomExerciseCalled = true; + lastSavedExercise = exercise; + _customExercises.add(exercise); + } + + @override + Future deleteCustomExercise(String id) async { + _customExercises.removeWhere((e) => e.id == id); + } + + @override + Future> getAllWorkoutSessions() async => []; + + @override + Future> getAllRoutines() async => []; + + @override + Future> getAllMuscleGroups() async => []; + + @override + Future> getAllTargets() async => []; + + @override + Future saveWorkoutSession(WorkoutSession session) async {} + @override + Future getWorkoutSession(String id) async => null; + @override + Future deleteWorkoutSession(String id) async {} + @override + Future> getSessionsForExercise( + String exerciseId, + ) async => []; + @override + Future> getSessionsInDateRange( + DateTime start, + DateTime end, + ) async => []; + @override + Future saveRoutine(Routine routine) async {} + @override + Future getRoutine(String id) async => null; + @override + Future deleteRoutine(String id) async {} + @override + Future saveTarget(Target target) async {} + @override + Future getTarget(String id) async => null; + @override + Future deleteTarget(String id) async {} + @override + Future> getTargetsForExercise(String exerciseId) async => []; + @override + Future updateMuscleGroupGrowthRate( + String muscleGroupId, + double rate, + ) async {} + @override + Future getMuscleGroup(String id) async => null; + @override + Future getExercise(String id) async => null; + @override + Future saveSetting(String key, String value) async {} + @override + Future getSetting(String key) async => null; + @override + Future exportAllData() async => '{}'; + @override + Future importData(String jsonData) async {} + @override + Future> getQuickStats() async => {}; +} diff --git a/workout-logger/test/workout_provider_test.dart b/workout-logger/test/workout_provider_test.dart index 3e327f3..8bac463 100644 --- a/workout-logger/test/workout_provider_test.dart +++ b/workout-logger/test/workout_provider_test.dart @@ -1,62 +1,8 @@ // Unit Tests for WorkoutProvider - Custom Exercise functionality import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:repforge/models/models.dart'; -import 'package:repforge/services/storage_service.dart'; import 'package:repforge/services/workout_provider.dart'; - -// Mock class for StorageService -class MockStorageService extends Mock implements StorageService { - final List _customExercises = []; - final List _sessions = []; - final List _routines = []; - - @override - Future init() async {} - - @override - Future> getAllExercises() async { - return _customExercises; - } - - @override - Future> getCustomExercises() async { - return _customExercises; - } - - @override - Future saveCustomExercise(Exercise exercise) async { - _customExercises.add(exercise); - } - - @override - Future deleteCustomExercise(String id) async { - _customExercises.removeWhere((e) => e.id == id); - } - - @override - Future> getAllWorkoutSessions() async { - return _sessions; - } - - @override - Future> getAllRoutines() async { - return _routines; - } - - @override - Future> getAllMuscleGroups() async { - return []; - } - - @override - Future> getAllTargets() async { - return []; - } - - List get customExercises => _customExercises; -} +import 'test_utils/mock_storage_service.dart'; void main() { group('WorkoutProvider - Custom Exercise Tests', () { @@ -85,10 +31,10 @@ void main() { // Assert expect(provider.allExercises.length, greaterThan(0)); + // firstWhere throws if not found, so finding it implies it exists/is not null final addedExercise = provider.allExercises.firstWhere( (e) => e.name == name, ); - expect(addedExercise, isNotNull); expect(addedExercise.name, equals(name)); }); From 07f9194eee4d043c0f496fd392d66da5fc262d8d Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:22:46 +0530 Subject: [PATCH 6/8] feat: Add screen for editing workout sessions and a workout provider. --- .../screens/edit_workout_session_screen.dart | 420 +++++++++++++++--- .../lib/services/workout_provider.dart | 38 ++ 2 files changed, 408 insertions(+), 50 deletions(-) diff --git a/workout-logger/lib/screens/edit_workout_session_screen.dart b/workout-logger/lib/screens/edit_workout_session_screen.dart index e9d187e..9400d05 100644 --- a/workout-logger/lib/screens/edit_workout_session_screen.dart +++ b/workout-logger/lib/screens/edit_workout_session_screen.dart @@ -1,4 +1,4 @@ -// Edit Workout Session Screen - Modify recorded workout sessions +// Edit Workout Session Screen - Modify recorded workout sessions import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -512,10 +512,20 @@ class _EditWorkoutSessionScreenState extends State { key: ValueKey('exercise_$exerciseIndex'), exerciseName: exercise?.name ?? 'Unknown Exercise', editableLog: editableLog, - onSetChanged: (setIndex, weight, reps) { + onSetChanged: (setIndex, weight, reps, isDropset, drops) { setState(() { editableLog.sets[setIndex].weight = weight; editableLog.sets[setIndex].reps = reps; + if (editableLog.sets[setIndex].isDropset != isDropset) { + editableLog.sets[setIndex].isDropset = isDropset; + if (isDropset && + editableLog.sets[setIndex].drops == null) { + editableLog.sets[setIndex].drops = []; + } + } + if (drops != null) { + editableLog.sets[setIndex].drops = drops; + } }); _markChanged(); }, @@ -578,18 +588,25 @@ class _EditableSet { _EditableSet({ required this.weight, required this.reps, + required this.timestamp, this.isDropset = false, this.drops, this.timeTaken, - DateTime? timestamp, - }) : timestamp = timestamp ?? DateTime.now(); + }); } // Editable Exercise Card Widget class _EditableExerciseCard extends StatelessWidget { final String exerciseName; final _EditableExerciseLog editableLog; - final Function(int setIndex, double weight, int reps) onSetChanged; + final Function( + int setIndex, + double weight, + int reps, + bool isDropset, + List? drops, + ) + onSetChanged; final VoidCallback onAddSet; final Function(int setIndex) onDeleteSet; final VoidCallback onDeleteExercise; @@ -646,10 +663,36 @@ class _EditableExerciseCard extends StatelessWidget { setNumber: setIndex + 1, weight: set.weight, reps: set.reps, - onWeightChanged: (weight) => - onSetChanged(setIndex, weight, set.reps), - onRepsChanged: (reps) => - onSetChanged(setIndex, set.weight, reps), + isDropset: set.isDropset, + drops: set.drops, + onWeightChanged: (weight) => onSetChanged( + setIndex, + weight, + set.reps, + set.isDropset, + set.drops, + ), + onRepsChanged: (reps) => onSetChanged( + setIndex, + set.weight, + reps, + set.isDropset, + set.drops, + ), + onDropsChanged: (drops) => onSetChanged( + setIndex, + set.weight, + set.reps, + set.isDropset, + drops, + ), + onIsDropsetChanged: (val) => onSetChanged( + setIndex, + set.weight, + set.reps, + val, + set.drops, + ), onDelete: () => onDeleteSet(setIndex), ); }), @@ -699,16 +742,25 @@ class _EditableSetRow extends StatefulWidget { final int setNumber; final double weight; final int reps; + final bool isDropset; + final List? drops; final Function(double) onWeightChanged; final Function(int) onRepsChanged; + final Function(List) onDropsChanged; + final Function(bool) onIsDropsetChanged; final VoidCallback onDelete; const _EditableSetRow({ + super.key, required this.setNumber, required this.weight, required this.reps, + this.isDropset = false, + this.drops, required this.onWeightChanged, required this.onRepsChanged, + required this.onDropsChanged, + required this.onIsDropsetChanged, required this.onDelete, }); @@ -732,10 +784,7 @@ class _EditableSetRowState extends State<_EditableSetRow> { @override void didUpdateWidget(covariant _EditableSetRow oldWidget) { super.didUpdateWidget(oldWidget); - // Only update controllers if the value genuinely changed AND we don't have focus - // This prevents clobbering user input while typing if (widget.weight != oldWidget.weight && !_weightFocus.hasFocus) { - // Also check if current text already matches to avoid cursor jumps if logic falls through if (double.tryParse(_weightController.text) != widget.weight) { _weightController.text = widget.weight.toString(); } @@ -760,32 +809,301 @@ class _EditableSetRowState extends State<_EditableSetRow> { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), - child: Row( + child: Column( children: [ - // Set number - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(14), - ), - child: Center( - child: Text( - '${widget.setNumber}', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: AppTheme.primaryColor, + Row( + children: [ + // Set number + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.2), + borderRadius: BorderRadius.circular(14), + ), + child: Center( + child: Text( + '${widget.setNumber}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + + // Weight input + SizedBox( + width: 80, + child: TextField( + controller: _weightController, + focusNode: _weightFocus, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + suffixText: 'kg', + suffixStyle: const TextStyle( + color: AppTheme.textMuted, + fontSize: 12, + ), + filled: true, + fillColor: AppTheme.surfaceColor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + onChanged: (value) { + final weight = double.tryParse(value) ?? 0; + widget.onWeightChanged(weight); + }, + ), + ), + const SizedBox(width: AppSpacing.sm), + + // × symbol + const Text('×', style: TextStyle(color: AppTheme.textMuted)), + const SizedBox(width: AppSpacing.sm), + + // Reps input + SizedBox( + width: 70, + child: TextField( + controller: _repsController, + focusNode: _repsFocus, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + suffixText: 'reps', + suffixStyle: const TextStyle( + color: AppTheme.textMuted, + fontSize: 12, + ), + filled: true, + fillColor: AppTheme.surfaceColor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + onChanged: (value) { + final reps = int.tryParse(value) ?? 0; + widget.onRepsChanged(reps); + }, + ), + ), + + const Spacer(), + + // Toggle Dropset button + IconButton( + onPressed: () => widget.onIsDropsetChanged(!widget.isDropset), + icon: Icon( + widget.isDropset ? Icons.layers : Icons.layers_outlined, + size: 18, + ), + color: widget.isDropset + ? AppTheme.primaryColor + : AppTheme.textMuted, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + tooltip: widget.isDropset + ? 'Remove drops' + : 'Convert to dropset', + ), + + // Delete button + IconButton( + onPressed: widget.onDelete, + icon: const Icon(Icons.close, size: 18), + color: AppTheme.textMuted, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + tooltip: 'Delete set', + ), + ], + ), + + // Dropset Rows + if (widget.isDropset) ...[ + if (widget.drops != null) ...[ + const SizedBox(height: 4), + ...widget.drops!.asMap().entries.map((entry) { + final index = entry.key; + final drop = entry.value; + return _EditableDropRow( + key: ValueKey('drop_${widget.setNumber}_$index'), + dropNumber: index + 1, + weight: drop.weight, + reps: drop.reps, + onWeightChanged: (val) { + final newDrops = List.from(widget.drops!); + newDrops[index] = DropsetEntry( + weight: val, + reps: drop.reps, + ); + widget.onDropsChanged(newDrops); + }, + onRepsChanged: (val) { + final newDrops = List.from(widget.drops!); + newDrops[index] = DropsetEntry( + weight: drop.weight, + reps: val, + ); + widget.onDropsChanged(newDrops); + }, + onDelete: () { + final newDrops = List.from(widget.drops!) + ..removeAt(index); + widget.onDropsChanged(newDrops); + }, + ); + }), + ], + + // Add drop button + Padding( + padding: const EdgeInsets.only(left: 32, top: 4, bottom: 4), + child: InkWell( + onTap: () { + final newDrops = List.from(widget.drops ?? []); + // Default to 80% of last weight or current weight + double initialWeight = widget.weight * 0.8; + if (newDrops.isNotEmpty) { + initialWeight = newDrops.last.weight * 0.8; + } + // Round to nearest 0.5 + initialWeight = (initialWeight * 2).round() / 2; + + newDrops.add( + DropsetEntry(weight: initialWeight, reps: widget.reps), + ); + widget.onDropsChanged(newDrops); + }, + child: Row( + children: [ + Icon( + Icons.add_circle_outline, + size: 14, + color: AppTheme.primaryColor.withOpacity(0.7), + ), + const SizedBox(width: 4), + Text( + 'Add Drop', + style: TextStyle( + fontSize: 12, + color: AppTheme.primaryColor.withOpacity(0.7), + fontWeight: FontWeight.bold, + ), + ), + ], ), ), ), + ], + ], + ), + ); + } +} + +class _EditableDropRow extends StatefulWidget { + final int dropNumber; + final double weight; + final int reps; + final Function(double) onWeightChanged; + final Function(int) onRepsChanged; + final VoidCallback onDelete; + + const _EditableDropRow({ + super.key, + required this.dropNumber, + required this.weight, + required this.reps, + required this.onWeightChanged, + required this.onRepsChanged, + required this.onDelete, + }); + + @override + State<_EditableDropRow> createState() => _EditableDropRowState(); +} + +class _EditableDropRowState extends State<_EditableDropRow> { + late TextEditingController _weightController; + late TextEditingController _repsController; + final FocusNode _weightFocus = FocusNode(); + final FocusNode _repsFocus = FocusNode(); + + @override + void initState() { + super.initState(); + _weightController = TextEditingController(text: widget.weight.toString()); + _repsController = TextEditingController(text: widget.reps.toString()); + } + + @override + void didUpdateWidget(covariant _EditableDropRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.weight != oldWidget.weight && !_weightFocus.hasFocus) { + if (double.tryParse(_weightController.text) != widget.weight) { + _weightController.text = widget.weight.toString(); + } + } + if (widget.reps != oldWidget.reps && !_repsFocus.hasFocus) { + if (int.tryParse(_repsController.text) != widget.reps) { + _repsController.text = widget.reps.toString(); + } + } + } + + @override + void dispose() { + _weightController.dispose(); + _repsController.dispose(); + _weightFocus.dispose(); + _repsFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 32, top: 4, bottom: 4), + child: Row( + children: [ + Icon( + Icons.subdirectory_arrow_right, + size: 16, + color: AppTheme.textMuted.withOpacity(0.5), + ), + const SizedBox(width: 8), + + Text( + 'Drop ${widget.dropNumber}', + style: const TextStyle(color: AppTheme.textMuted, fontSize: 12), ), const SizedBox(width: AppSpacing.sm), // Weight input SizedBox( - width: 80, + width: 70, + height: 32, child: TextField( controller: _weightController, focusNode: _weightFocus, @@ -793,21 +1111,21 @@ class _EditableSetRowState extends State<_EditableSetRow> { decimal: true, ), textAlign: TextAlign.center, - style: const TextStyle(fontSize: 14), + style: const TextStyle(fontSize: 13), decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, + horizontal: 4, + vertical: 0, ), suffixText: 'kg', suffixStyle: const TextStyle( + fontSize: 10, color: AppTheme.textMuted, - fontSize: 12, ), filled: true, - fillColor: AppTheme.surfaceColor, + fillColor: AppTheme.surfaceColor.withOpacity(0.7), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(6), borderSide: BorderSide.none, ), ), @@ -817,36 +1135,39 @@ class _EditableSetRowState extends State<_EditableSetRow> { }, ), ), - const SizedBox(width: AppSpacing.sm), - // × symbol - const Text('×', style: TextStyle(color: AppTheme.textMuted)), - const SizedBox(width: AppSpacing.sm), + const SizedBox(width: 8), + const Text( + '×', + style: TextStyle(color: AppTheme.textMuted, fontSize: 12), + ), + const SizedBox(width: 8), // Reps input SizedBox( - width: 70, + width: 60, + height: 32, child: TextField( controller: _repsController, focusNode: _repsFocus, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], textAlign: TextAlign.center, - style: const TextStyle(fontSize: 14), + style: const TextStyle(fontSize: 13), decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, + horizontal: 4, + vertical: 0, ), suffixText: 'reps', suffixStyle: const TextStyle( + fontSize: 10, color: AppTheme.textMuted, - fontSize: 12, ), filled: true, - fillColor: AppTheme.surfaceColor, + fillColor: AppTheme.surfaceColor.withOpacity(0.7), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(6), borderSide: BorderSide.none, ), ), @@ -859,14 +1180,13 @@ class _EditableSetRowState extends State<_EditableSetRow> { const Spacer(), - // Delete button IconButton( onPressed: widget.onDelete, - icon: const Icon(Icons.close, size: 18), + icon: const Icon(Icons.close, size: 16), color: AppTheme.textMuted, padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - tooltip: 'Delete set', + constraints: const BoxConstraints(), + tooltip: 'Remove drop', ), ], ), diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 5b2ada5..a6f6268 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -83,6 +83,9 @@ class WorkoutProvider extends ChangeNotifier { ); if (dataPoints.length >= 2) { _growthModels[exerciseId] = MLService.trainGrowthModel(dataPoints); + } else { + // Remove stale model if not enough data to train (e.g. after deletion) + _growthModels.remove(exerciseId); } } @@ -166,6 +169,8 @@ class WorkoutProvider extends ChangeNotifier { } /// Delete a custom exercise + /// + /// Returns false if exercise not found, not custom, or IN USE by sessions/routines/targets Future deleteCustomExercise(String exerciseId) async { // Only allow deleting custom exercises final exercise = getExercise(exerciseId); @@ -173,6 +178,36 @@ class WorkoutProvider extends ChangeNotifier { return false; } + // Check for references in Sessions + for (var session in _sessions) { + if (session.exercises.any((e) => e.exerciseId == exerciseId)) { + debugPrint( + 'Cannot delete custom exercise: Used in session ${session.id}', + ); + return false; + } + } + + // Check for references in Routines + for (var routine in _routines) { + if (routine.exerciseIds.contains(exerciseId)) { + debugPrint( + 'Cannot delete custom exercise: Used in routine ${routine.name}', + ); + return false; + } + } + + // Check for references in Targets + for (var target in _targets) { + if (target.exerciseId == exerciseId) { + debugPrint( + 'Cannot delete custom exercise: Used in target ${target.id}', + ); + return false; + } + } + // Remove from storage await _storage.deleteCustomExercise(exerciseId); @@ -180,6 +215,9 @@ class WorkoutProvider extends ChangeNotifier { _allExercises = List.from(_allExercises) ..removeWhere((e) => e.id == exerciseId); + // Also remove any growth model + _growthModels.remove(exerciseId); + notifyListeners(); return true; } From e60a4a83f8808449d1199cea74ff14d116f2c510 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Tue, 27 Jan 2026 00:32:15 +0530 Subject: [PATCH 7/8] feat: Add workout session editing, history screen, workout provider, and tests for exercise library and custom exercise management. --- .../screens/edit_workout_session_screen.dart | 1 - .../lib/screens/history_screen.dart | 1 - .../lib/services/workout_provider.dart | 23 +++++++++++++++---- .../test/add_custom_exercise_screen_test.dart | 1 - .../test/exercise_library_screen_test.dart | 1 - 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/workout-logger/lib/screens/edit_workout_session_screen.dart b/workout-logger/lib/screens/edit_workout_session_screen.dart index 9400d05..c9464fb 100644 --- a/workout-logger/lib/screens/edit_workout_session_screen.dart +++ b/workout-logger/lib/screens/edit_workout_session_screen.dart @@ -751,7 +751,6 @@ class _EditableSetRow extends StatefulWidget { final VoidCallback onDelete; const _EditableSetRow({ - super.key, required this.setNumber, required this.weight, required this.reps, diff --git a/workout-logger/lib/screens/history_screen.dart b/workout-logger/lib/screens/history_screen.dart index 312bcac..cbb2b57 100644 --- a/workout-logger/lib/screens/history_screen.dart +++ b/workout-logger/lib/screens/history_screen.dart @@ -1,6 +1,5 @@ // History Screen - View past workout sessions -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index a6f6268..bc8fa70 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -208,6 +208,16 @@ class WorkoutProvider extends ChangeNotifier { } } + // Check for references in active workout + if (_currentExerciseLogs.any((l) => l.exerciseId == exerciseId)) { + debugPrint('Cannot delete custom exercise: Used in active workout'); + return false; + } + if (_activeRoutine?.exerciseIds.contains(exerciseId) ?? false) { + debugPrint('Cannot delete custom exercise: Used in active routine'); + return false; + } + // Remove from storage await _storage.deleteCustomExercise(exerciseId); @@ -403,10 +413,15 @@ class WorkoutProvider extends ChangeNotifier { /// Delete a workout session Future deleteWorkoutSession(String sessionId) async { // Find the session to get exercise IDs for model retraining - final session = _sessions.firstWhere( - (s) => s.id == sessionId, - orElse: () => throw Exception('Session not found'), - ); + final sessionIndex = _sessions.indexWhere((s) => s.id == sessionId); + + // Return early if session not found (e.g., stale sessionId) + if (sessionIndex == -1) { + debugPrint('Session $sessionId not found, skipping deletion'); + return; + } + + final session = _sessions[sessionIndex]; // All exercises in the deleted session need their growth models retrained final affectedExerciseIds = session.exercises .map((e) => e.exerciseId) diff --git a/workout-logger/test/add_custom_exercise_screen_test.dart b/workout-logger/test/add_custom_exercise_screen_test.dart index bcc58b7..777cf51 100644 --- a/workout-logger/test/add_custom_exercise_screen_test.dart +++ b/workout-logger/test/add_custom_exercise_screen_test.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; -import 'package:repforge/models/models.dart'; import 'package:repforge/screens/add_custom_exercise_screen.dart'; import 'package:repforge/services/workout_provider.dart'; import 'test_utils/mock_storage_service.dart'; diff --git a/workout-logger/test/exercise_library_screen_test.dart b/workout-logger/test/exercise_library_screen_test.dart index 3fc7086..52c1436 100644 --- a/workout-logger/test/exercise_library_screen_test.dart +++ b/workout-logger/test/exercise_library_screen_test.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; -import 'package:repforge/models/models.dart'; import 'package:repforge/screens/exercise_library_screen.dart'; import 'package:repforge/services/workout_provider.dart'; import 'test_utils/mock_storage_service.dart'; From 363cf7703a279de73dfcea3dc9df4b608d0eb22c Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy@users.noreply.github.com> Date: Tue, 27 Jan 2026 00:45:14 +0530 Subject: [PATCH 8/8] Update workout-logger/lib/screens/edit_workout_session_screen.dart Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- workout-logger/lib/screens/edit_workout_session_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workout-logger/lib/screens/edit_workout_session_screen.dart b/workout-logger/lib/screens/edit_workout_session_screen.dart index c9464fb..45ab30a 100644 --- a/workout-logger/lib/screens/edit_workout_session_screen.dart +++ b/workout-logger/lib/screens/edit_workout_session_screen.dart @@ -541,7 +541,7 @@ class _EditWorkoutSessionScreenState extends State { child: Center( child: Column( children: [ - Icon( + const Icon( Icons.fitness_center, size: 48, color: AppTheme.textMuted,