Merge pull request #167 from cogwheel0/localization-improve-locale-resolution
feat(localization): Improve locale resolution and add Chinese script variants
This commit is contained in:
@@ -105,14 +105,40 @@ class AppLocale extends _$AppLocale {
|
||||
_storage = ref.watch(optimizedStorageServiceProvider);
|
||||
final code = _storage.getLocaleCode();
|
||||
if (code != null && code.isNotEmpty) {
|
||||
return Locale(code);
|
||||
final parsed = _parseLocaleCode(code);
|
||||
if (parsed != null) return parsed;
|
||||
}
|
||||
return null; // system default
|
||||
}
|
||||
|
||||
Future<void> setLocale(Locale? locale) async {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
return l10n.currentlyUsingLightTheme;
|
||||
}();
|
||||
final locale = ref.watch(appLocaleProvider);
|
||||
final currentLanguageCode = locale?.languageCode ?? 'system';
|
||||
final currentLanguageCode = locale?.toLanguageTag() ?? 'system';
|
||||
final languageLabel = _resolveLanguageLabel(context, currentLanguageCode);
|
||||
final activeTheme = ref.watch(appThemePaletteProvider);
|
||||
|
||||
@@ -198,7 +198,7 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
Widget _buildLanguageSection(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String currentLanguageCode,
|
||||
String currentLanguageTag,
|
||||
String languageLabel,
|
||||
) {
|
||||
final theme = context.conduitTheme;
|
||||
@@ -220,15 +220,16 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
onTap: () async {
|
||||
final selected = await _showLanguageSelector(
|
||||
context,
|
||||
currentLanguageCode,
|
||||
currentLanguageTag,
|
||||
);
|
||||
if (selected == null) return;
|
||||
if (selected == 'system') {
|
||||
await ref.read(appLocaleProvider.notifier).setLocale(null);
|
||||
} else {
|
||||
final parsed = _parseLocaleTag(selected);
|
||||
await ref
|
||||
.read(appLocaleProvider.notifier)
|
||||
.setLocale(Locale(selected));
|
||||
.setLocale(parsed ?? Locale(selected));
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -1741,6 +1742,8 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
}
|
||||
|
||||
String _resolveLanguageLabel(BuildContext context, String code) {
|
||||
final normalizedCode = code.replaceAll('_', '-');
|
||||
|
||||
switch (code) {
|
||||
case 'en':
|
||||
return AppLocalizations.of(context)!.english;
|
||||
@@ -1757,10 +1760,21 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
case 'ru':
|
||||
return AppLocalizations.of(context)!.russian;
|
||||
case 'zh':
|
||||
return AppLocalizations.of(context)!.chinese;
|
||||
return AppLocalizations.of(context)!.chineseSimplified;
|
||||
case 'ko':
|
||||
return AppLocalizations.of(context)!.korean;
|
||||
case 'zh-Hant':
|
||||
return AppLocalizations.of(context)!.chineseTraditional;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1929,6 +1943,8 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
}
|
||||
|
||||
Future<String?> _showLanguageSelector(BuildContext context, String current) {
|
||||
final normalizedCurrent = current.replaceAll('_', '-');
|
||||
|
||||
return showModalBottomSheet<String>(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
@@ -1949,52 +1965,79 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
const SizedBox(height: Spacing.sm),
|
||||
ListTile(
|
||||
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'),
|
||||
),
|
||||
ListTile(
|
||||
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'),
|
||||
),
|
||||
ListTile(
|
||||
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'),
|
||||
),
|
||||
ListTile(
|
||||
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'),
|
||||
),
|
||||
ListTile(
|
||||
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'),
|
||||
),
|
||||
ListTile(
|
||||
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'),
|
||||
),
|
||||
ListTile(
|
||||
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'),
|
||||
),
|
||||
ListTile(
|
||||
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'),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(AppLocalizations.of(context)!.chinese),
|
||||
trailing: current == 'zh' ? const Icon(Icons.check) : null,
|
||||
title: Text(AppLocalizations.of(context)!.chineseSimplified),
|
||||
trailing: normalizedCurrent == 'zh'
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
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(
|
||||
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'),
|
||||
),
|
||||
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 {
|
||||
const _PaletteOption({
|
||||
required this.themeDefinition,
|
||||
|
||||
@@ -178,6 +178,8 @@
|
||||
"nederlands": "Niederländisch",
|
||||
"russian": "Russisch",
|
||||
"chinese": "Chinesisch",
|
||||
"chineseSimplified": "Chinesisch (Vereinfacht)",
|
||||
"chineseTraditional": "Chinesisch (Traditionell)",
|
||||
"korean": "Koreanisch",
|
||||
"deleteMessagesTitle": "Nachrichten löschen",
|
||||
"deleteMessagesMessage": "{count} Nachrichten löschen?",
|
||||
|
||||
@@ -862,6 +862,14 @@
|
||||
"@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": {
|
||||
"description": "Language name: Korean."
|
||||
|
||||
@@ -178,6 +178,8 @@
|
||||
"nederlands": "Nederlands",
|
||||
"russian": "Русский",
|
||||
"chinese": "中文",
|
||||
"chineseSimplified": "Chino (simplificado)",
|
||||
"chineseTraditional": "Chino (tradicional)",
|
||||
"korean": "한국어",
|
||||
"deleteMessagesTitle": "Eliminar mensajes",
|
||||
"deleteMessagesMessage": "¿Eliminar {count} mensajes?",
|
||||
|
||||
@@ -178,6 +178,8 @@
|
||||
"nederlands": "Néerlandais",
|
||||
"russian": "Russe",
|
||||
"chinese": "Chinois",
|
||||
"chineseSimplified": "Chinois (simplifié)",
|
||||
"chineseTraditional": "Chinois (traditionnel)",
|
||||
"korean": "Coréen",
|
||||
"deleteMessagesTitle": "Supprimer les messages",
|
||||
"deleteMessagesMessage": "Supprimer {count} messages ?",
|
||||
|
||||
@@ -178,6 +178,8 @@
|
||||
"nederlands": "Olandese",
|
||||
"russian": "Russo",
|
||||
"chinese": "Cinese",
|
||||
"chineseSimplified": "Cinese (semplificato)",
|
||||
"chineseTraditional": "Cinese (tradizionale)",
|
||||
"korean": "Coreano",
|
||||
"deleteMessagesTitle": "Elimina messaggi",
|
||||
"deleteMessagesMessage": "Eliminare {count} messaggi?",
|
||||
|
||||
@@ -286,6 +286,8 @@
|
||||
"nederlands": "Nederlands",
|
||||
"russian": "Русский",
|
||||
"chinese": "中文",
|
||||
"chineseSimplified": "중국어(간체)",
|
||||
"chineseTraditional": "중국어(번체)",
|
||||
"korean": "한국어",
|
||||
"deleteMessagesTitle": "메시지 삭제",
|
||||
"deleteMessagesMessage": "{count}개의 메시지를 삭제하시겠습니까?",
|
||||
|
||||
@@ -178,6 +178,8 @@
|
||||
"nederlands": "Nederlands",
|
||||
"russian": "Русский",
|
||||
"chinese": "中文",
|
||||
"chineseSimplified": "Chinees (vereenvoudigd)",
|
||||
"chineseTraditional": "Chinees (traditioneel)",
|
||||
"korean": "한국어",
|
||||
"deleteMessagesTitle": "Berichten verwijderen",
|
||||
"deleteMessagesMessage": "{count} berichten verwijderen?",
|
||||
|
||||
@@ -178,6 +178,8 @@
|
||||
"nederlands": "Nederlands",
|
||||
"russian": "Русский",
|
||||
"chinese": "中文",
|
||||
"chineseSimplified": "Китайский (упрощённый)",
|
||||
"chineseTraditional": "Китайский (традиционный)",
|
||||
"korean": "한국어",
|
||||
"deleteMessagesTitle": "Удалить сообщения",
|
||||
"deleteMessagesMessage": "Удалить {count, plural, one{{count} сообщение} few{{count} сообщения} other{{count} сообщений}}?",
|
||||
|
||||
@@ -178,6 +178,8 @@
|
||||
"nederlands": "Nederlands",
|
||||
"russian": "Русский",
|
||||
"chinese": "中文",
|
||||
"chineseSimplified": "简体中文",
|
||||
"chineseTraditional": "繁體中文",
|
||||
"korean": "한국어",
|
||||
"deleteMessagesTitle": "删除消息",
|
||||
"deleteMessagesMessage": "删除 {count} 条消息?",
|
||||
|
||||
@@ -178,6 +178,8 @@
|
||||
"nederlands": "Nederlands",
|
||||
"russian": "Русский",
|
||||
"chinese": "中文",
|
||||
"chineseSimplified": "簡體中文",
|
||||
"chineseTraditional": "繁體中文",
|
||||
"korean": "한국어",
|
||||
"deleteMessagesTitle": "刪除消息",
|
||||
"deleteMessagesMessage": "刪除 {count} 條消息?",
|
||||
|
||||
@@ -189,12 +189,8 @@ class _ConduitAppState extends ConsumerState<ConduitApp> {
|
||||
if (deviceLocales == null || deviceLocales.isEmpty) {
|
||||
return supported.first;
|
||||
}
|
||||
for (final device in deviceLocales) {
|
||||
for (final loc in supported) {
|
||||
if (loc.languageCode == device.languageCode) return loc;
|
||||
}
|
||||
}
|
||||
return supported.first;
|
||||
final resolved = _resolveSupportedLocale(deviceLocales, supported);
|
||||
return resolved ?? supported.first;
|
||||
},
|
||||
builder: (context, child) {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user