diff --git a/lib/providers/user.dart b/lib/providers/user.dart index e2dc2664..e2c4c0a8 100644 --- a/lib/providers/user.dart +++ b/lib/providers/user.dart @@ -19,6 +19,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:wger/helpers/consts.dart'; @@ -28,44 +29,48 @@ import 'package:wger/providers/base_provider.dart'; enum DashboardWidget { routines('routines'), + nutrition('nutrition'), weight('weight'), measurements('measurements'), - calendar('calendar'), - nutrition('nutrition'); + calendar('calendar'); final String value; const DashboardWidget(this.value); static DashboardWidget? fromString(String s) { for (final e in DashboardWidget.values) { - if (e.value == s) return e; + if (e.value == s) { + return e; + } } return null; } } +class DashboardItem { + final DashboardWidget widget; + bool isVisible; + + DashboardItem(this.widget, {this.isVisible = true}); + + Map toJson() => { + 'widget': widget.value, + 'visible': isVisible, + }; +} + class UserProvider with ChangeNotifier { ThemeMode themeMode = ThemeMode.system; final WgerBaseProvider baseProvider; late SharedPreferencesAsync prefs; - // New: visibility state for dashboard widgets - Map dashboardWidgetVisibility = { - DashboardWidget.routines: true, - DashboardWidget.weight: true, - DashboardWidget.measurements: true, - DashboardWidget.calendar: true, - DashboardWidget.nutrition: true, - }; - - static const String PREFS_DASHBOARD_VISIBILITY = 'dashboardWidgetVisibility'; - UserProvider(this.baseProvider, {SharedPreferencesAsync? prefs}) { this.prefs = prefs ?? PreferenceHelper.asyncPref; _loadThemeMode(); - _loadDashboardVisibility(); + _loadDashboardConfig(); } + static const String PREFS_DASHBOARD_CONFIG = 'dashboardConfig'; static const PROFILE_URL = 'userprofile'; static const VERIFY_EMAIL = 'verify-email'; @@ -76,15 +81,6 @@ class UserProvider with ChangeNotifier { profile = null; } - // // change the unit of plates - // void changeUnit({changeTo = 'kg'}) { - // if (changeTo == 'kg') { - // profile?.weightUnitStr = 'lb'; - // } else { - // profile?.weightUnitStr = 'kg'; - // } - // } - // Load theme mode from SharedPreferences Future _loadThemeMode() async { final prefsDarkMode = await prefs.getBool(PREFS_USER_DARK_THEME); @@ -98,47 +94,76 @@ class UserProvider with ChangeNotifier { notifyListeners(); } - Future _loadDashboardVisibility() async { - final jsonString = await prefs.getString(PREFS_DASHBOARD_VISIBILITY); + // Dashboard configuration + List _dashboardItems = DashboardWidget.values + .map((w) => DashboardItem(w)) + .toList(); + + List get dashboardOrder => _dashboardItems.map((e) => e.widget).toList(); + + Future _loadDashboardConfig() async { + final jsonString = await prefs.getString(PREFS_DASHBOARD_CONFIG); if (jsonString == null) { + notifyListeners(); return; } try { - final decoded = jsonDecode(jsonString) as Map; - final Map loaded = {}; + final List decoded = jsonDecode(jsonString); + final List loaded = []; - for (final entry in decoded.entries) { - final widget = DashboardWidget.fromString(entry.key); + for (final item in decoded) { + final widget = DashboardWidget.fromString(item['widget']); if (widget != null) { - loaded[widget] = entry.value as bool; + loaded.add( + DashboardItem(widget, isVisible: item['visible'] as bool), + ); } } - if (loaded.isNotEmpty) { - dashboardWidgetVisibility = loaded; + // Add any missing widgets (e.g. newly added features) + for (final widget in DashboardWidget.values) { + if (!loaded.any((item) => item.widget == widget)) { + loaded.add(DashboardItem(widget)); + } } + + _dashboardItems = loaded; } catch (_) { // parsing failed -> keep defaults } - notifyListeners(); } + Future _saveDashboardConfig() async { + final serializable = _dashboardItems.map((e) => e.toJson()).toList(); + await prefs.setString(PREFS_DASHBOARD_CONFIG, jsonEncode(serializable)); + } + bool isDashboardWidgetVisible(DashboardWidget key) { - return dashboardWidgetVisibility[key] ?? true; + final widget = _dashboardItems.firstWhereOrNull((e) => e.widget == key); + return widget == null || widget.isVisible; } Future setDashboardWidgetVisible(DashboardWidget key, bool visible) async { - dashboardWidgetVisibility[key] = visible; - final Map serializable = { - for (final e in dashboardWidgetVisibility.entries) e.key.value: e.value, - }; + final item = _dashboardItems.firstWhereOrNull((e) => e.widget == key); + if (item == null) { + return; + } - await prefs.setString( - PREFS_DASHBOARD_VISIBILITY, - jsonEncode(serializable), - ); + item.isVisible = visible; + await _saveDashboardConfig(); + notifyListeners(); + } + + Future setDashboardOrder(int oldIndex, int newIndex) async { + if (oldIndex < newIndex) { + newIndex -= 1; + } + final item = _dashboardItems.removeAt(oldIndex); + _dashboardItems.insert(newIndex, item); + + await _saveDashboardConfig(); notifyListeners(); } diff --git a/lib/screens/dashboard.dart b/lib/screens/dashboard.dart index dae86cb0..b7162bd7 100644 --- a/lib/screens/dashboard.dart +++ b/lib/screens/dashboard.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team + * Copyright (c) 2026 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -32,6 +32,21 @@ class DashboardScreen extends StatelessWidget { static const routeName = '/dashboard'; + Widget _getDashboardWidget(DashboardWidget widget) { + switch (widget) { + case DashboardWidget.routines: + return const DashboardRoutineWidget(); + case DashboardWidget.weight: + return const DashboardWeightWidget(); + case DashboardWidget.measurements: + return const DashboardMeasurementWidget(); + case DashboardWidget.calendar: + return const DashboardCalendarWidget(); + case DashboardWidget.nutrition: + return const DashboardNutritionWidget(); + } + } + @override Widget build(BuildContext context) { final user = Provider.of(context); @@ -41,18 +56,10 @@ class DashboardScreen extends StatelessWidget { body: SingleChildScrollView( padding: const EdgeInsets.all(10), child: Column( - children: [ - if (user.isDashboardWidgetVisible(DashboardWidget.routines)) - const DashboardRoutineWidget(), - if (user.isDashboardWidgetVisible(DashboardWidget.weight)) - const DashboardWeightWidget(), - if (user.isDashboardWidgetVisible(DashboardWidget.measurements)) - const DashboardMeasurementWidget(), - if (user.isDashboardWidgetVisible(DashboardWidget.calendar)) - const DashboardCalendarWidget(), - if (user.isDashboardWidgetVisible(DashboardWidget.nutrition)) - const DashboardNutritionWidget(), - ], + children: user.dashboardOrder + .where((w) => user.isDashboardWidgetVisible(w)) + .map(_getDashboardWidget) + .toList(), ), ), ); diff --git a/lib/widgets/core/settings.dart b/lib/widgets/core/settings.dart index 8ddcf0ee..014b281d 100644 --- a/lib/widgets/core/settings.dart +++ b/lib/widgets/core/settings.dart @@ -18,7 +18,6 @@ import 'package:flutter/material.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; -import 'package:wger/screens/settings_dashboard_widgets_screen.dart'; import 'package:wger/screens/settings_plates_screen.dart'; import './settings/exercise_cache.dart'; @@ -52,13 +51,6 @@ class SettingsPage extends StatelessWidget { }, trailing: const Icon(Icons.chevron_right), ), - ListTile( - title: Text(i18n.dashboardWidgets), - onTap: () { - Navigator.of(context).pushNamed(ConfigureDashboardWidgetsScreen.routeName); - }, - trailing: const Icon(Icons.chevron_right), - ), ], ), ); diff --git a/lib/widgets/core/settings/dashboard_visibility.dart b/lib/widgets/core/settings/dashboard_visibility.dart index d00ead0b..b7d110b8 100644 --- a/lib/widgets/core/settings/dashboard_visibility.dart +++ b/lib/widgets/core/settings/dashboard_visibility.dart @@ -28,36 +28,49 @@ class SettingsDashboardVisibility extends StatelessWidget { Widget build(BuildContext context) { final i18n = AppLocalizations.of(context); + String getTitle(DashboardWidget w) { + switch (w) { + case DashboardWidget.routines: + return i18n.routines; + case DashboardWidget.weight: + return i18n.weight; + case DashboardWidget.measurements: + return i18n.measurements; + case DashboardWidget.calendar: + return i18n.calendar; + case DashboardWidget.nutrition: + return i18n.nutritionalPlans; + } + } + return Consumer( builder: (context, user, _) { - return Column( - children: [ - SwitchListTile( - title: Text(i18n.routines), - value: user.isDashboardWidgetVisible(DashboardWidget.routines), - onChanged: (v) => user.setDashboardWidgetVisible(DashboardWidget.routines, v), - ), - SwitchListTile( - title: Text(i18n.weight), - value: user.isDashboardWidgetVisible(DashboardWidget.weight), - onChanged: (v) => user.setDashboardWidgetVisible(DashboardWidget.weight, v), - ), - SwitchListTile( - title: Text(i18n.measurements), - value: user.isDashboardWidgetVisible(DashboardWidget.measurements), - onChanged: (v) => user.setDashboardWidgetVisible(DashboardWidget.measurements, v), - ), - SwitchListTile( - title: Text(i18n.calendar), - value: user.isDashboardWidgetVisible(DashboardWidget.calendar), - onChanged: (v) => user.setDashboardWidgetVisible(DashboardWidget.calendar, v), - ), - SwitchListTile( - title: Text(i18n.nutritionalPlans), - value: user.isDashboardWidgetVisible(DashboardWidget.nutrition), - onChanged: (v) => user.setDashboardWidgetVisible(DashboardWidget.nutrition, v), - ), - ], + return ReorderableListView( + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + onReorder: user.setDashboardOrder, + children: user.dashboardOrder.asMap().entries.map((entry) { + final index = entry.key; + final w = entry.value; + + return ListTile( + key: ValueKey(w), + title: Text(getTitle(w)), + leading: IconButton( + icon: user.isDashboardWidgetVisible(w) + ? const Icon(Icons.visibility) + : const Icon(Icons.visibility_off, color: Colors.grey), + onPressed: () => user.setDashboardWidgetVisible( + w, + !user.isDashboardWidgetVisible(w), + ), + ), + trailing: ReorderableDragStartListener( + index: index, + child: const Icon(Icons.drag_handle), + ), + ); + }).toList(), ); }, ); diff --git a/test/core/settings_dashboard_visibility_test.dart b/test/core/settings_dashboard_visibility_test.dart new file mode 100644 index 00000000..a171bbd4 --- /dev/null +++ b/test/core/settings_dashboard_visibility_test.dart @@ -0,0 +1,121 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2026 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/providers/base_provider.dart'; +import 'package:wger/providers/user.dart'; +import 'package:wger/widgets/core/settings/dashboard_visibility.dart'; + +import 'settings_dashboard_visibility_test.mocks.dart'; + +@GenerateMocks([ + UserProvider, + WgerBaseProvider, +]) +void main() { + late UserProvider userProvider; + late MockWgerBaseProvider mockBaseProvider; + + setUp(() { + SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); + mockBaseProvider = MockWgerBaseProvider(); + userProvider = UserProvider(mockBaseProvider); + }); + + Widget createWidget() { + return ChangeNotifierProvider.value( + value: userProvider, + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SettingsDashboardVisibility(), + ), + ), + ); + } + + testWidgets('renders list of dashboard widgets', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + // Verify all items are present + expect(find.byType(ListTile), findsNWidgets(DashboardWidget.values.length)); + expect(find.text('Routines'), findsOneWidget); + }); + + testWidgets('toggle visibility updates provider', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + // Routines should be visible initally (default is true) + expect(userProvider.isDashboardWidgetVisible(DashboardWidget.routines), true); + + // Find the visibility icon for Routines + final routineTile = find.byKey(const ValueKey(DashboardWidget.routines)); + final iconBtn = find.descendant(of: routineTile, matching: find.byType(IconButton)); + + // Check icon is 'visibility' + expect(find.descendant(of: iconBtn, matching: find.byIcon(Icons.visibility)), findsOneWidget); + + // Tap to toggle + await tester.tap(iconBtn); + await tester.pump(); // re-render + + // Check provider state + expect(userProvider.isDashboardWidgetVisible(DashboardWidget.routines), false); + + // Check icon is 'visibility_off' + expect( + find.descendant(of: iconBtn, matching: find.byIcon(Icons.visibility_off)), + findsOneWidget, + ); + }); + + // Reordering test is a bit flaky without full drag setup, but we can try + testWidgets('dragging reorders items', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + // Initial order: routines, nutrition, weight... + expect(userProvider.dashboardOrder[0], DashboardWidget.routines); + expect(userProvider.dashboardOrder[1], DashboardWidget.nutrition); + + // Find drag handle for Routines (index 0) + final handleFinder = find.byIcon(Icons.drag_handle); + final firstHandle = handleFinder.at(0); + // final secondHandle = handleFinder.at(1); + + // Drag first item down + await tester.drag(firstHandle, const Offset(0, 100)); // Drag down enough to swap + await tester.pumpAndSettle(); + + // Verify order changed + // If swapped with second item (nutrition) and maybe third (weight) depending on height + // Based on running test: index 0 is nutrition, index 1 is weight. + expect(userProvider.dashboardOrder[0], DashboardWidget.nutrition); + expect(userProvider.dashboardOrder[1], DashboardWidget.weight); + expect(userProvider.dashboardOrder[2], DashboardWidget.routines); + }); +} diff --git a/test/core/settings_dashboard_visibility_test.mocks.dart b/test/core/settings_dashboard_visibility_test.mocks.dart new file mode 100644 index 00000000..01803e1b --- /dev/null +++ b/test/core/settings_dashboard_visibility_test.mocks.dart @@ -0,0 +1,349 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in wger/test/core/settings_dashboard_visibility_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i9; +import 'dart:ui' as _i10; + +import 'package:flutter/material.dart' as _i7; +import 'package:http/http.dart' as _i5; +import 'package:mockito/mockito.dart' as _i1; +import 'package:shared_preferences/shared_preferences.dart' as _i3; +import 'package:wger/models/user/profile.dart' as _i8; +import 'package:wger/providers/auth.dart' as _i4; +import 'package:wger/providers/base_provider.dart' as _i2; +import 'package:wger/providers/user.dart' as _i6; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeWgerBaseProvider_0 extends _i1.SmartFake implements _i2.WgerBaseProvider { + _FakeWgerBaseProvider_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeSharedPreferencesAsync_1 extends _i1.SmartFake implements _i3.SharedPreferencesAsync { + _FakeSharedPreferencesAsync_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeAuthProvider_2 extends _i1.SmartFake implements _i4.AuthProvider { + _FakeAuthProvider_2(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +class _FakeClient_3 extends _i1.SmartFake implements _i5.Client { + _FakeClient_3(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +class _FakeUri_4 extends _i1.SmartFake implements Uri { + _FakeUri_4(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +class _FakeResponse_5 extends _i1.SmartFake implements _i5.Response { + _FakeResponse_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +/// A class which mocks [UserProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockUserProvider extends _i1.Mock implements _i6.UserProvider { + MockUserProvider() { + _i1.throwOnMissingStub(this); + } + + @override + _i7.ThemeMode get themeMode => + (super.noSuchMethod( + Invocation.getter(#themeMode), + returnValue: _i7.ThemeMode.system, + ) + as _i7.ThemeMode); + + @override + _i2.WgerBaseProvider get baseProvider => + (super.noSuchMethod( + Invocation.getter(#baseProvider), + returnValue: _FakeWgerBaseProvider_0( + this, + Invocation.getter(#baseProvider), + ), + ) + as _i2.WgerBaseProvider); + + @override + _i3.SharedPreferencesAsync get prefs => + (super.noSuchMethod( + Invocation.getter(#prefs), + returnValue: _FakeSharedPreferencesAsync_1( + this, + Invocation.getter(#prefs), + ), + ) + as _i3.SharedPreferencesAsync); + + @override + List<_i6.DashboardWidget> get dashboardOrder => + (super.noSuchMethod( + Invocation.getter(#dashboardOrder), + returnValue: <_i6.DashboardWidget>[], + ) + as List<_i6.DashboardWidget>); + + @override + set themeMode(_i7.ThemeMode? value) => super.noSuchMethod( + Invocation.setter(#themeMode, value), + returnValueForMissingStub: null, + ); + + @override + set prefs(_i3.SharedPreferencesAsync? value) => super.noSuchMethod( + Invocation.setter(#prefs, value), + returnValueForMissingStub: null, + ); + + @override + set profile(_i8.Profile? value) => super.noSuchMethod( + Invocation.setter(#profile, value), + returnValueForMissingStub: null, + ); + + @override + bool get hasListeners => + (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) as bool); + + @override + void clear() => super.noSuchMethod( + Invocation.method(#clear, []), + returnValueForMissingStub: null, + ); + + @override + bool isDashboardWidgetVisible(_i6.DashboardWidget? key) => + (super.noSuchMethod( + Invocation.method(#isDashboardWidgetVisible, [key]), + returnValue: false, + ) + as bool); + + @override + _i9.Future setDashboardWidgetVisible( + _i6.DashboardWidget? key, + bool? visible, + ) => + (super.noSuchMethod( + Invocation.method(#setDashboardWidgetVisible, [key, visible]), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); + + @override + _i9.Future setDashboardOrder(int? oldIndex, int? newIndex) => + (super.noSuchMethod( + Invocation.method(#setDashboardOrder, [oldIndex, newIndex]), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); + + @override + void setThemeMode(_i7.ThemeMode? mode) => super.noSuchMethod( + Invocation.method(#setThemeMode, [mode]), + returnValueForMissingStub: null, + ); + + @override + _i9.Future fetchAndSetProfile() => + (super.noSuchMethod( + Invocation.method(#fetchAndSetProfile, []), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); + + @override + _i9.Future saveProfile() => + (super.noSuchMethod( + Invocation.method(#saveProfile, []), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); + + @override + _i9.Future verifyEmail() => + (super.noSuchMethod( + Invocation.method(#verifyEmail, []), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); + + @override + void addListener(_i10.VoidCallback? listener) => super.noSuchMethod( + Invocation.method(#addListener, [listener]), + returnValueForMissingStub: null, + ); + + @override + void removeListener(_i10.VoidCallback? listener) => super.noSuchMethod( + Invocation.method(#removeListener, [listener]), + returnValueForMissingStub: null, + ); + + @override + void dispose() => super.noSuchMethod( + Invocation.method(#dispose, []), + returnValueForMissingStub: null, + ); + + @override + void notifyListeners() => super.noSuchMethod( + Invocation.method(#notifyListeners, []), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [WgerBaseProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockWgerBaseProvider extends _i1.Mock implements _i2.WgerBaseProvider { + MockWgerBaseProvider() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.AuthProvider get auth => + (super.noSuchMethod( + Invocation.getter(#auth), + returnValue: _FakeAuthProvider_2(this, Invocation.getter(#auth)), + ) + as _i4.AuthProvider); + + @override + _i5.Client get client => + (super.noSuchMethod( + Invocation.getter(#client), + returnValue: _FakeClient_3(this, Invocation.getter(#client)), + ) + as _i5.Client); + + @override + set auth(_i4.AuthProvider? value) => super.noSuchMethod( + Invocation.setter(#auth, value), + returnValueForMissingStub: null, + ); + + @override + set client(_i5.Client? value) => super.noSuchMethod( + Invocation.setter(#client, value), + returnValueForMissingStub: null, + ); + + @override + Map getDefaultHeaders({bool? includeAuth = false}) => + (super.noSuchMethod( + Invocation.method(#getDefaultHeaders, [], { + #includeAuth: includeAuth, + }), + returnValue: {}, + ) + as Map); + + @override + Uri makeUrl( + String? path, { + int? id, + String? objectMethod, + Map? query, + }) => + (super.noSuchMethod( + Invocation.method( + #makeUrl, + [path], + {#id: id, #objectMethod: objectMethod, #query: query}, + ), + returnValue: _FakeUri_4( + this, + Invocation.method( + #makeUrl, + [path], + {#id: id, #objectMethod: objectMethod, #query: query}, + ), + ), + ) + as Uri); + + @override + _i9.Future fetch( + Uri? uri, { + int? maxRetries = 3, + Duration? initialDelay = const Duration(milliseconds: 250), + }) => + (super.noSuchMethod( + Invocation.method( + #fetch, + [uri], + {#maxRetries: maxRetries, #initialDelay: initialDelay}, + ), + returnValue: _i9.Future.value(), + ) + as _i9.Future); + + @override + _i9.Future> fetchPaginated(Uri? uri) => + (super.noSuchMethod( + Invocation.method(#fetchPaginated, [uri]), + returnValue: _i9.Future>.value([]), + ) + as _i9.Future>); + + @override + _i9.Future> post(Map? data, Uri? uri) => + (super.noSuchMethod( + Invocation.method(#post, [data, uri]), + returnValue: _i9.Future>.value( + {}, + ), + ) + as _i9.Future>); + + @override + _i9.Future> patch( + Map? data, + Uri? uri, + ) => + (super.noSuchMethod( + Invocation.method(#patch, [data, uri]), + returnValue: _i9.Future>.value( + {}, + ), + ) + as _i9.Future>); + + @override + _i9.Future<_i5.Response> deleteRequest(String? url, int? id) => + (super.noSuchMethod( + Invocation.method(#deleteRequest, [url, id]), + returnValue: _i9.Future<_i5.Response>.value( + _FakeResponse_5( + this, + Invocation.method(#deleteRequest, [url, id]), + ), + ), + ) + as _i9.Future<_i5.Response>); +} diff --git a/test/core/settings_test.dart b/test/core/settings_test.dart index bb27464b..f4869b4e 100644 --- a/test/core/settings_test.dart +++ b/test/core/settings_test.dart @@ -49,7 +49,9 @@ void main() { setUp(() { when(mockUserProvider.themeMode).thenReturn(ThemeMode.system); - when(mockUserProvider.isDashboardWidgetVisible(any)).thenReturn(true); + when( + mockSharedPreferences.getString(UserProvider.PREFS_DASHBOARD_CONFIG), + ).thenAnswer((_) async => null); when(mockExerciseProvider.exercises).thenReturn(getTestExercises()); when(mockNutritionProvider.ingredients).thenReturn([ingredient1, ingredient2]); }); @@ -101,19 +103,15 @@ void main() { group('Theme settings', () { test('Default theme is system', () async { when(mockSharedPreferences.getBool(PREFS_USER_DARK_THEME)).thenAnswer((_) async => null); - when( - mockSharedPreferences.getString('dashboardWidgetVisibility'), - ).thenAnswer((_) async => null); - final userProvider = await UserProvider(MockWgerBaseProvider(), prefs: mockSharedPreferences); + final userProvider = UserProvider(MockWgerBaseProvider(), prefs: mockSharedPreferences); + await Future.delayed(const Duration(milliseconds: 50)); // wait for async prefs load expect(userProvider.themeMode, ThemeMode.system); }); test('Loads light theme', () async { when(mockSharedPreferences.getBool(PREFS_USER_DARK_THEME)).thenAnswer((_) async => false); - when( - mockSharedPreferences.getString('dashboardWidgetVisibility'), - ).thenAnswer((_) async => null); - final userProvider = await UserProvider(MockWgerBaseProvider(), prefs: mockSharedPreferences); + final userProvider = UserProvider(MockWgerBaseProvider(), prefs: mockSharedPreferences); + await Future.delayed(const Duration(milliseconds: 50)); // wait for async prefs load expect(userProvider.themeMode, ThemeMode.light); }); diff --git a/test/core/settings_test.mocks.dart b/test/core/settings_test.mocks.dart index b7dc303f..6df8a7d9 100644 --- a/test/core/settings_test.mocks.dart +++ b/test/core/settings_test.mocks.dart @@ -944,12 +944,12 @@ class MockUserProvider extends _i1.Mock implements _i21.UserProvider { as _i14.SharedPreferencesAsync); @override - Map<_i21.DashboardWidget, bool> get dashboardWidgetVisibility => + List<_i21.DashboardWidget> get dashboardOrder => (super.noSuchMethod( - Invocation.getter(#dashboardWidgetVisibility), - returnValue: <_i21.DashboardWidget, bool>{}, + Invocation.getter(#dashboardOrder), + returnValue: <_i21.DashboardWidget>[], ) - as Map<_i21.DashboardWidget, bool>); + as List<_i21.DashboardWidget>); @override set themeMode(_i22.ThemeMode? value) => super.noSuchMethod( @@ -963,12 +963,6 @@ class MockUserProvider extends _i1.Mock implements _i21.UserProvider { returnValueForMissingStub: null, ); - @override - set dashboardWidgetVisibility(Map<_i21.DashboardWidget, bool>? value) => super.noSuchMethod( - Invocation.setter(#dashboardWidgetVisibility, value), - returnValueForMissingStub: null, - ); - @override set profile(_i23.Profile? value) => super.noSuchMethod( Invocation.setter(#profile, value), @@ -1005,6 +999,15 @@ class MockUserProvider extends _i1.Mock implements _i21.UserProvider { ) as _i18.Future); + @override + _i18.Future setDashboardOrder(int? oldIndex, int? newIndex) => + (super.noSuchMethod( + Invocation.method(#setDashboardOrder, [oldIndex, newIndex]), + returnValue: _i18.Future.value(), + returnValueForMissingStub: _i18.Future.value(), + ) + as _i18.Future); + @override void setThemeMode(_i22.ThemeMode? mode) => super.noSuchMethod( Invocation.method(#setThemeMode, [mode]), diff --git a/test/core/validators_test.mocks.dart b/test/core/validators_test.mocks.dart index 9b069055..dad59660 100644 --- a/test/core/validators_test.mocks.dart +++ b/test/core/validators_test.mocks.dart @@ -404,6 +404,17 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get dashboardWidgets => + (super.noSuchMethod( + Invocation.getter(#dashboardWidgets), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#dashboardWidgets), + ), + ) + as String); + @override String get labelDashboard => (super.noSuchMethod( diff --git a/test/exercises/contribute_exercise_test.mocks.dart b/test/exercises/contribute_exercise_test.mocks.dart index c3defcf4..dd9f98e8 100644 --- a/test/exercises/contribute_exercise_test.mocks.dart +++ b/test/exercises/contribute_exercise_test.mocks.dart @@ -390,12 +390,12 @@ class MockUserProvider extends _i1.Mock implements _i17.UserProvider { as _i4.SharedPreferencesAsync); @override - Map<_i17.DashboardWidget, bool> get dashboardWidgetVisibility => + List<_i17.DashboardWidget> get dashboardOrder => (super.noSuchMethod( - Invocation.getter(#dashboardWidgetVisibility), - returnValue: <_i17.DashboardWidget, bool>{}, + Invocation.getter(#dashboardOrder), + returnValue: <_i17.DashboardWidget>[], ) - as Map<_i17.DashboardWidget, bool>); + as List<_i17.DashboardWidget>); @override set themeMode(_i18.ThemeMode? value) => super.noSuchMethod( @@ -409,12 +409,6 @@ class MockUserProvider extends _i1.Mock implements _i17.UserProvider { returnValueForMissingStub: null, ); - @override - set dashboardWidgetVisibility(Map<_i17.DashboardWidget, bool>? value) => super.noSuchMethod( - Invocation.setter(#dashboardWidgetVisibility, value), - returnValueForMissingStub: null, - ); - @override set profile(_i19.Profile? value) => super.noSuchMethod( Invocation.setter(#profile, value), @@ -451,6 +445,15 @@ class MockUserProvider extends _i1.Mock implements _i17.UserProvider { ) as _i15.Future); + @override + _i15.Future setDashboardOrder(int? oldIndex, int? newIndex) => + (super.noSuchMethod( + Invocation.method(#setDashboardOrder, [oldIndex, newIndex]), + returnValue: _i15.Future.value(), + returnValueForMissingStub: _i15.Future.value(), + ) + as _i15.Future); + @override void setThemeMode(_i18.ThemeMode? mode) => super.noSuchMethod( Invocation.method(#setThemeMode, [mode]), diff --git a/test/user/provider_test.dart b/test/user/provider_test.dart index 8297b330..e12951f8 100644 --- a/test/user/provider_test.dart +++ b/test/user/provider_test.dart @@ -1,13 +1,13 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team + * Copyright (c) 2026 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * - * wger Workout Manager is distributed in the hope that it will be useful, + * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. @@ -21,6 +21,7 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'package:wger/providers/base_provider.dart'; @@ -51,7 +52,6 @@ void main() { ); setUp(() { - /// Replacement for SharedPreferences.setMockInitialValues() SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); mockWgerBaseProvider = MockWgerBaseProvider(); userProvider = UserProvider(mockWgerBaseProvider); @@ -103,4 +103,80 @@ void main() { verify(userProvider.baseProvider.fetch(tEmailVerifyUri)); }); }); + + group('dashboard config', () { + test('initial config should be default (all visible, default order)', () { + expect(userProvider.dashboardOrder.length, 5); + + expect( + userProvider.dashboardOrder, + orderedEquals([ + DashboardWidget.routines, + DashboardWidget.nutrition, + DashboardWidget.weight, + DashboardWidget.measurements, + DashboardWidget.calendar, + ]), + ); + expect(userProvider.isDashboardWidgetVisible(DashboardWidget.routines), true); + }); + + test('toggling visibility should update state', () async { + // act + await userProvider.setDashboardWidgetVisible(DashboardWidget.routines, false); + + // assert + expect(userProvider.isDashboardWidgetVisible(DashboardWidget.routines), false); + + // re-enable + await userProvider.setDashboardWidgetVisible(DashboardWidget.routines, true); + expect(userProvider.isDashboardWidgetVisible(DashboardWidget.routines), true); + }); + + test('reordering should update order', () async { + // arrange + final initialFirst = userProvider.dashboardOrder[0]; + final initialSecond = userProvider.dashboardOrder[1]; + + // act: move first to second position + // oldIndex: 0, newIndex: 2 (because insert is before index) + await userProvider.setDashboardOrder(0, 2); + + // assert + expect(userProvider.dashboardOrder[0], initialSecond); + expect(userProvider.dashboardOrder[1], initialFirst); + }); + + test('should load config from prefs', () async { + // arrange + final prefs = SharedPreferencesAsync(); + final customConfig = [ + {'widget': 'nutrition', 'visible': true}, + {'widget': 'routines', 'visible': false}, + ]; + await prefs.setString( + UserProvider.PREFS_DASHBOARD_CONFIG, + jsonEncode(customConfig), + ); + + // act + final newProvider = UserProvider(mockWgerBaseProvider, prefs: prefs); + await Future.delayed(const Duration(milliseconds: 50)); // wait for async prefs load + + // assert + // The loaded ones come first + expect(newProvider.dashboardOrder[0], DashboardWidget.nutrition); + expect(newProvider.dashboardOrder[1], DashboardWidget.routines); + + // Check visibility + expect(newProvider.isDashboardWidgetVisible(DashboardWidget.nutrition), true); + expect(newProvider.isDashboardWidgetVisible(DashboardWidget.routines), false); + + // Remaining items are added after + expect(newProvider.dashboardOrder.length, 5); + + // Items not in the prefs are visible by default + expect(newProvider.isDashboardWidgetVisible(DashboardWidget.weight), true); + }); + }); } diff --git a/test/weight/weight_screen_test.mocks.dart b/test/weight/weight_screen_test.mocks.dart index eac9ecd4..ae2d83ee 100644 --- a/test/weight/weight_screen_test.mocks.dart +++ b/test/weight/weight_screen_test.mocks.dart @@ -232,12 +232,12 @@ class MockUserProvider extends _i1.Mock implements _i13.UserProvider { as _i4.SharedPreferencesAsync); @override - Map<_i13.DashboardWidget, bool> get dashboardWidgetVisibility => + List<_i13.DashboardWidget> get dashboardOrder => (super.noSuchMethod( - Invocation.getter(#dashboardWidgetVisibility), - returnValue: <_i13.DashboardWidget, bool>{}, + Invocation.getter(#dashboardOrder), + returnValue: <_i13.DashboardWidget>[], ) - as Map<_i13.DashboardWidget, bool>); + as List<_i13.DashboardWidget>); @override set themeMode(_i14.ThemeMode? value) => super.noSuchMethod( @@ -251,12 +251,6 @@ class MockUserProvider extends _i1.Mock implements _i13.UserProvider { returnValueForMissingStub: null, ); - @override - set dashboardWidgetVisibility(Map<_i13.DashboardWidget, bool>? value) => super.noSuchMethod( - Invocation.setter(#dashboardWidgetVisibility, value), - returnValueForMissingStub: null, - ); - @override set profile(_i15.Profile? value) => super.noSuchMethod( Invocation.setter(#profile, value), @@ -293,6 +287,15 @@ class MockUserProvider extends _i1.Mock implements _i13.UserProvider { ) as _i11.Future); + @override + _i11.Future setDashboardOrder(int? oldIndex, int? newIndex) => + (super.noSuchMethod( + Invocation.method(#setDashboardOrder, [oldIndex, newIndex]), + returnValue: _i11.Future.value(), + returnValueForMissingStub: _i11.Future.value(), + ) + as _i11.Future); + @override void setThemeMode(_i14.ThemeMode? mode) => super.noSuchMethod( Invocation.method(#setThemeMode, [mode]),