diff --git a/.github/workflows/analyze.yml b/.github/workflows/analyze.yml
index e66916e9d..6463dfd8e 100644
--- a/.github/workflows/analyze.yml
+++ b/.github/workflows/analyze.yml
@@ -5,12 +5,18 @@ on:
- '**.dart'
- 'pubspec.yaml'
- 'analysis_options.yaml'
+ - 'lib/l10n/**'
+ - 'ios/Runner/Info.plist'
+ - 'android/app/src/main/res/xml/locales_config.xml'
pull_request:
branches: [ master, ]
paths:
- '**/*.dart'
- 'pubspec.yaml'
- 'analysis_options.yaml'
+ - 'lib/l10n/**'
+ - 'ios/Runner/Info.plist'
+ - 'android/app/src/main/res/xml/locales_config.xml'
- '.github/actions/flutter-common/action.yml'
- '.github/workflows/analyze.yml'
workflow_call:
@@ -34,3 +40,6 @@ jobs:
- name: Analyze
run: flutter analyze --no-fatal-infos --no-fatal-warnings
+
+ - name: Check locale lists are in sync
+ run: dart run tool/check_locales.dart
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index d68b9cce6..21e5c267e 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -2,6 +2,8 @@ PODS:
- camera_avfoundation (0.0.1):
- Flutter
- Flutter (1.0.0)
+ - flutter_native_splash (2.4.3):
+ - Flutter
- flutter_zxing (0.0.1):
- Flutter
- image_picker_ios (0.0.1):
@@ -49,6 +51,7 @@ PODS:
DEPENDENCIES:
- camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`)
- Flutter (from `Flutter`)
+ - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_zxing (from `.symlinks/plugins/flutter_zxing/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
@@ -68,6 +71,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/camera_avfoundation/ios"
Flutter:
:path: Flutter
+ flutter_native_splash:
+ :path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_zxing:
:path: ".symlinks/plugins/flutter_zxing/ios"
image_picker_ios:
@@ -90,6 +95,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
camera_avfoundation: 968a9a5323c79a99c166ad9d7866bfd2047b5a9b
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
+ flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_zxing: fe1338b6d79b8a455d209a28381e336b6cfb4161
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index cb7d4d6dc..b1e29dcc5 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -12,6 +12,45 @@
$(PRODUCT_BUNDLE_IDENTIFIER)
CFBundleInfoDictionaryVersion
6.0
+ CFBundleLocalizations
+
+ am
+ ar
+ ca
+ cs
+ de
+ el
+ en
+ es
+ fa
+ fil
+ fr
+ he
+ hi
+ hr
+ hu
+ id
+ it
+ ja
+ ko
+ mk
+ nb
+ nl
+ pl
+ pt
+ pt-BR
+ pt-PT
+ ro
+ ru
+ sk
+ ta
+ th
+ tr
+ uk
+ ur
+ zh
+ zh-Hant
+
CFBundleName
wger
CFBundlePackageType
diff --git a/lib/helpers/consts.dart b/lib/helpers/consts.dart
index 4c4ad7936..c14b9e363 100644
--- a/lib/helpers/consts.dart
+++ b/lib/helpers/consts.dart
@@ -64,6 +64,7 @@ const PREFS_INGREDIENTS = 'ingredientData';
const PREFS_WORKOUT_UNITS = 'workoutUnits';
const PREFS_USER = 'userData';
const PREFS_USER_DARK_THEME = 'userDarkMode';
+const PREFS_USER_LOCALE = 'userLocale';
const PREFS_LAST_SERVER = 'lastServer';
const DEFAULT_ANIMATION_DURATION = Duration(milliseconds: 200);
diff --git a/lib/helpers/locale.dart b/lib/helpers/locale.dart
index c9e1073cf..fe51ea841 100644
--- a/lib/helpers/locale.dart
+++ b/lib/helpers/locale.dart
@@ -34,3 +34,19 @@ Locale? resolveLocale(List? locales, Iterable supportedLocales)
}
return null;
}
+
+/// Stable string encoding for a [Locale], matching the keys generated by
+/// Flutter's AppLocalizations (e.g. `pt_BR`, `zh_Hant`, `pl`). [scriptCode]
+/// takes precedence over [countryCode] so locales like `zh_Hant` round-trip
+/// distinctly from plain `zh`.
+String encodeLocale(Locale locale) {
+ final script = locale.scriptCode;
+ if (script != null && script.isNotEmpty) {
+ return '${locale.languageCode}_$script';
+ }
+ final country = locale.countryCode;
+ if (country != null && country.isNotEmpty) {
+ return '${locale.languageCode}_$country';
+ }
+ return locale.languageCode;
+}
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 3e0f17e73..538bc8c8d 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -1160,6 +1160,14 @@
"darkMode": "Always dark mode",
"lightMode": "Always light mode",
"systemMode": "System settings",
+ "appLanguage": "App language",
+ "@appLanguage": {
+ "description": "Label for the in-app language selector in settings"
+ },
+ "appLanguageSystem": "System default",
+ "@appLanguageSystem": {
+ "description": "Dropdown entry that follows the device system language"
+ },
"productNotFoundOpenFoodFacts": "You can add this product to Open Food Facts to help the community!",
"@productNotFoundOpenFoodFacts": {
"description": "Label shown when product is not found to encourage users to go on Open Food Facts and add it themselves"
diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb
index 42e26a54c..95c5da0aa 100644
--- a/lib/l10n/app_pl.arb
+++ b/lib/l10n/app_pl.arb
@@ -833,6 +833,8 @@
"darkMode": "Zawsze używaj trybu ciemnego",
"lightMode": "Zawsze używaj trybu jasnego",
"systemMode": "Ustawienia systemu",
+ "appLanguage": "Język aplikacji",
+ "appLanguageSystem": "Domyślny systemu",
"fitInWeek": "Dopasuj w tygodniu",
"dayTypeAmrap": "Tak dużo rund jak to możliwe",
"trophies": "Trofea",
diff --git a/lib/l10n/language_native_names.dart b/lib/l10n/language_native_names.dart
new file mode 100644
index 000000000..63d01f0b5
--- /dev/null
+++ b/lib/l10n/language_native_names.dart
@@ -0,0 +1,57 @@
+/*
+ * This file is part of wger Workout Manager .
+ * Copyright (c) 2026 - 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 .
+ */
+
+/// Native display names for each supported locale, keyed by the locale name
+const Map languageNativeNames = {
+ 'am': 'አማርኛ',
+ 'ar': 'العربية',
+ 'ca': 'Català',
+ 'cs': 'Čeština',
+ 'de': 'Deutsch',
+ 'el': 'Ελληνικά',
+ 'en': 'English',
+ 'es': 'Español',
+ 'fa': 'فارسی',
+ 'fil': 'Filipino',
+ 'fr': 'Français',
+ 'he': 'עברית',
+ 'hi': 'हिन्दी',
+ 'hr': 'Hrvatski',
+ 'hu': 'Magyar',
+ 'id': 'Bahasa Indonesia',
+ 'it': 'Italiano',
+ 'ja': '日本語',
+ 'ko': '한국어',
+ 'mk': 'Македонски',
+ 'nb': 'Norsk bokmål',
+ 'nl': 'Nederlands',
+ 'pl': 'Polski',
+ 'pt': 'Português',
+ 'pt_BR': 'Português (Brasil)',
+ 'pt_PT': 'Português (Portugal)',
+ 'ro': 'Română',
+ 'ru': 'Русский',
+ 'sk': 'Slovenčina',
+ 'ta': 'தமிழ்',
+ 'th': 'ไทย',
+ 'tr': 'Türkçe',
+ 'uk': 'Українська',
+ 'ur': 'اردو',
+ 'zh': '中文',
+ 'zh_Hant': '繁體中文',
+};
diff --git a/lib/main.dart b/lib/main.dart
index 0812357ee..eeafc61c3 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -249,6 +249,7 @@ class MainApp extends StatelessWidget {
highContrastTheme: wgerLightThemeHc,
highContrastDarkTheme: wgerDarkThemeHc,
themeMode: user.themeMode,
+ locale: user.userLocale,
home: _getHomeScreen(auth),
routes: {
DashboardScreen.routeName: (ctx) => const DashboardScreen(),
diff --git a/lib/providers/user.dart b/lib/providers/user.dart
index 07338fb41..7659e841f 100644
--- a/lib/providers/user.dart
+++ b/lib/providers/user.dart
@@ -23,7 +23,9 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wger/helpers/consts.dart';
+import 'package:wger/helpers/locale.dart';
import 'package:wger/helpers/shared_preferences.dart';
+import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/user/profile.dart';
import 'package:wger/providers/base_provider.dart';
@@ -63,12 +65,14 @@ class DashboardItem {
class UserProvider with ChangeNotifier {
ThemeMode themeMode = ThemeMode.system;
+ Locale? userLocale;
final WgerBaseProvider baseProvider;
late SharedPreferencesAsync prefs;
UserProvider(this.baseProvider, {SharedPreferencesAsync? prefs}) {
this.prefs = prefs ?? PreferenceHelper.asyncPref;
_loadThemeMode();
+ _loadUserLocale();
_loadDashboardConfig();
}
@@ -96,6 +100,38 @@ class UserProvider with ChangeNotifier {
notifyListeners();
}
+ /// Load saved user locale override from SharedPreferences. A null value means
+ /// the app should follow the system locale.
+ Future _loadUserLocale() async {
+ final raw = await prefs.getString(PREFS_USER_LOCALE);
+ userLocale = _matchSupportedLocale(raw);
+ notifyListeners();
+ }
+
+ /// Match a stored locale tag (`languageCode` or `languageCode_subtag`) against
+ /// the app's [AppLocalizations.supportedLocales]. Returns the exact supported
+ /// instance to keep dropdown identity stable, or null when no match is found.
+ static Locale? _matchSupportedLocale(String? raw) {
+ if (raw == null || raw.isEmpty) {
+ return null;
+ }
+ for (final locale in AppLocalizations.supportedLocales) {
+ if (encodeLocale(locale) == raw) {
+ return locale;
+ }
+ }
+ // Fallback: match by language only (e.g. stored "pl" picks the only pl).
+ final lang = raw.split('_').first;
+ for (final locale in AppLocalizations.supportedLocales) {
+ if (locale.languageCode == lang &&
+ (locale.countryCode == null || locale.countryCode!.isEmpty) &&
+ (locale.scriptCode == null || locale.scriptCode!.isEmpty)) {
+ return locale;
+ }
+ }
+ return null;
+ }
+
// Dashboard configuration
List _dashboardItems = DashboardWidget.values
.map((w) => DashboardItem(w))
@@ -193,6 +229,20 @@ class UserProvider with ChangeNotifier {
notifyListeners();
}
+ /// Override the app locale. Passing `null` clears the override and falls
+ /// back to the system locale via `localeListResolutionCallback`.
+ Future setUserLocale(Locale? locale) async {
+ userLocale = locale;
+
+ if (locale == null) {
+ await prefs.remove(PREFS_USER_LOCALE);
+ } else {
+ await prefs.setString(PREFS_USER_LOCALE, encodeLocale(locale));
+ }
+
+ notifyListeners();
+ }
+
/// Fetch the current user's profile
Future fetchAndSetProfile() async {
final userData = await baseProvider.fetch(baseProvider.makeUrl(PROFILE_URL));
diff --git a/lib/widgets/core/settings.dart b/lib/widgets/core/settings.dart
index 014b281d9..8d353723c 100644
--- a/lib/widgets/core/settings.dart
+++ b/lib/widgets/core/settings.dart
@@ -22,6 +22,7 @@ import 'package:wger/screens/settings_plates_screen.dart';
import './settings/exercise_cache.dart';
import './settings/ingredient_cache.dart';
+import './settings/language.dart';
import './settings/theme.dart';
class SettingsPage extends StatelessWidget {
@@ -44,6 +45,7 @@ class SettingsPage extends StatelessWidget {
const SettingsIngredientCache(),
ListTile(title: Text(i18n.others, style: Theme.of(context).textTheme.headlineSmall)),
const SettingsTheme(),
+ const SettingsLanguage(),
ListTile(
title: Text(i18n.selectAvailablePlates),
onTap: () {
diff --git a/lib/widgets/core/settings/language.dart b/lib/widgets/core/settings/language.dart
new file mode 100644
index 000000000..0f62d0e84
--- /dev/null
+++ b/lib/widgets/core/settings/language.dart
@@ -0,0 +1,66 @@
+/*
+ * This file is part of wger Workout Manager .
+ * Copyright (c) 2026 - 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/helpers/locale.dart';
+import 'package:wger/l10n/generated/app_localizations.dart';
+import 'package:wger/l10n/language_native_names.dart';
+import 'package:wger/providers/user.dart';
+
+String _displayNameFor(Locale locale) {
+ return languageNativeNames[encodeLocale(locale)] ??
+ languageNativeNames[locale.languageCode] ??
+ encodeLocale(locale);
+}
+
+class SettingsLanguage extends StatelessWidget {
+ const SettingsLanguage({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final i18n = AppLocalizations.of(context);
+ final userProvider = Provider.of(context);
+
+ final supported = [...AppLocalizations.supportedLocales]
+ ..sort((a, b) => _displayNameFor(a).compareTo(_displayNameFor(b)));
+
+ return ListTile(
+ title: Text(i18n.appLanguage),
+ trailing: DropdownButton(
+ key: const ValueKey('appLanguageDropdown'),
+ value: userProvider.userLocale,
+ onChanged: (Locale? newValue) {
+ userProvider.setUserLocale(newValue);
+ },
+ items: >[
+ DropdownMenuItem(
+ value: null,
+ child: Text(i18n.appLanguageSystem),
+ ),
+ ...supported.map(
+ (locale) => DropdownMenuItem(
+ value: locale,
+ child: Text(_displayNameFor(locale)),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/pubspec.lock b/pubspec.lock
index a608a9315..ea1998954 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -1642,7 +1642,7 @@ packages:
source: hosted
version: "1.1.0"
xml:
- dependency: transitive
+ dependency: "direct dev"
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
diff --git a/pubspec.yaml b/pubspec.yaml
index 288b261de..1b103c777 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -77,6 +77,7 @@ dev_dependencies:
sdk: flutter
build_runner: ^2.15.0
cider: ^0.2.9
+ xml: ^6.6.1
drift_dev: ^2.31.0
flutter_lints: ^6.0.0
flutter_native_splash: ^2.4.7
diff --git a/test/core/settings_test.dart b/test/core/settings_test.dart
index f4869b4e6..be3fa9e9b 100644
--- a/test/core/settings_test.dart
+++ b/test/core/settings_test.dart
@@ -49,6 +49,8 @@ void main() {
setUp(() {
when(mockUserProvider.themeMode).thenReturn(ThemeMode.system);
+ when(mockUserProvider.userLocale).thenReturn(null);
+ when(mockUserProvider.setUserLocale(any)).thenAnswer((_) async {});
when(
mockSharedPreferences.getString(UserProvider.PREFS_DASHBOARD_CONFIG),
).thenAnswer((_) async => null);
@@ -103,6 +105,7 @@ void main() {
group('Theme settings', () {
test('Default theme is system', () async {
when(mockSharedPreferences.getBool(PREFS_USER_DARK_THEME)).thenAnswer((_) async => null);
+ when(mockSharedPreferences.getString(PREFS_USER_LOCALE)).thenAnswer((_) async => null);
final userProvider = UserProvider(MockWgerBaseProvider(), prefs: mockSharedPreferences);
await Future.delayed(const Duration(milliseconds: 50)); // wait for async prefs load
expect(userProvider.themeMode, ThemeMode.system);
@@ -110,6 +113,7 @@ void main() {
test('Loads light theme', () async {
when(mockSharedPreferences.getBool(PREFS_USER_DARK_THEME)).thenAnswer((_) async => false);
+ when(mockSharedPreferences.getString(PREFS_USER_LOCALE)).thenAnswer((_) async => null);
final userProvider = UserProvider(MockWgerBaseProvider(), prefs: mockSharedPreferences);
await Future.delayed(const Duration(milliseconds: 50)); // wait for async prefs load
expect(userProvider.themeMode, ThemeMode.light);
@@ -117,9 +121,7 @@ void main() {
test('Saves theme to prefs', () {
when(mockSharedPreferences.getBool(any)).thenAnswer((_) async => null);
- when(
- mockSharedPreferences.getString('dashboardWidgetVisibility'),
- ).thenAnswer((_) async => null);
+ when(mockSharedPreferences.getString(any)).thenAnswer((_) async => null);
final userProvider = UserProvider(MockWgerBaseProvider(), prefs: mockSharedPreferences);
userProvider.setThemeMode(ThemeMode.dark);
verify(mockSharedPreferences.setBool(PREFS_USER_DARK_THEME, true)).called(1);
@@ -135,4 +137,78 @@ void main() {
verify(mockUserProvider.setThemeMode(ThemeMode.light)).called(1);
});
});
+
+ group('Language switcher', () {
+ testWidgets('shows system option when no override set', (WidgetTester tester) async {
+ await tester.pumpWidget(createSettingsScreen());
+ await tester.pumpAndSettle();
+
+ // The dropdown is built; tap to open it.
+ final dropdown = find.byKey(const ValueKey('appLanguageDropdown'));
+ expect(dropdown, findsOneWidget);
+
+ await tester.ensureVisible(dropdown);
+ await tester.pumpAndSettle();
+ await tester.tap(dropdown);
+ await tester.pumpAndSettle();
+
+ // "System language" option exists in the open menu.
+ expect(find.text('System default'), findsWidgets);
+ });
+
+ testWidgets('selecting a language calls setUserLocale', (WidgetTester tester) async {
+ await tester.pumpWidget(createSettingsScreen());
+ await tester.pumpAndSettle();
+
+ final dropdown = find.byKey(const ValueKey('appLanguageDropdown'));
+ await tester.ensureVisible(dropdown);
+ await tester.pumpAndSettle();
+ await tester.tap(dropdown);
+ await tester.pumpAndSettle();
+
+ // German is rendered in its native name ("Deutsch") in the menu.
+ await tester.tap(find.text('Deutsch').last);
+ await tester.pumpAndSettle();
+
+ final captured =
+ verify(mockUserProvider.setUserLocale(captureAny)).captured.single as Locale?;
+ expect(captured?.languageCode, 'de');
+ });
+
+ testWidgets('selecting "System language" passes null', (WidgetTester tester) async {
+ when(mockUserProvider.userLocale).thenReturn(const Locale('de'));
+
+ await tester.pumpWidget(createSettingsScreen());
+ await tester.pumpAndSettle();
+
+ final dropdown = find.byKey(const ValueKey('appLanguageDropdown'));
+ await tester.ensureVisible(dropdown);
+ await tester.pumpAndSettle();
+ await tester.tap(dropdown);
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('System default').last);
+ await tester.pumpAndSettle();
+
+ final captured = verify(mockUserProvider.setUserLocale(captureAny)).captured.single;
+ expect(captured, isNull);
+ });
+
+ testWidgets('renders supported locales in native script', (WidgetTester tester) async {
+ await tester.pumpWidget(createSettingsScreen());
+ await tester.pumpAndSettle();
+
+ final dropdown = find.byKey(const ValueKey('appLanguageDropdown'));
+ await tester.ensureVisible(dropdown);
+ await tester.pumpAndSettle();
+ await tester.tap(dropdown);
+ await tester.pumpAndSettle();
+
+ // Spot-check native names that sort near the top of the menu and are
+ // therefore visible without scrolling the (large) dropdown overlay.
+ expect(find.text('Deutsch'), findsWidgets);
+ expect(find.text('English'), findsWidgets);
+ expect(find.text('Català'), findsWidgets);
+ });
+ });
}
diff --git a/test/core/settings_test.mocks.dart b/test/core/settings_test.mocks.dart
index 8d2ab4a61..e53f803a5 100644
--- a/test/core/settings_test.mocks.dart
+++ b/test/core/settings_test.mocks.dart
@@ -1014,6 +1014,12 @@ class MockUserProvider extends _i1.Mock implements _i24.UserProvider {
returnValueForMissingStub: null,
);
+ @override
+ set userLocale(_i21.Locale? value) => super.noSuchMethod(
+ Invocation.setter(#userLocale, value),
+ returnValueForMissingStub: null,
+ );
+
@override
set prefs(_i14.SharedPreferencesAsync? value) => super.noSuchMethod(
Invocation.setter(#prefs, value),
@@ -1071,6 +1077,15 @@ class MockUserProvider extends _i1.Mock implements _i24.UserProvider {
returnValueForMissingStub: null,
);
+ @override
+ _i18.Future setUserLocale(_i21.Locale? locale) =>
+ (super.noSuchMethod(
+ Invocation.method(#setUserLocale, [locale]),
+ returnValue: _i18.Future.value(),
+ returnValueForMissingStub: _i18.Future.value(),
+ )
+ as _i18.Future);
+
@override
_i18.Future fetchAndSetProfile() =>
(super.noSuchMethod(
diff --git a/test/user/provider_test.dart b/test/user/provider_test.dart
index fdb9b1f00..cc83fea6d 100644
--- a/test/user/provider_test.dart
+++ b/test/user/provider_test.dart
@@ -18,12 +18,14 @@
import 'dart:convert';
+import 'package:flutter/material.dart';
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/helpers/consts.dart';
import 'package:wger/providers/base_provider.dart';
import 'package:wger/providers/user.dart';
@@ -162,25 +164,178 @@ void main() {
// act
final newProvider = UserProvider(mockWgerBaseProvider, prefs: prefs);
- await Future.delayed(const Duration(milliseconds: 100)); // wait for async prefs load
+ await Future.delayed(const Duration(milliseconds: 100));
// assert
- // Loaded: [nutrition, routines]
- // Missing: trophies (0), weight (3), measurements (4), calendar (5)
- // 1. trophies (index 0) inserted at 0 -> [trophies, nutrition, routines]
- // 2. weight (index 3) inserted at 3 -> [trophies, nutrition, routines, weight]
expect(newProvider.allDashboardWidgets[0], DashboardWidget.trophies);
expect(newProvider.allDashboardWidgets[1], DashboardWidget.nutrition);
expect(newProvider.allDashboardWidgets[2], DashboardWidget.routines);
expect(newProvider.allDashboardWidgets[3], DashboardWidget.weight);
- // Check visibility
expect(newProvider.isDashboardWidgetVisible(DashboardWidget.nutrition), true);
expect(newProvider.isDashboardWidgetVisible(DashboardWidget.routines), false);
- // Missing items should be visible by default
expect(newProvider.isDashboardWidgetVisible(DashboardWidget.weight), true);
expect(newProvider.isDashboardWidgetVisible(DashboardWidget.trophies), true);
});
});
+
+ group('user locale', () {
+ test('defaults to null when no override saved', () async {
+ // act
+ await Future.delayed(const Duration(milliseconds: 50));
+
+ // assert: null means "follow system locale"
+ expect(userProvider.userLocale, null);
+ });
+
+ test('setUserLocale persists language-only code to prefs', () async {
+ // act
+ await userProvider.setUserLocale(const Locale('pl'));
+
+ // assert
+ expect(userProvider.userLocale, const Locale('pl'));
+ final stored = await SharedPreferencesAsync().getString(PREFS_USER_LOCALE);
+ expect(stored, 'pl');
+ });
+
+ test('setUserLocale persists country-coded locale (pt_BR)', () async {
+ // act
+ await userProvider.setUserLocale(const Locale('pt', 'BR'));
+
+ // assert
+ final stored = await SharedPreferencesAsync().getString(PREFS_USER_LOCALE);
+ expect(stored, 'pt_BR');
+ });
+
+ test('setUserLocale persists script-coded locale (zh_Hant)', () async {
+ // act
+ await userProvider.setUserLocale(const Locale.fromSubtags(
+ languageCode: 'zh',
+ scriptCode: 'Hant',
+ ));
+
+ // assert
+ final stored = await SharedPreferencesAsync().getString(PREFS_USER_LOCALE);
+ expect(stored, 'zh_Hant');
+ });
+
+ test('setUserLocale(null) clears the stored override', () async {
+ // arrange
+ await userProvider.setUserLocale(const Locale('de'));
+ expect(await SharedPreferencesAsync().getString(PREFS_USER_LOCALE), 'de');
+
+ // act
+ await userProvider.setUserLocale(null);
+
+ // assert
+ expect(userProvider.userLocale, null);
+ expect(await SharedPreferencesAsync().getString(PREFS_USER_LOCALE), null);
+ });
+
+ test('setUserLocale notifies listeners', () async {
+ // arrange
+ var notifyCount = 0;
+ userProvider.addListener(() => notifyCount++);
+
+ // act
+ await userProvider.setUserLocale(const Locale('fr'));
+
+ // assert
+ expect(notifyCount, greaterThanOrEqualTo(1));
+ });
+
+ test('loads previously stored language-only locale on construction', () async {
+ // arrange
+ final prefs = SharedPreferencesAsync();
+ await prefs.setString(PREFS_USER_LOCALE, 'de');
+
+ // act
+ final newProvider = UserProvider(mockWgerBaseProvider, prefs: prefs);
+ await Future.delayed(const Duration(milliseconds: 50));
+
+ // assert
+ expect(newProvider.userLocale, const Locale('de'));
+ });
+
+ test('loads previously stored country-coded locale (pt_BR)', () async {
+ // arrange
+ final prefs = SharedPreferencesAsync();
+ await prefs.setString(PREFS_USER_LOCALE, 'pt_BR');
+
+ // act
+ final newProvider = UserProvider(mockWgerBaseProvider, prefs: prefs);
+ await Future.delayed(const Duration(milliseconds: 50));
+
+ // assert
+ expect(newProvider.userLocale?.languageCode, 'pt');
+ expect(newProvider.userLocale?.countryCode, 'BR');
+ });
+
+ test('loads previously stored script-coded locale (zh_Hant)', () async {
+ // arrange
+ final prefs = SharedPreferencesAsync();
+ await prefs.setString(PREFS_USER_LOCALE, 'zh_Hant');
+
+ // act
+ final newProvider = UserProvider(mockWgerBaseProvider, prefs: prefs);
+ await Future.delayed(const Duration(milliseconds: 50));
+
+ // assert
+ expect(newProvider.userLocale?.languageCode, 'zh');
+ expect(newProvider.userLocale?.scriptCode, 'Hant');
+ });
+
+ test('falls back to language-only match for unknown country code', () async {
+ // arrange: "pl_XX" is not a supported subtag; should fall back to "pl"
+ final prefs = SharedPreferencesAsync();
+ await prefs.setString(PREFS_USER_LOCALE, 'pl_XX');
+
+ // act
+ final newProvider = UserProvider(mockWgerBaseProvider, prefs: prefs);
+ await Future.delayed(const Duration(milliseconds: 50));
+
+ // assert
+ expect(newProvider.userLocale, const Locale('pl'));
+ });
+
+ test('returns null for completely unsupported locale tag', () async {
+ // arrange
+ final prefs = SharedPreferencesAsync();
+ await prefs.setString(PREFS_USER_LOCALE, 'xx_YY');
+
+ // act
+ final newProvider = UserProvider(mockWgerBaseProvider, prefs: prefs);
+ await Future.delayed(const Duration(milliseconds: 50));
+
+ // assert
+ expect(newProvider.userLocale, null);
+ });
+
+ test('returns null for empty stored value', () async {
+ // arrange
+ final prefs = SharedPreferencesAsync();
+ await prefs.setString(PREFS_USER_LOCALE, '');
+
+ // act
+ final newProvider = UserProvider(mockWgerBaseProvider, prefs: prefs);
+ await Future.delayed(const Duration(milliseconds: 50));
+
+ // assert
+ expect(newProvider.userLocale, null);
+ });
+
+ test('clear() does NOT reset the user locale (preference survives logout)', () async {
+ // arrange
+ await userProvider.setUserLocale(const Locale('it'));
+ expect(userProvider.userLocale, const Locale('it'));
+
+ // act
+ userProvider.clear();
+
+ // assert: clear() resets profile but locale preference is intentionally kept
+ expect(userProvider.userLocale, const Locale('it'));
+ });
+ });
+
}
diff --git a/tool/check_locales.dart b/tool/check_locales.dart
new file mode 100644
index 000000000..680ed713b
--- /dev/null
+++ b/tool/check_locales.dart
@@ -0,0 +1,231 @@
+/*
+ * 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 .
+ */
+
+library;
+
+import 'dart:io';
+
+import 'package:collection/collection.dart';
+import 'package:wger/l10n/language_native_names.dart';
+import 'package:xml/xml.dart';
+
+const _listEq = ListEquality();
+
+const _arbDir = 'lib/l10n';
+const _iosPlist = 'ios/Runner/Info.plist';
+const _androidXml = 'android/app/src/main/res/xml/locales_config.xml';
+
+/// Verifies that every place which enumerates the app's supported locales
+/// agrees with the canonical list derived from `lib/l10n/app_*.arb` filenames.
+///
+/// Checks:
+/// - `ios/Runner/Info.plist`
+/// - `android/app/src/main/res/xml/locales_config.xml`
+/// - `languageNativeNames` in `lib/l10n/language_native_names.dart`
+///
+/// Run `dart run tool/check_locales.dart` to check,
+/// or `dart run tool/check_locales.dart --fix` to rewrite the iOS/Android lists
+/// in place. The native-name map stays manual.
+void main(List args) {
+ final fix = args.contains('--fix');
+
+ final canonical = _readArbLocales();
+ if (canonical.isEmpty) {
+ stderr.writeln('No app_*.arb files found in $_arbDir');
+ exit(2);
+ }
+
+ // Dash form for iOS/Android, underscore form for Dart.
+ final dashForm = canonical.map((c) => c.replaceAll('_', '-')).toList()..sort();
+ final underscoreForm = [...canonical]..sort();
+
+ var drift = false;
+
+ drift |= _checkPlist(dashForm, fix: fix);
+ drift |= _checkAndroidXml(dashForm, fix: fix);
+ drift |= _checkLanguageDartCoverage(underscoreForm);
+
+ if (drift) {
+ if (fix) {
+ stdout.writeln('\nFixed drifted files. Re-run without --fix to verify.');
+ } else {
+ stderr.writeln('\nLocale lists are out of sync. Re-run with --fix to update.');
+ exit(1);
+ }
+ } else {
+ stdout.writeln('\nAll locale lists match (${canonical.length} locales).');
+ }
+}
+
+/// Canonical locale list from `app_.arb` filenames. Returns tags in
+/// underscore form (e.g. `pt_BR`, `zh_Hant`).
+List _readArbLocales() {
+ final dir = Directory(_arbDir);
+ if (!dir.existsSync()) {
+ return const [];
+ }
+ final tags = [];
+ for (final entity in dir.listSync()) {
+ if (entity is! File) {
+ continue;
+ }
+ final name = entity.uri.pathSegments.last;
+ final match = RegExp(r'^app_(.+)\.arb$').firstMatch(name);
+ if (match != null) {
+ tags.add(match.group(1)!);
+ }
+ }
+ tags.sort();
+ return tags;
+}
+
+bool _checkPlist(List expectedDash, {required bool fix}) {
+ final file = File(_iosPlist);
+ if (!file.existsSync()) {
+ stderr.writeln('[ios] $_iosPlist not found');
+ return false;
+ }
+ final content = file.readAsStringSync();
+ final doc = XmlDocument.parse(content);
+
+ // Plist structure: foo....
+ // Find the CFBundleLocalizations and take its following sibling.
+ final dict = doc.rootElement.findElements('dict').firstOrNull;
+ final keyNode = dict
+ ?.findElements('key')
+ .firstWhereOrNull(
+ (k) => k.innerText == 'CFBundleLocalizations',
+ );
+ final array = keyNode?.nextElementSibling;
+ if (array == null || array.name.local != 'array') {
+ stderr.writeln('[ios] CFBundleLocalizations not found in $_iosPlist');
+ return true;
+ }
+
+ final actual = array.findElements('string').map((e) => e.innerText).toList()..sort();
+
+ if (_listEq.equals(actual, expectedDash)) {
+ stdout.writeln('[ios] OK (${actual.length} locales)');
+ return false;
+ }
+
+ _reportDiff('ios', actual, expectedDash);
+
+ if (fix) {
+ // Mutate only the 's children
+ array.children.clear();
+ array.children.add(XmlText('\n\t\t\t'));
+ for (final tag in expectedDash) {
+ array.children.add(XmlElement(XmlName('string'), [], [XmlText(tag)]));
+ array.children.add(XmlText('\n\t\t\t'));
+ }
+ // Trim the trailing inter-child indent and replace with the closing one.
+ array.children.removeLast();
+ array.children.add(XmlText('\n\t\t'));
+ file.writeAsStringSync(doc.toXmlString());
+ stdout.writeln('[ios] rewrote $_iosPlist');
+ }
+ return true;
+}
+
+bool _checkAndroidXml(List expectedDash, {required bool fix}) {
+ final file = File(_androidXml);
+ if (!file.existsSync()) {
+ stderr.writeln('[android] $_androidXml not found');
+ return false;
+ }
+ final doc = XmlDocument.parse(file.readAsStringSync());
+
+ final actual = doc.rootElement
+ .findElements('locale')
+ .map((e) => e.getAttribute('android:name')!)
+ .toList();
+
+ // Convention: `en` first (matches `preferred-supported-locales: [en]` in
+ // l10n.yaml), then alphabetical.
+ final expectedOrdered = _enFirst(expectedDash);
+
+ if (_listEq.equals(actual, expectedOrdered)) {
+ stdout.writeln('[android] OK (${actual.length} locales)');
+ return false;
+ }
+
+ _reportDiff('android', actual..sort(), expectedOrdered.toList()..sort());
+
+ if (fix) {
+ final builder = XmlBuilder();
+ builder.processing('xml', 'version="1.0" encoding="utf-8"');
+ builder.element(
+ 'locale-config',
+ namespaces: {'http://schemas.android.com/apk/res/android': 'android'},
+ nest: () {
+ for (final tag in expectedOrdered) {
+ builder.element('locale', attributes: {'android:name': tag});
+ }
+ },
+ );
+ file.writeAsStringSync(
+ builder.buildDocument().toXmlString(pretty: true, indent: ' '),
+ );
+ stdout.writeln('[android] rewrote $_androidXml');
+ }
+ return true;
+}
+
+/// Returns the list with `en` first (if present), the rest alphabetically.
+List _enFirst(List tags) {
+ final rest = [...tags.where((t) => t != 'en')]..sort();
+ return tags.contains('en') ? ['en', ...rest] : rest;
+}
+
+/// The native-name map is hand-curated, since this script doesn't know how
+/// to spell `Українська`, we can only check coverage, not auto-fix it.
+bool _checkLanguageDartCoverage(List expectedUnderscore) {
+ final actual = languageNativeNames.keys.toSet();
+ final expected = expectedUnderscore.toSet();
+
+ final missing = expected.difference(actual).toList()..sort();
+ final extra = actual.difference(expected).toList()..sort();
+
+ if (missing.isEmpty && extra.isEmpty) {
+ stdout.writeln('[dart] OK (${actual.length} entries)');
+ return false;
+ }
+
+ if (missing.isNotEmpty) {
+ stderr.writeln('[dart] missing native-name entries: ${missing.join(', ')}');
+ stderr.writeln(' add them by hand to lib/l10n/language_native_names.dart');
+ }
+ if (extra.isNotEmpty) {
+ stderr.writeln('[dart] stale native-name entries (no matching .arb): ${extra.join(', ')}');
+ }
+ return true;
+}
+
+void _reportDiff(String label, List actual, List expected) {
+ final actualSet = actual.toSet();
+ final expectedSet = expected.toSet();
+ final missing = expectedSet.difference(actualSet).toList()..sort();
+ final extra = actualSet.difference(expectedSet).toList()..sort();
+ if (missing.isNotEmpty) {
+ stderr.writeln('[$label] missing: ${missing.join(', ')}');
+ }
+ if (extra.isNotEmpty) {
+ stderr.writeln('[$label] extra: ${extra.join(', ')}');
+ }
+}