From 9e24714cbd15b2925a0a4a48b117dd705e034d69 Mon Sep 17 00:00:00 2001 From: Diya Hituvalli Date: Wed, 19 Nov 2025 14:32:02 -0500 Subject: [PATCH 1/5] Add user preference in settings to hide diet plan if not used --- ios/Flutter/ephemeral/flutter_lldb_helper.py | 32 ++++++++++++++++++++ ios/Flutter/ephemeral/flutter_lldbinit | 5 +++ lib/providers/user.dart | 17 +++++++++++ lib/screens/dashboard.dart | 12 +++++--- lib/widgets/core/settings.dart | 15 +++++++++ 5 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 ios/Flutter/ephemeral/flutter_lldb_helper.py create mode 100644 ios/Flutter/ephemeral/flutter_lldbinit diff --git a/ios/Flutter/ephemeral/flutter_lldb_helper.py b/ios/Flutter/ephemeral/flutter_lldb_helper.py new file mode 100644 index 00000000..a88caf99 --- /dev/null +++ b/ios/Flutter/ephemeral/flutter_lldb_helper.py @@ -0,0 +1,32 @@ +# +# Generated file, do not edit. +# + +import lldb + +def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict): + """Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages.""" + base = frame.register["x0"].GetValueAsAddress() + page_len = frame.register["x1"].GetValueAsUnsigned() + + # Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the + # first page to see if handled it correctly. This makes diagnosing + # misconfiguration (e.g. missing breakpoint) easier. + data = bytearray(page_len) + data[0:8] = b'IHELPED!' + + error = lldb.SBError() + frame.GetThread().GetProcess().WriteMemory(base, data, error) + if not error.Success(): + print(f'Failed to write into {base}[+{page_len}]', error) + return + +def __lldb_init_module(debugger: lldb.SBDebugger, _): + target = debugger.GetDummyTarget() + # Caveat: must use BreakpointCreateByRegEx here and not + # BreakpointCreateByName. For some reasons callback function does not + # get carried over from dummy target for the later. + bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$") + bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__)) + bp.SetAutoContinue(True) + print("-- LLDB integration loaded --") diff --git a/ios/Flutter/ephemeral/flutter_lldbinit b/ios/Flutter/ephemeral/flutter_lldbinit new file mode 100644 index 00000000..e3ba6fbe --- /dev/null +++ b/ios/Flutter/ephemeral/flutter_lldbinit @@ -0,0 +1,5 @@ +# +# Generated file, do not edit. +# + +command script import --relative-to-command-file flutter_lldb_helper.py diff --git a/lib/providers/user.dart b/lib/providers/user.dart index 3765bfe2..24c2446d 100644 --- a/lib/providers/user.dart +++ b/lib/providers/user.dart @@ -29,10 +29,13 @@ class UserProvider with ChangeNotifier { ThemeMode themeMode = ThemeMode.system; final WgerBaseProvider baseProvider; late SharedPreferencesAsync prefs; + bool hideNutrition = false; UserProvider(this.baseProvider, {SharedPreferencesAsync? prefs}) { this.prefs = prefs ?? PreferenceHelper.asyncPref; _loadThemeMode(); + _loadHideNutrition(); + } static const PROFILE_URL = 'userprofile'; @@ -67,6 +70,13 @@ class UserProvider with ChangeNotifier { notifyListeners(); } + Future _loadHideNutrition() async { + final val = await prefs.getBool('hideNutrition'); + hideNutrition = val ?? false; + notifyListeners(); + } + + // Change mode on switch button click void setThemeMode(ThemeMode mode) async { themeMode = mode; @@ -81,6 +91,12 @@ class UserProvider with ChangeNotifier { notifyListeners(); } + void setHideNutrition(bool value) async { + hideNutrition = value; + await prefs.setBool('hideNutrition', value); + notifyListeners(); + } + /// Fetch the current user's profile Future fetchAndSetProfile() async { final userData = await baseProvider.fetch(baseProvider.makeUrl(PROFILE_URL)); @@ -91,6 +107,7 @@ class UserProvider with ChangeNotifier { } } + /// Save the user's profile to the server Future saveProfile() async { await baseProvider.post( diff --git a/lib/screens/dashboard.dart b/lib/screens/dashboard.dart index 06bae73e..e3ad1b7d 100644 --- a/lib/screens/dashboard.dart +++ b/lib/screens/dashboard.dart @@ -17,6 +17,7 @@ */ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/widgets/core/app_bar.dart'; import 'package:wger/widgets/dashboard/calendar.dart'; @@ -24,6 +25,7 @@ import 'package:wger/widgets/dashboard/widgets/measurements.dart'; import 'package:wger/widgets/dashboard/widgets/nutrition.dart'; import 'package:wger/widgets/dashboard/widgets/routines.dart'; import 'package:wger/widgets/dashboard/widgets/weight.dart'; +import 'package:wger/providers/user.dart'; class DashboardScreen extends StatelessWidget { const DashboardScreen(); @@ -32,20 +34,22 @@ class DashboardScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final user = Provider.of(context); + return Scaffold( appBar: MainAppBar(AppLocalizations.of(context).labelDashboard), - body: const SingleChildScrollView( - padding: EdgeInsets.all(10), + body: SingleChildScrollView( + padding: const EdgeInsets.all(10), child: Column( children: [ DashboardRoutineWidget(), - DashboardNutritionWidget(), DashboardWeightWidget(), DashboardMeasurementWidget(), DashboardCalendarWidget(), + if (!user.hideNutrition) DashboardNutritionWidget(), ], ), ), ); } -} +} \ No newline at end of file diff --git a/lib/widgets/core/settings.dart b/lib/widgets/core/settings.dart index e310ab03..d2e233da 100644 --- a/lib/widgets/core/settings.dart +++ b/lib/widgets/core/settings.dart @@ -23,6 +23,9 @@ import 'package:wger/screens/configure_plates_screen.dart'; import 'package:wger/widgets/core/settings/exercise_cache.dart'; import 'package:wger/widgets/core/settings/ingredient_cache.dart'; import 'package:wger/widgets/core/settings/theme.dart'; +import 'package:provider/provider.dart'; +import 'package:wger/providers/user.dart'; + class SettingsPage extends StatelessWidget { static String routeName = '/SettingsPage'; @@ -44,6 +47,18 @@ class SettingsPage extends StatelessWidget { const SettingsIngredientCache(), ListTile(title: Text(i18n.others, style: Theme.of(context).textTheme.headlineSmall)), const SettingsTheme(), + Consumer( + builder: (context, user, _) { + return SwitchListTile( + title: const Text('Show nutrition section on dashboard'), + value: !user.hideNutrition, + onChanged: (v) { + Provider.of(context, listen: false) + .setHideNutrition(!v); + }, + ); + }, + ), ListTile( title: Text(i18n.selectAvailablePlates), onTap: () { From c0d04cbb11094b87a1c71c53606d9455d6670920 Mon Sep 17 00:00:00 2001 From: Diya Hituvalli Date: Mon, 8 Dec 2025 13:57:42 -0500 Subject: [PATCH 2/5] allow the users to hide / show all widgets in the home screen --- lib/providers/user.dart | 50 ++++++++++++++++++++++++++-------- lib/screens/dashboard.dart | 15 ++++++---- lib/widgets/core/settings.dart | 40 ++++++++++++++++++++++----- 3 files changed, 81 insertions(+), 24 deletions(-) diff --git a/lib/providers/user.dart b/lib/providers/user.dart index 24c2446d..27f7516d 100644 --- a/lib/providers/user.dart +++ b/lib/providers/user.dart @@ -17,6 +17,7 @@ */ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -29,13 +30,23 @@ class UserProvider with ChangeNotifier { ThemeMode themeMode = ThemeMode.system; final WgerBaseProvider baseProvider; late SharedPreferencesAsync prefs; - bool hideNutrition = false; + // bool hideNutrition = false; + + // New: visibility state for dashboard widgets + Map dashboardWidgetVisibility = { + 'routines': true, + 'weight': true, + 'measurements': true, + 'calendar': true, + 'nutrition': true, + }; + + static const String PREFS_DASHBOARD_VISIBILITY = 'dashboardWidgetVisibility'; UserProvider(this.baseProvider, {SharedPreferencesAsync? prefs}) { this.prefs = prefs ?? PreferenceHelper.asyncPref; _loadThemeMode(); - _loadHideNutrition(); - + _loadDashboardVisibility(); } static const PROFILE_URL = 'userprofile'; @@ -69,13 +80,34 @@ class UserProvider with ChangeNotifier { notifyListeners(); } + Future _loadDashboardVisibility() async { + final jsonString = await prefs.getString(PREFS_DASHBOARD_VISIBILITY); + + if (jsonString != null) { + try { + final decoded = jsonDecode(jsonString) as Map; + dashboardWidgetVisibility = + decoded.map((k, v) => MapEntry(k, v as bool)); + } catch (_) { + // If parsing fails, keep defaults + } + } - Future _loadHideNutrition() async { - final val = await prefs.getBool('hideNutrition'); - hideNutrition = val ?? false; notifyListeners(); } + bool isDashboardWidgetVisible(String key) { + return dashboardWidgetVisibility[key] ?? true; + } + + Future setDashboardWidgetVisible(String key, bool visible) async { + dashboardWidgetVisibility[key] = visible; + await prefs.setString( + PREFS_DASHBOARD_VISIBILITY, + jsonEncode(dashboardWidgetVisibility), + ); + notifyListeners(); + } // Change mode on switch button click void setThemeMode(ThemeMode mode) async { @@ -91,12 +123,6 @@ class UserProvider with ChangeNotifier { notifyListeners(); } - void setHideNutrition(bool value) async { - hideNutrition = value; - await prefs.setBool('hideNutrition', value); - notifyListeners(); - } - /// Fetch the current user's profile Future fetchAndSetProfile() async { final userData = await baseProvider.fetch(baseProvider.makeUrl(PROFILE_URL)); diff --git a/lib/screens/dashboard.dart b/lib/screens/dashboard.dart index e3ad1b7d..802a91e6 100644 --- a/lib/screens/dashboard.dart +++ b/lib/screens/dashboard.dart @@ -42,11 +42,16 @@ class DashboardScreen extends StatelessWidget { padding: const EdgeInsets.all(10), child: Column( children: [ - DashboardRoutineWidget(), - DashboardWeightWidget(), - DashboardMeasurementWidget(), - DashboardCalendarWidget(), - if (!user.hideNutrition) DashboardNutritionWidget(), + if (user.isDashboardWidgetVisible('routines')) + const DashboardRoutineWidget(), + if (user.isDashboardWidgetVisible('weight')) + const DashboardWeightWidget(), + if (user.isDashboardWidgetVisible('measurements')) + const DashboardMeasurementWidget(), + if (user.isDashboardWidgetVisible('calendar')) + const DashboardCalendarWidget(), + if (user.isDashboardWidgetVisible('nutrition')) + const DashboardNutritionWidget(), ], ), ), diff --git a/lib/widgets/core/settings.dart b/lib/widgets/core/settings.dart index d2e233da..a0f55136 100644 --- a/lib/widgets/core/settings.dart +++ b/lib/widgets/core/settings.dart @@ -49,13 +49,39 @@ class SettingsPage extends StatelessWidget { const SettingsTheme(), Consumer( builder: (context, user, _) { - return SwitchListTile( - title: const Text('Show nutrition section on dashboard'), - value: !user.hideNutrition, - onChanged: (v) { - Provider.of(context, listen: false) - .setHideNutrition(!v); - }, + return Column( + children: [ + SwitchListTile( + title: const Text('Show routines on dashboard'), + value: user.isDashboardWidgetVisible('routines'), + onChanged: (v) => + user.setDashboardWidgetVisible('routines', v), + ), + SwitchListTile( + title: const Text('Show weight on dashboard'), + value: user.isDashboardWidgetVisible('weight'), + onChanged: (v) => + user.setDashboardWidgetVisible('weight', v), + ), + SwitchListTile( + title: const Text('Show measurements on dashboard'), + value: user.isDashboardWidgetVisible('measurements'), + onChanged: (v) => + user.setDashboardWidgetVisible('measurements', v), + ), + SwitchListTile( + title: const Text('Show calendar on dashboard'), + value: user.isDashboardWidgetVisible('calendar'), + onChanged: (v) => + user.setDashboardWidgetVisible('calendar', v), + ), + SwitchListTile( + title: const Text('Show nutrition on dashboard'), + value: user.isDashboardWidgetVisible('nutrition'), + onChanged: (v) => + user.setDashboardWidgetVisible('nutrition', v), + ), + ], ); }, ), From fa00cad40615c72c45eb0e3b761173fe924c8cf5 Mon Sep 17 00:00:00 2001 From: Diya Hituvalli Date: Mon, 8 Dec 2025 15:02:51 -0500 Subject: [PATCH 3/5] added test stubs --- test/core/settings_test.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/core/settings_test.dart b/test/core/settings_test.dart index 474603c9..b2934682 100644 --- a/test/core/settings_test.dart +++ b/test/core/settings_test.dart @@ -51,6 +51,11 @@ void main() { when(mockUserProvider.themeMode).thenReturn(ThemeMode.system); when(mockExerciseProvider.exercises).thenReturn(getTestExercises()); when(mockNutritionProvider.ingredients).thenReturn([ingredient1, ingredient2]); + when(mockUserProvider.isDashboardWidgetVisible('routines')).thenReturn(true); + when(mockUserProvider.isDashboardWidgetVisible('weight')).thenReturn(true); + when(mockUserProvider.isDashboardWidgetVisible('measurements')).thenReturn(true); + when(mockUserProvider.isDashboardWidgetVisible('calendar')).thenReturn(true); + when(mockUserProvider.isDashboardWidgetVisible('nutrition')).thenReturn(true); }); Widget createSettingsScreen({locale = 'en'}) { @@ -100,18 +105,24 @@ 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); 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); expect(userProvider.themeMode, ThemeMode.light); }); test('Saves theme to prefs', () { when(mockSharedPreferences.getBool(any)).thenAnswer((_) async => null); + when(mockSharedPreferences.getString('dashboardWidgetVisibility')) + .thenAnswer((_) async => null); final userProvider = UserProvider(MockWgerBaseProvider(), prefs: mockSharedPreferences); userProvider.setThemeMode(ThemeMode.dark); verify(mockSharedPreferences.setBool(PREFS_USER_DARK_THEME, true)).called(1); From 5574f74a2ffcdb0b238796cf2816b728ea58994c Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Sat, 17 Jan 2026 12:50:07 +0100 Subject: [PATCH 4/5] Move dashboard visibility settings to own screen This seems cleaner and can be opened from other places --- lib/l10n/app_en.arb | 1 + lib/main.dart | 9 +- lib/providers/user.dart | 74 +++++++--- lib/screens/dashboard.dart | 14 +- .../settings_dashboard_widgets_screen.dart | 38 +++++ ...creen.dart => settings_plates_screen.dart} | 0 lib/widgets/core/app_bar.dart | 136 ++++++++++-------- lib/widgets/core/settings.dart | 62 ++------ .../core/settings/dashboard_visibility.dart | 65 +++++++++ lib/widgets/routines/gym_mode/log_page.dart | 2 +- test/core/settings_test.dart | 25 ++-- test/core/settings_test.mocks.dart | 34 +++++ .../contribute_exercise_test.mocks.dart | 34 +++++ test/weight/weight_screen_test.mocks.dart | 34 +++++ 14 files changed, 375 insertions(+), 153 deletions(-) create mode 100644 lib/screens/settings_dashboard_widgets_screen.dart rename lib/screens/{configure_plates_screen.dart => settings_plates_screen.dart} (100%) create mode 100644 lib/widgets/core/settings/dashboard_visibility.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 003c43ea..6d845a4f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -100,6 +100,7 @@ "@labelWorkoutPlan": { "description": "Title for screen workout plan" }, + "dashboardWidgets": "Dashboard widgets", "labelDashboard": "Dashboard", "@labelDashboard": { "description": "Title for screen dashboard" diff --git a/lib/main.dart b/lib/main.dart index 47761881..577f346e 100644 --- a/lib/main.dart +++ b/lib/main.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. @@ -37,7 +37,6 @@ import 'package:wger/providers/routines.dart'; import 'package:wger/providers/user.dart'; import 'package:wger/screens/add_exercise_screen.dart'; import 'package:wger/screens/auth_screen.dart'; -import 'package:wger/screens/configure_plates_screen.dart'; import 'package:wger/screens/dashboard.dart'; import 'package:wger/screens/exercise_screen.dart'; import 'package:wger/screens/exercises_screen.dart'; @@ -56,6 +55,8 @@ import 'package:wger/screens/routine_edit_screen.dart'; import 'package:wger/screens/routine_list_screen.dart'; import 'package:wger/screens/routine_logs_screen.dart'; import 'package:wger/screens/routine_screen.dart'; +import 'package:wger/screens/settings_dashboard_widgets_screen.dart'; +import 'package:wger/screens/settings_plates_screen.dart'; import 'package:wger/screens/splash_screen.dart'; import 'package:wger/screens/update_app_screen.dart'; import 'package:wger/screens/weight_screen.dart'; @@ -252,6 +253,8 @@ class MainApp extends StatelessWidget { SettingsPage.routeName: (ctx) => const SettingsPage(), LogOverviewPage.routeName: (ctx) => const LogOverviewPage(), ConfigurePlatesScreen.routeName: (ctx) => const ConfigurePlatesScreen(), + ConfigureDashboardWidgetsScreen.routeName: (ctx) => + const ConfigureDashboardWidgetsScreen(), }, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, diff --git a/lib/providers/user.dart b/lib/providers/user.dart index 27f7516d..e2dc2664 100644 --- a/lib/providers/user.dart +++ b/lib/providers/user.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 @@ -26,19 +26,36 @@ import 'package:wger/helpers/shared_preferences.dart'; import 'package:wger/models/user/profile.dart'; import 'package:wger/providers/base_provider.dart'; +enum DashboardWidget { + routines('routines'), + weight('weight'), + measurements('measurements'), + calendar('calendar'), + nutrition('nutrition'); + + final String value; + const DashboardWidget(this.value); + + static DashboardWidget? fromString(String s) { + for (final e in DashboardWidget.values) { + if (e.value == s) return e; + } + return null; + } +} + class UserProvider with ChangeNotifier { ThemeMode themeMode = ThemeMode.system; final WgerBaseProvider baseProvider; late SharedPreferencesAsync prefs; - // bool hideNutrition = false; // New: visibility state for dashboard widgets - Map dashboardWidgetVisibility = { - 'routines': true, - 'weight': true, - 'measurements': true, - 'calendar': true, - 'nutrition': true, + Map dashboardWidgetVisibility = { + DashboardWidget.routines: true, + DashboardWidget.weight: true, + DashboardWidget.measurements: true, + DashboardWidget.calendar: true, + DashboardWidget.nutrition: true, }; static const String PREFS_DASHBOARD_VISIBILITY = 'dashboardWidgetVisibility'; @@ -80,31 +97,47 @@ class UserProvider with ChangeNotifier { notifyListeners(); } - Future _loadDashboardVisibility() async { - final jsonString = await prefs.getString(PREFS_DASHBOARD_VISIBILITY); - if (jsonString != null) { - try { - final decoded = jsonDecode(jsonString) as Map; - dashboardWidgetVisibility = - decoded.map((k, v) => MapEntry(k, v as bool)); - } catch (_) { - // If parsing fails, keep defaults + Future _loadDashboardVisibility() async { + final jsonString = await prefs.getString(PREFS_DASHBOARD_VISIBILITY); + if (jsonString == null) { + return; + } + + try { + final decoded = jsonDecode(jsonString) as Map; + final Map loaded = {}; + + for (final entry in decoded.entries) { + final widget = DashboardWidget.fromString(entry.key); + if (widget != null) { + loaded[widget] = entry.value as bool; + } } + + if (loaded.isNotEmpty) { + dashboardWidgetVisibility = loaded; + } + } catch (_) { + // parsing failed -> keep defaults } notifyListeners(); } - bool isDashboardWidgetVisible(String key) { + bool isDashboardWidgetVisible(DashboardWidget key) { return dashboardWidgetVisibility[key] ?? true; } - Future setDashboardWidgetVisible(String key, bool visible) async { + Future setDashboardWidgetVisible(DashboardWidget key, bool visible) async { dashboardWidgetVisibility[key] = visible; + final Map serializable = { + for (final e in dashboardWidgetVisibility.entries) e.key.value: e.value, + }; + await prefs.setString( PREFS_DASHBOARD_VISIBILITY, - jsonEncode(dashboardWidgetVisibility), + jsonEncode(serializable), ); notifyListeners(); } @@ -133,7 +166,6 @@ class UserProvider with ChangeNotifier { } } - /// Save the user's profile to the server Future saveProfile() async { await baseProvider.post( diff --git a/lib/screens/dashboard.dart b/lib/screens/dashboard.dart index ae41069e..dae86cb0 100644 --- a/lib/screens/dashboard.dart +++ b/lib/screens/dashboard.dart @@ -19,13 +19,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/providers/user.dart'; import 'package:wger/widgets/core/app_bar.dart'; import 'package:wger/widgets/dashboard/calendar.dart'; import 'package:wger/widgets/dashboard/widgets/measurements.dart'; import 'package:wger/widgets/dashboard/widgets/nutrition.dart'; import 'package:wger/widgets/dashboard/widgets/routines.dart'; import 'package:wger/widgets/dashboard/widgets/weight.dart'; -import 'package:wger/providers/user.dart'; class DashboardScreen extends StatelessWidget { const DashboardScreen({super.key}); @@ -42,19 +42,19 @@ class DashboardScreen extends StatelessWidget { padding: const EdgeInsets.all(10), child: Column( children: [ - if (user.isDashboardWidgetVisible('routines')) + if (user.isDashboardWidgetVisible(DashboardWidget.routines)) const DashboardRoutineWidget(), - if (user.isDashboardWidgetVisible('weight')) + if (user.isDashboardWidgetVisible(DashboardWidget.weight)) const DashboardWeightWidget(), - if (user.isDashboardWidgetVisible('measurements')) + if (user.isDashboardWidgetVisible(DashboardWidget.measurements)) const DashboardMeasurementWidget(), - if (user.isDashboardWidgetVisible('calendar')) + if (user.isDashboardWidgetVisible(DashboardWidget.calendar)) const DashboardCalendarWidget(), - if (user.isDashboardWidgetVisible('nutrition')) + if (user.isDashboardWidgetVisible(DashboardWidget.nutrition)) const DashboardNutritionWidget(), ], ), ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/settings_dashboard_widgets_screen.dart b/lib/screens/settings_dashboard_widgets_screen.dart new file mode 100644 index 00000000..d3be7771 --- /dev/null +++ b/lib/screens/settings_dashboard_widgets_screen.dart @@ -0,0 +1,38 @@ +/* + * 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:wger/core/wide_screen_wrapper.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/widgets/core/settings/dashboard_visibility.dart'; + +class ConfigureDashboardWidgetsScreen extends StatelessWidget { + static const routeName = '/ConfigureDashboardWidgetScreen'; + + const ConfigureDashboardWidgetsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context); + + return Scaffold( + appBar: AppBar(title: Text(i18n.dashboardWidgets)), + body: const WidescreenWrapper(child: SettingsDashboardVisibility()), + ); + } +} diff --git a/lib/screens/configure_plates_screen.dart b/lib/screens/settings_plates_screen.dart similarity index 100% rename from lib/screens/configure_plates_screen.dart rename to lib/screens/settings_plates_screen.dart diff --git a/lib/widgets/core/app_bar.dart b/lib/widgets/core/app_bar.dart index cf0948d5..be52f063 100644 --- a/lib/widgets/core/app_bar.dart +++ b/lib/widgets/core/app_bar.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 @@ -26,6 +26,7 @@ import 'package:wger/providers/nutrition.dart'; import 'package:wger/providers/routines.dart'; import 'package:wger/providers/user.dart'; import 'package:wger/screens/form_screen.dart'; +import 'package:wger/screens/settings_dashboard_widgets_screen.dart'; import 'package:wger/widgets/core/about.dart'; import 'package:wger/widgets/core/settings.dart'; import 'package:wger/widgets/user/forms.dart'; @@ -40,71 +41,19 @@ class MainAppBar extends StatelessWidget implements PreferredSizeWidget { return AppBar( title: Text(_title), actions: [ + IconButton( + icon: const Icon(Icons.widgets_outlined), + onPressed: () { + Navigator.of(context).pushNamed(ConfigureDashboardWidgetsScreen.routeName); + }, + ), IconButton( icon: const Icon(Icons.settings), onPressed: () async { return showDialog( context: context, builder: (BuildContext context) { - return AlertDialog( - title: Text(AppLocalizations.of(context).optionsLabel), - actions: [ - TextButton( - child: Text( - MaterialLocalizations.of(context).closeButtonLabel, - ), - onPressed: () => Navigator.of(context).pop(), - ), - ], - contentPadding: EdgeInsets.zero, - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - //dense: true, - leading: const Icon(Icons.person), - title: Text(AppLocalizations.of(context).userProfile), - onTap: () => Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).userProfile, - UserProfileForm( - context.read().profile!, - ), - ), - ), - ), - ListTile( - leading: const Icon(Icons.settings), - onTap: () => Navigator.of(context).pushNamed(SettingsPage.routeName), - title: Text(AppLocalizations.of(context).settingsTitle), - ), - ListTile( - leading: const Icon(Icons.info), - onTap: () => Navigator.of(context).pushNamed(AboutPage.routeName), - title: Text(AppLocalizations.of(context).aboutPageTitle), - ), - const Divider(), - ListTile( - //dense: true, - leading: const Icon(Icons.exit_to_app), - title: Text(AppLocalizations.of(context).logout), - onTap: () { - context.read().logout(); - context.read().clear(); - context.read().clear(); - context.read().clear(); - context.read().clear(); - context.read().clear(); - - Navigator.of(context).pop(); - Navigator.of(context).pushReplacementNamed('/'); - }, - ), - ], - ), - ); + return const MainSettingsDialog(); }, ); }, @@ -117,6 +66,73 @@ class MainAppBar extends StatelessWidget implements PreferredSizeWidget { Size get preferredSize => const Size.fromHeight(kToolbarHeight); } +class MainSettingsDialog extends StatelessWidget { + const MainSettingsDialog({super.key}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(AppLocalizations.of(context).optionsLabel), + actions: [ + TextButton( + child: Text( + MaterialLocalizations.of(context).closeButtonLabel, + ), + onPressed: () => Navigator.of(context).pop(), + ), + ], + contentPadding: EdgeInsets.zero, + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + //dense: true, + leading: const Icon(Icons.person), + title: Text(AppLocalizations.of(context).userProfile), + onTap: () => Navigator.pushNamed( + context, + FormScreen.routeName, + arguments: FormScreenArguments( + AppLocalizations.of(context).userProfile, + UserProfileForm( + context.read().profile!, + ), + ), + ), + ), + ListTile( + leading: const Icon(Icons.settings), + onTap: () => Navigator.of(context).pushNamed(SettingsPage.routeName), + title: Text(AppLocalizations.of(context).settingsTitle), + ), + ListTile( + leading: const Icon(Icons.info), + onTap: () => Navigator.of(context).pushNamed(AboutPage.routeName), + title: Text(AppLocalizations.of(context).aboutPageTitle), + ), + const Divider(), + ListTile( + //dense: true, + leading: const Icon(Icons.exit_to_app), + title: Text(AppLocalizations.of(context).logout), + onTap: () { + context.read().logout(); + context.read().clear(); + context.read().clear(); + context.read().clear(); + context.read().clear(); + context.read().clear(); + + Navigator.of(context).pop(); + Navigator.of(context).pushReplacementNamed('/'); + }, + ), + ], + ), + ); + } +} + /// App bar that only displays a title class EmptyAppBar extends StatelessWidget implements PreferredSizeWidget { final String _title; diff --git a/lib/widgets/core/settings.dart b/lib/widgets/core/settings.dart index 336e13b8..8ddcf0ee 100644 --- a/lib/widgets/core/settings.dart +++ b/lib/widgets/core/settings.dart @@ -1,13 +1,13 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 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. @@ -16,17 +16,14 @@ * along with this program. If not, see . */ -//import 'package:drift/drift.dart'; import 'package:flutter/material.dart'; -import 'package:wger/core/wide_screen_wrapper.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; -import 'package:wger/screens/configure_plates_screen.dart'; -import 'package:wger/widgets/core/settings/exercise_cache.dart'; -import 'package:wger/widgets/core/settings/ingredient_cache.dart'; -import 'package:wger/widgets/core/settings/theme.dart'; -import 'package:provider/provider.dart'; -import 'package:wger/providers/user.dart'; +import 'package:wger/screens/settings_dashboard_widgets_screen.dart'; +import 'package:wger/screens/settings_plates_screen.dart'; +import './settings/exercise_cache.dart'; +import './settings/ingredient_cache.dart'; +import './settings/theme.dart'; class SettingsPage extends StatelessWidget { static String routeName = '/SettingsPage'; @@ -48,44 +45,6 @@ class SettingsPage extends StatelessWidget { const SettingsIngredientCache(), ListTile(title: Text(i18n.others, style: Theme.of(context).textTheme.headlineSmall)), const SettingsTheme(), - Consumer( - builder: (context, user, _) { - return Column( - children: [ - SwitchListTile( - title: const Text('Show routines on dashboard'), - value: user.isDashboardWidgetVisible('routines'), - onChanged: (v) => - user.setDashboardWidgetVisible('routines', v), - ), - SwitchListTile( - title: const Text('Show weight on dashboard'), - value: user.isDashboardWidgetVisible('weight'), - onChanged: (v) => - user.setDashboardWidgetVisible('weight', v), - ), - SwitchListTile( - title: const Text('Show measurements on dashboard'), - value: user.isDashboardWidgetVisible('measurements'), - onChanged: (v) => - user.setDashboardWidgetVisible('measurements', v), - ), - SwitchListTile( - title: const Text('Show calendar on dashboard'), - value: user.isDashboardWidgetVisible('calendar'), - onChanged: (v) => - user.setDashboardWidgetVisible('calendar', v), - ), - SwitchListTile( - title: const Text('Show nutrition on dashboard'), - value: user.isDashboardWidgetVisible('nutrition'), - onChanged: (v) => - user.setDashboardWidgetVisible('nutrition', v), - ), - ], - ); - }, - ), ListTile( title: Text(i18n.selectAvailablePlates), onTap: () { @@ -93,6 +52,13 @@ 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 new file mode 100644 index 00000000..d00ead0b --- /dev/null +++ b/lib/widgets/core/settings/dashboard_visibility.dart @@ -0,0 +1,65 @@ +/* + * 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:provider/provider.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/providers/user.dart'; + +class SettingsDashboardVisibility extends StatelessWidget { + const SettingsDashboardVisibility({super.key}); + + @override + Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context); + + 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), + ), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/routines/gym_mode/log_page.dart b/lib/widgets/routines/gym_mode/log_page.dart index 088836d6..49865e7e 100644 --- a/lib/widgets/routines/gym_mode/log_page.dart +++ b/lib/widgets/routines/gym_mode/log_page.dart @@ -31,7 +31,7 @@ import 'package:wger/providers/gym_log_state.dart'; import 'package:wger/providers/gym_state.dart'; import 'package:wger/providers/plate_weights.dart'; import 'package:wger/providers/routines.dart'; -import 'package:wger/screens/configure_plates_screen.dart'; +import 'package:wger/screens/settings_plates_screen.dart'; import 'package:wger/widgets/core/core.dart'; import 'package:wger/widgets/core/progress_indicator.dart'; import 'package:wger/widgets/routines/forms/reps_unit.dart'; diff --git a/test/core/settings_test.dart b/test/core/settings_test.dart index b2934682..bb27464b 100644 --- a/test/core/settings_test.dart +++ b/test/core/settings_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. @@ -49,13 +49,9 @@ void main() { setUp(() { when(mockUserProvider.themeMode).thenReturn(ThemeMode.system); + when(mockUserProvider.isDashboardWidgetVisible(any)).thenReturn(true); when(mockExerciseProvider.exercises).thenReturn(getTestExercises()); when(mockNutritionProvider.ingredients).thenReturn([ingredient1, ingredient2]); - when(mockUserProvider.isDashboardWidgetVisible('routines')).thenReturn(true); - when(mockUserProvider.isDashboardWidgetVisible('weight')).thenReturn(true); - when(mockUserProvider.isDashboardWidgetVisible('measurements')).thenReturn(true); - when(mockUserProvider.isDashboardWidgetVisible('calendar')).thenReturn(true); - when(mockUserProvider.isDashboardWidgetVisible('nutrition')).thenReturn(true); }); Widget createSettingsScreen({locale = 'en'}) { @@ -105,24 +101,27 @@ 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); + when( + mockSharedPreferences.getString('dashboardWidgetVisibility'), + ).thenAnswer((_) async => null); final userProvider = await UserProvider(MockWgerBaseProvider(), prefs: mockSharedPreferences); 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); + when( + mockSharedPreferences.getString('dashboardWidgetVisibility'), + ).thenAnswer((_) async => null); final userProvider = await UserProvider(MockWgerBaseProvider(), prefs: mockSharedPreferences); expect(userProvider.themeMode, ThemeMode.light); }); test('Saves theme to prefs', () { when(mockSharedPreferences.getBool(any)).thenAnswer((_) async => null); - when(mockSharedPreferences.getString('dashboardWidgetVisibility')) - .thenAnswer((_) async => null); + when( + mockSharedPreferences.getString('dashboardWidgetVisibility'), + ).thenAnswer((_) async => null); final userProvider = UserProvider(MockWgerBaseProvider(), prefs: mockSharedPreferences); userProvider.setThemeMode(ThemeMode.dark); verify(mockSharedPreferences.setBool(PREFS_USER_DARK_THEME, true)).called(1); diff --git a/test/core/settings_test.mocks.dart b/test/core/settings_test.mocks.dart index 4e060322..b7dc303f 100644 --- a/test/core/settings_test.mocks.dart +++ b/test/core/settings_test.mocks.dart @@ -943,6 +943,14 @@ class MockUserProvider extends _i1.Mock implements _i21.UserProvider { ) as _i14.SharedPreferencesAsync); + @override + Map<_i21.DashboardWidget, bool> get dashboardWidgetVisibility => + (super.noSuchMethod( + Invocation.getter(#dashboardWidgetVisibility), + returnValue: <_i21.DashboardWidget, bool>{}, + ) + as Map<_i21.DashboardWidget, bool>); + @override set themeMode(_i22.ThemeMode? value) => super.noSuchMethod( Invocation.setter(#themeMode, value), @@ -955,6 +963,12 @@ 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), @@ -971,6 +985,26 @@ class MockUserProvider extends _i1.Mock implements _i21.UserProvider { returnValueForMissingStub: null, ); + @override + bool isDashboardWidgetVisible(_i21.DashboardWidget? key) => + (super.noSuchMethod( + Invocation.method(#isDashboardWidgetVisible, [key]), + returnValue: false, + ) + as bool); + + @override + _i18.Future setDashboardWidgetVisible( + _i21.DashboardWidget? key, + bool? visible, + ) => + (super.noSuchMethod( + Invocation.method(#setDashboardWidgetVisible, [key, visible]), + 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/exercises/contribute_exercise_test.mocks.dart b/test/exercises/contribute_exercise_test.mocks.dart index 726b8a14..c3defcf4 100644 --- a/test/exercises/contribute_exercise_test.mocks.dart +++ b/test/exercises/contribute_exercise_test.mocks.dart @@ -389,6 +389,14 @@ class MockUserProvider extends _i1.Mock implements _i17.UserProvider { ) as _i4.SharedPreferencesAsync); + @override + Map<_i17.DashboardWidget, bool> get dashboardWidgetVisibility => + (super.noSuchMethod( + Invocation.getter(#dashboardWidgetVisibility), + returnValue: <_i17.DashboardWidget, bool>{}, + ) + as Map<_i17.DashboardWidget, bool>); + @override set themeMode(_i18.ThemeMode? value) => super.noSuchMethod( Invocation.setter(#themeMode, value), @@ -401,6 +409,12 @@ 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), @@ -417,6 +431,26 @@ class MockUserProvider extends _i1.Mock implements _i17.UserProvider { returnValueForMissingStub: null, ); + @override + bool isDashboardWidgetVisible(_i17.DashboardWidget? key) => + (super.noSuchMethod( + Invocation.method(#isDashboardWidgetVisible, [key]), + returnValue: false, + ) + as bool); + + @override + _i15.Future setDashboardWidgetVisible( + _i17.DashboardWidget? key, + bool? visible, + ) => + (super.noSuchMethod( + Invocation.method(#setDashboardWidgetVisible, [key, visible]), + 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/weight/weight_screen_test.mocks.dart b/test/weight/weight_screen_test.mocks.dart index 8f508d55..eac9ecd4 100644 --- a/test/weight/weight_screen_test.mocks.dart +++ b/test/weight/weight_screen_test.mocks.dart @@ -231,6 +231,14 @@ class MockUserProvider extends _i1.Mock implements _i13.UserProvider { ) as _i4.SharedPreferencesAsync); + @override + Map<_i13.DashboardWidget, bool> get dashboardWidgetVisibility => + (super.noSuchMethod( + Invocation.getter(#dashboardWidgetVisibility), + returnValue: <_i13.DashboardWidget, bool>{}, + ) + as Map<_i13.DashboardWidget, bool>); + @override set themeMode(_i14.ThemeMode? value) => super.noSuchMethod( Invocation.setter(#themeMode, value), @@ -243,6 +251,12 @@ 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), @@ -259,6 +273,26 @@ class MockUserProvider extends _i1.Mock implements _i13.UserProvider { returnValueForMissingStub: null, ); + @override + bool isDashboardWidgetVisible(_i13.DashboardWidget? key) => + (super.noSuchMethod( + Invocation.method(#isDashboardWidgetVisible, [key]), + returnValue: false, + ) + as bool); + + @override + _i11.Future setDashboardWidgetVisible( + _i13.DashboardWidget? key, + bool? visible, + ) => + (super.noSuchMethod( + Invocation.method(#setDashboardWidgetVisible, [key, visible]), + returnValue: _i11.Future.value(), + returnValueForMissingStub: _i11.Future.value(), + ) + as _i11.Future); + @override void setThemeMode(_i14.ThemeMode? mode) => super.noSuchMethod( Invocation.method(#setThemeMode, [mode]), From b81331609af2a325546b4a36da49cd1ec9ea09da Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Sat, 17 Jan 2026 13:23:55 +0100 Subject: [PATCH 5/5] 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 --- lib/providers/user.dart | 111 +++--- lib/screens/dashboard.dart | 33 +- lib/widgets/core/settings.dart | 8 - .../core/settings/dashboard_visibility.dart | 69 ++-- .../settings_dashboard_visibility_test.dart | 121 ++++++ ...tings_dashboard_visibility_test.mocks.dart | 349 ++++++++++++++++++ test/core/settings_test.dart | 16 +- test/core/settings_test.mocks.dart | 23 +- test/core/validators_test.mocks.dart | 11 + .../contribute_exercise_test.mocks.dart | 23 +- test/user/provider_test.dart | 82 +++- test/weight/weight_screen_test.mocks.dart | 23 +- 12 files changed, 735 insertions(+), 134 deletions(-) create mode 100644 test/core/settings_dashboard_visibility_test.dart create mode 100644 test/core/settings_dashboard_visibility_test.mocks.dart 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]),