feat(localization): Improve locale resolution and add Chinese script variants

This commit is contained in:
cogwheel0
2025-11-24 16:08:55 +05:30
parent aadabf90ae
commit 6e8a19371c
13 changed files with 207 additions and 24 deletions

View File

@@ -105,14 +105,40 @@ class AppLocale extends _$AppLocale {
_storage = ref.watch(optimizedStorageServiceProvider); _storage = ref.watch(optimizedStorageServiceProvider);
final code = _storage.getLocaleCode(); final code = _storage.getLocaleCode();
if (code != null && code.isNotEmpty) { if (code != null && code.isNotEmpty) {
return Locale(code); final parsed = _parseLocaleCode(code);
if (parsed != null) return parsed;
} }
return null; // system default return null; // system default
} }
Future<void> setLocale(Locale? locale) async { Future<void> setLocale(Locale? locale) async {
state = locale; state = locale;
await _storage.setLocaleCode(locale?.languageCode); await _storage.setLocaleCode(locale?.toLanguageTag());
}
Locale? _parseLocaleCode(String code) {
final normalized = code.replaceAll('_', '-');
final parts = normalized.split('-');
if (parts.isEmpty || parts.first.isEmpty) return null;
final language = parts.first;
String? script;
String? country;
for (var i = 1; i < parts.length; i++) {
final part = parts[i];
if (part.length == 4) {
script = '${part[0].toUpperCase()}${part.substring(1).toLowerCase()}';
} else if (part.length == 2 || part.length == 3) {
country = part.toUpperCase();
}
}
return Locale.fromSubtags(
languageCode: language,
scriptCode: script,
countryCode: country,
);
} }
} }

View File

@@ -38,7 +38,7 @@ class AppCustomizationPage extends ConsumerWidget {
return l10n.currentlyUsingLightTheme; return l10n.currentlyUsingLightTheme;
}(); }();
final locale = ref.watch(appLocaleProvider); final locale = ref.watch(appLocaleProvider);
final currentLanguageCode = locale?.languageCode ?? 'system'; final currentLanguageCode = locale?.toLanguageTag() ?? 'system';
final languageLabel = _resolveLanguageLabel(context, currentLanguageCode); final languageLabel = _resolveLanguageLabel(context, currentLanguageCode);
final activeTheme = ref.watch(appThemePaletteProvider); final activeTheme = ref.watch(appThemePaletteProvider);
@@ -198,7 +198,7 @@ class AppCustomizationPage extends ConsumerWidget {
Widget _buildLanguageSection( Widget _buildLanguageSection(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
String currentLanguageCode, String currentLanguageTag,
String languageLabel, String languageLabel,
) { ) {
final theme = context.conduitTheme; final theme = context.conduitTheme;
@@ -220,15 +220,16 @@ class AppCustomizationPage extends ConsumerWidget {
onTap: () async { onTap: () async {
final selected = await _showLanguageSelector( final selected = await _showLanguageSelector(
context, context,
currentLanguageCode, currentLanguageTag,
); );
if (selected == null) return; if (selected == null) return;
if (selected == 'system') { if (selected == 'system') {
await ref.read(appLocaleProvider.notifier).setLocale(null); await ref.read(appLocaleProvider.notifier).setLocale(null);
} else { } else {
final parsed = _parseLocaleTag(selected);
await ref await ref
.read(appLocaleProvider.notifier) .read(appLocaleProvider.notifier)
.setLocale(Locale(selected)); .setLocale(parsed ?? Locale(selected));
} }
}, },
), ),
@@ -1741,6 +1742,8 @@ class AppCustomizationPage extends ConsumerWidget {
} }
String _resolveLanguageLabel(BuildContext context, String code) { String _resolveLanguageLabel(BuildContext context, String code) {
final normalizedCode = code.replaceAll('_', '-');
switch (code) { switch (code) {
case 'en': case 'en':
return AppLocalizations.of(context)!.english; return AppLocalizations.of(context)!.english;
@@ -1757,10 +1760,21 @@ class AppCustomizationPage extends ConsumerWidget {
case 'ru': case 'ru':
return AppLocalizations.of(context)!.russian; return AppLocalizations.of(context)!.russian;
case 'zh': case 'zh':
return AppLocalizations.of(context)!.chinese; return AppLocalizations.of(context)!.chineseSimplified;
case 'ko': case 'ko':
return AppLocalizations.of(context)!.korean; return AppLocalizations.of(context)!.korean;
case 'zh-Hant':
return AppLocalizations.of(context)!.chineseTraditional;
default: default:
if (normalizedCode == 'zh-hant') {
return AppLocalizations.of(context)!.chineseTraditional;
}
if (normalizedCode == 'zh') {
return AppLocalizations.of(context)!.chineseSimplified;
}
if (normalizedCode == 'ko') {
return AppLocalizations.of(context)!.korean;
}
return AppLocalizations.of(context)!.system; return AppLocalizations.of(context)!.system;
} }
} }
@@ -1929,6 +1943,8 @@ class AppCustomizationPage extends ConsumerWidget {
} }
Future<String?> _showLanguageSelector(BuildContext context, String current) { Future<String?> _showLanguageSelector(BuildContext context, String current) {
final normalizedCurrent = current.replaceAll('_', '-');
return showModalBottomSheet<String>( return showModalBottomSheet<String>(
context: context, context: context,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
@@ -1949,52 +1965,79 @@ class AppCustomizationPage extends ConsumerWidget {
const SizedBox(height: Spacing.sm), const SizedBox(height: Spacing.sm),
ListTile( ListTile(
title: Text(AppLocalizations.of(context)!.system), title: Text(AppLocalizations.of(context)!.system),
trailing: current == 'system' ? const Icon(Icons.check) : null, trailing: normalizedCurrent == 'system'
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(context, 'system'), onTap: () => Navigator.pop(context, 'system'),
), ),
ListTile( ListTile(
title: Text(AppLocalizations.of(context)!.english), title: Text(AppLocalizations.of(context)!.english),
trailing: current == 'en' ? const Icon(Icons.check) : null, trailing: normalizedCurrent == 'en'
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(context, 'en'), onTap: () => Navigator.pop(context, 'en'),
), ),
ListTile( ListTile(
title: Text(AppLocalizations.of(context)!.deutsch), title: Text(AppLocalizations.of(context)!.deutsch),
trailing: current == 'de' ? const Icon(Icons.check) : null, trailing: normalizedCurrent == 'de'
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(context, 'de'), onTap: () => Navigator.pop(context, 'de'),
), ),
ListTile( ListTile(
title: Text(AppLocalizations.of(context)!.espanol), title: Text(AppLocalizations.of(context)!.espanol),
trailing: current == 'es' ? const Icon(Icons.check) : null, trailing: normalizedCurrent == 'es'
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(context, 'es'), onTap: () => Navigator.pop(context, 'es'),
), ),
ListTile( ListTile(
title: Text(AppLocalizations.of(context)!.francais), title: Text(AppLocalizations.of(context)!.francais),
trailing: current == 'fr' ? const Icon(Icons.check) : null, trailing: normalizedCurrent == 'fr'
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(context, 'fr'), onTap: () => Navigator.pop(context, 'fr'),
), ),
ListTile( ListTile(
title: Text(AppLocalizations.of(context)!.italiano), title: Text(AppLocalizations.of(context)!.italiano),
trailing: current == 'it' ? const Icon(Icons.check) : null, trailing: normalizedCurrent == 'it'
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(context, 'it'), onTap: () => Navigator.pop(context, 'it'),
), ),
ListTile( ListTile(
title: Text(AppLocalizations.of(context)!.nederlands), title: Text(AppLocalizations.of(context)!.nederlands),
trailing: current == 'nl' ? const Icon(Icons.check) : null, trailing: normalizedCurrent == 'nl'
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(context, 'nl'), onTap: () => Navigator.pop(context, 'nl'),
), ),
ListTile( ListTile(
title: Text(AppLocalizations.of(context)!.russian), title: Text(AppLocalizations.of(context)!.russian),
trailing: current == 'ru' ? const Icon(Icons.check) : null, trailing: normalizedCurrent == 'ru'
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(context, 'ru'), onTap: () => Navigator.pop(context, 'ru'),
), ),
ListTile( ListTile(
title: Text(AppLocalizations.of(context)!.chinese), title: Text(AppLocalizations.of(context)!.chineseSimplified),
trailing: current == 'zh' ? const Icon(Icons.check) : null, trailing: normalizedCurrent == 'zh'
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(context, 'zh'), onTap: () => Navigator.pop(context, 'zh'),
), ),
ListTile(
title: Text(AppLocalizations.of(context)!.chineseTraditional),
trailing: normalizedCurrent == 'zh-Hant'
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(context, 'zh-Hant'),
),
ListTile( ListTile(
title: Text(AppLocalizations.of(context)!.korean), title: Text(AppLocalizations.of(context)!.korean),
trailing: current == 'ko' ? const Icon(Icons.check) : null, trailing: normalizedCurrent == 'ko'
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(context, 'ko'), onTap: () => Navigator.pop(context, 'ko'),
), ),
const SizedBox(height: Spacing.sm), const SizedBox(height: Spacing.sm),
@@ -2006,6 +2049,31 @@ class AppCustomizationPage extends ConsumerWidget {
} }
} }
Locale? _parseLocaleTag(String code) {
final normalized = code.replaceAll('_', '-');
final parts = normalized.split('-');
if (parts.isEmpty || parts.first.isEmpty) return null;
final language = parts.first;
String? script;
String? country;
for (var i = 1; i < parts.length; i++) {
final part = parts[i];
if (part.length == 4) {
script = '${part[0].toUpperCase()}${part.substring(1).toLowerCase()}';
} else if (part.length == 2 || part.length == 3) {
country = part.toUpperCase();
}
}
return Locale.fromSubtags(
languageCode: language,
scriptCode: script,
countryCode: country,
);
}
class _PaletteOption extends StatelessWidget { class _PaletteOption extends StatelessWidget {
const _PaletteOption({ const _PaletteOption({
required this.themeDefinition, required this.themeDefinition,

View File

@@ -178,6 +178,8 @@
"nederlands": "Niederländisch", "nederlands": "Niederländisch",
"russian": "Russisch", "russian": "Russisch",
"chinese": "Chinesisch", "chinese": "Chinesisch",
"chineseSimplified": "Chinesisch (Vereinfacht)",
"chineseTraditional": "Chinesisch (Traditionell)",
"korean": "Koreanisch", "korean": "Koreanisch",
"deleteMessagesTitle": "Nachrichten löschen", "deleteMessagesTitle": "Nachrichten löschen",
"deleteMessagesMessage": "{count} Nachrichten löschen?", "deleteMessagesMessage": "{count} Nachrichten löschen?",

View File

@@ -862,6 +862,14 @@
"@chinese": { "@chinese": {
"description": "Language name: Chinese." "description": "Language name: Chinese."
}, },
"chineseSimplified": "Chinese (Simplified)",
"@chineseSimplified": {
"description": "Language name: Chinese (Simplified)."
},
"chineseTraditional": "Chinese (Traditional)",
"@chineseTraditional": {
"description": "Language name: Chinese (Traditional)."
},
"korean": "한국어", "korean": "한국어",
"@korean": { "@korean": {
"description": "Language name: Korean." "description": "Language name: Korean."

View File

@@ -178,6 +178,8 @@
"nederlands": "Nederlands", "nederlands": "Nederlands",
"russian": "Русский", "russian": "Русский",
"chinese": "中文", "chinese": "中文",
"chineseSimplified": "Chino (simplificado)",
"chineseTraditional": "Chino (tradicional)",
"korean": "한국어", "korean": "한국어",
"deleteMessagesTitle": "Eliminar mensajes", "deleteMessagesTitle": "Eliminar mensajes",
"deleteMessagesMessage": "¿Eliminar {count} mensajes?", "deleteMessagesMessage": "¿Eliminar {count} mensajes?",

View File

@@ -178,6 +178,8 @@
"nederlands": "Néerlandais", "nederlands": "Néerlandais",
"russian": "Russe", "russian": "Russe",
"chinese": "Chinois", "chinese": "Chinois",
"chineseSimplified": "Chinois (simplifié)",
"chineseTraditional": "Chinois (traditionnel)",
"korean": "Coréen", "korean": "Coréen",
"deleteMessagesTitle": "Supprimer les messages", "deleteMessagesTitle": "Supprimer les messages",
"deleteMessagesMessage": "Supprimer {count} messages ?", "deleteMessagesMessage": "Supprimer {count} messages ?",

View File

@@ -178,6 +178,8 @@
"nederlands": "Olandese", "nederlands": "Olandese",
"russian": "Russo", "russian": "Russo",
"chinese": "Cinese", "chinese": "Cinese",
"chineseSimplified": "Cinese (semplificato)",
"chineseTraditional": "Cinese (tradizionale)",
"korean": "Coreano", "korean": "Coreano",
"deleteMessagesTitle": "Elimina messaggi", "deleteMessagesTitle": "Elimina messaggi",
"deleteMessagesMessage": "Eliminare {count} messaggi?", "deleteMessagesMessage": "Eliminare {count} messaggi?",

View File

@@ -286,6 +286,8 @@
"nederlands": "Nederlands", "nederlands": "Nederlands",
"russian": "Русский", "russian": "Русский",
"chinese": "中文", "chinese": "中文",
"chineseSimplified": "중국어(간체)",
"chineseTraditional": "중국어(번체)",
"korean": "한국어", "korean": "한국어",
"deleteMessagesTitle": "메시지 삭제", "deleteMessagesTitle": "메시지 삭제",
"deleteMessagesMessage": "{count}개의 메시지를 삭제하시겠습니까?", "deleteMessagesMessage": "{count}개의 메시지를 삭제하시겠습니까?",

View File

@@ -178,6 +178,8 @@
"nederlands": "Nederlands", "nederlands": "Nederlands",
"russian": "Русский", "russian": "Русский",
"chinese": "中文", "chinese": "中文",
"chineseSimplified": "Chinees (vereenvoudigd)",
"chineseTraditional": "Chinees (traditioneel)",
"korean": "한국어", "korean": "한국어",
"deleteMessagesTitle": "Berichten verwijderen", "deleteMessagesTitle": "Berichten verwijderen",
"deleteMessagesMessage": "{count} berichten verwijderen?", "deleteMessagesMessage": "{count} berichten verwijderen?",

View File

@@ -178,6 +178,8 @@
"nederlands": "Nederlands", "nederlands": "Nederlands",
"russian": "Русский", "russian": "Русский",
"chinese": "中文", "chinese": "中文",
"chineseSimplified": "Китайский (упрощённый)",
"chineseTraditional": "Китайский (традиционный)",
"korean": "한국어", "korean": "한국어",
"deleteMessagesTitle": "Удалить сообщения", "deleteMessagesTitle": "Удалить сообщения",
"deleteMessagesMessage": "Удалить {count, plural, one{{count} сообщение} few{{count} сообщения} other{{count} сообщений}}?", "deleteMessagesMessage": "Удалить {count, plural, one{{count} сообщение} few{{count} сообщения} other{{count} сообщений}}?",

View File

@@ -178,6 +178,8 @@
"nederlands": "Nederlands", "nederlands": "Nederlands",
"russian": "Русский", "russian": "Русский",
"chinese": "中文", "chinese": "中文",
"chineseSimplified": "简体中文",
"chineseTraditional": "繁體中文",
"korean": "한국어", "korean": "한국어",
"deleteMessagesTitle": "删除消息", "deleteMessagesTitle": "删除消息",
"deleteMessagesMessage": "删除 {count} 条消息?", "deleteMessagesMessage": "删除 {count} 条消息?",

View File

@@ -178,6 +178,8 @@
"nederlands": "Nederlands", "nederlands": "Nederlands",
"russian": "Русский", "russian": "Русский",
"chinese": "中文", "chinese": "中文",
"chineseSimplified": "簡體中文",
"chineseTraditional": "繁體中文",
"korean": "한국어", "korean": "한국어",
"deleteMessagesTitle": "刪除消息", "deleteMessagesTitle": "刪除消息",
"deleteMessagesMessage": "刪除 {count} 條消息?", "deleteMessagesMessage": "刪除 {count} 條消息?",

View File

@@ -189,12 +189,8 @@ class _ConduitAppState extends ConsumerState<ConduitApp> {
if (deviceLocales == null || deviceLocales.isEmpty) { if (deviceLocales == null || deviceLocales.isEmpty) {
return supported.first; return supported.first;
} }
for (final device in deviceLocales) { final resolved = _resolveSupportedLocale(deviceLocales, supported);
for (final loc in supported) { return resolved ?? supported.first;
if (loc.languageCode == device.languageCode) return loc;
}
}
return supported.first;
}, },
builder: (context, child) { builder: (context, child) {
final brightness = Theme.of(context).brightness; final brightness = Theme.of(context).brightness;
@@ -221,6 +217,73 @@ class _ConduitAppState extends ConsumerState<ConduitApp> {
), ),
); );
} }
bool _prefersTraditionalChinese(Locale deviceLocale) {
final script = deviceLocale.scriptCode?.toLowerCase();
if (script == 'hant') return true;
final country = deviceLocale.countryCode?.toUpperCase();
return country == 'TW' || country == 'HK' || country == 'MO';
}
Locale? _resolveSupportedLocale(
List<Locale>? deviceLocales,
Iterable<Locale> supported,
) {
if (deviceLocales == null || deviceLocales.isEmpty) return null;
for (final device in deviceLocales) {
final prefersTraditional = _prefersTraditionalChinese(device);
final deviceLanguage = device.languageCode.toLowerCase();
final deviceScript = device.scriptCode?.toLowerCase();
final deviceCountry = device.countryCode?.toUpperCase();
// Pass 1: match language with script (or preferred Traditional)
for (final loc in supported) {
final languageMatches =
loc.languageCode.toLowerCase() == deviceLanguage;
if (!languageMatches) continue;
final locScript = loc.scriptCode?.toLowerCase();
final scriptMatches =
locScript != null &&
locScript.isNotEmpty &&
(locScript == deviceScript ||
(loc.languageCode == 'zh' &&
locScript == 'hant' &&
prefersTraditional));
if (!scriptMatches) continue;
final locCountry = loc.countryCode?.toUpperCase();
final countryMatches =
locCountry == null ||
locCountry.isEmpty ||
locCountry == deviceCountry;
if (countryMatches) {
return loc;
}
}
// Pass 2: prefer Traditional Chinese when applicable
if (prefersTraditional) {
for (final loc in supported) {
if (loc.languageCode == 'zh' && loc.scriptCode == 'Hant') {
return loc;
}
}
}
// Pass 3: language-only match
for (final loc in supported) {
if (loc.languageCode.toLowerCase() == deviceLanguage) {
return loc;
}
}
}
return null;
}
} }
/// Dismisses the soft keyboard whenever the user scrolls. /// Dismisses the soft keyboard whenever the user scrolls.