@tduncan / Cortana / commits / 3cc631d

refactor: migrate settings UI to shadcn components and fixes #21

abdulmominsakib committed May 31, 2026 at 19:24 UTC 3cc631d132cda81c2489d05f1a6debcf57aece5a
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