7 files changed
+1911
-1205
lib/features/servers/views/add_server_screen.dart
+414
-165
@@ -36,6 +36,8 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
36
String? _testResult;
37
38
bool get _isEditing => widget.editServer != null;
39
+ bool get _requiresEndpoint => _selectedType != ServerType.openRouter;
40
+ bool get _requiresMandatoryApiKey => _selectedType == ServerType.openRouter;
41
42
@override
43
void initState() {
@@ -192,7 +194,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
194
}
195
196
String? _validateHost(String? value) {
195
- if (_selectedType == ServerType.openRouter) return null;
197
+ if (!_requiresEndpoint) return null;
198
final l10n = AppLocalizations.of(context)!;
199
200
if (value == null || value.trim().isEmpty) {
@@ -208,7 +210,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
210
}
211
212
String? _validatePort(String? value) {
211
- if (_selectedType == ServerType.openRouter) return null;
213
+ if (!_requiresEndpoint) return null;
214
final l10n = AppLocalizations.of(context)!;
215
216
if (value == null || value.trim().isEmpty) {
@@ -222,7 +224,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
224
}
225
226
String? _validateApiKey(String? value) {
225
- if (_selectedType == ServerType.openRouter) {
227
+ if (_requiresMandatoryApiKey) {
228
final l10n = AppLocalizations.of(context)!;
229
if (value == null || value.trim().isEmpty) {
230
return l10n.api_key_required_openrouter;
@@ -254,6 +256,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
256
final l10n = AppLocalizations.of(context)!;
257
final theme = Theme.of(context);
258
final systemBottomInset = bottomSystemInset(context);
259
+ final colorScheme = theme.colorScheme;
260
261
return Scaffold(
262
appBar: AppBar(
@@ -263,202 +266,448 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
266
onPressed: () => context.pop(),
267
),
268
),
269
+ floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
270
+ floatingActionButton: _buildFloatingActionBar(
271
+ context,
272
+ bottomInset: systemBottomInset,
273
+ ),
274
body: Form(
275
key: _formKey,
276
child: ListView(
269
- padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + systemBottomInset),
277
+ padding: EdgeInsets.fromLTRB(16, 12, 16, 120 + systemBottomInset),
278
children: [
271
- Text(l10n.server_type_label, style: theme.textTheme.titleSmall),
272
- const SizedBox(height: 8),
273
- ServerTypeSelector(
274
- selectedType: _selectedType,
275
- onChanged: _onTypeChanged,
279
+ _buildHeaderCard(context),
280
+ const SizedBox(height: 14),
281
+ _buildSectionCard(
282
+ context,
283
+ title: l10n.server_type_label,
284
+ subtitle: 'Pick the provider before filling connection details.',
285
+ child: ServerTypeSelector(
286
+ selectedType: _selectedType,
287
+ onChanged: _onTypeChanged,
288
+ ),
289
),
277
- const SizedBox(height: 24),
278
-
279
- Text(l10n.server_icon_label, style: theme.textTheme.titleSmall),
280
- const SizedBox(height: 8),
281
- GestureDetector(
282
- onTap: _showIconPicker,
283
- child: Container(
284
- padding: const EdgeInsets.all(12),
285
- decoration: BoxDecoration(
286
- border: Border.all(color: theme.colorScheme.outline),
287
- borderRadius: BorderRadius.circular(8),
288
- ),
289
- child: Row(
290
- children: [
291
- Container(
292
- width: 40,
293
- height: 40,
290
+ const SizedBox(height: 12),
291
+ _buildSectionCard(
292
+ context,
293
+ title: 'Identity',
294
+ subtitle:
295
+ 'Name this server and choose how it appears in the list.',
296
+ child: Column(
297
+ children: [
298
+ GestureDetector(
299
+ onTap: _showIconPicker,
300
+ child: Container(
301
+ padding: const EdgeInsets.all(14),
302
decoration: BoxDecoration(
295
- borderRadius: BorderRadius.circular(8),
303
+ color: colorScheme.surfaceContainerHighest.withValues(
304
+ alpha: 0.35,
305
+ ),
306
+ border: Border.all(
307
+ color: colorScheme.outline.withValues(alpha: 0.4),
308
+ ),
309
+ borderRadius: BorderRadius.circular(16),
310
),
297
- child: HugeIcon(
298
- icon: _selectedIconName != null
299
- ? (getHugeIconByName(_selectedIconName)?.icon ??
300
- getDefaultServerIcon(
301
- _selectedType.name,
302
- )!.icon)
303
- : getDefaultServerIcon(_selectedType.name)!.icon,
304
- size: 24,
305
- color: theme.colorScheme.primary,
311
+ child: Row(
312
+ children: [
313
+ Container(
314
+ width: 48,
315
+ height: 48,
316
+ decoration: BoxDecoration(
317
+ color: colorScheme.primary.withValues(
318
+ alpha: 0.12,
319
+ ),
320
+ borderRadius: BorderRadius.circular(14),
321
+ ),
322
+ child: HugeIcon(
323
+ icon: _selectedIconName != null
324
+ ? (getHugeIconByName(
325
+ _selectedIconName,
326
+ )?.icon ??
327
+ getDefaultServerIcon(
328
+ _selectedType.name,
329
+ )!.icon)
330
+ : getDefaultServerIcon(
331
+ _selectedType.name,
332
+ )!.icon,
333
+ size: 24,
334
+ color: colorScheme.primary,
335
+ ),
336
+ ),
337
+ const SizedBox(width: 14),
338
+ Expanded(
339
+ child: Column(
340
+ crossAxisAlignment: CrossAxisAlignment.start,
341
+ children: [
342
+ Text(
343
+ l10n.server_icon_label,
344
+ style: theme.textTheme.labelLarge?.copyWith(
345
+ fontWeight: FontWeight.w600,
346
+ ),
347
+ ),
348
+ const SizedBox(height: 4),
349
+ Text(
350
+ _selectedIconName ?? l10n.default_icon,
351
+ style: theme.textTheme.bodyMedium?.copyWith(
352
+ color: colorScheme.onSurfaceVariant,
353
+ ),
354
+ ),
355
+ ],
356
+ ),
357
+ ),
358
+ Icon(
359
+ Icons.arrow_forward_ios_rounded,
360
+ size: 16,
361
+ color: colorScheme.onSurfaceVariant,
362
+ ),
363
+ ],
364
),
365
),
308
- const SizedBox(width: 12),
309
- Expanded(
310
- child: Text(
311
- _selectedIconName ?? l10n.default_icon,
312
- style: theme.textTheme.bodyMedium,
366
+ ),
367
+ const SizedBox(height: 12),
368
+ TextFormField(
369
+ controller: _nameController,
370
+ decoration: InputDecoration(
371
+ labelText: l10n.name_label,
372
+ hintText: l10n.my_server_hint,
373
+ ),
374
+ validator: _validateName,
375
+ textInputAction: TextInputAction.next,
376
+ ),
377
+ ],
378
+ ),
379
+ ),
380
+ if (_requiresEndpoint) ...[
381
+ const SizedBox(height: 12),
382
+ _buildSectionCard(
383
+ context,
384
+ title: 'Connection',
385
+ subtitle: 'Use the address and port exposed by your server.',
386
+ child: Column(
387
+ children: [
388
+ TextFormField(
389
+ controller: _hostController,
390
+ decoration: InputDecoration(
391
+ labelText: l10n.host_label,
392
+ hintText: '192.168.1.100',
393
),
394
+ validator: _validateHost,
395
+ keyboardType: TextInputType.text,
396
+ textInputAction: TextInputAction.next,
397
),
315
- Icon(
316
- Icons.edit,
317
- size: 18,
318
- color: theme.colorScheme.onSurfaceVariant,
398
+ const SizedBox(height: 12),
399
+ TextFormField(
400
+ controller: _portController,
401
+ decoration: InputDecoration(
402
+ labelText: l10n.port_label,
403
+ hintText: _selectedType == ServerType.lmStudio
404
+ ? AppConstants.lmStudioDefaultPort.toString()
405
+ : AppConstants.ollamaDefaultPort.toString(),
406
+ ),
407
+ validator: _validatePort,
408
+ keyboardType: TextInputType.number,
409
+ textInputAction: TextInputAction.next,
410
),
411
],
412
),
413
),
323
- ),
324
- const SizedBox(height: 24),
325
-
326
- TextFormField(
327
- controller: _nameController,
328
- decoration: InputDecoration(
329
- labelText: l10n.name_label,
330
- hintText: l10n.my_server_hint,
331
- ),
332
- validator: _validateName,
333
- textInputAction: TextInputAction.next,
334
- ),
335
- const SizedBox(height: 16),
336
-
337
- if (_selectedType != ServerType.openRouter) ...[
338
- TextFormField(
339
- controller: _hostController,
414
+ ],
415
+ const SizedBox(height: 12),
416
+ _buildSectionCard(
417
+ context,
418
+ title: 'Authentication',
419
+ subtitle: _requiresMandatoryApiKey
420
+ ? 'OpenRouter requires an API key before testing.'
421
+ : 'Leave the API key empty if this server does not require one.',
422
+ child: TextFormField(
423
+ controller: _apiKeyController,
424
decoration: InputDecoration(
341
- labelText: l10n.host_label,
342
- hintText: '192.168.1.100',
425
+ labelText: _requiresMandatoryApiKey
426
+ ? l10n.api_key_required
427
+ : l10n.api_key_optional,
428
+ hintText: _requiresMandatoryApiKey
429
+ ? l10n.api_key_hint_openrouter
430
+ : l10n.api_key_hint_generic,
431
),
344
- validator: _validateHost,
345
- keyboardType: TextInputType.text,
346
- textInputAction: TextInputAction.next,
432
+ validator: _validateApiKey,
433
+ obscureText: true,
434
+ textInputAction: TextInputAction.done,
435
),
348
- const SizedBox(height: 16),
436
+ ),
437
+ if (_testResult != null) ...[
438
+ const SizedBox(height: 12),
439
+ _buildTestResultBanner(context),
440
+ ],
441
+ ],
442
+ ),
443
+ ),
444
+ );
445
+ }
446
350
- TextFormField(
351
- controller: _portController,
352
- decoration: InputDecoration(
353
- labelText: l10n.port_label,
354
- hintText: _selectedType == ServerType.lmStudio
355
- ? AppConstants.lmStudioDefaultPort.toString()
356
- : AppConstants.ollamaDefaultPort.toString(),
357
- ),
358
- validator: _validatePort,
359
- keyboardType: TextInputType.number,
360
- textInputAction: TextInputAction.next,
447
+ Widget _buildHeaderCard(BuildContext context) {
448
+ final l10n = AppLocalizations.of(context)!;
449
+ final theme = Theme.of(context);
450
+ final colorScheme = theme.colorScheme;
451
+
452
+ return Container(
453
+ padding: const EdgeInsets.all(18),
454
+ decoration: BoxDecoration(
455
+ color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.38),
456
+ borderRadius: BorderRadius.circular(24),
457
+ border: Border.all(color: colorScheme.outline.withValues(alpha: 0.2)),
458
+ ),
459
+ child: Column(
460
+ crossAxisAlignment: CrossAxisAlignment.start,
461
+ children: [
462
+ Container(
463
+ width: 52,
464
+ height: 52,
465
+ decoration: BoxDecoration(
466
+ color: colorScheme.primary.withValues(alpha: 0.14),
467
+ borderRadius: BorderRadius.circular(16),
468
+ ),
469
+ child: HugeIcon(
470
+ icon: getDefaultServerIcon(_selectedType.name)!.icon,
471
+ size: 28,
472
+ color: colorScheme.primary,
473
+ ),
474
+ ),
475
+ const SizedBox(height: 12),
476
+ Text(
477
+ _isEditing ? l10n.edit_server : l10n.add_server_title,
478
+ style: theme.textTheme.headlineSmall?.copyWith(
479
+ fontWeight: FontWeight.w700,
480
+ ),
481
+ ),
482
+ const SizedBox(height: 6),
483
+ Text(
484
+ _requiresEndpoint
485
+ ? 'Configure a local or self-hosted endpoint, then verify the connection before saving.'
486
+ : 'Connect through OpenRouter with a valid API key and keep this profile ready for model routing.',
487
+ style: theme.textTheme.bodyMedium?.copyWith(
488
+ color: colorScheme.onSurfaceVariant,
489
+ height: 1.4,
490
+ ),
491
+ ),
492
+ const SizedBox(height: 12),
493
+ Wrap(
494
+ spacing: 8,
495
+ runSpacing: 8,
496
+ children: [
497
+ _buildInfoPill(
498
+ context,
499
+ icon: Icons.hub_outlined,
500
+ label: _serverTypeLabel(context),
501
+ ),
502
+ _buildInfoPill(
503
+ context,
504
+ icon: _requiresMandatoryApiKey ? Icons.key_outlined : Icons.dns,
505
+ label: _requiresMandatoryApiKey
506
+ ? l10n.api_key_required
507
+ : l10n.host_label,
508
),
362
- const SizedBox(height: 16),
509
],
510
+ ),
511
+ ],
512
+ ),
513
+ );
514
+ }
515
365
- TextFormField(
366
- controller: _apiKeyController,
367
- decoration: InputDecoration(
368
- labelText: _selectedType == ServerType.openRouter
369
- ? l10n.api_key_required
370
- : l10n.api_key_optional,
371
- hintText: _selectedType == ServerType.openRouter
372
- ? l10n.api_key_hint_openrouter
373
- : l10n.api_key_hint_generic,
516
+ Widget _buildSectionCard(
517
+ BuildContext context, {
518
+ required String title,
519
+ required String subtitle,
520
+ required Widget child,
521
+ }) {
522
+ final theme = Theme.of(context);
523
+ final colorScheme = theme.colorScheme;
524
+
525
+ return Container(
526
+ padding: const EdgeInsets.all(16),
527
+ decoration: BoxDecoration(
528
+ color: colorScheme.surface,
529
+ borderRadius: BorderRadius.circular(20),
530
+ border: Border.all(color: colorScheme.outline.withValues(alpha: 0.16)),
531
+ ),
532
+ child: Column(
533
+ crossAxisAlignment: CrossAxisAlignment.start,
534
+ children: [
535
+ Text(
536
+ title,
537
+ style: theme.textTheme.titleMedium?.copyWith(
538
+ fontWeight: FontWeight.w700,
539
+ ),
540
+ ),
541
+ const SizedBox(height: 4),
542
+ Text(
543
+ subtitle,
544
+ style: theme.textTheme.bodySmall?.copyWith(
545
+ color: colorScheme.onSurfaceVariant,
546
+ height: 1.35,
547
+ ),
548
+ ),
549
+ const SizedBox(height: 12),
550
+ child,
551
+ ],
552
+ ),
553
+ );
554
+ }
555
+
556
+ Widget _buildTestResultBanner(BuildContext context) {
557
+ final l10n = AppLocalizations.of(context)!;
558
+ final theme = Theme.of(context);
559
+ final success = _testResult!.contains(
560
+ l10n.connection_successful.split('!')[0],
561
+ );
562
+ final accent = success ? Colors.green : theme.colorScheme.error;
563
+
564
+ return Container(
565
+ padding: const EdgeInsets.all(14),
566
+ decoration: BoxDecoration(
567
+ color: accent.withValues(alpha: 0.08),
568
+ borderRadius: BorderRadius.circular(18),
569
+ border: Border.all(color: accent.withValues(alpha: 0.3)),
570
+ ),
571
+ child: Row(
572
+ crossAxisAlignment: CrossAxisAlignment.start,
573
+ children: [
574
+ Icon(
575
+ success ? Icons.check_circle_rounded : Icons.error_rounded,
576
+ color: accent,
577
+ ),
578
+ const SizedBox(width: 10),
579
+ Expanded(
580
+ child: Text(
581
+ _testResult!,
582
+ style: theme.textTheme.bodyMedium?.copyWith(
583
+ color: accent,
584
+ fontWeight: FontWeight.w600,
585
),
375
- validator: _validateApiKey,
376
- obscureText: true,
377
- textInputAction: TextInputAction.done,
586
),
379
- const SizedBox(height: 24),
587
+ ),
588
+ ],
589
+ ),
590
+ );
591
+ }
592
381
- if (_testResult != null) ...[
382
- Container(
383
- padding: const EdgeInsets.all(12),
384
- decoration: BoxDecoration(
385
- color:
386
- _testResult!.contains(
387
- l10n.connection_successful.split('!')[0],
388
- )
389
- ? Colors.green.withAlpha(25)
390
- : Colors.red.withAlpha(25),
391
- borderRadius: BorderRadius.circular(8),
392
- border: Border.all(
393
- color:
394
- _testResult!.contains(
395
- l10n.connection_successful.split('!')[0],
396
- )
397
- ? Colors.green
398
- : Colors.red,
593
+ Widget _buildFloatingActionBar(
594
+ BuildContext context, {
595
+ required double bottomInset,
596
+ }) {
597
+ final l10n = AppLocalizations.of(context)!;
598
+ final theme = Theme.of(context);
599
+ final colorScheme = theme.colorScheme;
600
+
601
+ return Padding(
602
+ padding: EdgeInsets.fromLTRB(16, 0, 16, bottomInset > 0 ? 4 : 8),
603
+ child: DecoratedBox(
604
+ decoration: BoxDecoration(
605
+ color: colorScheme.surface.withValues(alpha: 0.96),
606
+ borderRadius: BorderRadius.circular(24),
607
+ border: Border.all(
608
+ color: colorScheme.outline.withValues(alpha: 0.14),
609
+ ),
610
+ boxShadow: [
611
+ BoxShadow(
612
+ color: Colors.black.withValues(alpha: 0.08),
613
+ blurRadius: 24,
614
+ offset: const Offset(0, 12),
615
+ ),
616
+ ],
617
+ ),
618
+ child: Padding(
619
+ padding: const EdgeInsets.all(12),
620
+ child: Row(
621
+ children: [
622
+ Expanded(
623
+ child: OutlinedButton.icon(
624
+ onPressed: _isTesting ? null : _testConnection,
625
+ style: OutlinedButton.styleFrom(
626
+ minimumSize: const Size.fromHeight(52),
627
+ shape: RoundedRectangleBorder(
628
+ borderRadius: BorderRadius.circular(16),
629
+ ),
630
),
631
+ icon: _isTesting
632
+ ? const SizedBox(
633
+ width: 16,
634
+ height: 16,
635
+ child: CircularProgressIndicator(strokeWidth: 2),
636
+ )
637
+ : const Icon(Icons.network_check_rounded),
638
+ label: Text(_isTesting ? l10n.testing : l10n.test_connection),
639
),
401
- child: Row(
402
- children: [
403
- Icon(
404
- _testResult!.contains(
405
- l10n.connection_successful.split('!')[0],
406
- )
407
- ? Icons.check_circle
408
- : Icons.error,
409
- color:
410
- _testResult!.contains(
411
- l10n.connection_successful.split('!')[0],
412
- )
413
- ? Colors.green
414
- : Colors.red,
415
- ),
416
- const SizedBox(width: 8),
417
- Expanded(
418
- child: Text(
419
- _testResult!,
420
- style: TextStyle(
421
- color:
422
- _testResult!.contains(
423
- l10n.connection_successful.split('!')[0],
424
- )
425
- ? Colors.green
426
- : Colors.red,
427
- ),
428
- ),
640
+ ),
641
+ const SizedBox(width: 12),
642
+ Expanded(
643
+ child: ElevatedButton.icon(
644
+ onPressed: _isSaving ? null : _saveServer,
645
+ style: ElevatedButton.styleFrom(
646
+ minimumSize: const Size.fromHeight(52),
647
+ shape: RoundedRectangleBorder(
648
+ borderRadius: BorderRadius.circular(16),
649
),
430
- ],
650
+ ),
651
+ icon: _isSaving
652
+ ? const SizedBox(
653
+ width: 18,
654
+ height: 18,
655
+ child: CircularProgressIndicator(strokeWidth: 2),
656
+ )
657
+ : const Icon(Icons.save_rounded),
658
+ label: Text(
659
+ _isSaving
660
+ ? l10n.save
661
+ : (_isEditing ? l10n.update_server : l10n.save_server),
662
+ ),
663
),
664
),
433
- const SizedBox(height: 16),
665
],
666
+ ),
667
+ ),
668
+ ),
669
+ );
670
+ }
671
436
- OutlinedButton.icon(
437
- onPressed: _isTesting ? null : _testConnection,
438
- icon: _isTesting
439
- ? const SizedBox(
440
- width: 16,
441
- height: 16,
442
- child: CircularProgressIndicator(strokeWidth: 2),
443
- )
444
- : const Icon(Icons.network_check),
445
- label: Text(_isTesting ? l10n.testing : l10n.test_connection),
446
- ),
447
- const SizedBox(height: 24),
448
-
449
- ElevatedButton(
450
- onPressed: _isSaving ? null : _saveServer,
451
- child: _isSaving
452
- ? const SizedBox(
453
- width: 20,
454
- height: 20,
455
- child: CircularProgressIndicator(strokeWidth: 2),
456
- )
457
- : Text(_isEditing ? l10n.update_server : l10n.save_server),
672
+ Widget _buildInfoPill(
673
+ BuildContext context, {
674
+ required IconData icon,
675
+ required String label,
676
+ }) {
677
+ final theme = Theme.of(context);
678
+ final colorScheme = theme.colorScheme;
679
+
680
+ return Container(
681
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
682
+ decoration: BoxDecoration(
683
+ color: colorScheme.surface.withValues(alpha: 0.7),
684
+ borderRadius: BorderRadius.circular(999),
685
+ border: Border.all(color: colorScheme.outline.withValues(alpha: 0.18)),
686
+ ),
687
+ child: Row(
688
+ mainAxisSize: MainAxisSize.min,
689
+ children: [
690
+ Icon(icon, size: 16, color: colorScheme.primary),
691
+ const SizedBox(width: 8),
692
+ Text(
693
+ label,
694
+ style: theme.textTheme.labelLarge?.copyWith(
695
+ fontWeight: FontWeight.w600,
696
),
459
- ],
460
- ),
697
+ ),
698
+ ],
699
),
700
);
701
}
702
+
703
+ String _serverTypeLabel(BuildContext context) {
704
+ final l10n = AppLocalizations.of(context)!;
705
+ return switch (_selectedType) {
706
+ ServerType.lmStudio => l10n.server_type_lm_studio,
707
+ ServerType.openAICompatible => 'OpenAI',
708
+ ServerType.ollama => l10n.server_type_ollama,
709
+ ServerType.openRouter => l10n.server_type_openrouter,
710
+ ServerType.onDevice => l10n.server_type_on_device_display,
711
+ };
712
+ }
713
}
lib/features/servers/views/components/server_icon_picker.dart
+55
-51
@@ -115,6 +115,8 @@ class _ServerIconPickerState extends State<ServerIconPicker> {
115
final theme = Theme.of(context);
116
117
return ShadSheet(
118
+ padding: EdgeInsets.all(8),
119
+ radius: BorderRadius.circular(32),
120
title: Text(l10n.select_icon),
121
description: Text(l10n.select_icon_desc),
122
child: SizedBox(
@@ -135,64 +137,66 @@ class _ServerIconPickerState extends State<ServerIconPicker> {
137
),
138
),
139
Expanded(
138
- child: GridView.builder(
139
- padding: const EdgeInsets.all(16),
140
- gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
141
- crossAxisCount: 5,
142
- mainAxisSpacing: 8,
143
- crossAxisSpacing: 8,
144
- ),
145
- itemCount: _filteredIcons.length,
146
- itemBuilder: (context, index) {
147
- final icon = _filteredIcons[index];
148
- final isSelected = _selected == icon.name;
140
+ child: Scrollbar(
141
+ child: GridView.builder(
142
+ padding: const EdgeInsets.all(16),
143
+ gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
144
+ crossAxisCount: 5,
145
+ mainAxisSpacing: 8,
146
+ crossAxisSpacing: 8,
147
+ ),
148
+ itemCount: _filteredIcons.length,
149
+ itemBuilder: (context, index) {
150
+ final icon = _filteredIcons[index];
151
+ final isSelected = _selected == icon.name;
152
150
- return GestureDetector(
151
- onTap: () {
152
- setState(() {
153
- _selected = icon.name;
154
- });
155
- widget.onIconSelected(icon.name);
156
- },
157
- child: Container(
158
- decoration: BoxDecoration(
159
- color: isSelected
160
- ? theme.colorScheme.primary.withValues(alpha: 0.2)
161
- : theme.colorScheme.surfaceContainerHighest
162
- .withValues(alpha: 0.3),
163
- borderRadius: BorderRadius.circular(8),
164
- border: Border.all(
153
+ return GestureDetector(
154
+ onTap: () {
155
+ setState(() {
156
+ _selected = icon.name;
157
+ });
158
+ widget.onIconSelected(icon.name);
159
+ },
160
+ child: Container(
161
+ decoration: BoxDecoration(
162
color: isSelected
166
- ? theme.colorScheme.primary
167
- : Colors.transparent,
168
- width: 2,
169
- ),
170
- ),
171
- child: Column(
172
- mainAxisAlignment: MainAxisAlignment.center,
173
- children: [
174
- HugeIcon(
175
- icon: icon.icon,
176
- size: 24,
163
+ ? theme.colorScheme.primary.withValues(alpha: 0.2)
164
+ : theme.colorScheme.surfaceContainerHighest
165
+ .withValues(alpha: 0.3),
166
+ borderRadius: BorderRadius.circular(8),
167
+ border: Border.all(
168
color: isSelected
169
? theme.colorScheme.primary
179
- : theme.colorScheme.onSurface,
170
+ : Colors.transparent,
171
+ width: 2,
172
),
181
- const SizedBox(height: 4),
182
- Text(
183
- icon.name,
184
- style: theme.textTheme.labelSmall?.copyWith(
185
- fontSize: 8,
173
+ ),
174
+ child: Column(
175
+ mainAxisAlignment: MainAxisAlignment.center,
176
+ children: [
177
+ HugeIcon(
178
+ icon: icon.icon,
179
+ size: 24,
180
+ color: isSelected
181
+ ? theme.colorScheme.primary
182
+ : theme.colorScheme.onSurface,
183
),
187
- textAlign: TextAlign.center,
188
- maxLines: 1,
189
- overflow: TextOverflow.ellipsis,
190
- ),
191
- ],
184
+ const SizedBox(height: 4),
185
+ Text(
186
+ icon.name,
187
+ style: theme.textTheme.labelSmall?.copyWith(
188
+ fontSize: 8,
189
+ ),
190
+ textAlign: TextAlign.center,
191
+ maxLines: 1,
192
+ overflow: TextOverflow.ellipsis,
193
+ ),
194
+ ],
195
+ ),
196
),
193
- ),
194
- );
195
- },
197
+ );
198
+ },
199
+ ),
200
),
201
),
202
Padding(
lib/features/servers/views/components/server_type_selector.dart
+96
-53
@@ -46,66 +46,109 @@ class ServerTypeSelector extends StatelessWidget {
46
),
47
];
48
49
- return GridView.count(
50
- shrinkWrap: true,
51
- physics: const NeverScrollableScrollPhysics(),
52
- crossAxisCount: 2,
53
- mainAxisSpacing: 8,
54
- crossAxisSpacing: 8,
55
- childAspectRatio: 2.0,
56
- children: types.map((item) {
57
- final type = item.$1;
58
- final label = item.$2;
59
- final icon = item.$3;
60
- final accentColor = item.$4;
61
- final isSelected = selectedType == type;
49
+ return LayoutBuilder(
50
+ builder: (context, constraints) {
51
+ final itemAspectRatio = constraints.maxWidth >= 420 ? 2.15 : 1.95;
52
63
- return GestureDetector(
64
- onTap: () => onChanged(type),
65
- child: Container(
66
- decoration: BoxDecoration(
67
- color: isSelected
68
- ? accentColor.withValues(alpha: 0.15)
69
- : colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
70
- borderRadius: BorderRadius.circular(12),
71
- border: Border.all(
72
- color: isSelected
73
- ? accentColor.withValues(alpha: 0.5)
74
- : colorScheme.outline.withValues(alpha: 0.3),
75
- width: isSelected ? 2 : 1,
76
- ),
77
- ),
78
- child: Column(
79
- mainAxisAlignment: MainAxisAlignment.center,
80
- children: [
81
- Container(
82
- width: 32,
83
- height: 32,
53
+ return GridView.count(
54
+ shrinkWrap: true,
55
+ physics: const NeverScrollableScrollPhysics(),
56
+ padding: EdgeInsets.zero,
57
+ crossAxisCount: 2,
58
+ mainAxisSpacing: 8,
59
+ crossAxisSpacing: 8,
60
+ childAspectRatio: itemAspectRatio,
61
+ children: types.map((item) {
62
+ final type = item.$1;
63
+ final label = item.$2;
64
+ final icon = item.$3;
65
+ final accentColor = item.$4;
66
+ final isSelected = selectedType == type;
67
+
68
+ return Material(
69
+ color: Colors.transparent,
70
+ child: InkWell(
71
+ borderRadius: BorderRadius.circular(14),
72
+ onTap: () => onChanged(type),
73
+ child: AnimatedContainer(
74
+ duration: const Duration(milliseconds: 180),
75
+ curve: Curves.easeOut,
76
+ padding: const EdgeInsets.symmetric(
77
+ horizontal: 12,
78
+ vertical: 10,
79
+ ),
80
decoration: BoxDecoration(
81
color: isSelected
86
- ? accentColor.withValues(alpha: 0.2)
87
- : colorScheme.surface,
88
- borderRadius: BorderRadius.circular(8),
82
+ ? accentColor.withValues(alpha: 0.14)
83
+ : colorScheme.surfaceContainerHighest.withValues(
84
+ alpha: 0.38,
85
+ ),
86
+ borderRadius: BorderRadius.circular(14),
87
+ border: Border.all(
88
+ color: isSelected
89
+ ? accentColor.withValues(alpha: 0.58)
90
+ : colorScheme.outline.withValues(alpha: 0.24),
91
+ width: isSelected ? 2 : 1,
92
+ ),
93
),
90
- child: HugeIcon(
91
- icon: icon,
92
- size: 18,
93
- color: isSelected ? accentColor : colorScheme.onSurface,
94
+ child: Stack(
95
+ children: [
96
+ Center(
97
+ child: Column(
98
+ mainAxisAlignment: MainAxisAlignment.center,
99
+ mainAxisSize: MainAxisSize.min,
100
+ children: [
101
+ Container(
102
+ width: 32,
103
+ height: 32,
104
+ decoration: BoxDecoration(
105
+ color: isSelected
106
+ ? accentColor.withValues(alpha: 0.20)
107
+ : colorScheme.surface,
108
+ borderRadius: BorderRadius.circular(10),
109
+ ),
110
+ child: HugeIcon(
111
+ icon: icon,
112
+ size: 18,
113
+ color: isSelected
114
+ ? accentColor
115
+ : colorScheme.onSurface,
116
+ ),
117
+ ),
118
+ const SizedBox(height: 6),
119
+ Text(
120
+ label,
121
+ style: theme.textTheme.bodyMedium?.copyWith(
122
+ fontSize: 15,
123
+ fontWeight: isSelected
124
+ ? FontWeight.w600
125
+ : FontWeight.w500,
126
+ color: isSelected
127
+ ? accentColor
128
+ : colorScheme.onSurface,
129
+ ),
130
+ ),
131
+ ],
132
+ ),
133
+ ),
134
+ if (isSelected)
135
+ PositionedDirectional(
136
+ top: 6,
137
+ end: 6,
138
+ child: Icon(
139
+ Icons.check_circle,
140
+ size: 17,
141
+ color: accentColor,
142
+ ),
143
+ ),
144
+ ],
145
),
146
),
96
- const SizedBox(height: 6),
97
- Text(
98
- label,
99
- style: theme.textTheme.bodySmall?.copyWith(
100
- fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
101
- color: isSelected ? accentColor : colorScheme.onSurface,
102
- ),
103
- ),
104
- ],
105
- ),
106
- ),
147
+ ),
148
+ );
149
+ }).toList(),
150
);
108
- }).toList(),
151
+ },
152
);
153
}
154
}
lib/features/servers/views/server_list_screen.dart
+1
-1
@@ -156,7 +156,7 @@ class ServerListScreen extends ConsumerWidget {
156
],
157
),
158
PositionedDirectional(
159
- bottom: 24 + systemBottomInset,
159
+ bottom: 24,
160
end: 24,
161
child: FloatingActionButton(
162
onPressed: () => context.push(AppRoutes.addServer),
lib/features/settings/views/settings_screen.dart
+1341
-934
@@ -1,18 +1,21 @@
1
import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
import 'package:go_router/go_router.dart';
4
+import 'package:shadcn_ui/shadcn_ui.dart';
5
+
6
import 'package:localmind/features/sidebar/sidebar_widget.dart';
7
import 'package:localmind/l10n/app_localizations.dart';
6
-import '../../../core/providers/app_providers.dart';
8
+
9
import '../../../core/models/enums.dart';
10
+import '../../../core/providers/app_providers.dart';
11
import '../../../core/routes/app_routes.dart';
12
import '../../../core/theme/app_theme.dart';
13
import '../../../core/utils/system_insets.dart';
14
import '../../conversations/providers/conversation_providers.dart';
15
import '../../on_device/providers/on_device_providers.dart';
16
+import '../../personas/providers/personas_providers.dart';
17
import '../../servers/providers/server_providers.dart';
18
import '../data/models/app_settings.dart';
15
-import '../../personas/providers/personas_providers.dart';
19
20
class SettingsViews extends ConsumerWidget {
21
const SettingsViews({super.key});
@@ -21,302 +24,766 @@ class SettingsViews extends ConsumerWidget {
24
Widget build(BuildContext context, WidgetRef ref) {
25
final l10n = AppLocalizations.of(context)!;
26
final settings = ref.watch(settingsProvider);
24
- final theme = Theme.of(context);
25
- final isDark = theme.brightness == Brightness.dark;
27
+ final currentTheme = ref.watch(themeModeProvider);
28
final systemBottomInset = bottomSystemInset(context);
29
+ final servers = (ref.watch(serversProvider).value ?? [])
30
+ .map((server) => (server.id, server.name))
31
+ .toList();
32
+ final personas = (ref.watch(personasNotifierProvider).value ?? [])
33
+ .map((persona) => (persona.id, '${persona.emoji} ${persona.name}'))
34
+ .toList();
35
36
return Scaffold(
37
appBar: AppBar(title: Text(l10n.settings_title)),
38
drawer: SidebarWidget(),
31
- body: ListView(
32
- padding: EdgeInsets.fromLTRB(16, 8, 16, 8 + systemBottomInset),
33
- children: [
34
- _SectionHeader(title: l10n.settings_appearance),
35
- _ThemeToggle(current: settings.themeMode, ref: ref),
36
- _LanguageSetting(
37
- current: settings.localeCode,
38
- onChanged: (v) =>
39
- ref.read(settingsProvider.notifier).setLocaleCode(v),
40
- isDark: isDark,
41
- ),
42
- _SliderSetting(
43
- label: l10n.font_size,
44
- value: settings.fontSize,
45
- min: 12.0,
46
- max: 24.0,
47
- divisions: 12,
48
- description: l10n.font_size_desc,
49
- onChanged: (v) =>
50
- ref.read(settingsProvider.notifier).setFontSize(v),
51
- isDark: isDark,
52
- valueFormat: (v) => v.toStringAsFixed(0),
53
- previewText: l10n.font_preview,
54
- ),
55
- _CodeThemeDropdown(
56
- label: l10n.code_theme_dark,
57
- current: settings.codeThemeDark,
58
- onChanged: (v) =>
59
- ref.read(settingsProvider.notifier).setCodeThemeDark(v),
60
- isDark: isDark,
61
- ),
62
- _CodeThemeDropdown(
63
- label: l10n.code_theme_light,
64
- current: settings.codeThemeLight,
65
- onChanged: (v) =>
66
- ref.read(settingsProvider.notifier).setCodeThemeLight(v),
67
- isDark: isDark,
68
- ),
69
- const Divider(height: 32),
70
- _SectionHeader(title: l10n.settings_tts),
71
- _EngineDropdown(
72
- current: settings.ttsEngine,
73
- onChanged: (v) =>
74
- ref.read(settingsProvider.notifier).setTtsEngine(v),
75
- isDark: isDark,
76
- ),
77
- Padding(
78
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
79
- child: OutlinedButton.icon(
80
- onPressed: () => context.push(AppRoutes.ttsModels),
81
- icon: const Icon(Icons.record_voice_over, size: 18),
82
- label: Text(l10n.manage_tts_models),
83
- style: OutlinedButton.styleFrom(
84
- minimumSize: const Size(double.infinity, 44),
85
- shape: RoundedRectangleBorder(
86
- borderRadius: BorderRadius.circular(8),
39
+ body: LayoutBuilder(
40
+ builder: (context, constraints) {
41
+ final useTwoColumns = constraints.maxWidth >= 1080;
42
+ final contentMaxWidth = useTwoColumns ? 1120.0 : 720.0;
43
+ final horizontalPadding = constraints.maxWidth >= 720 ? 20.0 : 12.0;
44
+
45
+ final appearanceCard = _SettingsSectionCard(
46
+ title: l10n.settings_appearance,
47
+ icon: Icons.palette_outlined,
48
+ accent: const Color(0xFF8B5CF6),
49
+ children: [
50
+ _ThemeToggle(ref: ref),
51
+ _LanguageSetting(
52
+ current: settings.localeCode,
53
+ onChanged: (value) =>
54
+ ref.read(settingsProvider.notifier).setLocaleCode(value),
55
+ ),
56
+ _SliderSetting(
57
+ label: l10n.font_size,
58
+ value: settings.fontSize,
59
+ min: 12.0,
60
+ max: 24.0,
61
+ divisions: 12,
62
+ description: l10n.font_size_desc,
63
+ onChanged: (value) =>
64
+ ref.read(settingsProvider.notifier).setFontSize(value),
65
+ valueFormat: (value) => value.toStringAsFixed(0),
66
+ previewText: l10n.font_preview,
67
+ ),
68
+ _CodeThemeDropdown(
69
+ label: l10n.code_theme_dark,
70
+ current: settings.codeThemeDark,
71
+ onChanged: (value) =>
72
+ ref.read(settingsProvider.notifier).setCodeThemeDark(value),
73
+ ),
74
+ _CodeThemeDropdown(
75
+ label: l10n.code_theme_light,
76
+ current: settings.codeThemeLight,
77
+ onChanged: (value) => ref
78
+ .read(settingsProvider.notifier)
79
+ .setCodeThemeLight(value),
80
+ ),
81
+ ],
82
+ );
83
+
84
+ final ttsCard = _SettingsSectionCard(
85
+ title: l10n.settings_tts,
86
+ icon: Icons.graphic_eq_rounded,
87
+ accent: const Color(0xFF0EA5E9),
88
+ children: [
89
+ _EngineDropdown(
90
+ current: settings.ttsEngine,
91
+ onChanged: (value) =>
92
+ ref.read(settingsProvider.notifier).setTtsEngine(value),
93
+ ),
94
+ _SectionActionButton(
95
+ icon: Icons.record_voice_over_outlined,
96
+ label: l10n.manage_tts_models,
97
+ onPressed: () => context.push(AppRoutes.ttsModels),
98
+ ),
99
+ if (settings.ttsEngine != EngineId.system) ...[
100
+ _VoiceSelector(
101
+ engine: settings.ttsEngine,
102
+ currentVoiceId: settings.ttsVoiceId,
103
+ onChanged: (voice) => ref
104
+ .read(settingsProvider.notifier)
105
+ .setTtsVoiceId(voice?.id),
106
+ ),
107
+ _SliderSetting(
108
+ label: l10n.tts_speed,
109
+ value: settings.ttsSpeed,
110
+ min: 0.5,
111
+ max: 2.0,
112
+ divisions: 15,
113
+ description: l10n.tts_speed_desc,
114
+ onChanged: (value) =>
115
+ ref.read(settingsProvider.notifier).setTtsSpeed(value),
116
+ valueFormat: (value) => '${value.toStringAsFixed(2)}x',
117
),
118
+ ],
119
+ ],
120
+ );
121
+
122
+ final behaviorCard = _SettingsSectionCard(
123
+ title: l10n.settings_behavior,
124
+ icon: Icons.tune_rounded,
125
+ accent: const Color(0xFF22C55E),
126
+ children: [
127
+ _ToggleSetting(
128
+ label: l10n.streaming_responses,
129
+ value: settings.streamingEnabled,
130
+ onChanged: (value) => ref
131
+ .read(settingsProvider.notifier)
132
+ .setStreamingEnabled(value),
133
+ ),
134
+ _ToggleSetting(
135
+ label: l10n.auto_generate_titles,
136
+ value: settings.autoGenerateTitle,
137
+ onChanged: (value) => ref
138
+ .read(settingsProvider.notifier)
139
+ .setAutoGenerateTitle(value),
140
+ ),
141
+ _ToggleSetting(
142
+ label: l10n.send_on_enter,
143
+ value: settings.sendOnEnter,
144
+ onChanged: (value) =>
145
+ ref.read(settingsProvider.notifier).setSendOnEnter(value),
146
+ ),
147
+ _ToggleSetting(
148
+ label: l10n.show_system_messages,
149
+ value: settings.showSystemMessages,
150
+ onChanged: (value) => ref
151
+ .read(settingsProvider.notifier)
152
+ .setShowSystemMessages(value),
153
+ ),
154
+ _ToggleSetting(
155
+ label: l10n.haptic_feedback,
156
+ value: settings.hapticFeedbackEnabled,
157
+ onChanged: (value) => ref
158
+ .read(settingsProvider.notifier)
159
+ .setHapticFeedback(value),
160
+ ),
161
+ _ToggleSetting(
162
+ label: l10n.enable_mcp,
163
+ value: settings.mcpEnabled,
164
+ badges: [
165
+ _FeatureBadge(label: l10n.beta_label),
166
+ _FeatureBadge(label: l10n.experimental_label),
167
+ ],
168
+ onChanged: (value) =>
169
+ ref.read(settingsProvider.notifier).setMcpEnabled(value),
170
+ ),
171
+ if (settings.mcpEnabled)
172
+ _ToggleSetting(
173
+ label: l10n.new_chat_mcp_default,
174
+ value: settings.newChatMcpEnabled,
175
+ badges: [_FeatureBadge(label: l10n.beta_label)],
176
+ onChanged: (value) => ref
177
+ .read(settingsProvider.notifier)
178
+ .setNewChatMcpEnabled(value),
179
+ ),
180
+ ],
181
+ );
182
+
183
+ final onDeviceCard = _SettingsSectionCard(
184
+ title: l10n.settings_on_device,
185
+ icon: Icons.memory_rounded,
186
+ accent: const Color(0xFFF97316),
187
+ children: [
188
+ _ToggleSetting(
189
+ label: l10n.enable_smart_reply,
190
+ value: settings.smartReplyEnabled,
191
+ onChanged: (value) => ref
192
+ .read(settingsProvider.notifier)
193
+ .setSmartReplyEnabled(value),
194
+ ),
195
+ _SectionActionButton(
196
+ icon: Icons.phone_android_rounded,
197
+ label: l10n.manage_on_device_models,
198
+ onPressed: () => context.push(AppRoutes.onDeviceModels),
199
),
200
+ const _OnDeviceEngineStatusCard(),
201
+ ],
202
+ );
203
+
204
+ final defaultsCard = _SettingsSectionCard(
205
+ title: l10n.settings_default_server,
206
+ icon: Icons.hub_outlined,
207
+ accent: const Color(0xFF06B6D4),
208
+ children: [
209
+ _DropdownSetting(
210
+ label: l10n.settings_default_server,
211
+ currentValue: settings.defaultServerId,
212
+ items: servers,
213
+ onChanged: (value) =>
214
+ ref.read(settingsProvider.notifier).setDefaultServer(value),
215
+ icon: Icons.computer_rounded,
216
+ ),
217
+ _DropdownSetting(
218
+ label: l10n.settings_default_persona,
219
+ currentValue: settings.defaultPersonaId,
220
+ items: personas,
221
+ onChanged: (value) => ref
222
+ .read(settingsProvider.notifier)
223
+ .setDefaultPersona(value),
224
+ icon: Icons.smart_toy_outlined,
225
+ ),
226
+ ],
227
+ );
228
+
229
+ final privacyCard = _SettingsSectionCard(
230
+ title: l10n.settings_privacy,
231
+ icon: Icons.lock_outline_rounded,
232
+ accent: const Color(0xFF14B8A6),
233
+ children: [
234
+ _ToggleSetting(
235
+ label: l10n.show_data_indicator,
236
+ value: settings.showDataIndicator,
237
+ onChanged: (value) => ref
238
+ .read(settingsProvider.notifier)
239
+ .setShowDataIndicator(value),
240
+ ),
241
+ _MutedCallout(message: l10n.privacy_info),
242
+ ],
243
+ );
244
+
245
+ final dataCard = _SettingsSectionCard(
246
+ title: l10n.settings_data_management,
247
+ icon: Icons.restore_page_outlined,
248
+ accent: const Color(0xFFEF4444),
249
+ children: [
250
+ _DangerousAction(
251
+ label: l10n.delete_all_conversations,
252
+ icon: Icons.delete_outline_rounded,
253
+ onConfirm: () =>
254
+ ref.read(conversationsProvider.notifier).deleteAll(),
255
+ ),
256
+ _DangerousAction(
257
+ label: l10n.reset_settings_defaults,
258
+ icon: Icons.restore_rounded,
259
+ onConfirm: () =>
260
+ ref.read(settingsProvider.notifier).resetToDefaults(),
261
+ ),
262
+ ],
263
+ );
264
+
265
+ final aboutCard = _SettingsSectionCard(
266
+ title: l10n.settings_about,
267
+ icon: Icons.info_outline_rounded,
268
+ accent: const Color(0xFFF59E0B),
269
+ children: [
270
+ _AboutPanel(
271
+ title: '${l10n.app_name} v${l10n.app_version}',
272
+ subtitle: l10n.onboarding_connect_desc.replaceAll('\n', ' '),
273
+ providers: [
274
+ l10n.server_type_lm_studio,
275
+ l10n.server_type_ollama,
276
+ l10n.server_type_openrouter,
277
+ ],
278
+ highlights: [
279
+ l10n.settings_on_device,
280
+ l10n.streaming_responses,
281
+ l10n.nav_personas,
282
+ ],
283
+ stack: const ['Flutter', 'Riverpod', 'shadcn_ui'],
284
+ openSource: l10n.open_source_desc,
285
+ ),
286
+ ],
287
+ );
288
+
289
+ return ListView(
290
+ padding: EdgeInsets.fromLTRB(
291
+ horizontalPadding,
292
+ 12,
293
+ horizontalPadding,
294
+ 24 + systemBottomInset,
295
),
90
- ),
91
- if (settings.ttsEngine != EngineId.system) ...[
92
- const SizedBox(height: 8),
93
- _VoiceSelector(
94
- engine: settings.ttsEngine,
95
- currentVoiceId: settings.ttsVoiceId,
96
- onChanged: (v) =>
97
- ref.read(settingsProvider.notifier).setTtsVoiceId(v?.id),
98
- isDark: isDark,
296
+ children: [
297
+ Center(
298
+ child: ConstrainedBox(
299
+ constraints: BoxConstraints(maxWidth: contentMaxWidth),
300
+ child: Column(
301
+ crossAxisAlignment: CrossAxisAlignment.stretch,
302
+ children: [
303
+ _SettingsHero(
304
+ settings: settings,
305
+ themeType: currentTheme,
306
+ ),
307
+ const SizedBox(height: 16),
308
+ if (useTwoColumns)
309
+ Row(
310
+ crossAxisAlignment: CrossAxisAlignment.start,
311
+ children: [
312
+ Expanded(
313
+ child: _SettingsSectionColumn(
314
+ children: [
315
+ appearanceCard,
316
+ behaviorCard,
317
+ defaultsCard,
318
+ privacyCard,
319
+ ],
320
+ ),
321
+ ),
322
+ const SizedBox(width: 20),
323
+ Expanded(
324
+ child: _SettingsSectionColumn(
325
+ children: [
326
+ ttsCard,
327
+ onDeviceCard,
328
+ dataCard,
329
+ aboutCard,
330
+ ],
331
+ ),
332
+ ),
333
+ ],
334
+ )
335
+ else
336
+ _SettingsSectionColumn(
337
+ children: [
338
+ appearanceCard,
339
+ ttsCard,
340
+ behaviorCard,
341
+ onDeviceCard,
342
+ defaultsCard,
343
+ privacyCard,
344
+ dataCard,
345
+ aboutCard,
346
+ ],
347
+ ),
348
+ ],
349
+ ),
350
+ ),
351
+ ),
352
+ ],
353
+ );
354
+ },
355
+ ),
356
+ );
357
+ }
358
+}
359
+
360
+class _SettingsSectionColumn extends StatelessWidget {
361
+ const _SettingsSectionColumn({required this.children});
362
+
363
+ final List<Widget> children;
364
+
365
+ @override
366
+ Widget build(BuildContext context) {
367
+ return Column(
368
+ crossAxisAlignment: CrossAxisAlignment.stretch,
369
+ children: _withVerticalSpacing(children, gap: 16),
370
+ );
371
+ }
372
+}
373
+
374
+class _SettingsHero extends StatelessWidget {
375
+ const _SettingsHero({required this.settings, required this.themeType});
376
+
377
+ final AppSettings settings;
378
+ final AppThemeType themeType;
379
+
380
+ @override
381
+ Widget build(BuildContext context) {
382
+ final l10n = AppLocalizations.of(context)!;
383
+ final theme = Theme.of(context);
384
+ final primary = _primaryColor(context);
385
+
386
+ return Container(
387
+ decoration: BoxDecoration(
388
+ color: _surfaceColor(context),
389
+ borderRadius: BorderRadius.circular(20),
390
+ border: Border.all(color: _outlineColor(context, alpha: 0.9)),
391
+ ),
392
+ child: Padding(
393
+ padding: const EdgeInsets.all(16),
394
+ child: Column(
395
+ crossAxisAlignment: CrossAxisAlignment.start,
396
+ children: [
397
+ Row(
398
+ children: [
399
+ Container(
400
+ width: 36,
401
+ height: 36,
402
+ decoration: BoxDecoration(
403
+ color: primary.withValues(alpha: 0.10),
404
+ borderRadius: BorderRadius.circular(12),
405
+ ),
406
+ child: Icon(
407
+ Icons.settings_outlined,
408
+ size: 18,
409
+ color: primary,
410
+ ),
411
+ ),
412
+ const SizedBox(width: 10),
413
+ Expanded(
414
+ child: Text(
415
+ l10n.settings_title,
416
+ style: theme.textTheme.headlineSmall?.copyWith(
417
+ fontWeight: FontWeight.w700,
418
+ ),
419
+ ),
420
+ ),
421
+ ],
422
+ ),
423
+ const SizedBox(height: 6),
424
+ Text(
425
+ l10n.app_tagline,
426
+ style: theme.textTheme.bodyMedium?.copyWith(
427
+ color: _mutedColor(context),
428
+ height: 1.35,
429
+ ),
430
),
100
- _SliderSetting(
101
- label: l10n.tts_speed,
102
- value: settings.ttsSpeed,
103
- min: 0.5,
104
- max: 2.0,
105
- divisions: 15,
106
- description: l10n.tts_speed_desc,
107
- onChanged: (v) =>
108
- ref.read(settingsProvider.notifier).setTtsSpeed(v),
109
- isDark: isDark,
110
- valueFormat: (v) => '${v.toStringAsFixed(2)}x',
431
+ const SizedBox(height: 14),
432
+ Wrap(
433
+ spacing: 8,
434
+ runSpacing: 8,
435
+ children: [
436
+ _HeroStat(
437
+ label: l10n.theme,
438
+ value: _themeLabel(themeType, l10n),
439
+ icon: Icons.auto_awesome_rounded,
440
+ ),
441
+ _HeroStat(
442
+ label: l10n.settings_language,
443
+ value: _languageLabel(settings.localeCode, l10n),
444
+ icon: Icons.language_rounded,
445
+ ),
446
+ _HeroStat(
447
+ label: l10n.tts_engine,
448
+ value: _engineLabel(settings.ttsEngine, l10n),
449
+ icon: Icons.record_voice_over_rounded,
450
+ ),
451
+ ],
452
),
453
],
113
- const Divider(height: 32),
114
- _SectionHeader(title: l10n.settings_behavior),
115
- _ToggleSetting(
116
- label: l10n.streaming_responses,
117
- value: settings.streamingEnabled,
118
- onChanged: (v) =>
119
- ref.read(settingsProvider.notifier).setStreamingEnabled(v),
120
- isDark: isDark,
121
- ),
122
- _ToggleSetting(
123
- label: l10n.auto_generate_titles,
124
- value: settings.autoGenerateTitle,
125
- onChanged: (v) =>
126
- ref.read(settingsProvider.notifier).setAutoGenerateTitle(v),
127
- isDark: isDark,
128
- ),
129
- _ToggleSetting(
130
- label: l10n.send_on_enter,
131
- value: settings.sendOnEnter,
132
- onChanged: (v) =>
133
- ref.read(settingsProvider.notifier).setSendOnEnter(v),
134
- isDark: isDark,
454
+ ),
455
+ ),
456
+ );
457
+ }
458
+}
459
+
460
+class _HeroStat extends StatelessWidget {
461
+ const _HeroStat({
462
+ required this.label,
463
+ required this.value,
464
+ required this.icon,
465
+ });
466
+
467
+ final String label;
468
+ final String value;
469
+ final IconData icon;
470
+
471
+ @override
472
+ Widget build(BuildContext context) {
473
+ final theme = Theme.of(context);
474
+ final primary = _primaryColor(context);
475
+
476
+ return Container(
477
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
478
+ decoration: BoxDecoration(
479
+ color: _panelColor(context),
480
+ borderRadius: BorderRadius.circular(12),
481
+ border: Border.all(color: _outlineColor(context)),
482
+ ),
483
+ child: Row(
484
+ mainAxisSize: MainAxisSize.min,
485
+ children: [
486
+ Icon(icon, size: 14, color: primary),
487
+ const SizedBox(width: 8),
488
+ Text(
489
+ '$label: ',
490
+ style: theme.textTheme.labelMedium?.copyWith(
491
+ color: _mutedColor(context),
492
+ fontWeight: FontWeight.w600,
493
+ ),
494
),
136
- _ToggleSetting(
137
- label: l10n.show_system_messages,
138
- value: settings.showSystemMessages,
139
- onChanged: (v) =>
140
- ref.read(settingsProvider.notifier).setShowSystemMessages(v),
141
- isDark: isDark,
495
+ Flexible(
496
+ child: Text(
497
+ value,
498
+ overflow: TextOverflow.ellipsis,
499
+ style: theme.textTheme.labelLarge?.copyWith(
500
+ fontWeight: FontWeight.w700,
501
+ ),
502
+ ),
503
),
143
- _ToggleSetting(
144
- label: l10n.haptic_feedback,
145
- value: settings.hapticFeedbackEnabled,
146
- onChanged: (v) =>
147
- ref.read(settingsProvider.notifier).setHapticFeedback(v),
148
- isDark: isDark,
504
+ ],
505
+ ),
506
+ );
507
+ }
508
+}
509
+
510
+class _SettingsSectionCard extends StatelessWidget {
511
+ const _SettingsSectionCard({
512
+ required this.title,
513
+ required this.icon,
514
+ required this.accent,
515
+ required this.children,
516
+ });
517
+
518
+ final String title;
519
+ final IconData icon;
520
+ final Color accent;
521
+ final List<Widget> children;
522
+
523
+ @override
524
+ Widget build(BuildContext context) {
525
+ return Container(
526
+ decoration: BoxDecoration(
527
+ color: _surfaceColor(context),
528
+ borderRadius: BorderRadius.circular(18),
529
+ border: Border.all(color: _outlineColor(context, alpha: 0.9)),
530
+ ),
531
+ child: Padding(
532
+ padding: const EdgeInsets.all(16),
533
+ child: Column(
534
+ crossAxisAlignment: CrossAxisAlignment.start,
535
+ children: [
536
+ Row(
537
+ children: [
538
+ Container(
539
+ width: 34,
540
+ height: 34,
541
+ decoration: BoxDecoration(
542
+ color: accent.withValues(alpha: 0.10),
543
+ borderRadius: BorderRadius.circular(10),
544
+ ),
545
+ child: Icon(icon, color: accent, size: 16),
546
+ ),
547
+ const SizedBox(width: 10),
548
+ Expanded(
549
+ child: Text(
550
+ title,
551
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
552
+ fontWeight: FontWeight.w700,
553
+ ),
554
+ ),
555
+ ),
556
+ ],
557
+ ),
558
+ const SizedBox(height: 12),
559
+ ..._withVerticalSpacing(children, gap: 10),
560
+ ],
561
+ ),
562
+ ),
563
+ );
564
+ }
565
+}
566
+
567
+class _SettingPanel extends StatelessWidget {
568
+ const _SettingPanel({required this.child});
569
+
570
+ final Widget child;
571
+
572
+ @override
573
+ Widget build(BuildContext context) {
574
+ return Container(
575
+ padding: const EdgeInsets.all(12),
576
+ decoration: BoxDecoration(
577
+ color: _panelColor(context),
578
+ borderRadius: BorderRadius.circular(14),
579
+ border: Border.all(color: _outlineColor(context)),
580
+ ),
581
+ child: child,
582
+ );
583
+ }
584
+}
585
+
586
+class _MutedCallout extends StatelessWidget {
587
+ const _MutedCallout({required this.message});
588
+
589
+ final String message;
590
+
591
+ @override
592
+ Widget build(BuildContext context) {
593
+ return _SettingPanel(
594
+ child: Row(
595
+ crossAxisAlignment: CrossAxisAlignment.start,
596
+ children: [
597
+ Icon(
598
+ Icons.shield_moon_outlined,
599
+ size: 18,
600
+ color: _mutedColor(context),
601
),
150
- _ToggleSetting(
151
- label: l10n.enable_mcp,
152
- badges: [
153
- _FeatureBadge(label: l10n.beta_label, isDark: isDark),
154
- _FeatureBadge(label: l10n.experimental_label, isDark: isDark),
155
- ],
156
- value: settings.mcpEnabled,
157
- onChanged: (v) =>
158
- ref.read(settingsProvider.notifier).setMcpEnabled(v),
159
- isDark: isDark,
602
+ const SizedBox(width: 10),
603
+ Expanded(
604
+ child: Text(
605
+ message,
606
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
607
+ color: _mutedColor(context),
608
+ height: 1.35,
609
+ ),
610
+ ),
611
),
161
- if (settings.mcpEnabled)
162
- _ToggleSetting(
163
- label: l10n.new_chat_mcp_default,
164
- badges: [_FeatureBadge(label: l10n.beta_label, isDark: isDark)],
165
- value: settings.newChatMcpEnabled,
166
- onChanged: (v) =>
167
- ref.read(settingsProvider.notifier).setNewChatMcpEnabled(v),
168
- isDark: isDark,
612
+ ],
613
+ ),
614
+ );
615
+ }
616
+}
617
+
618
+class _AboutPanel extends StatelessWidget {
619
+ const _AboutPanel({
620
+ required this.title,
621
+ required this.subtitle,
622
+ required this.providers,
623
+ required this.highlights,
624
+ required this.stack,
625
+ required this.openSource,
626
+ });
627
+
628
+ final String title;
629
+ final String subtitle;
630
+ final List<String> providers;
631
+ final List<String> highlights;
632
+ final List<String> stack;
633
+ final String openSource;
634
+
635
+ @override
636
+ Widget build(BuildContext context) {
637
+ final theme = Theme.of(context);
638
+ final l10n = AppLocalizations.of(context)!;
639
+
640
+ return _SettingPanel(
641
+ child: Column(
642
+ crossAxisAlignment: CrossAxisAlignment.start,
643
+ children: [
644
+ Text(
645
+ title,
646
+ style: theme.textTheme.titleMedium?.copyWith(
647
+ fontWeight: FontWeight.w700,
648
),
170
- const Divider(height: 32),
171
- _SectionHeader(title: l10n.settings_on_device),
172
- _ToggleSetting(
173
- label: l10n.enable_smart_reply,
174
- value: settings.smartReplyEnabled,
175
- onChanged: (v) =>
176
- ref.read(settingsProvider.notifier).setSmartReplyEnabled(v),
177
- isDark: isDark,
649
),
179
- Padding(
180
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
181
- child: OutlinedButton.icon(
182
- onPressed: () => context.push(AppRoutes.onDeviceModels),
183
- icon: const Icon(Icons.phone_android, size: 18),
184
- label: Text(l10n.manage_on_device_models),
185
- style: OutlinedButton.styleFrom(
186
- minimumSize: const Size(double.infinity, 44),
187
- shape: RoundedRectangleBorder(
188
- borderRadius: BorderRadius.circular(8),
189
- ),
190
- ),
650
+ const SizedBox(height: 6),
651
+ Text(
652
+ subtitle,
653
+ style: theme.textTheme.bodyMedium?.copyWith(
654
+ color: _mutedColor(context),
655
+ height: 1.35,
656
),
657
),
193
- const _OnDeviceOnDeviceEngineStatus(),
194
- const Divider(height: 32),
195
- _SectionHeader(title: l10n.settings_default_server),
196
- _DropdownSetting(
197
- label: l10n.settings_default_server,
198
- currentValue: settings.defaultServerId,
199
- items: (ref.watch(serversProvider).value ?? [])
200
- .map((s) => (s.id, s.name))
658
+ const SizedBox(height: 12),
659
+ _AboutLabel(label: l10n.app_tagline),
660
+ const SizedBox(height: 8),
661
+ Wrap(
662
+ spacing: 6,
663
+ runSpacing: 6,
664
+ children: providers
665
+ .map(
666
+ (provider) =>
667
+ _AboutChip(label: provider, icon: Icons.hub_outlined),
668
+ )
669
.toList(),
202
- onChanged: (v) =>
203
- ref.read(settingsProvider.notifier).setDefaultServer(v),
204
- isDark: isDark,
205
- icon: Icons.computer,
670
),
207
- const Divider(height: 32),
208
- _SectionHeader(title: l10n.settings_default_persona),
209
- _DropdownSetting(
210
- label: l10n.settings_default_persona,
211
- currentValue: settings.defaultPersonaId,
212
- items: (ref.watch(personasNotifierProvider).value ?? [])
213
- .map((p) => (p.id, '${p.emoji} ${p.name}'))
671
+ const SizedBox(height: 12),
672
+ _AboutLabel(label: 'Highlights'),
673
+ const SizedBox(height: 8),
674
+ Wrap(
675
+ spacing: 6,
676
+ runSpacing: 6,
677
+ children: highlights
678
+ .map(
679
+ (highlight) => _AboutChip(
680
+ label: highlight,
681
+ icon: Icons.check_circle_outline_rounded,
682
+ ),
683
+ )
684
.toList(),
215
- onChanged: (v) =>
216
- ref.read(settingsProvider.notifier).setDefaultPersona(v),
217
- isDark: isDark,
218
- icon: Icons.smart_toy_outlined,
219
- ),
220
- const Divider(height: 32),
221
- _SectionHeader(title: l10n.settings_privacy),
222
- _ToggleSetting(
223
- label: l10n.show_data_indicator,
224
- value: settings.showDataIndicator,
225
- onChanged: (v) =>
226
- ref.read(settingsProvider.notifier).setShowDataIndicator(v),
227
- isDark: isDark,
228
- ),
229
- Padding(
230
- padding: const EdgeInsetsDirectional.only(start: 16, bottom: 8),
231
- child: Text(
232
- l10n.privacy_info,
233
- style: TextStyle(
234
- fontSize: 12,
235
- color: isDark
236
- ? const Color(0xFF888888)
237
- : const Color(0xFF999999),
238
- fontStyle: FontStyle.italic,
239
- ),
240
- ),
685
),
242
- const Divider(height: 32),
243
- _SectionHeader(title: l10n.settings_data_management),
244
- _DangerousAction(
245
- label: l10n.delete_all_conversations,
246
- icon: Icons.delete_outline,
247
- onConfirm: () =>
248
- ref.read(conversationsProvider.notifier).deleteAll(),
249
- isDark: isDark,
250
- ),
251
- _DangerousAction(
252
- label: l10n.reset_settings_defaults,
253
- icon: Icons.restore,
254
- onConfirm: () =>
255
- ref.read(settingsProvider.notifier).resetToDefaults(),
256
- isDark: isDark,
686
+ const SizedBox(height: 12),
687
+ _AboutLabel(label: 'Built with'),
688
+ const SizedBox(height: 8),
689
+ Wrap(
690
+ spacing: 6,
691
+ runSpacing: 6,
692
+ children: stack
693
+ .map(
694
+ (item) => _AboutChip(label: item, icon: Icons.code_rounded),
695
+ )
696
+ .toList(),
697
),
258
- const Divider(height: 32),
259
- _SectionHeader(title: l10n.settings_about),
260
- Padding(
261
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
262
- child: Text(
263
- '${l10n.app_name} v${l10n.app_version}',
264
- style: TextStyle(
265
- fontSize: 14,
266
- fontWeight: FontWeight.w500,
267
- color: isDark ? Colors.white : Colors.black,
268
- ),
698
+ const SizedBox(height: 12),
699
+ Container(
700
+ width: double.infinity,
701
+ padding: const EdgeInsets.all(10),
702
+ decoration: BoxDecoration(
703
+ color: _surfaceColor(context),
704
+ borderRadius: BorderRadius.circular(10),
705
+ border: Border.all(color: _outlineColor(context, alpha: 0.8)),
706
),
270
- ),
271
- Padding(
272
- padding: const EdgeInsetsDirectional.only(start: 16, bottom: 8),
273
- child: Text(
274
- l10n.app_tagline,
275
- style: TextStyle(
276
- fontSize: 13,
277
- color: isDark
278
- ? const Color(0xFF888888)
279
- : const Color(0xFF999999),
280
- ),
707
+ child: Row(
708
+ crossAxisAlignment: CrossAxisAlignment.start,
709
+ children: [
710
+ Icon(
711
+ Icons.open_in_new_rounded,
712
+ size: 15,
713
+ color: _mutedColor(context),
714
+ ),
715
+ const SizedBox(width: 8),
716
+ Expanded(
717
+ child: Text(
718
+ openSource,
719
+ style: theme.textTheme.bodySmall?.copyWith(
720
+ color: _mutedColor(context),
721
+ height: 1.35,
722
+ ),
723
+ ),
724
+ ),
725
+ ],
726
),
727
),
283
- const SizedBox(height: 32),
728
],
729
),
730
);
731
}
732
}
733
290
-class _SectionHeader extends StatelessWidget {
291
- const _SectionHeader({required this.title});
292
- final String title;
734
+class _AboutLabel extends StatelessWidget {
735
+ const _AboutLabel({required this.label});
736
+
737
+ final String label;
738
739
@override
740
Widget build(BuildContext context) {
296
- final isDark = Theme.of(context).brightness == Brightness.dark;
297
- return Padding(
298
- padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8, top: 8),
299
- child: Text(
300
- title.toUpperCase(),
301
- style: TextStyle(
302
- fontSize: 12,
303
- fontWeight: FontWeight.w600,
304
- letterSpacing: 0.8,
305
- color: isDark ? const Color(0xFF888888) : const Color(0xFF999999),
306
- ),
741
+ return Text(
742
+ label,
743
+ style: Theme.of(context).textTheme.labelMedium?.copyWith(
744
+ color: _mutedColor(context),
745
+ fontWeight: FontWeight.w700,
746
+ ),
747
+ );
748
+ }
749
+}
750
+
751
+class _AboutChip extends StatelessWidget {
752
+ const _AboutChip({required this.label, required this.icon});
753
+
754
+ final String label;
755
+ final IconData icon;
756
+
757
+ @override
758
+ Widget build(BuildContext context) {
759
+ return Container(
760
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
761
+ decoration: BoxDecoration(
762
+ color: _surfaceColor(context),
763
+ borderRadius: BorderRadius.circular(10),
764
+ border: Border.all(color: _outlineColor(context, alpha: 0.75)),
765
+ ),
766
+ child: Row(
767
+ mainAxisSize: MainAxisSize.min,
768
+ children: [
769
+ Icon(icon, size: 13, color: _mutedColor(context)),
770
+ const SizedBox(width: 6),
771
+ Text(
772
+ label,
773
+ style: Theme.of(
774
+ context,
775
+ ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
776
+ ),
777
+ ],
778
),
779
);
780
}
781
}
782
783
class _LanguageSetting extends StatelessWidget {
313
- const _LanguageSetting({
314
- required this.current,
315
- required this.onChanged,
316
- required this.isDark,
317
- });
784
+ const _LanguageSetting({required this.current, required this.onChanged});
785
319
- static const _localeItems = <(String, String, String, String)>[
786
+ static const localeItems = <(String, String, String, String)>[
787
('en', 'English', 'assets/images/flag_us.png', '🇺🇸'),
788
('ja', '日本語', 'assets/images/flag_jp.png', '🇯🇵'),
789
('it', 'Italiano', 'assets/images/flag_it.png', '🇮🇹'),
@@ -329,67 +796,48 @@ class _LanguageSetting extends StatelessWidget {
796
797
final String? current;
798
final ValueChanged<String?> onChanged;
332
- final bool isDark;
799
800
@override
801
Widget build(BuildContext context) {
802
final l10n = AppLocalizations.of(context)!;
337
- return Padding(
338
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
803
+ final theme = Theme.of(context);
804
+
805
+ return _SettingPanel(
806
child: Column(
807
crossAxisAlignment: CrossAxisAlignment.start,
808
children: [
342
- Text(
343
- l10n.settings_language,
344
- style: TextStyle(
345
- fontSize: 14,
346
- fontWeight: FontWeight.w500,
347
- color: isDark ? Colors.white : Colors.black,
348
- ),
349
- ),
350
- const SizedBox(height: 8),
351
- Container(
352
- decoration: BoxDecoration(
353
- color: isDark ? const Color(0xFF1F1F1F) : const Color(0xFFF5F5F5),
354
- borderRadius: BorderRadius.circular(8),
355
- border: Border.all(
356
- color: isDark
357
- ? const Color(0xFF3A3A3A)
358
- : const Color(0xFFE5E5E5),
359
- ),
360
- ),
809
+ _SettingHeader(label: l10n.settings_language),
810
+ const SizedBox(height: 10),
811
+ _InputShell(
812
child: DropdownButtonHideUnderline(
813
child: DropdownButton<String?>(
814
value: current,
815
isExpanded: true,
365
- padding: const EdgeInsets.symmetric(horizontal: 12),
366
- borderRadius: BorderRadius.circular(8),
367
- dropdownColor: isDark ? const Color(0xFF1F1F1F) : Colors.white,
816
+ padding: const EdgeInsets.symmetric(horizontal: 14),
817
+ borderRadius: BorderRadius.circular(16),
818
+ dropdownColor: theme.colorScheme.surface,
819
items: [
369
- DropdownMenuItem(
820
+ DropdownMenuItem<String?>(
821
value: null,
822
child: Row(
823
children: [
824
Icon(
374
- Icons.settings_suggest,
825
+ Icons.settings_suggest_outlined,
826
size: 18,
376
- color: isDark
377
- ? const Color(0xFF888888)
378
- : const Color(0xFF999999),
827
+ color: _mutedColor(context),
828
),
380
- const SizedBox(width: 8),
381
- Text(
382
- l10n.language_system_default,
383
- style: TextStyle(
384
- fontSize: 14,
385
- color: isDark ? Colors.white : Colors.black,
829
+ const SizedBox(width: 10),
830
+ Flexible(
831
+ child: Text(
832
+ l10n.language_system_default,
833
+ overflow: TextOverflow.ellipsis,
834
),
835
),
836
],
837
),
838
),
391
- ..._localeItems.map(
392
- (item) => DropdownMenuItem(
839
+ ...localeItems.map(
840
+ (item) => DropdownMenuItem<String?>(
841
value: item.$1,
842
child: Row(
843
children: [
@@ -397,36 +845,33 @@ class _LanguageSetting extends StatelessWidget {
845
width: 24,
846
height: 16,
847
decoration: BoxDecoration(
400
- borderRadius: BorderRadius.circular(2),
848
+ borderRadius: BorderRadius.circular(4),
849
border: Border.all(
402
- color: isDark
403
- ? const Color(0xFF444444)
404
- : const Color(0xFFDDDDDD),
405
- width: 0.5,
850
+ color: _outlineColor(context, alpha: 0.8),
851
+ width: 0.6,
852
),
853
),
854
child: ClipRRect(
409
- borderRadius: BorderRadius.circular(2),
855
+ borderRadius: BorderRadius.circular(4),
856
child: Image.asset(
857
item.$3,
412
- width: 24,
413
- height: 16,
858
fit: BoxFit.cover,
859
errorBuilder: (context, error, stackTrace) {
416
- return Text(
417
- item.$4,
418
- style: const TextStyle(fontSize: 12),
860
+ return Center(
861
+ child: Text(
862
+ item.$4,
863
+ style: const TextStyle(fontSize: 12),
864
+ ),
865
);
866
},
867
),
868
),
869
),
870
const SizedBox(width: 10),
425
- Text(
426
- item.$2,
427
- style: TextStyle(
428
- fontSize: 14,
429
- color: isDark ? Colors.white : Colors.black,
871
+ Flexible(
872
+ child: Text(
873
+ item.$2,
874
+ overflow: TextOverflow.ellipsis,
875
),
876
),
877
],
@@ -453,7 +898,6 @@ class _SliderSetting extends StatelessWidget {
898
required this.divisions,
899
required this.description,
900
required this.onChanged,
456
- required this.isDark,
901
this.valueFormat,
902
this.previewText,
903
});
@@ -465,58 +909,57 @@ class _SliderSetting extends StatelessWidget {
909
final int divisions;
910
final String description;
911
final ValueChanged<double> onChanged;
468
- final bool isDark;
912
final String Function(double)? valueFormat;
913
final String? previewText;
914
915
@override
916
Widget build(BuildContext context) {
917
+ final theme = Theme.of(context);
918
+ final primary = theme.colorScheme.primary;
919
final displayValue = valueFormat != null
920
? valueFormat!(value)
921
: value.toStringAsFixed(2);
922
478
- return Padding(
479
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
923
+ return _SettingPanel(
924
child: Column(
925
crossAxisAlignment: CrossAxisAlignment.start,
926
children: [
927
Row(
484
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
928
children: [
486
- Text(
487
- label,
488
- style: TextStyle(
489
- fontSize: 14,
490
- fontWeight: FontWeight.w500,
491
- color: isDark ? Colors.white : Colors.black,
929
+ Expanded(
930
+ child: Text(
931
+ label,
932
+ style: theme.textTheme.titleSmall?.copyWith(
933
+ fontWeight: FontWeight.w700,
934
+ ),
935
),
936
),
494
- Text(
495
- displayValue,
496
- style: TextStyle(
497
- fontSize: 14,
498
- fontWeight: FontWeight.w600,
499
- color: isDark
500
- ? const Color(0xFF3B82F6)
501
- : const Color(0xFF2563EB),
937
+ Container(
938
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
939
+ decoration: BoxDecoration(
940
+ color: primary.withValues(alpha: 0.10),
941
+ borderRadius: BorderRadius.circular(999),
942
+ ),
943
+ child: Text(
944
+ displayValue,
945
+ style: theme.textTheme.labelLarge?.copyWith(
946
+ color: primary,
947
+ fontWeight: FontWeight.w700,
948
+ ),
949
),
950
),
951
],
952
),
953
+ const SizedBox(height: 8),
954
SliderTheme(
507
- data: SliderThemeData(
955
+ data: SliderTheme.of(context).copyWith(
956
trackHeight: 4,
957
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
510
- overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
511
- activeTrackColor: isDark
512
- ? const Color(0xFF3B82F6)
513
- : const Color(0xFF2563EB),
514
- thumbColor: isDark
515
- ? const Color(0xFF3B82F6)
516
- : const Color(0xFF2563EB),
517
- inactiveTrackColor: isDark
518
- ? const Color(0xFF3A3A3A)
519
- : const Color(0xFFE5E5E5),
958
+ overlayShape: const RoundSliderOverlayShape(overlayRadius: 18),
959
+ activeTrackColor: primary,
960
+ thumbColor: primary,
961
+ overlayColor: primary.withValues(alpha: 0.12),
962
+ inactiveTrackColor: _outlineColor(context, alpha: 0.6),
963
),
964
child: Slider(
965
value: value.clamp(min, max),
@@ -528,120 +971,30 @@ class _SliderSetting extends StatelessWidget {
971
),
972
Text(
973
description,
531
- style: TextStyle(
532
- fontSize: 12,
533
- color: isDark ? const Color(0xFF666666) : const Color(0xFF999999),
974
+ style: theme.textTheme.bodySmall?.copyWith(
975
+ color: _mutedColor(context),
976
+ height: 1.4,
977
),
978
),
979
if (previewText != null) ...[
537
- const SizedBox(height: 4),
538
- Text(
539
- previewText!,
540
- style: TextStyle(
541
- fontSize: value.clamp(12, 24),
542
- color: isDark
543
- ? const Color(0xFF888888)
544
- : const Color(0xFF666666),
980
+ const SizedBox(height: 8),
981
+ Container(
982
+ width: double.infinity,
983
+ padding: const EdgeInsets.all(10),
984
+ decoration: BoxDecoration(
985
+ color: theme.scaffoldBackgroundColor.withValues(alpha: 0.65),
986
+ borderRadius: BorderRadius.circular(12),
987
+ border: Border.all(color: _outlineColor(context, alpha: 0.75)),
988
),
546
- ),
547
- ],
548
- const SizedBox(height: 4),
549
- ],
550
- ),
551
- );
552
- }
553
-}
554
-
555
-class _IntInputSetting extends StatefulWidget {
556
- const _IntInputSetting({
557
- required this.label,
558
- required this.value,
559
- required this.description,
560
- required this.onChanged,
561
- required this.isDark,
562
- });
563
-
564
- final String label;
565
- final int value;
566
- final String description;
567
- final ValueChanged<int> onChanged;
568
- final bool isDark;
569
-
570
- @override
571
- State<_IntInputSetting> createState() => _IntInputSettingState();
572
-}
573
-
574
-class _IntInputSettingState extends State<_IntInputSetting> {
575
- late TextEditingController _controller;
576
-
577
- @override
578
- void initState() {
579
- super.initState();
580
- _controller = TextEditingController(text: widget.value.toString());
581
- }
582
-
583
- @override
584
- void didUpdateWidget(_IntInputSetting old) {
585
- super.didUpdateWidget(old);
586
- if (old.value != widget.value) {
587
- _controller.text = widget.value.toString();
588
- }
589
- }
590
-
591
- @override
592
- void dispose() {
593
- _controller.dispose();
594
- super.dispose();
595
- }
596
-
597
- @override
598
- Widget build(BuildContext context) {
599
- return Padding(
600
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
601
- child: Column(
602
- crossAxisAlignment: CrossAxisAlignment.start,
603
- children: [
604
- Text(
605
- widget.label,
606
- style: TextStyle(
607
- fontSize: 14,
608
- fontWeight: FontWeight.w500,
609
- color: widget.isDark ? Colors.white : Colors.black,
610
- ),
611
- ),
612
- const SizedBox(height: 8),
613
- SizedBox(
614
- width: 120,
615
- child: TextField(
616
- controller: _controller,
617
- keyboardType: TextInputType.number,
618
- decoration: InputDecoration(
619
- isDense: true,
620
- contentPadding: const EdgeInsets.symmetric(
621
- horizontal: 12,
622
- vertical: 10,
623
- ),
624
- border: OutlineInputBorder(
625
- borderRadius: BorderRadius.circular(8),
989
+ child: Text(
990
+ previewText!,
991
+ style: theme.textTheme.bodyMedium?.copyWith(
992
+ fontSize: value.clamp(12.0, 24.0),
993
+ color: _mutedColor(context),
994
),
995
),
628
- onChanged: (text) {
629
- final val = int.tryParse(text);
630
- if (val != null && val > 0) widget.onChanged(val);
631
- },
996
),
633
- ),
634
- const SizedBox(height: 4),
635
- Text(
636
- widget.description,
637
- style: TextStyle(
638
- fontSize: 12,
639
- color: widget.isDark
640
- ? const Color(0xFF666666)
641
- : const Color(0xFF999999),
642
- ),
643
- ),
644
- const SizedBox(height: 4),
997
+ ],
998
],
999
),
1000
);
@@ -653,64 +1006,66 @@ class _ToggleSetting extends StatelessWidget {
1006
required this.label,
1007
required this.value,
1008
required this.onChanged,
656
- required this.isDark,
1009
this.badges = const [],
1010
});
1011
1012
final String label;
1013
final bool value;
1014
final ValueChanged<bool> onChanged;
663
- final bool isDark;
1015
final List<Widget> badges;
1016
1017
@override
1018
Widget build(BuildContext context) {
668
- return SwitchListTile(
669
- title: Wrap(
670
- spacing: 6,
671
- runSpacing: 4,
672
- crossAxisAlignment: WrapCrossAlignment.center,
1019
+ final theme = Theme.of(context);
1020
+
1021
+ return _SettingPanel(
1022
+ child: Row(
1023
+ crossAxisAlignment: CrossAxisAlignment.center,
1024
children: [
674
- Text(
675
- label,
676
- style: TextStyle(
677
- fontSize: 14,
678
- color: isDark ? Colors.white : Colors.black,
1025
+ Expanded(
1026
+ child: Wrap(
1027
+ spacing: 8,
1028
+ runSpacing: 6,
1029
+ crossAxisAlignment: WrapCrossAlignment.center,
1030
+ children: [
1031
+ Text(
1032
+ label,
1033
+ style: theme.textTheme.titleSmall?.copyWith(
1034
+ fontWeight: FontWeight.w700,
1035
+ ),
1036
+ ),
1037
+ ...badges,
1038
+ ],
1039
),
1040
),
681
- ...badges,
1041
+ const SizedBox(width: 8),
1042
+ Switch.adaptive(value: value, onChanged: onChanged),
1043
],
1044
),
684
- value: value,
685
- onChanged: onChanged,
686
- dense: true,
687
- contentPadding: const EdgeInsets.symmetric(horizontal: 12),
1045
);
1046
}
1047
}
1048
1049
class _FeatureBadge extends StatelessWidget {
693
- const _FeatureBadge({required this.label, required this.isDark});
1050
+ const _FeatureBadge({required this.label});
1051
1052
final String label;
696
- final bool isDark;
1053
1054
@override
1055
Widget build(BuildContext context) {
1056
return Container(
701
- padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
1057
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
1058
decoration: BoxDecoration(
703
- color: Colors.amber.withValues(alpha: isDark ? 0.18 : 0.14),
1059
+ color: Colors.amber.withValues(alpha: 0.10),
1060
borderRadius: BorderRadius.circular(999),
705
- border: Border.all(color: Colors.amber.withValues(alpha: 0.45)),
1061
+ border: Border.all(color: Colors.amber.withValues(alpha: 0.22)),
1062
),
1063
child: Text(
1064
label.toUpperCase(),
709
- style: const TextStyle(
710
- color: Color(0xFFB45309),
711
- fontSize: 9,
712
- fontWeight: FontWeight.w700,
713
- letterSpacing: 0.4,
1065
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
1066
+ color: const Color(0xFFB45309),
1067
+ fontWeight: FontWeight.w800,
1068
+ letterSpacing: 0.45,
1069
),
1070
),
1071
);
@@ -718,72 +1073,83 @@ class _FeatureBadge extends StatelessWidget {
1073
}
1074
1075
class _ThemeToggle extends StatelessWidget {
721
- const _ThemeToggle({required this.current, required this.ref});
722
- final ThemeMode current;
1076
+ const _ThemeToggle({required this.ref});
1077
+
1078
final WidgetRef ref;
1079
1080
@override
1081
Widget build(BuildContext context) {
1082
final l10n = AppLocalizations.of(context)!;
728
- final isDark = Theme.of(context).brightness == Brightness.dark;
729
- final currentType = ref.watch(themeModeProvider);
1083
+ final currentTheme = ref.watch(themeModeProvider);
1084
731
- return Padding(
732
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
1085
+ return _SettingPanel(
1086
child: Column(
1087
crossAxisAlignment: CrossAxisAlignment.start,
1088
children: [
736
- Text(
737
- l10n.theme,
738
- style: TextStyle(
739
- fontSize: 14,
740
- fontWeight: FontWeight.w500,
741
- color: isDark ? Colors.white : Colors.black,
742
- ),
743
- ),
1089
+ _SettingHeader(label: l10n.theme),
1090
const SizedBox(height: 8),
745
- Row(
746
- children: [
747
- _ThemeOption(
748
- label: l10n.theme_system,
749
- icon: Icons.brightness_auto,
750
- isSelected: currentType == AppThemeType.system,
751
- onTap: () => ref
752
- .read(themeModeProvider.notifier)
753
- .setThemeMode(AppThemeType.system),
754
- isDark: isDark,
755
- ),
756
- const SizedBox(width: 8),
757
- _ThemeOption(
758
- label: l10n.theme_light,
759
- icon: Icons.light_mode,
760
- isSelected: currentType == AppThemeType.light,
761
- onTap: () => ref
762
- .read(themeModeProvider.notifier)
763
- .setThemeMode(AppThemeType.light),
764
- isDark: isDark,
765
- ),
766
- const SizedBox(width: 8),
767
- _ThemeOption(
768
- label: l10n.theme_dark,
769
- icon: Icons.dark_mode,
770
- isSelected: currentType == AppThemeType.dark,
771
- onTap: () => ref
772
- .read(themeModeProvider.notifier)
773
- .setThemeMode(AppThemeType.dark),
774
- isDark: isDark,
775
- ),
776
- const SizedBox(width: 8),
777
- _ThemeOption(
778
- label: l10n.theme_claude,
779
- icon: Icons.auto_awesome,
780
- isSelected: currentType == AppThemeType.claude,
781
- onTap: () => ref
782
- .read(themeModeProvider.notifier)
783
- .setThemeMode(AppThemeType.claude),
784
- isDark: isDark,
785
- ),
786
- ],
1091
+ LayoutBuilder(
1092
+ builder: (context, constraints) {
1093
+ final spacing = 8.0;
1094
+ final columns = constraints.maxWidth >= 700
1095
+ ? 4
1096
+ : constraints.maxWidth >= 420
1097
+ ? 2
1098
+ : 1;
1099
+ final itemWidth =
1100
+ (constraints.maxWidth - (spacing * (columns - 1))) / columns;
1101
+
1102
+ return Wrap(
1103
+ spacing: spacing,
1104
+ runSpacing: spacing,
1105
+ children: [
1106
+ SizedBox(
1107
+ width: itemWidth,
1108
+ child: _ThemeOption(
1109
+ label: l10n.theme_system,
1110
+ icon: Icons.brightness_auto_rounded,
1111
+ isSelected: currentTheme == AppThemeType.system,
1112
+ onTap: () => ref
1113
+ .read(themeModeProvider.notifier)
1114
+ .setThemeMode(AppThemeType.system),
1115
+ ),
1116
+ ),
1117
+ SizedBox(
1118
+ width: itemWidth,
1119
+ child: _ThemeOption(
1120
+ label: l10n.theme_light,
1121
+ icon: Icons.light_mode_rounded,
1122
+ isSelected: currentTheme == AppThemeType.light,
1123
+ onTap: () => ref
1124
+ .read(themeModeProvider.notifier)
1125
+ .setThemeMode(AppThemeType.light),
1126
+ ),
1127
+ ),
1128
+ SizedBox(
1129
+ width: itemWidth,
1130
+ child: _ThemeOption(
1131
+ label: l10n.theme_dark,
1132
+ icon: Icons.dark_mode_rounded,
1133
+ isSelected: currentTheme == AppThemeType.dark,
1134
+ onTap: () => ref
1135
+ .read(themeModeProvider.notifier)
1136
+ .setThemeMode(AppThemeType.dark),
1137
+ ),
1138
+ ),
1139
+ SizedBox(
1140
+ width: itemWidth,
1141
+ child: _ThemeOption(
1142
+ label: l10n.theme_claude,
1143
+ icon: Icons.auto_awesome_rounded,
1144
+ isSelected: currentTheme == AppThemeType.claude,
1145
+ onTap: () => ref
1146
+ .read(themeModeProvider.notifier)
1147
+ .setThemeMode(AppThemeType.claude),
1148
+ ),
1149
+ ),
1150
+ ],
1151
+ );
1152
+ },
1153
),
1154
],
1155
),
@@ -797,61 +1163,68 @@ class _ThemeOption extends StatelessWidget {
1163
required this.icon,
1164
required this.isSelected,
1165
required this.onTap,
800
- required this.isDark,
1166
});
1167
1168
final String label;
1169
final IconData icon;
1170
final bool isSelected;
1171
final VoidCallback onTap;
807
- final bool isDark;
1172
1173
@override
1174
Widget build(BuildContext context) {
811
- final accent = isDark ? const Color(0xFF3B82F6) : const Color(0xFF2563EB);
1175
+ final theme = Theme.of(context);
1176
+ final primary = theme.colorScheme.primary;
1177
813
- return Expanded(
814
- child: GestureDetector(
1178
+ return Material(
1179
+ color: Colors.transparent,
1180
+ child: InkWell(
1181
onTap: onTap,
1182
+ borderRadius: BorderRadius.circular(14),
1183
child: Container(
817
- padding: const EdgeInsets.symmetric(vertical: 10),
1184
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
1185
decoration: BoxDecoration(
1186
color: isSelected
820
- ? accent.withValues(alpha: 0.15)
821
- : (isDark ? const Color(0xFF1F1F1F) : const Color(0xFFF5F5F5)),
822
- borderRadius: BorderRadius.circular(8),
1187
+ ? primary.withValues(alpha: 0.10)
1188
+ : theme.scaffoldBackgroundColor.withValues(alpha: 0.55),
1189
+ borderRadius: BorderRadius.circular(14),
1190
border: Border.all(
824
- color: isSelected
825
- ? accent
826
- : (isDark
827
- ? const Color(0xFF3A3A3A)
828
- : const Color(0xFFE5E5E5)),
1191
+ color: isSelected ? primary : _outlineColor(context, alpha: 0.8),
1192
),
1193
),
831
- child: Column(
1194
+ child: Row(
1195
children: [
833
- Icon(
834
- icon,
835
- size: 20,
836
- color: isSelected
837
- ? accent
838
- : (isDark
839
- ? const Color(0xFF888888)
840
- : const Color(0xFF999999)),
841
- ),
842
- const SizedBox(height: 4),
843
- Text(
844
- label,
845
- style: TextStyle(
846
- fontSize: 11,
847
- fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
1196
+ Container(
1197
+ width: 30,
1198
+ height: 30,
1199
+ decoration: BoxDecoration(
1200
color: isSelected
849
- ? accent
850
- : (isDark
851
- ? const Color(0xFF888888)
852
- : const Color(0xFF999999)),
1201
+ ? primary.withValues(alpha: 0.14)
1202
+ : _panelColor(context),
1203
+ borderRadius: BorderRadius.circular(10),
1204
+ ),
1205
+ child: Icon(
1206
+ icon,
1207
+ size: 16,
1208
+ color: isSelected ? primary : _mutedColor(context),
1209
+ ),
1210
+ ),
1211
+ const SizedBox(width: 10),
1212
+ Expanded(
1213
+ child: Text(
1214
+ label,
1215
+ style: theme.textTheme.titleSmall?.copyWith(
1216
+ fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
1217
+ color: isSelected ? primary : null,
1218
+ ),
1219
),
1220
),
1221
+ Icon(
1222
+ isSelected
1223
+ ? Icons.check_circle_rounded
1224
+ : Icons.radio_button_unchecked_rounded,
1225
+ size: 16,
1226
+ color: isSelected ? primary : _mutedColor(context),
1227
+ ),
1228
],
1229
),
1230
),
@@ -866,7 +1239,6 @@ class _DropdownSetting extends StatelessWidget {
1239
required this.currentValue,
1240
required this.items,
1241
required this.onChanged,
869
- required this.isDark,
1242
required this.icon,
1243
});
1244
@@ -874,79 +1246,61 @@ class _DropdownSetting extends StatelessWidget {
1246
final String? currentValue;
1247
final List<(String id, String name)> items;
1248
final ValueChanged<String?> onChanged;
877
- final bool isDark;
1249
final IconData icon;
1250
1251
@override
1252
Widget build(BuildContext context) {
1253
final l10n = AppLocalizations.of(context)!;
883
- return Padding(
884
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
885
- child: Container(
886
- decoration: BoxDecoration(
887
- color: isDark ? const Color(0xFF1F1F1F) : const Color(0xFFF5F5F5),
888
- borderRadius: BorderRadius.circular(8),
889
- border: Border.all(
890
- color: isDark ? const Color(0xFF3A3A3A) : const Color(0xFFE5E5E5),
891
- ),
892
- ),
893
- child: DropdownButtonHideUnderline(
894
- child: DropdownButton<String>(
895
- value: currentValue,
896
- isExpanded: true,
897
- padding: const EdgeInsets.symmetric(horizontal: 12),
898
- borderRadius: BorderRadius.circular(8),
899
- dropdownColor: isDark ? const Color(0xFF1F1F1F) : Colors.white,
900
- hint: Text(
901
- l10n.none_selected,
902
- style: TextStyle(
903
- fontSize: 14,
904
- color: isDark
905
- ? const Color(0xFF666666)
906
- : const Color(0xFF999999),
907
- ),
908
- ),
909
- items: [
910
- DropdownMenuItem(
911
- value: null,
912
- child: Text(
913
- l10n.none,
914
- style: TextStyle(
915
- fontSize: 14,
916
- color: isDark
917
- ? const Color(0xFF888888)
918
- : const Color(0xFF666666),
1254
+ final theme = Theme.of(context);
1255
+
1256
+ return _SettingPanel(
1257
+ child: Column(
1258
+ crossAxisAlignment: CrossAxisAlignment.start,
1259
+ children: [
1260
+ _SettingHeader(label: label),
1261
+ const SizedBox(height: 10),
1262
+ _InputShell(
1263
+ child: DropdownButtonHideUnderline(
1264
+ child: DropdownButton<String?>(
1265
+ value: currentValue,
1266
+ isExpanded: true,
1267
+ padding: const EdgeInsets.symmetric(horizontal: 14),
1268
+ borderRadius: BorderRadius.circular(16),
1269
+ dropdownColor: theme.colorScheme.surface,
1270
+ hint: Text(
1271
+ l10n.none_selected,
1272
+ style: theme.textTheme.bodyMedium?.copyWith(
1273
+ color: _mutedColor(context),
1274
),
1275
),
921
- ),
922
- ...items.map(
923
- (item) => DropdownMenuItem(
924
- value: item.$1,
925
- child: Row(
926
- children: [
927
- Icon(
928
- icon,
929
- size: 16,
930
- color: isDark
931
- ? const Color(0xFF888888)
932
- : const Color(0xFF999999),
933
- ),
934
- const SizedBox(width: 8),
935
- Text(
936
- item.$2,
937
- style: TextStyle(
938
- fontSize: 14,
939
- color: isDark ? Colors.white : Colors.black,
940
- ),
1276
+ items: [
1277
+ DropdownMenuItem<String?>(
1278
+ value: null,
1279
+ child: Text(l10n.none),
1280
+ ),
1281
+ ...items.map(
1282
+ (item) => DropdownMenuItem<String?>(
1283
+ value: item.$1,
1284
+ child: Row(
1285
+ children: [
1286
+ Icon(icon, size: 16, color: _mutedColor(context)),
1287
+ const SizedBox(width: 8),
1288
+ Flexible(
1289
+ child: Text(
1290
+ item.$2,
1291
+ overflow: TextOverflow.ellipsis,
1292
+ ),
1293
+ ),
1294
+ ],
1295
),
942
- ],
1296
+ ),
1297
),
944
- ),
1298
+ ],
1299
+ onChanged: onChanged,
1300
),
946
- ],
947
- onChanged: onChanged,
1301
+ ),
1302
),
949
- ),
1303
+ ],
1304
),
1305
);
1306
}
@@ -957,76 +1311,55 @@ class _CodeThemeDropdown extends StatelessWidget {
1311
required this.label,
1312
required this.current,
1313
required this.onChanged,
960
- required this.isDark,
1314
});
1315
1316
final String label;
1317
final SyntaxThemeName current;
1318
final ValueChanged<SyntaxThemeName> onChanged;
966
- final bool isDark;
1319
1320
@override
1321
Widget build(BuildContext context) {
1322
final l10n = AppLocalizations.of(context)!;
971
- return Padding(
972
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
1323
+ final theme = Theme.of(context);
1324
+
1325
+ return _SettingPanel(
1326
child: Column(
1327
crossAxisAlignment: CrossAxisAlignment.start,
1328
children: [
976
- Text(
977
- label,
978
- style: TextStyle(
979
- fontSize: 14,
980
- fontWeight: FontWeight.w500,
981
- color: isDark ? Colors.white : Colors.black,
982
- ),
983
- ),
984
- const SizedBox(height: 8),
985
- Container(
986
- decoration: BoxDecoration(
987
- color: isDark ? const Color(0xFF1F1F1F) : const Color(0xFFF5F5F5),
988
- borderRadius: BorderRadius.circular(8),
989
- border: Border.all(
990
- color: isDark
991
- ? const Color(0xFF3A3A3A)
992
- : const Color(0xFFE5E5E5),
993
- ),
994
- ),
1329
+ _SettingHeader(label: label),
1330
+ const SizedBox(height: 10),
1331
+ _InputShell(
1332
child: DropdownButtonHideUnderline(
1333
child: DropdownButton<SyntaxThemeName>(
1334
value: current,
1335
isExpanded: true,
999
- padding: const EdgeInsets.symmetric(horizontal: 12),
1000
- borderRadius: BorderRadius.circular(8),
1001
- dropdownColor: isDark ? const Color(0xFF1F1F1F) : Colors.white,
1002
- items: SyntaxThemeName.values.map((theme) {
1003
- final displayName = switch (theme) {
1336
+ padding: const EdgeInsets.symmetric(horizontal: 14),
1337
+ borderRadius: BorderRadius.circular(16),
1338
+ dropdownColor: theme.colorScheme.surface,
1339
+ items: SyntaxThemeName.values.map((codeTheme) {
1340
+ final displayName = switch (codeTheme) {
1341
SyntaxThemeName.light => l10n.theme_light,
1342
SyntaxThemeName.dark => l10n.theme_dark,
1343
};
1007
- return DropdownMenuItem(
1008
- value: theme,
1009
- child: Text(
1010
- displayName,
1011
- style: TextStyle(
1012
- fontSize: 14,
1013
- color: isDark ? Colors.white : Colors.black,
1014
- ),
1015
- ),
1344
+
1345
+ return DropdownMenuItem<SyntaxThemeName>(
1346
+ value: codeTheme,
1347
+ child: Text(displayName),
1348
);
1349
}).toList(),
1018
- onChanged: (v) {
1019
- if (v != null) onChanged(v);
1350
+ onChanged: (value) {
1351
+ if (value != null) {
1352
+ onChanged(value);
1353
+ }
1354
},
1355
),
1356
),
1357
),
1024
- const SizedBox(height: 4),
1358
+ const SizedBox(height: 8),
1359
Text(
1360
l10n.code_theme_desc,
1027
- style: TextStyle(
1028
- fontSize: 12,
1029
- color: isDark ? const Color(0xFF666666) : const Color(0xFF999999),
1361
+ style: theme.textTheme.bodySmall?.copyWith(
1362
+ color: _mutedColor(context),
1363
),
1364
),
1365
],
@@ -1040,120 +1373,128 @@ class _DangerousAction extends StatelessWidget {
1373
required this.label,
1374
required this.icon,
1375
required this.onConfirm,
1043
- required this.isDark,
1376
});
1377
1378
final String label;
1379
final IconData icon;
1380
final VoidCallback onConfirm;
1049
- final bool isDark;
1381
1382
@override
1383
Widget build(BuildContext context) {
1384
final l10n = AppLocalizations.of(context)!;
1054
- return Padding(
1055
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
1056
- child: OutlinedButton.icon(
1057
- onPressed: () {
1058
- showDialog(
1059
- context: context,
1060
- builder: (ctx) {
1061
- final dlgL10n = AppLocalizations.of(ctx)!;
1062
- return AlertDialog(
1063
- title: Text(label),
1064
- content: Text(dlgL10n.cannot_undo),
1065
- actions: [
1066
- TextButton(
1067
- onPressed: () => Navigator.pop(ctx),
1068
- child: Text(dlgL10n.cancel),
1069
- ),
1070
- TextButton(
1071
- onPressed: () {
1072
- Navigator.pop(ctx);
1073
- onConfirm();
1074
- ScaffoldMessenger.of(context).showSnackBar(
1075
- SnackBar(content: Text(l10n.label_completed(label))),
1076
- );
1077
- },
1078
- style: TextButton.styleFrom(foregroundColor: Colors.red),
1079
- child: Text(dlgL10n.confirm),
1080
- ),
1081
- ],
1082
- );
1083
- },
1084
- );
1085
- },
1086
- icon: Icon(icon, color: Colors.red, size: 18),
1087
- label: Text(label, style: const TextStyle(color: Colors.red)),
1088
- style: OutlinedButton.styleFrom(
1089
- side: const BorderSide(color: Colors.red),
1090
- minimumSize: const Size(double.infinity, 44),
1091
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
1092
- ),
1385
+
1386
+ return OutlinedButton.icon(
1387
+ onPressed: () {
1388
+ showDialog<void>(
1389
+ context: context,
1390
+ builder: (dialogContext) {
1391
+ final dialogL10n = AppLocalizations.of(dialogContext)!;
1392
+ return AlertDialog(
1393
+ title: Text(label),
1394
+ content: Text(dialogL10n.cannot_undo),
1395
+ actions: [
1396
+ TextButton(
1397
+ onPressed: () => Navigator.pop(dialogContext),
1398
+ child: Text(dialogL10n.cancel),
1399
+ ),
1400
+ TextButton(
1401
+ onPressed: () {
1402
+ Navigator.pop(dialogContext);
1403
+ onConfirm();
1404
+ ScaffoldMessenger.of(context).showSnackBar(
1405
+ SnackBar(content: Text(l10n.label_completed(label))),
1406
+ );
1407
+ },
1408
+ style: TextButton.styleFrom(foregroundColor: Colors.red),
1409
+ child: Text(dialogL10n.confirm),
1410
+ ),
1411
+ ],
1412
+ );
1413
+ },
1414
+ );
1415
+ },
1416
+ icon: Icon(icon, color: Colors.red, size: 16),
1417
+ label: Align(
1418
+ alignment: Alignment.centerLeft,
1419
+ child: Text(label, style: const TextStyle(color: Colors.red)),
1420
+ ),
1421
+ style: OutlinedButton.styleFrom(
1422
+ foregroundColor: Colors.red,
1423
+ side: BorderSide(color: Colors.red.withValues(alpha: 0.55)),
1424
+ minimumSize: const Size(double.infinity, 44),
1425
+ alignment: Alignment.centerLeft,
1426
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
1427
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
1428
),
1429
);
1430
}
1431
}
1432
1098
-class _EngineDropdown extends StatelessWidget {
1099
- const _EngineDropdown({
1100
- required this.current,
1101
- required this.onChanged,
1102
- required this.isDark,
1433
+class _SectionActionButton extends StatelessWidget {
1434
+ const _SectionActionButton({
1435
+ required this.icon,
1436
+ required this.label,
1437
+ required this.onPressed,
1438
});
1439
1440
+ final IconData icon;
1441
+ final String label;
1442
+ final VoidCallback onPressed;
1443
+
1444
+ @override
1445
+ Widget build(BuildContext context) {
1446
+ return ShadButton.outline(
1447
+ onPressed: onPressed,
1448
+ width: double.infinity,
1449
+ leading: Icon(icon, size: 16),
1450
+ child: Align(alignment: Alignment.centerLeft, child: Text(label)),
1451
+ );
1452
+ }
1453
+}
1454
+
1455
+class _EngineDropdown extends StatelessWidget {
1456
+ const _EngineDropdown({required this.current, required this.onChanged});
1457
+
1458
final EngineId current;
1459
final ValueChanged<EngineId> onChanged;
1107
- final bool isDark;
1460
1461
@override
1462
Widget build(BuildContext context) {
1463
final l10n = AppLocalizations.of(context)!;
1112
- return Padding(
1113
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
1464
+ final theme = Theme.of(context);
1465
+
1466
+ return _SettingPanel(
1467
child: Column(
1468
crossAxisAlignment: CrossAxisAlignment.start,
1469
children: [
1117
- Text(
1118
- l10n.tts_engine,
1119
- style: TextStyle(
1120
- fontSize: 14,
1121
- fontWeight: FontWeight.w500,
1122
- color: isDark ? Colors.white : Colors.black,
1123
- ),
1124
- ),
1125
- const SizedBox(height: 8),
1126
- Container(
1127
- decoration: BoxDecoration(
1128
- color: isDark ? const Color(0xFF1F1F1F) : const Color(0xFFF5F5F5),
1129
- borderRadius: BorderRadius.circular(8),
1130
- border: Border.all(
1131
- color: isDark
1132
- ? const Color(0xFF3A3A3A)
1133
- : const Color(0xFFE5E5E5),
1134
- ),
1135
- ),
1470
+ _SettingHeader(label: l10n.tts_engine),
1471
+ const SizedBox(height: 10),
1472
+ _InputShell(
1473
child: DropdownButtonHideUnderline(
1474
child: DropdownButton<EngineId>(
1475
value: current,
1476
isExpanded: true,
1140
- padding: const EdgeInsets.symmetric(horizontal: 12),
1141
- borderRadius: BorderRadius.circular(8),
1142
- dropdownColor: isDark ? const Color(0xFF1F1F1F) : Colors.white,
1477
+ padding: const EdgeInsets.symmetric(horizontal: 14),
1478
+ borderRadius: BorderRadius.circular(16),
1479
+ dropdownColor: theme.colorScheme.surface,
1480
items: [
1481
_engineItem(
1482
+ context,
1483
EngineId.system,
1484
l10n.tts_engine_system,
1147
- Icons.record_voice_over,
1485
+ Icons.record_voice_over_rounded,
1486
),
1487
_engineItem(
1488
+ context,
1489
EngineId.kitten,
1490
l10n.tts_engine_kitten,
1152
- Icons.auto_awesome,
1491
+ Icons.auto_awesome_rounded,
1492
),
1493
],
1155
- onChanged: (v) {
1156
- if (v != null) onChanged(v);
1494
+ onChanged: (value) {
1495
+ if (value != null) {
1496
+ onChanged(value);
1497
+ }
1498
},
1499
),
1500
),
@@ -1164,24 +1505,19 @@ class _EngineDropdown extends StatelessWidget {
1505
}
1506
1507
DropdownMenuItem<EngineId> _engineItem(
1508
+ BuildContext context,
1509
EngineId id,
1510
String label,
1511
IconData icon,
1512
) {
1513
final meta = EngineMeta.forEngine(id);
1172
- return DropdownMenuItem(
1514
+ return DropdownMenuItem<EngineId>(
1515
value: id,
1516
child: Row(
1517
children: [
1176
- Icon(icon, size: 16, color: Color(meta.accentColor)),
1177
- const SizedBox(width: 8),
1178
- Text(
1179
- label,
1180
- style: TextStyle(
1181
- fontSize: 14,
1182
- color: isDark ? Colors.white : Colors.black,
1183
- ),
1184
- ),
1518
+ Icon(icon, size: 18, color: Color(meta.accentColor)),
1519
+ const SizedBox(width: 10),
1520
+ Flexible(child: Text(label, overflow: TextOverflow.ellipsis)),
1521
],
1522
),
1523
);
@@ -1193,13 +1529,11 @@ class _VoiceSelector extends StatelessWidget {
1529
required this.engine,
1530
required this.currentVoiceId,
1531
required this.onChanged,
1196
- required this.isDark,
1532
});
1533
1534
final EngineId engine;
1535
final String? currentVoiceId;
1536
final ValueChanged<Voice?> onChanged;
1202
- final bool isDark;
1537
1538
@override
1539
Widget build(BuildContext context) {
@@ -1210,88 +1544,46 @@ class _VoiceSelector extends StatelessWidget {
1544
engine,
1545
)?.id;
1546
1213
- if (voices.isEmpty) return const SizedBox.shrink();
1547
+ if (voices.isEmpty) {
1548
+ return const SizedBox.shrink();
1549
+ }
1550
1215
- final femaleVoices = voices.where((v) => v.gender == 'f').toList();
1216
- final maleVoices = voices.where((v) => v.gender == 'm').toList();
1551
+ final femaleVoices = voices.where((voice) => voice.gender == 'f').toList();
1552
+ final maleVoices = voices.where((voice) => voice.gender == 'm').toList();
1553
final otherVoices = voices
1218
- .where((v) => v.gender != 'f' && v.gender != 'm')
1554
+ .where((voice) => voice.gender != 'f' && voice.gender != 'm')
1555
.toList();
1556
1221
- return Padding(
1222
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
1557
+ return _SettingPanel(
1558
child: Column(
1559
crossAxisAlignment: CrossAxisAlignment.start,
1560
children: [
1226
- Text(
1227
- l10n.voice,
1228
- style: TextStyle(
1229
- fontSize: 14,
1230
- fontWeight: FontWeight.w500,
1231
- color: isDark ? Colors.white : Colors.black,
1232
- ),
1233
- ),
1234
- const SizedBox(height: 8),
1561
+ _SettingHeader(label: l10n.voice),
1562
if (femaleVoices.isNotEmpty) ...[
1236
- Text(
1237
- l10n.voice_female,
1238
- style: TextStyle(
1239
- fontSize: 11,
1240
- color: isDark
1241
- ? const Color(0xFF888888)
1242
- : const Color(0xFF999999),
1243
- ),
1244
- ),
1245
- const SizedBox(height: 4),
1246
- ...femaleVoices.map(
1247
- (v) => _VoiceTile(
1248
- voice: v,
1249
- selected: v.id == resolvedCurrentVoiceId,
1250
- onTap: () => onChanged(v),
1251
- isDark: isDark,
1252
- ),
1253
- ),
1563
const SizedBox(height: 8),
1564
+ _VoiceGroup(
1565
+ title: l10n.voice_female,
1566
+ voices: femaleVoices,
1567
+ selectedVoiceId: resolvedCurrentVoiceId,
1568
+ onChanged: onChanged,
1569
+ ),
1570
],
1571
if (maleVoices.isNotEmpty) ...[
1257
- Text(
1258
- l10n.voice_male,
1259
- style: TextStyle(
1260
- fontSize: 11,
1261
- color: isDark
1262
- ? const Color(0xFF888888)
1263
- : const Color(0xFF999999),
1264
- ),
1265
- ),
1266
- const SizedBox(height: 4),
1267
- ...maleVoices.map(
1268
- (v) => _VoiceTile(
1269
- voice: v,
1270
- selected: v.id == resolvedCurrentVoiceId,
1271
- onTap: () => onChanged(v),
1272
- isDark: isDark,
1273
- ),
1572
+ const SizedBox(height: 12),
1573
+ _VoiceGroup(
1574
+ title: l10n.voice_male,
1575
+ voices: maleVoices,
1576
+ selectedVoiceId: resolvedCurrentVoiceId,
1577
+ onChanged: onChanged,
1578
),
1579
],
1580
if (otherVoices.isNotEmpty) ...[
1277
- const SizedBox(height: 8),
1278
- Text(
1279
- l10n.voice_other,
1280
- style: TextStyle(
1281
- fontSize: 11,
1282
- color: isDark
1283
- ? const Color(0xFF888888)
1284
- : const Color(0xFF999999),
1285
- ),
1286
- ),
1287
- const SizedBox(height: 4),
1288
- ...otherVoices.map(
1289
- (v) => _VoiceTile(
1290
- voice: v,
1291
- selected: v.id == resolvedCurrentVoiceId,
1292
- onTap: () => onChanged(v),
1293
- isDark: isDark,
1294
- ),
1581
+ const SizedBox(height: 12),
1582
+ _VoiceGroup(
1583
+ title: l10n.voice_other,
1584
+ voices: otherVoices,
1585
+ selectedVoiceId: resolvedCurrentVoiceId,
1586
+ onChanged: onChanged,
1587
),
1588
],
1589
],
@@ -1300,81 +1592,117 @@ class _VoiceSelector extends StatelessWidget {
1592
}
1593
}
1594
1303
-class _VoiceTile extends StatelessWidget {
1304
- const _VoiceTile({
1595
+class _VoiceGroup extends StatelessWidget {
1596
+ const _VoiceGroup({
1597
+ required this.title,
1598
+ required this.voices,
1599
+ required this.selectedVoiceId,
1600
+ required this.onChanged,
1601
+ });
1602
+
1603
+ final String title;
1604
+ final List<Voice> voices;
1605
+ final String? selectedVoiceId;
1606
+ final ValueChanged<Voice?> onChanged;
1607
+
1608
+ @override
1609
+ Widget build(BuildContext context) {
1610
+ return Column(
1611
+ crossAxisAlignment: CrossAxisAlignment.start,
1612
+ children: [
1613
+ Text(
1614
+ title.toUpperCase(),
1615
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
1616
+ color: _mutedColor(context),
1617
+ fontWeight: FontWeight.w700,
1618
+ letterSpacing: 0.5,
1619
+ ),
1620
+ ),
1621
+ const SizedBox(height: 6),
1622
+ Wrap(
1623
+ spacing: 8,
1624
+ runSpacing: 8,
1625
+ children: voices
1626
+ .map(
1627
+ (voice) => _VoiceChip(
1628
+ voice: voice,
1629
+ selected: voice.id == selectedVoiceId,
1630
+ onTap: () => onChanged(voice),
1631
+ ),
1632
+ )
1633
+ .toList(),
1634
+ ),
1635
+ ],
1636
+ );
1637
+ }
1638
+}
1639
+
1640
+class _VoiceChip extends StatelessWidget {
1641
+ const _VoiceChip({
1642
required this.voice,
1643
required this.selected,
1644
required this.onTap,
1308
- required this.isDark,
1645
});
1646
1647
final Voice voice;
1648
final bool selected;
1649
final VoidCallback onTap;
1314
- final bool isDark;
1650
1651
@override
1652
Widget build(BuildContext context) {
1318
- final accent = isDark ? const Color(0xFF3B82F6) : const Color(0xFF2563EB);
1319
- return Padding(
1320
- padding: const EdgeInsets.only(bottom: 4),
1321
- child: GestureDetector(
1653
+ final theme = Theme.of(context);
1654
+ final primary = theme.colorScheme.primary;
1655
+
1656
+ return Material(
1657
+ color: Colors.transparent,
1658
+ child: InkWell(
1659
onTap: onTap,
1660
+ borderRadius: BorderRadius.circular(12),
1661
child: Container(
1324
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
1662
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
1663
decoration: BoxDecoration(
1664
color: selected
1327
- ? accent.withValues(alpha: 0.1)
1328
- : (isDark ? const Color(0xFF1A1A1A) : const Color(0xFFFAFAFA)),
1329
- borderRadius: BorderRadius.circular(6),
1665
+ ? primary.withValues(alpha: 0.10)
1666
+ : theme.scaffoldBackgroundColor.withValues(alpha: 0.5),
1667
+ borderRadius: BorderRadius.circular(12),
1668
border: Border.all(
1331
- color: selected
1332
- ? accent
1333
- : (isDark
1334
- ? const Color(0xFF3A3A3A)
1335
- : const Color(0xFFE5E5E5)),
1669
+ color: selected ? primary : _outlineColor(context, alpha: 0.8),
1670
),
1671
),
1672
child: Row(
1673
+ mainAxisSize: MainAxisSize.min,
1674
children: [
1675
Icon(
1341
- selected ? Icons.radio_button_checked : Icons.radio_button_off,
1676
+ selected
1677
+ ? Icons.radio_button_checked_rounded
1678
+ : Icons.radio_button_off_rounded,
1679
size: 16,
1343
- color: selected
1344
- ? accent
1345
- : (isDark
1346
- ? const Color(0xFF666666)
1347
- : const Color(0xFF999999)),
1680
+ color: selected ? primary : _mutedColor(context),
1681
),
1682
const SizedBox(width: 8),
1683
Text(
1684
voice.name,
1352
- style: TextStyle(
1353
- fontSize: 13,
1354
- color: isDark ? Colors.white : Colors.black,
1355
- fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
1685
+ style: theme.textTheme.bodyMedium?.copyWith(
1686
+ fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
1687
+ color: selected ? primary : null,
1688
),
1689
),
1690
if (voice.language != null) ...[
1691
const SizedBox(width: 6),
1692
Container(
1693
padding: const EdgeInsets.symmetric(
1362
- horizontal: 4,
1363
- vertical: 1,
1694
+ horizontal: 5,
1695
+ vertical: 2,
1696
),
1697
decoration: BoxDecoration(
1366
- color: isDark
1367
- ? const Color(0xFF2A2A2A)
1368
- : const Color(0xFFEEEEEE),
1369
- borderRadius: BorderRadius.circular(3),
1698
+ color: _panelColor(context),
1699
+ borderRadius: BorderRadius.circular(999),
1700
),
1701
child: Text(
1702
voice.language!,
1373
- style: TextStyle(
1374
- fontSize: 9,
1375
- color: isDark
1376
- ? const Color(0xFF888888)
1377
- : const Color(0xFF666666),
1703
+ style: theme.textTheme.labelSmall?.copyWith(
1704
+ color: _mutedColor(context),
1705
+ fontWeight: FontWeight.w700,
1706
),
1707
),
1708
),
@@ -1387,88 +1715,167 @@ class _VoiceTile extends StatelessWidget {
1715
}
1716
}
1717
1390
-class _OnDeviceOnDeviceEngineStatus extends ConsumerWidget {
1391
- const _OnDeviceOnDeviceEngineStatus();
1718
+class _OnDeviceEngineStatusCard extends ConsumerWidget {
1719
+ const _OnDeviceEngineStatusCard();
1720
1721
@override
1722
Widget build(BuildContext context, WidgetRef ref) {
1723
final l10n = AppLocalizations.of(context)!;
1724
final engineState = ref.watch(onDeviceEngineProvider);
1397
- final isDark = Theme.of(context).brightness == Brightness.dark;
1398
-
1399
- if (engineState.status == OnDeviceEngineStatus.notLoaded) {
1400
- return Padding(
1401
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
1402
- child: Text(
1403
- l10n.no_model_loaded,
1404
- style: TextStyle(
1405
- fontSize: 12,
1406
- color: isDark ? const Color(0xFF666666) : const Color(0xFF999999),
1407
- ),
1408
- ),
1409
- );
1410
- }
1725
1412
- return Padding(
1413
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
1414
- child: Container(
1415
- padding: const EdgeInsets.all(12),
1416
- decoration: BoxDecoration(
1417
- color: engineState.status == OnDeviceEngineStatus.loaded
1418
- ? Colors.green.withValues(alpha: 0.1)
1419
- : engineState.status == OnDeviceEngineStatus.error
1420
- ? Colors.red.withValues(alpha: 0.1)
1421
- : Colors.blue.withValues(alpha: 0.1),
1422
- borderRadius: BorderRadius.circular(8),
1423
- border: Border.all(
1424
- color: engineState.status == OnDeviceEngineStatus.loaded
1425
- ? Colors.green
1426
- : engineState.status == OnDeviceEngineStatus.error
1427
- ? Colors.red
1428
- : Colors.blue,
1429
- ),
1726
+ final (
1727
+ Color accent,
1728
+ IconData icon,
1729
+ String message,
1730
+ ) = switch (engineState.status) {
1731
+ OnDeviceEngineStatus.loaded => (
1732
+ Colors.green,
1733
+ Icons.check_circle_rounded,
1734
+ l10n.model_loaded(
1735
+ engineState.loadedModelId ?? 'unknown',
1736
+ engineState.backend?.name ?? 'CPU',
1737
),
1431
- child: Row(
1432
- children: [
1433
- Icon(
1434
- engineState.status == OnDeviceEngineStatus.loaded
1435
- ? Icons.check_circle
1436
- : engineState.status == OnDeviceEngineStatus.error
1437
- ? Icons.error
1438
- : Icons.hourglass_top,
1439
- size: 18,
1440
- color: engineState.status == OnDeviceEngineStatus.loaded
1441
- ? Colors.green
1442
- : engineState.status == OnDeviceEngineStatus.error
1443
- ? Colors.red
1444
- : Colors.blue,
1445
- ),
1446
- const SizedBox(width: 8),
1447
- Expanded(
1448
- child: Text(
1449
- engineState.status == OnDeviceEngineStatus.loaded
1450
- ? l10n.model_loaded(
1451
- engineState.loadedModelId ?? "unknown",
1452
- engineState.backend?.name ?? "CPU",
1453
- )
1454
- : engineState.status == OnDeviceEngineStatus.loading
1455
- ? l10n.loading
1456
- : l10n.error_with_message(
1457
- engineState.error ?? l10n.unknown_error,
1458
- ),
1459
- style: TextStyle(
1460
- fontSize: 13,
1461
- color: engineState.status == OnDeviceEngineStatus.loaded
1462
- ? Colors.green
1463
- : engineState.status == OnDeviceEngineStatus.error
1464
- ? Colors.red
1465
- : Colors.blue,
1466
- ),
1738
+ ),
1739
+ OnDeviceEngineStatus.loading => (
1740
+ Colors.blue,
1741
+ Icons.hourglass_top_rounded,
1742
+ l10n.loading,
1743
+ ),
1744
+ OnDeviceEngineStatus.error => (
1745
+ Colors.red,
1746
+ Icons.error_rounded,
1747
+ l10n.error_with_message(engineState.error ?? l10n.unknown_error),
1748
+ ),
1749
+ OnDeviceEngineStatus.notLoaded => (
1750
+ Colors.grey,
1751
+ Icons.info_outline_rounded,
1752
+ l10n.no_model_loaded,
1753
+ ),
1754
+ };
1755
+
1756
+ return Container(
1757
+ padding: const EdgeInsets.all(14),
1758
+ decoration: BoxDecoration(
1759
+ color: accent.withValues(alpha: 0.08),
1760
+ borderRadius: BorderRadius.circular(14),
1761
+ border: Border.all(color: accent.withValues(alpha: 0.25)),
1762
+ ),
1763
+ child: Row(
1764
+ crossAxisAlignment: CrossAxisAlignment.start,
1765
+ children: [
1766
+ Icon(icon, size: 18, color: accent),
1767
+ const SizedBox(width: 8),
1768
+ Expanded(
1769
+ child: Text(
1770
+ message,
1771
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
1772
+ color: accent,
1773
+ fontWeight: FontWeight.w600,
1774
+ height: 1.3,
1775
),
1776
),
1469
- ],
1470
- ),
1777
+ ),
1778
+ ],
1779
+ ),
1780
+ );
1781
+ }
1782
+}
1783
+
1784
+class _SettingHeader extends StatelessWidget {
1785
+ const _SettingHeader({required this.label});
1786
+
1787
+ final String label;
1788
+
1789
+ @override
1790
+ Widget build(BuildContext context) {
1791
+ return Text(
1792
+ label,
1793
+ style: Theme.of(
1794
+ context,
1795
+ ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
1796
+ );
1797
+ }
1798
+}
1799
+
1800
+class _InputShell extends StatelessWidget {
1801
+ const _InputShell({required this.child});
1802
+
1803
+ final Widget child;
1804
+
1805
+ @override
1806
+ Widget build(BuildContext context) {
1807
+ return Container(
1808
+ decoration: BoxDecoration(
1809
+ color: _surfaceColor(context),
1810
+ borderRadius: BorderRadius.circular(12),
1811
+ border: Border.all(color: _outlineColor(context, alpha: 0.8)),
1812
),
1813
+ child: child,
1814
);
1815
}
1816
}
1817
+
1818
+List<Widget> _withVerticalSpacing(List<Widget> children, {double gap = 12}) {
1819
+ if (children.isEmpty) {
1820
+ return const [];
1821
+ }
1822
+
1823
+ return [
1824
+ for (var index = 0; index < children.length; index++) ...[
1825
+ children[index],
1826
+ if (index != children.length - 1) SizedBox(height: gap),
1827
+ ],
1828
+ ];
1829
+}
1830
+
1831
+Color _panelColor(BuildContext context) {
1832
+ final scheme = ShadTheme.of(context).colorScheme;
1833
+ return scheme.secondary;
1834
+}
1835
+
1836
+Color _surfaceColor(BuildContext context) {
1837
+ return ShadTheme.of(context).colorScheme.card;
1838
+}
1839
+
1840
+Color _primaryColor(BuildContext context) {
1841
+ return ShadTheme.of(context).colorScheme.primary;
1842
+}
1843
+
1844
+Color _outlineColor(BuildContext context, {double alpha = 0.6}) {
1845
+ return ShadTheme.of(context).colorScheme.border.withValues(alpha: alpha);
1846
+}
1847
+
1848
+Color _mutedColor(BuildContext context) {
1849
+ return ShadTheme.of(context).colorScheme.mutedForeground;
1850
+}
1851
+
1852
+String _themeLabel(AppThemeType themeType, AppLocalizations l10n) {
1853
+ return switch (themeType) {
1854
+ AppThemeType.system => l10n.theme_system,
1855
+ AppThemeType.light => l10n.theme_light,
1856
+ AppThemeType.dark => l10n.theme_dark,
1857
+ AppThemeType.claude => l10n.theme_claude,
1858
+ };
1859
+}
1860
+
1861
+String _engineLabel(EngineId engine, AppLocalizations l10n) {
1862
+ return switch (engine) {
1863
+ EngineId.system => l10n.tts_engine_system,
1864
+ EngineId.kitten => l10n.tts_engine_kitten,
1865
+ EngineId.piper => EngineMeta.piper.name,
1866
+ };
1867
+}
1868
+
1869
+String _languageLabel(String? localeCode, AppLocalizations l10n) {
1870
+ if (localeCode == null) {
1871
+ return l10n.language_system_default;
1872
+ }
1873
+
1874
+ for (final item in _LanguageSetting.localeItems) {
1875
+ if (item.$1 == localeCode) {
1876
+ return item.$2;
1877
+ }
1878
+ }
1879
+
1880
+ return localeCode.toUpperCase();
1881
+}
lib/features/sidebar/components/active_server_indicator.dart
+3
@@ -101,6 +101,9 @@ class ActiveServerIndicator extends ConsumerWidget {
101
isScrollControlled: true,
102
backgroundColor: Colors.transparent,
103
builder: (context) => Container(
104
+ padding: EdgeInsets.only(
105
+ bottom: MediaQuery.of(context).padding.bottom,
106
+ ),
107
decoration: BoxDecoration(
108
color: theme.colorScheme.surface,
109
borderRadius: const BorderRadius.vertical(
pubspec.yaml
+1
-1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
# In Windows, build-name is used as the major, minor, and patch parts
18
# of the product and file versions while build-number is used as the build suffix.
19
-version: 1.3.3+35
19
+version: 1.3.4+36
20
21
environment:
22
sdk: ^3.11.0