diff --git a/workout-logger/lib/screens/profile_screen.dart b/workout-logger/lib/screens/profile_screen.dart index 5bc1aa0..37309dc 100644 --- a/workout-logger/lib/screens/profile_screen.dart +++ b/workout-logger/lib/screens/profile_screen.dart @@ -199,7 +199,10 @@ class _ProfileScreenState extends State { ), child: SafeArea( child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), child: Column( mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/workout-logger/lib/screens/settings_screen.dart b/workout-logger/lib/screens/settings_screen.dart index 3d2541a..c4191ed 100644 --- a/workout-logger/lib/screens/settings_screen.dart +++ b/workout-logger/lib/screens/settings_screen.dart @@ -23,6 +23,28 @@ class _SettingsScreenState extends State { bool _isExporting = false; bool _isImporting = false; + // ==================== Feedback helpers ==================== + + void _showErrorSnackBar(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: AppTheme.error, + ), + ); + } + + void _showSuccessSnackBar(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: AppTheme.success, + ), + ); + } + // ==================== Remote Backup ==================== Future _performBackup() async { @@ -37,32 +59,14 @@ class _SettingsScreenState extends State { await api.trackEvent('backup_triggered').catchError((_) => null); final success = await api.backupData(data); - if (!mounted) return; - if (success) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Backup successful!'), - backgroundColor: AppTheme.success, - ), - ); + _showSuccessSnackBar('Backup successful!'); } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Backup failed. Please try again.'), - backgroundColor: AppTheme.error, - ), - ); + _showErrorSnackBar('Backup failed. Please try again.'); } } catch (e, stackTrace) { debugPrint('Backup error: $e\n$stackTrace'); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Something went wrong. Please try again.'), - backgroundColor: AppTheme.error, - ), - ); + _showErrorSnackBar('Something went wrong. Please try again.'); } finally { if (mounted) { setState(() => _isBackingUp = false); @@ -89,26 +93,13 @@ class _SettingsScreenState extends State { XFile(file.path), ], subject: 'RepForge Backup'); - if (!mounted) return; - if (result.status == ShareResultStatus.success || result.status == ShareResultStatus.dismissed) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Backup file exported successfully!'), - backgroundColor: AppTheme.success, - ), - ); + _showSuccessSnackBar('Backup file exported successfully!'); } } catch (e, stackTrace) { debugPrint('Export error: $e\n$stackTrace'); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Export failed. Please try again.'), - backgroundColor: AppTheme.error, - ), - ); + _showErrorSnackBar('Export failed. Please try again.'); } finally { if (mounted) { setState(() => _isExporting = false); @@ -179,42 +170,23 @@ class _SettingsScreenState extends State { // Basic validation: ensure it's valid JSON with expected keys final data = jsonDecode(jsonString) as Map; if (!data.containsKey('sessions') && !data.containsKey('routines')) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Invalid backup file. Expected a RepForge backup.'), - backgroundColor: AppTheme.error, - ), - ); + _showErrorSnackBar('Invalid backup file. Expected a RepForge backup.'); return; } final provider = context.read(); await provider.importData(jsonString); - if (!mounted) return; - final itemCount = (data['sessions'] as List?)?.length ?? 0; final routineCount = (data['routines'] as List?)?.length ?? 0; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Import complete! Processed $itemCount sessions, $routineCount routines.', - ), - backgroundColor: AppTheme.success, - ), + _showSuccessSnackBar( + 'Import complete! Processed $itemCount sessions, $routineCount routines.', ); } catch (e, stackTrace) { debugPrint('Import error: $e\n$stackTrace'); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Import failed. Make sure you selected a valid backup file.', - ), - backgroundColor: AppTheme.error, - ), + _showErrorSnackBar( + 'Import failed. Make sure you selected a valid backup file.', ); } finally { if (mounted) { diff --git a/workout-logger/lib/services/managers/analytics_manager.dart b/workout-logger/lib/services/managers/analytics_manager.dart index 7f951bd..5af7df2 100644 --- a/workout-logger/lib/services/managers/analytics_manager.dart +++ b/workout-logger/lib/services/managers/analytics_manager.dart @@ -13,6 +13,7 @@ import 'package:flutter/foundation.dart'; import '../../models/models.dart'; import '../interfaces/storage_service_interface.dart'; import '../interfaces/ml_service_interface.dart'; +import '../utils/exercise_history.dart'; /// Manages analytics and statistics for workouts. /// @@ -93,28 +94,14 @@ class AnalyticsManager extends ChangeNotifier { notifyListeners(); } - /// Get set recommendations for an exercise + /// Get set recommendations for an exercise. /// - /// Sessions are sorted newest-first to find the most recent exercise log. + /// Uses the most-recently-dated session containing this exercise. List getRecommendations( String exerciseId, List sessions, ) { - // Sort sessions newest-first to find the most recent exercise log - final sortedSessions = List.from(sessions) - ..sort((a, b) => b.date.compareTo(a.date)); - - // Find last session with this exercise - ExerciseLog? lastLog; - for (var session in sortedSessions) { - for (var log in session.exercises) { - if (log.exerciseId == exerciseId) { - lastLog = log; - break; - } - } - if (lastLog != null) break; - } + final lastLog = findMostRecentExerciseLog(exerciseId, sessions); if (lastLog == null || lastLog.sets.isEmpty) { return _mlService.getDefaultRecommendations(3); diff --git a/workout-logger/lib/services/managers/history_manager.dart b/workout-logger/lib/services/managers/history_manager.dart index 6145fba..a55e77f 100644 --- a/workout-logger/lib/services/managers/history_manager.dart +++ b/workout-logger/lib/services/managers/history_manager.dart @@ -64,12 +64,18 @@ class HistoryManager extends ChangeNotifier { .toList(); } - /// Get sessions within a date range (inclusive of start and end) + /// Get sessions within a date range (inclusive of start and end). + /// + /// Tolerates inverted ranges (mirrors [StorageService.getSessionsInDateRange]) + /// so callers don't silently get an empty list when start/end are passed in + /// the wrong order. List getSessionsInDateRange(DateTime start, DateTime end) { + final lo = start.isAfter(end) ? end : start; + final hi = start.isAfter(end) ? start : end; return _sessions .where( (session) => - !session.date.isBefore(start) && !session.date.isAfter(end), + !session.date.isBefore(lo) && !session.date.isAfter(hi), ) .toList(); } diff --git a/workout-logger/lib/services/storage_service.dart b/workout-logger/lib/services/storage_service.dart index 25cc76c..f06c0e2 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -129,10 +129,14 @@ class StorageService implements IStorageService { DateTime end, ) async { final allSessions = await getAllWorkoutSessions(); + // Tolerate inverted ranges so analytics callers don't silently get an + // empty list when start/end are passed in the wrong order. + final lo = start.isAfter(end) ? end : start; + final hi = start.isAfter(end) ? start : end; return allSessions .where( (session) => - !session.date.isBefore(start) && !session.date.isAfter(end), + !session.date.isBefore(lo) && !session.date.isAfter(hi), ) .toList(); } diff --git a/workout-logger/lib/services/utils/exercise_history.dart b/workout-logger/lib/services/utils/exercise_history.dart new file mode 100644 index 0000000..319a051 --- /dev/null +++ b/workout-logger/lib/services/utils/exercise_history.dart @@ -0,0 +1,29 @@ +import '../../models/models.dart'; + +/// Returns the [ExerciseLog] for [exerciseId] from the most-recently-dated +/// [WorkoutSession] in [sessions], or `null` if the exercise has never been +/// logged. +/// +/// Sessions are sorted by date descending before scanning so the result does +/// not depend on the storage's insertion order. Used by recommendation and +/// "last session" lookups so the two paths never disagree. +/// +/// Tie-break: when two sessions share the exact same [WorkoutSession.date], +/// the choice between them is unspecified. `List.sort` is not stable in Dart, +/// so callers must not rely on a deterministic winner for equal timestamps. +ExerciseLog? findMostRecentExerciseLog( + String exerciseId, + List sessions, +) { + final sorted = List.from(sessions) + ..sort((a, b) => b.date.compareTo(a.date)); + + for (final session in sorted) { + for (final log in session.exercises) { + if (log.exerciseId == exerciseId) { + return log; + } + } + } + return null; +} diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index d0c131f..172c7f9 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -22,6 +22,7 @@ import 'interfaces/ml_service_interface.dart'; import 'ml_service.dart'; import 'strategies/target_calculator.dart'; import 'managers/program_manager.dart'; +import 'utils/exercise_history.dart'; class WorkoutProvider extends ChangeNotifier { final IStorageService _storage; @@ -409,19 +410,12 @@ class WorkoutProvider extends ChangeNotifier { // ==================== RECOMMENDATIONS ==================== - /// Get set recommendations for an exercise + /// Get set recommendations for an exercise. + /// + /// Uses the most-recently-dated session that contains this exercise as the + /// basis for the recommendation. Order in `_sessions` is not assumed. List getRecommendations(String exerciseId) { - // Find last session with this exercise - ExerciseLog? lastLog; - for (var session in _sessions) { - for (var log in session.exercises) { - if (log.exerciseId == exerciseId) { - lastLog = log; - break; - } - } - if (lastLog != null) break; - } + final lastLog = findMostRecentExerciseLog(exerciseId, _sessions); if (lastLog == null || lastLog.sets.isEmpty) { return _mlService.getDefaultRecommendations(3); @@ -433,16 +427,9 @@ class WorkoutProvider extends ChangeNotifier { ); } - /// Get last session data for an exercise + /// Get the most recent exercise log for [exerciseId], or null if never logged. ExerciseLog? getLastSessionForExercise(String exerciseId) { - for (var session in _sessions) { - for (var log in session.exercises) { - if (log.exerciseId == exerciseId) { - return log; - } - } - } - return null; + return findMostRecentExerciseLog(exerciseId, _sessions); } // ==================== SESSION MANAGEMENT ==================== diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index ed2cfe3..5fb4b73 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -20,7 +20,7 @@ version: 1.0.14+15 environment: sdk: ^3.9.2 - flutter: 3.41.5 + flutter: 3.41.6 # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions diff --git a/workout-logger/test/exercise_history_test.dart b/workout-logger/test/exercise_history_test.dart new file mode 100644 index 0000000..fd1105d --- /dev/null +++ b/workout-logger/test/exercise_history_test.dart @@ -0,0 +1,109 @@ +// Tests for the shared exercise-history helper. +// +// Sessions can be passed in any order — the helper must always return the +// log from the most-recently-dated session. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/utils/exercise_history.dart'; + +WorkoutSet _set({double weight = 50, int reps = 8}) => + WorkoutSet(weight: weight, reps: reps); + +ExerciseLog _log(String exerciseId, {List? sets}) => + ExerciseLog(exerciseId: exerciseId, sets: sets ?? [_set()]); + +WorkoutSession _session( + String id, + DateTime date, + List logs, +) => WorkoutSession( + id: id, + date: date, + exercises: logs, + duration: 30, +); + +void main() { + group('findMostRecentExerciseLog', () { + test('returns null when sessions is empty', () { + expect(findMostRecentExerciseLog('bench', const []), isNull); + }); + + test('returns null when no session contains the exercise', () { + final sessions = [ + _session('s1', DateTime(2025, 1, 1), [_log('squat')]), + ]; + expect(findMostRecentExerciseLog('bench', sessions), isNull); + }); + + test('returns the log from the most-recently-dated session', () { + final marker = [_set(weight: 100, reps: 5)]; + final sessions = [ + _session('old', DateTime(2025, 1, 1), [_log('bench')]), + _session('newest', DateTime(2025, 3, 1), [_log('bench', sets: marker)]), + _session('mid', DateTime(2025, 2, 1), [_log('bench')]), + ]; + + final result = findMostRecentExerciseLog('bench', sessions); + + expect(result, isNotNull); + expect(result!.sets.first.weight, 100); + expect(result.sets.first.reps, 5); + }); + + test('does not depend on input order', () { + final marker = [_set(weight: 200, reps: 3)]; + final newest = _session('newest', DateTime(2025, 6, 1), [ + _log('bench', sets: marker), + ]); + final older1 = _session('a', DateTime(2025, 1, 1), [_log('bench')]); + final older2 = _session('b', DateTime(2025, 4, 1), [_log('bench')]); + + // Pass with the newest session in the middle — wrong-order input. + final result = findMostRecentExerciseLog('bench', [ + older1, + newest, + older2, + ]); + + expect(result!.sets.first.weight, 200); + }); + + test( + 'tie-break is unspecified when two sessions share the exact same date', + () { + // Documents the contract: List.sort is not stable in Dart, so when + // two sessions have identical DateTime values the helper may return + // either log. Callers must not depend on a particular winner. + final sameDate = DateTime(2025, 5, 1); + final a = _session('a', sameDate, [ + _log('bench', sets: [_set(weight: 1)]), + ]); + final b = _session('b', sameDate, [ + _log('bench', sets: [_set(weight: 2)]), + ]); + + final result = findMostRecentExerciseLog('bench', [a, b]); + + expect(result, isNotNull); + expect(result!.sets.first.weight, anyOf(1, 2)); + }, + ); + + test('skips sessions that do not contain the target exercise', () { + final markerSets = [_set(weight: 80, reps: 10)]; + final sessions = [ + _session('newer-without-bench', DateTime(2025, 5, 1), [_log('squat')]), + _session('older-with-bench', DateTime(2025, 2, 1), [ + _log('bench', sets: markerSets), + ]), + ]; + + final result = findMostRecentExerciseLog('bench', sessions); + + expect(result, isNotNull); + expect(result!.sets.first.weight, 80); + }); + }); +} diff --git a/workout-logger/test/test_utils/mock_storage_service.dart b/workout-logger/test/test_utils/mock_storage_service.dart index a422ba5..8f730e5 100644 --- a/workout-logger/test/test_utils/mock_storage_service.dart +++ b/workout-logger/test/test_utils/mock_storage_service.dart @@ -115,10 +115,12 @@ class MockStorageService implements IStorageService { DateTime start, DateTime end, ) async { + final lo = start.isAfter(end) ? end : start; + final hi = start.isAfter(end) ? start : end; return _sessions .where( (session) => - !session.date.isBefore(start) && !session.date.isAfter(end), + !session.date.isBefore(lo) && !session.date.isAfter(hi), ) .toList(); } diff --git a/workout-logger/test/workout_provider_test.dart b/workout-logger/test/workout_provider_test.dart index 1171d28..473f349 100644 --- a/workout-logger/test/workout_provider_test.dart +++ b/workout-logger/test/workout_provider_test.dart @@ -1,6 +1,7 @@ // Unit Tests for WorkoutProvider - Custom Exercise functionality import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; import 'package:repforge/services/workout_provider.dart'; import 'package:repforge/services/managers/program_manager.dart'; import 'test_utils/mock_storage_service.dart'; @@ -174,6 +175,75 @@ void main() { }); }); + group('getRecommendations / getLastSessionForExercise', () { + WorkoutSession session(String id, DateTime date, List logs) => + WorkoutSession(id: id, date: date, exercises: logs, duration: 30); + + ExerciseLog log(String exerciseId, {List? sets}) => + ExerciseLog( + exerciseId: exerciseId, + sets: sets ?? [WorkoutSet(weight: 50, reps: 8)], + ); + + test( + 'getLastSessionForExercise returns the most-recently-dated log ' + 'regardless of session insert order', + () async { + final markerSets = [WorkoutSet(weight: 100, reps: 5)]; + // Seed storage with sessions in non-chronological order so the + // provider's internal _sessions list does not happen to be sorted. + mockStorage.addMockSession( + session('old', DateTime(2025, 1, 1), [log('bench')]), + ); + mockStorage.addMockSession( + session('newest', DateTime(2025, 6, 1), [ + log('bench', sets: markerSets), + ]), + ); + mockStorage.addMockSession( + session('mid', DateTime(2025, 3, 1), [log('bench')]), + ); + + // Re-init so the provider reloads sessions from the mock. + await provider.init(); + + final last = provider.getLastSessionForExercise('bench'); + + expect(last, isNotNull); + expect(last!.sets.first.weight, 100); + expect(last.sets.first.reps, 5); + }, + ); + + test('getLastSessionForExercise returns null when never logged', () { + expect(provider.getLastSessionForExercise('never_done'), isNull); + }); + + test( + 'getRecommendations bases output on the most-recent log', + () async { + final marker = [WorkoutSet(weight: 120, reps: 6)]; + mockStorage.addMockSession( + session('old', DateTime(2025, 1, 1), [log('bench')]), + ); + mockStorage.addMockSession( + session('newest', DateTime(2025, 8, 1), [ + log('bench', sets: marker), + ]), + ); + + await provider.init(); + + final recs = provider.getRecommendations('bench'); + // Default fallback is 3 generic sets at low weight; a real + // recommendation derived from the marker should be non-empty and + // weighted near 120kg, not the default. + expect(recs, isNotEmpty); + expect(recs.first.weight, greaterThanOrEqualTo(120)); + }, + ); + }); + group('deleteCustomExercise', () { test('should remove custom exercise from list', () async { // Arrange - Add an exercise first