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
+}