From d85ee13ed9bf15f4af2f4bb1cbae2cf22fbf3032 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Sat, 6 Sep 2025 00:04:45 +0200 Subject: [PATCH] Give users more control over the exercise cache This allows users to manually refresh the cache and load all exercises from the server. --- lib/providers/exercises.dart | 14 +++ lib/providers/routines.dart | 6 +- lib/widgets/core/settings.dart | 28 ++---- lib/widgets/core/settings/exercise_cache.dart | 89 +++++++++++++++++++ test/core/settings_test.dart | 14 ++- test/core/settings_test.mocks.dart | 10 +++ .../contribute_exercise_test.mocks.dart | 10 +++ .../exercises_detail_widget_test.mocks.dart | 10 +++ test/routine/gym_mode_screen_test.mocks.dart | 10 +++ .../routine/routines_provider_test.mocks.dart | 10 +++ 10 files changed, 177 insertions(+), 24 deletions(-) create mode 100644 lib/widgets/core/settings/exercise_cache.dart diff --git a/lib/providers/exercises.dart b/lib/providers/exercises.dart index 6a4d90bf..1040df25 100644 --- a/lib/providers/exercises.dart +++ b/lib/providers/exercises.dart @@ -47,6 +47,7 @@ class ExercisesProvider with ChangeNotifier { static const EXERCISE_CACHE_DAYS = 7; static const CACHE_VERSION = 4; + static const exerciseUrlPath = 'exercise'; static const exerciseInfoUrlPath = 'exerciseinfo'; static const exerciseSearchPath = 'exercise/search'; @@ -274,6 +275,18 @@ class ExercisesProvider with ChangeNotifier { } } + Future fetchAndSetAllExercises() async { + _logger.info('Loading all exercises from API'); + final exerciseData = await baseProvider.fetchPaginated( + baseProvider.makeUrl(exerciseUrlPath, query: {'limit': API_MAX_PAGE_SIZE}), + ); + final exerciseIds = exerciseData.map((e) => e['id'] as int).toSet(); + + for (final exerciseId in exerciseIds) { + await handleUpdateExerciseFromApi(database, exerciseId); + } + } + /// Returns the exercise with the given ID /// /// If the exercise is not known locally, it is fetched from the server. @@ -291,6 +304,7 @@ class ExercisesProvider with ChangeNotifier { return exercise; } on NoSuchEntryException { + // _logger.finer('Exercise not found locally, fetching from the API'); return handleUpdateExerciseFromApi(database, exerciseId); } } diff --git a/lib/providers/routines.dart b/lib/providers/routines.dart index e270f7d3..946c9fa3 100644 --- a/lib/providers/routines.dart +++ b/lib/providers/routines.dart @@ -153,13 +153,14 @@ class RoutinesProvider with ChangeNotifier { /// Fetches and sets all workout plans fully, i.e. with all corresponding child /// attributes Future fetchAndSetAllRoutinesFull() async { - final data = await baseProvider.fetch( + _logger.fine('Fetching all routines fully'); + final data = await baseProvider.fetchPaginated( baseProvider.makeUrl( _routinesUrlPath, query: {'ordering': '-creation_date', 'limit': API_MAX_PAGE_SIZE, 'is_template': 'false'}, ), ); - for (final entry in data['results']) { + for (final entry in data) { await fetchAndSetRoutineFull(entry['id']); } @@ -169,6 +170,7 @@ class RoutinesProvider with ChangeNotifier { /// Fetches all routines sparsely, i.e. only with the data on the object itself /// and no child attributes Future fetchAndSetAllRoutinesSparse() async { + _logger.fine('Fetching all routines sparsely'); final data = await baseProvider.fetch( baseProvider.makeUrl( _routinesUrlPath, diff --git a/lib/widgets/core/settings.dart b/lib/widgets/core/settings.dart index 19802532..66e7fce8 100644 --- a/lib/widgets/core/settings.dart +++ b/lib/widgets/core/settings.dart @@ -20,10 +20,10 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; -import 'package:wger/providers/exercises.dart'; import 'package:wger/providers/nutrition.dart'; import 'package:wger/providers/user.dart'; import 'package:wger/screens/configure_plates_screen.dart'; +import 'package:wger/widgets/core/settings/exercise_cache.dart'; class SettingsPage extends StatelessWidget { static String routeName = '/SettingsPage'; @@ -33,7 +33,6 @@ class SettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { final i18n = AppLocalizations.of(context); - final exerciseProvider = Provider.of(context, listen: false); final nutritionProvider = Provider.of(context, listen: false); final userProvider = Provider.of(context); @@ -47,24 +46,7 @@ class SettingsPage extends StatelessWidget { style: Theme.of(context).textTheme.headlineSmall, ), ), - ListTile( - title: Text(i18n.settingsExerciseCacheDescription), - trailing: IconButton( - key: const ValueKey('cacheIconExercises'), - icon: const Icon(Icons.delete), - onPressed: () async { - await exerciseProvider.clearAllCachesAndPrefs(); - - if (context.mounted) { - final snackBar = SnackBar( - content: Text(i18n.settingsCacheDeletedSnackbar), - ); - - ScaffoldMessenger.of(context).showSnackBar(snackBar); - } - }, - ), - ), + const SettingsExerciseCache(), ListTile( title: Text(i18n.settingsIngredientCacheDescription), trailing: IconButton( @@ -83,6 +65,12 @@ class SettingsPage extends StatelessWidget { }, ), ), + ListTile( + title: Text( + i18n.others, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), ListTile( title: Text(i18n.themeMode), trailing: DropdownButton( diff --git a/lib/widgets/core/settings/exercise_cache.dart b/lib/widgets/core/settings/exercise_cache.dart new file mode 100644 index 00000000..1eef754f --- /dev/null +++ b/lib/widgets/core/settings/exercise_cache.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/providers/exercises.dart'; + +class SettingsExerciseCache extends StatefulWidget { + const SettingsExerciseCache({super.key}); + + @override + State createState() => _SettingsExerciseCacheState(); +} + +class _SettingsExerciseCacheState extends State { + bool _isRefreshLoading = false; + String _subtitle = ''; + + @override + Widget build(BuildContext context) { + final exerciseProvider = Provider.of(context, listen: false); + final i18n = AppLocalizations.of(context); + + return ListTile( + enabled: !_isRefreshLoading, + title: Text(i18n.settingsExerciseCacheDescription), + subtitle: _subtitle.isNotEmpty ? Text(_subtitle) : null, + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + IconButton( + key: const ValueKey('cacheIconExercisesRefresh'), + icon: _isRefreshLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + onPressed: _isRefreshLoading + ? null + : () async { + setState(() => _isRefreshLoading = true); + + // Note: status messages are currently left in English on purpose + try { + setState(() => _subtitle = 'Clearing cache...'); + await exerciseProvider.clearAllCachesAndPrefs(); + + if (mounted) { + setState(() => _subtitle = 'Loading languages and units...'); + } + await exerciseProvider.fetchAndSetInitialData(); + + if (mounted) { + setState(() => _subtitle = 'Loading all exercises from server...'); + } + await exerciseProvider.fetchAndSetAllExercises(); + + if (mounted) { + setState(() => _subtitle = ''); + } + } finally { + if (mounted) { + setState(() => _isRefreshLoading = false); + } + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(i18n.success)), + ); + } + } + }, + ), + IconButton( + key: const ValueKey('cacheIconExercisesDelete'), + icon: const Icon(Icons.delete), + onPressed: () async { + await exerciseProvider.clearAllCachesAndPrefs(); + + if (context.mounted) { + final snackBar = SnackBar( + content: Text(i18n.settingsCacheDeletedSnackbar), + ); + + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + }, + ) + ]), + ); + } +} diff --git a/test/core/settings_test.dart b/test/core/settings_test.dart index 8c88fc43..7fe2d7ed 100644 --- a/test/core/settings_test.dart +++ b/test/core/settings_test.dart @@ -39,7 +39,7 @@ import 'settings_test.mocks.dart'; WgerBaseProvider, SharedPreferencesAsync, ]) -void main() async { +void main() { final mockExerciseProvider = MockExercisesProvider(); final mockNutritionProvider = MockNutritionPlansProvider(); final mockSharedPreferences = MockSharedPreferencesAsync(); @@ -68,12 +68,22 @@ void main() async { group('Cache', () { testWidgets('Test resetting the exercise cache', (WidgetTester tester) async { await tester.pumpWidget(createSettingsScreen()); - await tester.tap(find.byKey(const ValueKey('cacheIconExercises'))); + await tester.tap(find.byKey(const ValueKey('cacheIconExercisesDelete'))); await tester.pumpAndSettle(); verify(mockExerciseProvider.clearAllCachesAndPrefs()); }); + testWidgets('Test refreshing the exercise cache', (WidgetTester tester) async { + await tester.pumpWidget(createSettingsScreen()); + await tester.tap(find.byKey(const ValueKey('cacheIconExercisesRefresh'))); + await tester.pumpAndSettle(); + + verify(mockExerciseProvider.clearAllCachesAndPrefs()); + verify(mockExerciseProvider.fetchAndSetInitialData()); + verify(mockExerciseProvider.fetchAndSetAllExercises()); + }); + testWidgets('Test resetting the ingredient cache', (WidgetTester tester) async { await tester.pumpWidget(createSettingsScreen()); await tester.tap(find.byKey(const ValueKey('cacheIconIngredients'))); diff --git a/test/core/settings_test.mocks.dart b/test/core/settings_test.mocks.dart index e5cc01bd..39764e01 100644 --- a/test/core/settings_test.mocks.dart +++ b/test/core/settings_test.mocks.dart @@ -490,6 +490,16 @@ class MockExercisesProvider extends _i1.Mock implements _i17.ExercisesProvider { returnValueForMissingStub: _i18.Future.value(), ) as _i18.Future); + @override + _i18.Future fetchAndSetAllExercises() => (super.noSuchMethod( + Invocation.method( + #fetchAndSetAllExercises, + [], + ), + returnValue: _i18.Future.value(), + returnValueForMissingStub: _i18.Future.value(), + ) as _i18.Future); + @override _i18.Future<_i4.Exercise?> fetchAndSetExercise(int? exerciseId) => (super.noSuchMethod( Invocation.method( diff --git a/test/exercises/contribute_exercise_test.mocks.dart b/test/exercises/contribute_exercise_test.mocks.dart index 41cf8eff..45db393b 100644 --- a/test/exercises/contribute_exercise_test.mocks.dart +++ b/test/exercises/contribute_exercise_test.mocks.dart @@ -945,6 +945,16 @@ class MockExercisesProvider extends _i1.Mock implements _i20.ExercisesProvider { returnValueForMissingStub: _i15.Future.value(), ) as _i15.Future); + @override + _i15.Future fetchAndSetAllExercises() => (super.noSuchMethod( + Invocation.method( + #fetchAndSetAllExercises, + [], + ), + returnValue: _i15.Future.value(), + returnValueForMissingStub: _i15.Future.value(), + ) as _i15.Future); + @override _i15.Future<_i3.Exercise?> fetchAndSetExercise(int? exerciseId) => (super.noSuchMethod( Invocation.method( diff --git a/test/exercises/exercises_detail_widget_test.mocks.dart b/test/exercises/exercises_detail_widget_test.mocks.dart index 62742657..70c259b8 100644 --- a/test/exercises/exercises_detail_widget_test.mocks.dart +++ b/test/exercises/exercises_detail_widget_test.mocks.dart @@ -377,6 +377,16 @@ class MockExercisesProvider extends _i1.Mock implements _i9.ExercisesProvider { returnValueForMissingStub: _i10.Future.value(), ) as _i10.Future); + @override + _i10.Future fetchAndSetAllExercises() => (super.noSuchMethod( + Invocation.method( + #fetchAndSetAllExercises, + [], + ), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) as _i10.Future); + @override _i10.Future<_i4.Exercise?> fetchAndSetExercise(int? exerciseId) => (super.noSuchMethod( Invocation.method( diff --git a/test/routine/gym_mode_screen_test.mocks.dart b/test/routine/gym_mode_screen_test.mocks.dart index 704e421b..6be6a132 100644 --- a/test/routine/gym_mode_screen_test.mocks.dart +++ b/test/routine/gym_mode_screen_test.mocks.dart @@ -580,6 +580,16 @@ class MockExercisesProvider extends _i1.Mock implements _i12.ExercisesProvider { returnValueForMissingStub: _i11.Future.value(), ) as _i11.Future); + @override + _i11.Future fetchAndSetAllExercises() => (super.noSuchMethod( + Invocation.method( + #fetchAndSetAllExercises, + [], + ), + returnValue: _i11.Future.value(), + returnValueForMissingStub: _i11.Future.value(), + ) as _i11.Future); + @override _i11.Future<_i6.Exercise?> fetchAndSetExercise(int? exerciseId) => (super.noSuchMethod( Invocation.method( diff --git a/test/routine/routines_provider_test.mocks.dart b/test/routine/routines_provider_test.mocks.dart index 3512d8a9..76f22596 100644 --- a/test/routine/routines_provider_test.mocks.dart +++ b/test/routine/routines_provider_test.mocks.dart @@ -580,6 +580,16 @@ class MockExercisesProvider extends _i1.Mock implements _i12.ExercisesProvider { returnValueForMissingStub: _i11.Future.value(), ) as _i11.Future); + @override + _i11.Future fetchAndSetAllExercises() => (super.noSuchMethod( + Invocation.method( + #fetchAndSetAllExercises, + [], + ), + returnValue: _i11.Future.value(), + returnValueForMissingStub: _i11.Future.value(), + ) as _i11.Future); + @override _i11.Future<_i6.Exercise?> fetchAndSetExercise(int? exerciseId) => (super.noSuchMethod( Invocation.method(