Skip to content

Commit b1f3ffb

Browse files
authored
Merge pull request #1195 from PawelHaracz/feat/language-switcher
feat(settings): add in-app language switcher (#439)
2 parents 4d9132a + dcebb3a commit b1f3ffb

18 files changed

Lines changed: 746 additions & 11 deletions

File tree

.github/workflows/analyze.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,18 @@ on:
55
- '**.dart'
66
- 'pubspec.yaml'
77
- 'analysis_options.yaml'
8+
- 'lib/l10n/**'
9+
- 'ios/Runner/Info.plist'
10+
- 'android/app/src/main/res/xml/locales_config.xml'
811
pull_request:
912
branches: [ master, ]
1013
paths:
1114
- '**/*.dart'
1215
- 'pubspec.yaml'
1316
- 'analysis_options.yaml'
17+
- 'lib/l10n/**'
18+
- 'ios/Runner/Info.plist'
19+
- 'android/app/src/main/res/xml/locales_config.xml'
1420
- '.github/actions/flutter-common/action.yml'
1521
- '.github/workflows/analyze.yml'
1622
workflow_call:
@@ -34,3 +40,6 @@ jobs:
3440

3541
- name: Analyze
3642
run: flutter analyze --no-fatal-infos --no-fatal-warnings
43+
44+
- name: Check locale lists are in sync
45+
run: dart run tool/check_locales.dart

ios/Podfile.lock

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ PODS:
22
- camera_avfoundation (0.0.1):
33
- Flutter
44
- Flutter (1.0.0)
5+
- flutter_native_splash (2.4.3):
6+
- Flutter
57
- flutter_zxing (0.0.1):
68
- Flutter
79
- image_picker_ios (0.0.1):
@@ -49,6 +51,7 @@ PODS:
4951
DEPENDENCIES:
5052
- camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`)
5153
- Flutter (from `Flutter`)
54+
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
5255
- flutter_zxing (from `.symlinks/plugins/flutter_zxing/ios`)
5356
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
5457
- integration_test (from `.symlinks/plugins/integration_test/ios`)
@@ -68,6 +71,8 @@ EXTERNAL SOURCES:
6871
:path: ".symlinks/plugins/camera_avfoundation/ios"
6972
Flutter:
7073
:path: Flutter
74+
flutter_native_splash:
75+
:path: ".symlinks/plugins/flutter_native_splash/ios"
7176
flutter_zxing:
7277
:path: ".symlinks/plugins/flutter_zxing/ios"
7378
image_picker_ios:
@@ -90,6 +95,7 @@ EXTERNAL SOURCES:
9095
SPEC CHECKSUMS:
9196
camera_avfoundation: 968a9a5323c79a99c166ad9d7866bfd2047b5a9b
9297
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
98+
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
9399
flutter_zxing: fe1338b6d79b8a455d209a28381e336b6cfb4161
94100
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
95101
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e

ios/Runner/Info.plist

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,45 @@
1212
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
1313
<key>CFBundleInfoDictionaryVersion</key>
1414
<string>6.0</string>
15+
<key>CFBundleLocalizations</key>
16+
<array>
17+
<string>am</string>
18+
<string>ar</string>
19+
<string>ca</string>
20+
<string>cs</string>
21+
<string>de</string>
22+
<string>el</string>
23+
<string>en</string>
24+
<string>es</string>
25+
<string>fa</string>
26+
<string>fil</string>
27+
<string>fr</string>
28+
<string>he</string>
29+
<string>hi</string>
30+
<string>hr</string>
31+
<string>hu</string>
32+
<string>id</string>
33+
<string>it</string>
34+
<string>ja</string>
35+
<string>ko</string>
36+
<string>mk</string>
37+
<string>nb</string>
38+
<string>nl</string>
39+
<string>pl</string>
40+
<string>pt</string>
41+
<string>pt-BR</string>
42+
<string>pt-PT</string>
43+
<string>ro</string>
44+
<string>ru</string>
45+
<string>sk</string>
46+
<string>ta</string>
47+
<string>th</string>
48+
<string>tr</string>
49+
<string>uk</string>
50+
<string>ur</string>
51+
<string>zh</string>
52+
<string>zh-Hant</string>
53+
</array>
1554
<key>CFBundleName</key>
1655
<string>wger</string>
1756
<key>CFBundlePackageType</key>

lib/helpers/consts.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const PREFS_INGREDIENTS = 'ingredientData';
6464
const PREFS_WORKOUT_UNITS = 'workoutUnits';
6565
const PREFS_USER = 'userData';
6666
const PREFS_USER_DARK_THEME = 'userDarkMode';
67+
const PREFS_USER_LOCALE = 'userLocale';
6768
const PREFS_LAST_SERVER = 'lastServer';
6869

6970
const DEFAULT_ANIMATION_DURATION = Duration(milliseconds: 200);

lib/helpers/locale.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,19 @@ Locale? resolveLocale(List<Locale>? locales, Iterable<Locale> supportedLocales)
3434
}
3535
return null;
3636
}
37+
38+
/// Stable string encoding for a [Locale], matching the keys generated by
39+
/// Flutter's AppLocalizations (e.g. `pt_BR`, `zh_Hant`, `pl`). [scriptCode]
40+
/// takes precedence over [countryCode] so locales like `zh_Hant` round-trip
41+
/// distinctly from plain `zh`.
42+
String encodeLocale(Locale locale) {
43+
final script = locale.scriptCode;
44+
if (script != null && script.isNotEmpty) {
45+
return '${locale.languageCode}_$script';
46+
}
47+
final country = locale.countryCode;
48+
if (country != null && country.isNotEmpty) {
49+
return '${locale.languageCode}_$country';
50+
}
51+
return locale.languageCode;
52+
}

lib/l10n/app_en.arb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1160,6 +1160,14 @@
11601160
"darkMode": "Always dark mode",
11611161
"lightMode": "Always light mode",
11621162
"systemMode": "System settings",
1163+
"appLanguage": "App language",
1164+
"@appLanguage": {
1165+
"description": "Label for the in-app language selector in settings"
1166+
},
1167+
"appLanguageSystem": "System default",
1168+
"@appLanguageSystem": {
1169+
"description": "Dropdown entry that follows the device system language"
1170+
},
11631171
"productNotFoundOpenFoodFacts": "You can add this product to Open Food Facts to help the community!",
11641172
"@productNotFoundOpenFoodFacts": {
11651173
"description": "Label shown when product is not found to encourage users to go on Open Food Facts and add it themselves"

lib/l10n/app_pl.arb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,6 +833,8 @@
833833
"darkMode": "Zawsze używaj trybu ciemnego",
834834
"lightMode": "Zawsze używaj trybu jasnego",
835835
"systemMode": "Ustawienia systemu",
836+
"appLanguage": "Język aplikacji",
837+
"appLanguageSystem": "Domyślny systemu",
836838
"fitInWeek": "Dopasuj w tygodniu",
837839
"dayTypeAmrap": "Tak dużo rund jak to możliwe",
838840
"trophies": "Trofea",
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* This file is part of wger Workout Manager <https://github.com/wger-project>.
3+
* Copyright (c) 2026 - 2026 wger Team
4+
*
5+
* wger Workout Manager is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
/// Native display names for each supported locale, keyed by the locale name
20+
const Map<String, String> languageNativeNames = {
21+
'am': 'አማርኛ',
22+
'ar': 'العربية',
23+
'ca': 'Català',
24+
'cs': 'Čeština',
25+
'de': 'Deutsch',
26+
'el': 'Ελληνικά',
27+
'en': 'English',
28+
'es': 'Español',
29+
'fa': 'فارسی',
30+
'fil': 'Filipino',
31+
'fr': 'Français',
32+
'he': 'עברית',
33+
'hi': 'हिन्दी',
34+
'hr': 'Hrvatski',
35+
'hu': 'Magyar',
36+
'id': 'Bahasa Indonesia',
37+
'it': 'Italiano',
38+
'ja': '日本語',
39+
'ko': '한국어',
40+
'mk': 'Македонски',
41+
'nb': 'Norsk bokmål',
42+
'nl': 'Nederlands',
43+
'pl': 'Polski',
44+
'pt': 'Português',
45+
'pt_BR': 'Português (Brasil)',
46+
'pt_PT': 'Português (Portugal)',
47+
'ro': 'Română',
48+
'ru': 'Русский',
49+
'sk': 'Slovenčina',
50+
'ta': 'தமிழ்',
51+
'th': 'ไทย',
52+
'tr': 'Türkçe',
53+
'uk': 'Українська',
54+
'ur': 'اردو',
55+
'zh': '中文',
56+
'zh_Hant': '繁體中文',
57+
};

lib/main.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ class MainApp extends StatelessWidget {
249249
highContrastTheme: wgerLightThemeHc,
250250
highContrastDarkTheme: wgerDarkThemeHc,
251251
themeMode: user.themeMode,
252+
locale: user.userLocale,
252253
home: _getHomeScreen(auth),
253254
routes: {
254255
DashboardScreen.routeName: (ctx) => const DashboardScreen(),

lib/providers/user.dart

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ import 'package:collection/collection.dart';
2323
import 'package:flutter/material.dart';
2424
import 'package:shared_preferences/shared_preferences.dart';
2525
import 'package:wger/helpers/consts.dart';
26+
import 'package:wger/helpers/locale.dart';
2627
import 'package:wger/helpers/shared_preferences.dart';
28+
import 'package:wger/l10n/generated/app_localizations.dart';
2729
import 'package:wger/models/user/profile.dart';
2830
import 'package:wger/providers/base_provider.dart';
2931

@@ -63,12 +65,14 @@ class DashboardItem {
6365

6466
class UserProvider with ChangeNotifier {
6567
ThemeMode themeMode = ThemeMode.system;
68+
Locale? userLocale;
6669
final WgerBaseProvider baseProvider;
6770
late SharedPreferencesAsync prefs;
6871

6972
UserProvider(this.baseProvider, {SharedPreferencesAsync? prefs}) {
7073
this.prefs = prefs ?? PreferenceHelper.asyncPref;
7174
_loadThemeMode();
75+
_loadUserLocale();
7276
_loadDashboardConfig();
7377
}
7478

@@ -96,6 +100,38 @@ class UserProvider with ChangeNotifier {
96100
notifyListeners();
97101
}
98102

103+
/// Load saved user locale override from SharedPreferences. A null value means
104+
/// the app should follow the system locale.
105+
Future<void> _loadUserLocale() async {
106+
final raw = await prefs.getString(PREFS_USER_LOCALE);
107+
userLocale = _matchSupportedLocale(raw);
108+
notifyListeners();
109+
}
110+
111+
/// Match a stored locale tag (`languageCode` or `languageCode_subtag`) against
112+
/// the app's [AppLocalizations.supportedLocales]. Returns the exact supported
113+
/// instance to keep dropdown identity stable, or null when no match is found.
114+
static Locale? _matchSupportedLocale(String? raw) {
115+
if (raw == null || raw.isEmpty) {
116+
return null;
117+
}
118+
for (final locale in AppLocalizations.supportedLocales) {
119+
if (encodeLocale(locale) == raw) {
120+
return locale;
121+
}
122+
}
123+
// Fallback: match by language only (e.g. stored "pl" picks the only pl).
124+
final lang = raw.split('_').first;
125+
for (final locale in AppLocalizations.supportedLocales) {
126+
if (locale.languageCode == lang &&
127+
(locale.countryCode == null || locale.countryCode!.isEmpty) &&
128+
(locale.scriptCode == null || locale.scriptCode!.isEmpty)) {
129+
return locale;
130+
}
131+
}
132+
return null;
133+
}
134+
99135
// Dashboard configuration
100136
List<DashboardItem> _dashboardItems = DashboardWidget.values
101137
.map((w) => DashboardItem(w))
@@ -193,6 +229,20 @@ class UserProvider with ChangeNotifier {
193229
notifyListeners();
194230
}
195231

232+
/// Override the app locale. Passing `null` clears the override and falls
233+
/// back to the system locale via `localeListResolutionCallback`.
234+
Future<void> setUserLocale(Locale? locale) async {
235+
userLocale = locale;
236+
237+
if (locale == null) {
238+
await prefs.remove(PREFS_USER_LOCALE);
239+
} else {
240+
await prefs.setString(PREFS_USER_LOCALE, encodeLocale(locale));
241+
}
242+
243+
notifyListeners();
244+
}
245+
196246
/// Fetch the current user's profile
197247
Future<void> fetchAndSetProfile() async {
198248
final userData = await baseProvider.fetch(baseProvider.makeUrl(PROFILE_URL));

0 commit comments

Comments
 (0)