-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Add clone routine functionality #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -259,6 +259,14 @@ class _RoutineCard extends StatelessWidget { | |||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||
| ListTile( | ||||||||||||||||||||||||||||||||||
| leading: const Icon(Icons.copy), | ||||||||||||||||||||||||||||||||||
| title: const Text('Clone Routine'), | ||||||||||||||||||||||||||||||||||
| onTap: () { | ||||||||||||||||||||||||||||||||||
| Navigator.pop(context); | ||||||||||||||||||||||||||||||||||
| _showCloneDialog(context); | ||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||
| ListTile( | ||||||||||||||||||||||||||||||||||
| leading: const Icon(Icons.delete, color: AppTheme.error), | ||||||||||||||||||||||||||||||||||
| title: const Text( | ||||||||||||||||||||||||||||||||||
|
|
@@ -276,6 +284,42 @@ class _RoutineCard extends StatelessWidget { | |||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| void _showCloneDialog(BuildContext context) { | ||||||||||||||||||||||||||||||||||
| final TextEditingController nameController = TextEditingController(); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| showDialog( | ||||||||||||||||||||||||||||||||||
| context: context, | ||||||||||||||||||||||||||||||||||
| builder: (context) => AlertDialog( | ||||||||||||||||||||||||||||||||||
| title: const Text('Clone Routine'), | ||||||||||||||||||||||||||||||||||
| content: TextField( | ||||||||||||||||||||||||||||||||||
| controller: nameController, | ||||||||||||||||||||||||||||||||||
| autofocus: true, | ||||||||||||||||||||||||||||||||||
| decoration: InputDecoration( | ||||||||||||||||||||||||||||||||||
| hintText: routine.name, | ||||||||||||||||||||||||||||||||||
| labelText: 'New Routine Name', | ||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||
| textCapitalization: TextCapitalization.words, | ||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||
| actions: [ | ||||||||||||||||||||||||||||||||||
| TextButton( | ||||||||||||||||||||||||||||||||||
| onPressed: () => Navigator.pop(context), | ||||||||||||||||||||||||||||||||||
| child: const Text('Cancel'), | ||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||
| FilledButton( | ||||||||||||||||||||||||||||||||||
| onPressed: () { | ||||||||||||||||||||||||||||||||||
| final newName = nameController.text.trim().isEmpty | ||||||||||||||||||||||||||||||||||
| ? routine.name | ||||||||||||||||||||||||||||||||||
| : nameController.text.trim(); | ||||||||||||||||||||||||||||||||||
| provider.createRoutine(newName, routine.exerciseIds); | ||||||||||||||||||||||||||||||||||
| Navigator.pop(context); | ||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+308
to
+315
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🐛 Proposed fix- onPressed: () {
+ onPressed: () async {
final newName = nameController.text.trim().isEmpty
? routine.name
: nameController.text.trim();
- provider.createRoutine(newName, routine.exerciseIds);
- Navigator.pop(context);
+ await provider.createRoutine(newName, routine.exerciseIds);
+ if (context.mounted) Navigator.pop(context);
},📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
| child: const Text('Clone'), | ||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| void _confirmDelete(BuildContext context) { | ||||||||||||||||||||||||||||||||||
| showDialog( | ||||||||||||||||||||||||||||||||||
| context: context, | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| 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/routines_screen.dart'; | ||
| import 'package:repforge/services/managers/program_manager.dart'; | ||
| import 'package:repforge/services/workout_provider.dart'; | ||
| import '../test_utils/mock_storage_service.dart'; | ||
|
|
||
| void main() { | ||
| group('RoutinesScreen clone routine tests', () { | ||
| late MockStorageService mockStorage; | ||
| late WorkoutProvider provider; | ||
|
|
||
| setUp(() async { | ||
| mockStorage = MockStorageService(); | ||
| provider = WorkoutProvider( | ||
| mockStorage, | ||
| programManager: ProgramManager(mockStorage), | ||
| ); | ||
| await provider.init(); | ||
|
|
||
| // Setup a pre-existing routine | ||
| await provider.createRoutine('Leg Day', ['squat', 'leg_press']); | ||
| }); | ||
|
Comment on lines
+12
to
+25
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win Add a
♻️ Proposed fix setUp(() async {
mockStorage = MockStorageService();
provider = WorkoutProvider(
mockStorage,
programManager: ProgramManager(mockStorage),
);
await provider.init();
// Setup a pre-existing routine
await provider.createRoutine('Leg Day', ['squat', 'leg_press']);
});
+
+ tearDown(() {
+ provider.dispose();
+ });🤖 Prompt for AI Agents |
||
|
|
||
| testWidgets('shows clone option and clones routine successfully', (WidgetTester tester) async { | ||
| await tester.pumpWidget( | ||
| MaterialApp( | ||
| home: ChangeNotifierProvider<WorkoutProvider>.value( | ||
| value: provider, | ||
| child: const RoutinesScreen(), | ||
| ), | ||
| ), | ||
| ); | ||
|
|
||
| await tester.pumpAndSettle(); | ||
|
|
||
| // Find the routine card | ||
| final routineCard = find.text('Leg Day'); | ||
| expect(routineCard, findsOneWidget); | ||
|
|
||
| // Long press to open options | ||
| await tester.longPress(routineCard); | ||
| await tester.pumpAndSettle(); | ||
|
|
||
| // Find and tap the clone option | ||
| final cloneOption = find.text('Clone Routine'); | ||
| expect(cloneOption, findsOneWidget); | ||
| await tester.tap(cloneOption); | ||
| await tester.pumpAndSettle(); | ||
|
|
||
| // Verify clone dialog appears | ||
| final dialogTitle = find.text('Clone Routine'); | ||
| expect(dialogTitle, findsOneWidget); | ||
|
|
||
| // Provide a new name | ||
| final textField = find.byType(TextField); | ||
| expect(textField, findsOneWidget); | ||
| await tester.enterText(textField, 'Leg Day Copy'); | ||
| await tester.pumpAndSettle(); | ||
|
|
||
| // Tap Clone button | ||
| final cloneButton = find.widgetWithText(FilledButton, 'Clone'); | ||
| expect(cloneButton, findsOneWidget); | ||
| await tester.tap(cloneButton); | ||
| await tester.pumpAndSettle(); | ||
|
|
||
| // Verify dialog is closed and new routine is in the provider | ||
| expect(find.text('Clone Routine'), findsNothing); | ||
| expect(provider.routines.length, 2); | ||
| expect(provider.routines[1].name, 'Leg Day Copy'); | ||
| expect(provider.routines[1].exerciseIds, ['squat', 'leg_press']); | ||
|
|
||
| // Verify new routine is shown in UI | ||
| expect(find.text('Leg Day Copy'), findsOneWidget); | ||
| }); | ||
|
|
||
| testWidgets('clones with original name if text field is empty', (WidgetTester tester) async { | ||
| await tester.pumpWidget( | ||
| MaterialApp( | ||
| home: ChangeNotifierProvider<WorkoutProvider>.value( | ||
| value: provider, | ||
| child: const RoutinesScreen(), | ||
| ), | ||
| ), | ||
| ); | ||
|
|
||
| await tester.pumpAndSettle(); | ||
|
|
||
| // Long press to open options | ||
| await tester.longPress(find.text('Leg Day')); | ||
| await tester.pumpAndSettle(); | ||
|
|
||
| // Tap clone | ||
| await tester.tap(find.text('Clone Routine')); | ||
| await tester.pumpAndSettle(); | ||
|
|
||
| // Do NOT enter text, just tap Clone button | ||
| await tester.tap(find.widgetWithText(FilledButton, 'Clone')); | ||
| await tester.pumpAndSettle(); | ||
|
|
||
| // Verify provider has the cloned routine with the original name | ||
| expect(provider.routines.length, 2); | ||
| expect(provider.routines[1].name, 'Leg Day'); | ||
| expect(provider.routines[1].exerciseIds, ['squat', 'leg_press']); | ||
| }); | ||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TextEditingControlleris never disposed — resource leakThe Flutter SDK requires calling
dispose()on aTextEditingControllerwhen it is no longer needed, to ensure any resources used by the object are discarded. Each time a user opens the clone dialog, a new controller is allocated but never freed. Since_RoutineCardis aStatelessWidget, attach disposal to the dialog's returnedFuture:🛠️ Proposed fix
void _showCloneDialog(BuildContext context) { final TextEditingController nameController = TextEditingController(); - showDialog( + showDialog<void>( context: context, builder: (context) => AlertDialog( ... ), - ); + ).then((_) => nameController.dispose()); }🤖 Prompt for AI Agents