From 7e0f0fc3cd909d90d0c4b91b9dd3f8158771a5be Mon Sep 17 00:00:00 2001 From: Blazebrain Date: Wed, 9 Sep 2026 02:35:27 +0100 Subject: [PATCH] refactor: improve locale handling fixing issues with yoruba, hausa and guarani languages --- cw_core/lib/amount/money_local.dart | 7 +-- cw_core/lib/crypto_amount_format.dart | 5 ++- cw_core/test/amount/money_local_test.dart | 4 ++ cw_core/test/crypto_amount_format.dart | 9 ++++ lib/entities/language_service.dart | 11 ++--- lib/main.dart | 2 + .../list_item_selector_widget.dart | 43 +++++++++++-------- lib/store/settings_store.dart | 17 ++++++-- tool/generate_localization.dart | 14 +++--- 9 files changed, 68 insertions(+), 44 deletions(-) diff --git a/cw_core/lib/amount/money_local.dart b/cw_core/lib/amount/money_local.dart index c5a3c0ea3..8f88985c7 100644 --- a/cw_core/lib/amount/money_local.dart +++ b/cw_core/lib/amount/money_local.dart @@ -1,5 +1,5 @@ import "package:cw_core/amount/money.dart"; -import "package:intl/intl.dart"; +import "package:cw_core/crypto_amount_format.dart"; extension WithLocalSeparator on Money { String toLocalStringWithSymbol({ @@ -37,10 +37,7 @@ extension WithLocalSeparator on Money { String _withLocalSeparator(String amount, {String? locale}) { final isNegative = amount.startsWith("-"); - final formater = NumberFormat("#,###", locale); - final parts = (isNegative ? amount.substring(1) : amount).split("."); - final formatted = [formater.format(int.tryParse(parts.first) ?? 0), ...parts.sublist(1)] - .join(formater.symbols.DECIMAL_SEP); + final formatted = (isNegative ? amount.substring(1) : amount).withLocalSeperator(locale); return isNegative ? "-$formatted" : formatted; } diff --git a/cw_core/lib/crypto_amount_format.dart b/cw_core/lib/crypto_amount_format.dart index 2c1d96f4e..ddefd9eec 100644 --- a/cw_core/lib/crypto_amount_format.dart +++ b/cw_core/lib/crypto_amount_format.dart @@ -67,7 +67,10 @@ extension MaxDecimals on String { return [parts.first.withLocalSeperator(locale), ...parts.sublist(1)].join(" "); } - final formater = NumberFormat("#,###", locale); + final formater = NumberFormat( + "#,###", + Intl.verifiedLocale(locale, NumberFormat.localeExists, onFailure: (_) => "en"), + ); final parts = replaceAll(",", "").split("."); if (parts.first.contains("< 0")) parts.first = "0"; diff --git a/cw_core/test/amount/money_local_test.dart b/cw_core/test/amount/money_local_test.dart index 24a4fffac..15aeaea0e 100644 --- a/cw_core/test/amount/money_local_test.dart +++ b/cw_core/test/amount/money_local_test.dart @@ -23,6 +23,10 @@ void main() { expect(_btcGrouping.toLocalStringWithPrecision(locale: "de_DE"), "1.234,5"); }); + test("formats as English when intl has no number data for the locale (yo)", () { + expect(_btcGrouping.toLocalStringWithPrecision(locale: "yo"), "1,234.5"); + }); + test("base unit renders integer sats with grouping", () { expect( _btc012.toLocalStringWithPrecision(useBaseUnit: true, locale: "en_US"), diff --git a/cw_core/test/crypto_amount_format.dart b/cw_core/test/crypto_amount_format.dart index c611ad7d8..fa0458bde 100644 --- a/cw_core/test/crypto_amount_format.dart +++ b/cw_core/test/crypto_amount_format.dart @@ -108,6 +108,15 @@ void main() { }); }); + group("Locale without intl number data", () { + test("should format as English instead of throwing (ha)", () { + const input = "1123.4567"; + final result = input.withLocalSeperator("ha"); + + expect(result, equals("1,123.4567")); + }); + }); + group('With XMR Suffix', () { const locale = 'de_CH'; diff --git a/lib/entities/language_service.dart b/lib/entities/language_service.dart index 5331b1e83..95200fbcc 100644 --- a/lib/entities/language_service.dart +++ b/lib/entities/language_service.dart @@ -3,16 +3,11 @@ import 'package:devicelocale/devicelocale.dart'; import "package:flutter/widgets.dart"; import 'package:intl/intl.dart'; -// This will turn stored language code into Flutter Locale. -// Some of our language codes uses an underscore (like "pt_BR") and this must be split into language -// and country subtags, if we pass the whole string as the languageCode, GlobalMaterialLocalizations -// would reject it as an invalid ISO 639-1 code and crash the app on locale switch +// Flutter's Material delegates match on the language subtag, so "pt_BR" must be split into two Locale localeFromLanguageCode(String code) { final parts = code.split("_"); - if (parts.length == 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) { - return Locale(parts[0], parts[1]); - } - return Locale(code); + + return parts.length == 2 ? Locale(parts[0], parts[1]) : Locale(code); } class LanguageService { diff --git a/lib/main.dart b/lib/main.dart index eeb9e0394..300fb19d4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -65,6 +65,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_daemon/flutter_daemon.dart'; +import "package:flutter_localizations/flutter_localizations.dart"; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:hive/hive.dart'; import 'package:quick_actions/quick_actions.dart'; @@ -329,6 +330,7 @@ Future initialSetup({ required int initialMigrationVersion, }) async { LanguageService.loadLocaleList(); + await GlobalMaterialLocalizations.delegate.load(const Locale("en")); await defaultSettingsMigration( secureStorage: secureStorage, version: initialMigrationVersion, diff --git a/lib/src/widgets/new_list_row/list_item_selector_widget.dart b/lib/src/widgets/new_list_row/list_item_selector_widget.dart index fc7071445..7989fdbd3 100644 --- a/lib/src/widgets/new_list_row/list_item_selector_widget.dart +++ b/lib/src/widgets/new_list_row/list_item_selector_widget.dart @@ -32,30 +32,35 @@ class ListItemSelectorWidget extends StatelessWidget { onTap: onTap, isFirstInSection: isFirstInSection, isLastInSection: isLastInSection, - builder: (context, textStyle, labelStyle) { - return Row( + builder: (context, textStyle, labelStyle) => Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible(child: Text(label, style: textStyle)), - Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: Row( - spacing: 8, - children: [ - Text( - options[selectedIndex], - style: labelStyle, - ), - CakeImageWidget( - imageUrl: "assets/new-ui/chooser.svg", - colorFilter: - ColorFilter.mode(theme.colorScheme.onSurfaceVariant, BlendMode.srcIn), - ), - ], + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + Flexible( + child: Text( + options[selectedIndex], + style: labelStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + CakeImageWidget( + imageUrl: "assets/new-ui/chooser.svg", + colorFilter: + ColorFilter.mode(theme.colorScheme.onSurfaceVariant, BlendMode.srcIn), + ), + ], + ), ), ), ], - ); - }); + )); } } diff --git a/lib/store/settings_store.dart b/lib/store/settings_store.dart index f845cf86a..bbbffa0c0 100644 --- a/lib/store/settings_store.dart +++ b/lib/store/settings_store.dart @@ -1378,8 +1378,8 @@ abstract class SettingsStoreBase with Store { pinLength = defaultPinLength; } - final savedLanguageCode = sharedPreferences.getString(PreferencesKey.currentLanguageCode) ?? - await LanguageService.localeDetection(); + final savedLanguageCode = + _offeredLanguageCode(sharedPreferences) ?? await LanguageService.localeDetection(); final nodeId = sharedPreferences.getInt(PreferencesKey.currentNodeIdKey); final bitcoinElectrumServerId = sharedPreferences.getInt(PreferencesKey.currentBitcoinElectrumSererIdKey); @@ -1938,7 +1938,12 @@ abstract class SettingsStoreBase with Store { } pinCodeLength = pinLength; - languageCode = sharedPreferences.getString(PreferencesKey.currentLanguageCode) ?? languageCode; + final restoredLanguageCode = _offeredLanguageCode(sharedPreferences); + if (restoredLanguageCode == null) { + await sharedPreferences.setString(PreferencesKey.currentLanguageCode, languageCode); + } else { + languageCode = restoredLanguageCode; + } shouldShowYatPopup = sharedPreferences.getBool(PreferencesKey.shouldShowYatPopup) ?? shouldShowYatPopup; shouldShowDEuroDisclaimer = @@ -2315,6 +2320,12 @@ abstract class SettingsStoreBase with Store { await _sharedPreferences.setString(key, serializedData); } + static String? _offeredLanguageCode(SharedPreferences sharedPreferences) { + final code = sharedPreferences.getString(PreferencesKey.currentLanguageCode); + + return LanguageService.list.containsKey(code) ? code : null; + } + static Future _getDeviceName() async { String? deviceName = ''; final deviceInfoPlugin = DeviceInfoPlugin(); diff --git a/tool/generate_localization.dart b/tool/generate_localization.dart index 5bbf016a4..ed2a4679f 100644 --- a/tool/generate_localization.dart +++ b/tool/generate_localization.dart @@ -1,3 +1,4 @@ +import "dart:collection"; import 'dart:io'; import 'dart:convert'; @@ -48,7 +49,7 @@ Future main(List args) async { return; } - final arbFiles = {}; + final localePath = SplayTreeMap(); await dir.list(recursive: false).forEach((element) { // Parse the locale from the file name (e.g. strings_pt_br.arb -> pt_BR), // normalizing the case so keys match LanguageService.supportedLocales. @@ -62,12 +63,9 @@ Future main(List args) async { final locale = parts.length > 1 ? '${parts.first.toLowerCase()}_${parts.sublist(1).join('_').toUpperCase()}' : parts.first.toLowerCase(); - arbFiles[locale] = element.path; + localePath[locale] = element.path; }); - final sortedLocales = arbFiles.keys.toList()..sort(); - final localePath = {for (final key in sortedLocales) key: arbFiles[key]}; - if (!localePath.keys.contains(defaultLocale)) { print("Locale list doesn't contain $defaultLocale"); return; @@ -105,13 +103,13 @@ Future main(List args) async { output += classDeclaration; - // for handling keys like pt_BR so entry becomes Locale("pt", "BR") and doesn't crash app + // Flutter matches on the language subtag, so pt_BR is emitted as Locale("pt", "BR") localePath.keys.forEach((key) { final parts = key.split("_"); if (parts.length == 2) { - output += " Locale(\"${parts[0]}\", \"${parts[1]}\"),\n"; + output += ' Locale("${parts[0]}", "${parts[1]}"),\n'; } else { - output += " Locale(\"$key\", \"\"),\n"; + output += ' Locale("$key", ""),\n'; } });