@tduncan / Cortana / commits / 0cbc0a5

fix: correctly resolve Traditional Chinese locale (#33)

Co-authored-by: abdulmominsakib <mdmomin322@gmail.com>

startgo committed Jul 10, 2026 at 21:36 UTC 0cbc0a5f96f7a2ce483ea3c39194b9bb04c63f0a
5 files changed +164 -66
lib/app.dart
+12 -24
@@ -9,9 +9,9 @@ import 'core/models/enums.dart';
9 import 'core/providers/app_providers.dart';
10 import 'core/routes/app_routes.dart';
11 import 'core/theme/app_theme.dart';
12 +import 'core/utils/locale_utils.dart';
13 import 'features/chat/providers/chat_providers.dart';
13 -import 'features/conversations/providers/conversation_providers.dart'
14 - as conv;
14 +import 'features/conversations/providers/conversation_providers.dart' as conv;
15 import 'features/chat/views/chat_screen.dart';
16 import 'features/conversations/views/chat_history_screen.dart';
17 import 'features/mcp/views/mcp_tools_screen.dart';
@@ -189,6 +189,10 @@ class App extends ConsumerWidget {
189 final router = ref.watch(routerProvider);
190 final appThemeType = ref.watch(themeModeProvider);
191 final localeCode = ref.watch(settingsProvider.select((s) => s.localeCode));
192 + final selectedLocale = findSupportedLocale(
193 + localeCode,
194 + AppLocalizations.supportedLocales,
195 + );
196
197 ThemeData theme = AppTheme.lightTheme;
198 ThemeData darkTheme = AppTheme.darkTheme;
@@ -238,22 +242,11 @@ class App extends ConsumerWidget {
242 GlobalWidgetsLocalizations.delegate,
243 GlobalCupertinoLocalizations.delegate,
244 ],
241 - supportedLocales: const [
242 - Locale('en'),
243 - Locale('ar'),
244 - Locale('bn'),
245 - Locale('zh'),
246 - Locale('es'),
247 - Locale('hi'),
248 - Locale('it'),
249 - Locale('ja'),
250 - Locale('ru'),
251 - ],
252 - locale: localeCode != null ? Locale(localeCode) : null,
245 + supportedLocales: AppLocalizations.supportedLocales,
246 + locale: selectedLocale,
247 localeResolutionCallback: (locale, supportedLocales) {
254 - if (localeCode != null) {
255 - final langLocale = Locale(localeCode);
256 - if (supportedLocales.contains(langLocale)) return langLocale;
248 + if (selectedLocale != null) {
249 + return selectedLocale;
250 }
251 for (final l in supportedLocales) {
252 if (l.languageCode == locale?.languageCode) return l;
@@ -323,18 +316,13 @@ class AppShell extends ConsumerWidget {
316 ? const Color(0xFF1A1A1A)
317 : const Color(0xFFE5E5E5),
318 ),
326 - Expanded(
327 - child: child,
328 - ),
319 + Expanded(child: child),
320 ],
321 ),
322 );
323 }
324
334 - return Scaffold(
335 - body: child,
336 - drawer: const ConversationDrawer(),
337 - );
325 + return Scaffold(body: child, drawer: const ConversationDrawer());
326 },
327 ),
328 );
lib/bootstrap/bootstrap_host.dart
+6 -2
@@ -14,6 +14,7 @@ import '../core/models/enums.dart';
14 import '../features/servers/data/models/server.dart';
15 import '../features/servers/providers/server_providers.dart';
16 import '../core/logger/app_logger.dart';
17 +import '../core/utils/locale_utils.dart';
18 import 'bootstrap_screen.dart';
19 import 'bootstrap_state.dart';
20
@@ -55,7 +56,7 @@ class _BootstrapHostState extends State<BootstrapHost> {
56 if (settingsJson != null) {
57 final settings = json.decode(settingsJson) as Map<String, dynamic>;
58 final code = settings['localeCode'] as String?;
58 - if (code != null) _savedLocale = Locale(code);
59 + _savedLocale = parseLocaleCode(code);
60 }
61 } catch (_) {}
62
@@ -66,7 +67,10 @@ class _BootstrapHostState extends State<BootstrapHost> {
67 ],
68 );
69
69 - _updateStage(BootstrapStage.initializingServices, 'Initializing services...');
70 + _updateStage(
71 + BootstrapStage.initializingServices,
72 + 'Initializing services...',
73 + );
74
75 final hfToken = container.read(
76 settingsProvider.select((s) => s.huggingFaceToken),
lib/core/utils/locale_utils.dart new
+62
@@ -0,0 +1,62 @@
1 +import 'package:flutter/material.dart';
2 +
3 +Locale? parseLocaleCode(String? localeCode) {
4 + if (localeCode == null) {
5 + return null;
6 + }
7 +
8 + final normalized = localeCode.trim().replaceAll('-', '_');
9 + if (normalized.isEmpty) {
10 + return null;
11 + }
12 +
13 + final parts = normalized.split('_');
14 + final languageCode = parts.first.toLowerCase();
15 +
16 + if (parts.length == 1) {
17 + return Locale(languageCode);
18 + }
19 +
20 + final secondPart = parts[1];
21 + final scriptCode = secondPart.length == 4 ? secondPart : null;
22 + final countryCode = scriptCode == null ? secondPart.toUpperCase() : null;
23 + final resolvedCountryCode = parts.length > 2
24 + ? parts[2].toUpperCase()
25 + : countryCode;
26 +
27 + return Locale.fromSubtags(
28 + languageCode: languageCode,
29 + scriptCode: scriptCode,
30 + countryCode: resolvedCountryCode,
31 + );
32 +}
33 +
34 +Locale? findSupportedLocale(
35 + String? localeCode,
36 + Iterable<Locale> supportedLocales,
37 +) {
38 + final parsedLocale = parseLocaleCode(localeCode);
39 + if (parsedLocale == null) {
40 + return null;
41 + }
42 +
43 + for (final locale in supportedLocales) {
44 + if (_sameLocale(locale, parsedLocale)) {
45 + return locale;
46 + }
47 + }
48 +
49 + for (final locale in supportedLocales) {
50 + if (locale.languageCode == parsedLocale.languageCode) {
51 + return locale;
52 + }
53 + }
54 +
55 + return null;
56 +}
57 +
58 +bool _sameLocale(Locale a, Locale b) {
59 + return a.languageCode == b.languageCode &&
60 + (a.scriptCode ?? '') == (b.scriptCode ?? '') &&
61 + (a.countryCode ?? '') == (b.countryCode ?? '');
62 +}
lib/features/onboarding/screens/onboarding_language_screen.dart
+51 -40
@@ -72,6 +72,16 @@ const _languages = [
72 shortText: 'Zh',
73 gradient: [Color(0xFFEF4444), Color(0xFFDC2626)],
74 ),
75 + _LanguageOption(
76 + code: 'zh_TW',
77 + nativeName: '繁體中文',
78 + englishName: 'Traditional Chinese',
79 + flag: '🇹🇼',
80 + flagAsset: 'assets/images/flag_tw.png',
81 + countryCode: 'TW',
82 + shortText: 'Zh-TW',
83 + gradient: [Color(0xFF2563EB), Color(0xFF1D4ED8)],
84 + ),
85 _LanguageOption(
86 code: 'ar',
87 nativeName: 'العربية',
@@ -153,7 +163,6 @@ class _OnboardingLanguageScreenState
163 final l10n = AppLocalizations.of(context)!;
164 final theme = Theme.of(context);
165
156 -
166 return Scaffold(
167 appBar: AppBar(
168 title: Row(
@@ -198,10 +207,7 @@ class _OnboardingLanguageScreenState
207 children: [
208 const SizedBox(height: 24),
209 Actor(
201 - acts: [
202 - .fadeIn(),
203 - .slideY(from: 0.08),
204 - ],
210 + acts: [.fadeIn(), .slideY(from: 0.08)],
211 child: Column(
212 crossAxisAlignment: CrossAxisAlignment.stretch,
213 children: [
@@ -227,7 +233,9 @@ class _OnboardingLanguageScreenState
233 l10n.onboarding_choose_language_desc,
234 textAlign: TextAlign.center,
235 style: theme.textTheme.bodySmall?.copyWith(
230 - color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
236 + color: theme.colorScheme.onSurface.withValues(
237 + alpha: 0.7,
238 + ),
239 height: 1.5,
240 ),
241 ),
@@ -237,15 +245,13 @@ class _OnboardingLanguageScreenState
245 const SizedBox(height: 32),
246 Actor(
247 delay: 60.ms,
240 - acts: [
241 - .fadeIn(),
242 - .slideY(from: 0.08),
243 - ],
248 + acts: [.fadeIn(), .slideY(from: 0.08)],
249 child: ListView.separated(
250 shrinkWrap: true,
251 physics: const NeverScrollableScrollPhysics(),
252 itemCount: _languages.length,
248 - separatorBuilder: (context, index) => const SizedBox(height: 12),
253 + separatorBuilder: (context, index) =>
254 + const SizedBox(height: 12),
255 itemBuilder: (context, index) {
256 final lang = _languages[index];
257 final isSelected = _selectedCode == lang.code;
@@ -272,8 +278,9 @@ class _OnboardingLanguageScreenState
278 border: Border.all(
279 color: isSelected
280 ? theme.colorScheme.primary
275 - : theme.colorScheme.outline
276 - .withValues(alpha: 0.15),
281 + : theme.colorScheme.outline.withValues(
282 + alpha: 0.15,
283 + ),
284 width: isSelected ? 2 : 1,
285 ),
286 boxShadow: isSelected
@@ -283,7 +290,7 @@ class _OnboardingLanguageScreenState
290 .withValues(alpha: 0.1),
291 blurRadius: 8,
292 offset: const Offset(0, 2),
286 - )
293 + ),
294 ]
295 : null,
296 ),
@@ -302,7 +309,9 @@ class _OnboardingLanguageScreenState
309 ),
310 boxShadow: [
311 BoxShadow(
305 - color: Colors.black.withValues(alpha: 0.05),
312 + color: Colors.black.withValues(
313 + alpha: 0.05,
314 + ),
315 blurRadius: 4,
316 offset: const Offset(0, 2),
317 ),
@@ -316,35 +325,42 @@ class _OnboardingLanguageScreenState
325 width: 48,
326 height: 32,
327 fit: BoxFit.cover,
319 - errorBuilder: (context, error, stackTrace) {
320 - return Text(
321 - lang.flag,
322 - style: const TextStyle(fontSize: 18),
323 - );
324 - },
328 + errorBuilder:
329 + (context, error, stackTrace) {
330 + return Text(
331 + lang.flag,
332 + style: const TextStyle(
333 + fontSize: 18,
334 + ),
335 + );
336 + },
337 ),
338 ),
339 ),
340 const SizedBox(width: 16),
341 Expanded(
342 child: Column(
331 - crossAxisAlignment: CrossAxisAlignment.start,
343 + crossAxisAlignment:
344 + CrossAxisAlignment.start,
345 mainAxisSize: MainAxisSize.min,
346 children: [
347 Text(
348 lang.nativeName,
336 - style:
337 - theme.textTheme.titleMedium?.copyWith(
338 - fontWeight: FontWeight.bold,
339 - ),
349 + style: theme.textTheme.titleMedium
350 + ?.copyWith(
351 + fontWeight: FontWeight.bold,
352 + ),
353 ),
354 const SizedBox(height: 2),
355 Text(
356 '${lang.englishName} • ${lang.shortText.toUpperCase()}',
344 - style: theme.textTheme.labelSmall?.copyWith(
345 - color: theme.colorScheme.onSurface
346 - .withValues(alpha: 0.5),
347 - ),
357 + style: theme.textTheme.labelSmall
358 + ?.copyWith(
359 + color: theme
360 + .colorScheme
361 + .onSurface
362 + .withValues(alpha: 0.5),
363 + ),
364 ),
365 ],
366 ),
@@ -363,13 +379,13 @@ class _OnboardingLanguageScreenState
379 color: isSelected
380 ? theme.colorScheme.primary
381 : theme.colorScheme.outline
366 - .withValues(alpha: 0.3),
382 + .withValues(alpha: 0.3),
383 width: 2,
384 ),
385 ),
386 child: isSelected
371 - ? const HugeIcon(icon:
372 - HugeIcons.strokeRoundedTick01,
387 + ? const HugeIcon(
388 + icon: HugeIcons.strokeRoundedTick01,
389 color: Colors.white,
390 size: 14,
391 )
@@ -389,10 +405,7 @@ class _OnboardingLanguageScreenState
405 ),
406 Actor(
407 delay: 120.ms,
392 - acts: [
393 - .fadeIn(),
394 - .slideY(from: 0.08),
395 - ],
408 + acts: [.fadeIn(), .slideY(from: 0.08)],
409 child: Padding(
410 padding: const EdgeInsets.symmetric(horizontal: 24.0),
411 child: Column(
@@ -435,9 +448,7 @@ class _OnboardingLanguageScreenState
448 ),
449 ),
450 ),
438 - SizedBox(
439 - height: MediaQuery.of(context).padding.bottom,
440 - ),
451 + SizedBox(height: MediaQuery.of(context).padding.bottom),
452 ],
453 ),
454 ),
test/core/utils/locale_utils_test.dart new
+33
@@ -0,0 +1,33 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_test/flutter_test.dart';
3 +import 'package:localmind/core/utils/locale_utils.dart';
4 +import 'package:localmind/l10n/app_localizations.dart';
5 +
6 +void main() {
7 + group('parseLocaleCode', () {
8 + test('parses language only locales', () {
9 + expect(parseLocaleCode('zh'), const Locale('zh'));
10 + });
11 +
12 + test('parses locales with country codes', () {
13 + expect(parseLocaleCode('zh_TW'), const Locale('zh', 'TW'));
14 + expect(parseLocaleCode('zh-TW'), const Locale('zh', 'TW'));
15 + });
16 + });
17 +
18 + group('findSupportedLocale', () {
19 + test('matches Traditional Chinese exactly', () {
20 + expect(
21 + findSupportedLocale('zh_TW', AppLocalizations.supportedLocales),
22 + const Locale('zh', 'TW'),
23 + );
24 + });
25 +
26 + test('falls back to language-only locale when region is unsupported', () {
27 + expect(
28 + findSupportedLocale('zh_HK', AppLocalizations.supportedLocales),
29 + const Locale('zh'),
30 + );
31 + });
32 + });
33 +}