Also allow changing the order of the dashboard widgets

This makes the behaviour more consistent and allows users to remove widgets
they won't be using, like the trophies
This commit is contained in:
Roland Geider
2026-01-17 13:23:55 +01:00
parent 5574f74a2f
commit b81331609a
12 changed files with 735 additions and 134 deletions

View File

@@ -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<String, dynamic> 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<DashboardWidget, bool> 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<void> _loadThemeMode() async {
final prefsDarkMode = await prefs.getBool(PREFS_USER_DARK_THEME);
@@ -98,47 +94,76 @@ class UserProvider with ChangeNotifier {
notifyListeners();
}
Future<void> _loadDashboardVisibility() async {
final jsonString = await prefs.getString(PREFS_DASHBOARD_VISIBILITY);
// Dashboard configuration
List<DashboardItem> _dashboardItems = DashboardWidget.values
.map((w) => DashboardItem(w))
.toList();
List<DashboardWidget> get dashboardOrder => _dashboardItems.map((e) => e.widget).toList();
Future<void> _loadDashboardConfig() async {
final jsonString = await prefs.getString(PREFS_DASHBOARD_CONFIG);
if (jsonString == null) {
notifyListeners();
return;
}
try {
final decoded = jsonDecode(jsonString) as Map<String, dynamic>;
final Map<DashboardWidget, bool> loaded = {};
final List<dynamic> decoded = jsonDecode(jsonString);
final List<DashboardItem> 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<void> _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<void> setDashboardWidgetVisible(DashboardWidget key, bool visible) async {
dashboardWidgetVisibility[key] = visible;
final Map<String, bool> 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<void> setDashboardOrder(int oldIndex, int newIndex) async {
if (oldIndex < newIndex) {
newIndex -= 1;
}
final item = _dashboardItems.removeAt(oldIndex);
_dashboardItems.insert(newIndex, item);
await _saveDashboardConfig();
notifyListeners();
}

View File

@@ -1,6 +1,6 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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<UserProvider>(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(),
),
),
);

View File

@@ -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),
),
],
),
);

View File

@@ -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<UserProvider>(
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(),
);
},
);

View File

@@ -0,0 +1,121 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<UserProvider>.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);
});
}

View File

@@ -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<void> setDashboardWidgetVisible(
_i6.DashboardWidget? key,
bool? visible,
) =>
(super.noSuchMethod(
Invocation.method(#setDashboardWidgetVisible, [key, visible]),
returnValue: _i9.Future<void>.value(),
returnValueForMissingStub: _i9.Future<void>.value(),
)
as _i9.Future<void>);
@override
_i9.Future<void> setDashboardOrder(int? oldIndex, int? newIndex) =>
(super.noSuchMethod(
Invocation.method(#setDashboardOrder, [oldIndex, newIndex]),
returnValue: _i9.Future<void>.value(),
returnValueForMissingStub: _i9.Future<void>.value(),
)
as _i9.Future<void>);
@override
void setThemeMode(_i7.ThemeMode? mode) => super.noSuchMethod(
Invocation.method(#setThemeMode, [mode]),
returnValueForMissingStub: null,
);
@override
_i9.Future<void> fetchAndSetProfile() =>
(super.noSuchMethod(
Invocation.method(#fetchAndSetProfile, []),
returnValue: _i9.Future<void>.value(),
returnValueForMissingStub: _i9.Future<void>.value(),
)
as _i9.Future<void>);
@override
_i9.Future<void> saveProfile() =>
(super.noSuchMethod(
Invocation.method(#saveProfile, []),
returnValue: _i9.Future<void>.value(),
returnValueForMissingStub: _i9.Future<void>.value(),
)
as _i9.Future<void>);
@override
_i9.Future<void> verifyEmail() =>
(super.noSuchMethod(
Invocation.method(#verifyEmail, []),
returnValue: _i9.Future<void>.value(),
returnValueForMissingStub: _i9.Future<void>.value(),
)
as _i9.Future<void>);
@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<String, String> getDefaultHeaders({bool? includeAuth = false}) =>
(super.noSuchMethod(
Invocation.method(#getDefaultHeaders, [], {
#includeAuth: includeAuth,
}),
returnValue: <String, String>{},
)
as Map<String, String>);
@override
Uri makeUrl(
String? path, {
int? id,
String? objectMethod,
Map<String, dynamic>? 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<dynamic> 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<dynamic>.value(),
)
as _i9.Future<dynamic>);
@override
_i9.Future<List<dynamic>> fetchPaginated(Uri? uri) =>
(super.noSuchMethod(
Invocation.method(#fetchPaginated, [uri]),
returnValue: _i9.Future<List<dynamic>>.value(<dynamic>[]),
)
as _i9.Future<List<dynamic>>);
@override
_i9.Future<Map<String, dynamic>> post(Map<String, dynamic>? data, Uri? uri) =>
(super.noSuchMethod(
Invocation.method(#post, [data, uri]),
returnValue: _i9.Future<Map<String, dynamic>>.value(
<String, dynamic>{},
),
)
as _i9.Future<Map<String, dynamic>>);
@override
_i9.Future<Map<String, dynamic>> patch(
Map<String, dynamic>? data,
Uri? uri,
) =>
(super.noSuchMethod(
Invocation.method(#patch, [data, uri]),
returnValue: _i9.Future<Map<String, dynamic>>.value(
<String, dynamic>{},
),
)
as _i9.Future<Map<String, dynamic>>);
@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>);
}

View File

@@ -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);
});

View File

@@ -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<void>);
@override
_i18.Future<void> setDashboardOrder(int? oldIndex, int? newIndex) =>
(super.noSuchMethod(
Invocation.method(#setDashboardOrder, [oldIndex, newIndex]),
returnValue: _i18.Future<void>.value(),
returnValueForMissingStub: _i18.Future<void>.value(),
)
as _i18.Future<void>);
@override
void setThemeMode(_i22.ThemeMode? mode) => super.noSuchMethod(
Invocation.method(#setThemeMode, [mode]),

View File

@@ -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<String>(
this,
Invocation.getter(#dashboardWidgets),
),
)
as String);
@override
String get labelDashboard =>
(super.noSuchMethod(

View File

@@ -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<void>);
@override
_i15.Future<void> setDashboardOrder(int? oldIndex, int? newIndex) =>
(super.noSuchMethod(
Invocation.method(#setDashboardOrder, [oldIndex, newIndex]),
returnValue: _i15.Future<void>.value(),
returnValueForMissingStub: _i15.Future<void>.value(),
)
as _i15.Future<void>);
@override
void setThemeMode(_i18.ThemeMode? mode) => super.noSuchMethod(
Invocation.method(#setThemeMode, [mode]),

View File

@@ -1,13 +1,13 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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);
});
});
}

View File

@@ -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<void>);
@override
_i11.Future<void> setDashboardOrder(int? oldIndex, int? newIndex) =>
(super.noSuchMethod(
Invocation.method(#setDashboardOrder, [oldIndex, newIndex]),
returnValue: _i11.Future<void>.value(),
returnValueForMissingStub: _i11.Future<void>.value(),
)
as _i11.Future<void>);
@override
void setThemeMode(_i14.ThemeMode? mode) => super.noSuchMethod(
Invocation.method(#setThemeMode, [mode]),