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 11c892df3..6da74346a 100644 --- a/lib/entities/language_service.dart +++ b/lib/entities/language_service.dart @@ -1,7 +1,15 @@ import 'package:cake_wallet/generated/locales.dart'; import 'package:devicelocale/devicelocale.dart'; +import "package:flutter/widgets.dart"; import 'package:intl/intl.dart'; +// 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("_"); + + return parts.length == 2 ? Locale(parts[0], parts[1]) : Locale(code); +} + class LanguageService { static const Map supportedLocales = { 'en': 'English', @@ -85,12 +93,16 @@ class LanguageService { static Future localeDetection() async { try { - var locale = await Devicelocale.currentLocale ?? ''; - locale = Intl.shortLocale(locale); - + final locale = Intl.canonicalizedLocale(await Devicelocale.currentLocale ?? ""); if (list.keys.contains(locale)) { return locale; } + + final language = Intl.shortLocale(locale); + if (list.keys.contains(language)) { + return language; + } + return LanguageService.defaultLocale; } catch (_) { return LanguageService.defaultLocale; diff --git a/lib/main.dart b/lib/main.dart index 28fed70d6..53268a304 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, @@ -426,7 +428,7 @@ class AppState extends State with SingleTickerProviderStateMixin { themeMode: themeMode, localizationsDelegates: localizationDelegates, supportedLocales: S.delegate.supportedLocales, - locale: Locale(appStore.settingsStore.languageCode), + locale: localeFromLanguageCode(appStore.settingsStore.languageCode), onGenerateRoute: (settings) => Router.createRoute(settings), initialRoute: initialRoute, scrollBehavior: AppScrollBehavior(), 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 719cf4c5c..ed2a4679f 100644 --- a/tool/generate_localization.dart +++ b/tool/generate_localization.dart @@ -1,6 +1,6 @@ +import "dart:collection"; import 'dart:io'; import 'dart:convert'; -import './print_verbose_dummy.dart'; import 'localization/localization_constants.dart'; import 'utils/utils.dart'; @@ -49,7 +49,7 @@ Future main(List args) async { return; } - final localePath = {}; + 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. @@ -103,8 +103,14 @@ Future main(List args) async { output += classDeclaration; + // Flutter matches on the language subtag, so pt_BR is emitted as Locale("pt", "BR") localePath.keys.forEach((key) { - output += ' Locale("$key", ""),' + '\n'; + final parts = key.split("_"); + if (parts.length == 2) { + output += ' Locale("${parts[0]}", "${parts[1]}"),\n'; + } else { + output += ' Locale("$key", ""),\n'; + } }); output += part2;