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(', ')}'); + } +}