Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/analyze.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
6 changes: 6 additions & 0 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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`)
Expand All @@ -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:
Expand All @@ -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
Expand Down
39 changes: 39 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,45 @@
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>am</string>
<string>ar</string>
<string>ca</string>
<string>cs</string>
<string>de</string>
<string>el</string>
<string>en</string>
<string>es</string>
<string>fa</string>
<string>fil</string>
<string>fr</string>
<string>he</string>
<string>hi</string>
<string>hr</string>
<string>hu</string>
<string>id</string>
<string>it</string>
<string>ja</string>
<string>ko</string>
<string>mk</string>
<string>nb</string>
<string>nl</string>
<string>pl</string>
<string>pt</string>
<string>pt-BR</string>
<string>pt-PT</string>
<string>ro</string>
<string>ru</string>
<string>sk</string>
<string>ta</string>
<string>th</string>
<string>tr</string>
<string>uk</string>
<string>ur</string>
<string>zh</string>
<string>zh-Hant</string>
</array>
<key>CFBundleName</key>
<string>wger</string>
<key>CFBundlePackageType</key>
Expand Down
1 change: 1 addition & 0 deletions lib/helpers/consts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions lib/helpers/locale.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,19 @@ Locale? resolveLocale(List<Locale>? locales, Iterable<Locale> 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;
}
8 changes: 8 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions lib/l10n/app_pl.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
57 changes: 57 additions & 0 deletions lib/l10n/language_native_names.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/

/// Native display names for each supported locale, keyed by the locale name
const Map<String, String> 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': '繁體中文',
};
1 change: 1 addition & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
50 changes: 50 additions & 0 deletions lib/providers/user.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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<void> _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<DashboardItem> _dashboardItems = DashboardWidget.values
.map((w) => DashboardItem(w))
Expand Down Expand Up @@ -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<void> 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<void> fetchAndSetProfile() async {
final userData = await baseProvider.fetch(baseProvider.makeUrl(PROFILE_URL));
Expand Down
2 changes: 2 additions & 0 deletions lib/widgets/core/settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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: () {
Expand Down
66 changes: 66 additions & 0 deletions lib/widgets/core/settings/language.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/

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<UserProvider>(context);

final supported = [...AppLocalizations.supportedLocales]
..sort((a, b) => _displayNameFor(a).compareTo(_displayNameFor(b)));

return ListTile(
title: Text(i18n.appLanguage),
trailing: DropdownButton<Locale?>(
key: const ValueKey('appLanguageDropdown'),
value: userProvider.userLocale,
onChanged: (Locale? newValue) {
userProvider.setUserLocale(newValue);
},
items: <DropdownMenuItem<Locale?>>[
DropdownMenuItem<Locale?>(
value: null,
child: Text(i18n.appLanguageSystem),
),
...supported.map(
(locale) => DropdownMenuItem<Locale?>(
value: locale,
child: Text(_displayNameFor(locale)),
),
),
],
),
);
}
}
Loading