main
dart 2,834 lines 92.4 KB
Raw
1 import 'package:cue/cue.dart';
2 import 'package:hugeicons/hugeicons.dart';
3 import 'package:flutter/material.dart';
4 import 'package:flutter/services.dart';
5 import 'package:flutter_riverpod/flutter_riverpod.dart';
6 import 'package:go_router/go_router.dart';
7 import 'package:shadcn_ui/shadcn_ui.dart';
8 import 'package:url_launcher/url_launcher.dart';
9
10 import 'package:localmind/l10n/app_localizations.dart';
11
12 import '../../../core/models/enums.dart';
13 import '../../../core/providers/app_providers.dart';
14 import '../../../core/routes/app_routes.dart';
15 import '../../../core/services/android_assistant_service.dart';
16 import '../../../core/services/app_haptics.dart';
17 import '../../../core/services/crash_report_service.dart';
18 import '../../../core/theme/app_theme.dart';
19 import '../../../core/utils/system_insets.dart';
20 import '../../chat/providers/chat_providers.dart';
21 import '../../chat/utils/image_upload_utils.dart';
22 import '../../conversations/providers/conversation_providers.dart';
23 import '../../models/data/models/model_info.dart';
24 import '../../on_device/providers/on_device_providers.dart';
25 import '../../personas/providers/personas_providers.dart';
26 import '../../servers/providers/server_providers.dart';
27 import '../data/models/app_settings.dart';
28 import 'data_backup_actions.dart';
29
30 class SettingsViews extends ConsumerWidget {
31 const SettingsViews({super.key});
32
33 @override
34 Widget build(BuildContext context, WidgetRef ref) {
35 final l10n = AppLocalizations.of(context)!;
36 final settings = ref.watch(settingsProvider);
37 final currentTheme = ref.watch(themeModeProvider);
38 final systemBottomInset = bottomSystemInset(context);
39 final packageInfo = ref.watch(packageInfoProvider);
40 final assistantService = ref.watch(androidAssistantServiceProvider);
41 final servers = (ref.watch(serversProvider).value ?? [])
42 .map((server) => (server.id, server.name))
43 .toList();
44 final personas = (ref.watch(personasNotifierProvider).value ?? [])
45 .map((persona) => (persona.id, '${persona.emoji} ${persona.name}'))
46 .toList();
47
48 // The default model dropdown lists models from the server used as the
49 // default (the configured default server, falling back to the first one).
50 final defaultServerId =
51 settings.defaultServerId ??
52 (servers.isNotEmpty ? servers.first.$1 : null);
53 final availableModels = defaultServerId != null
54 ? (ref.watch(availableModelsProvider(defaultServerId)).value ??
55 const <dynamic>[])
56 .cast<ModelInfo>()
57 : <ModelInfo>[];
58 final models = availableModels.map((m) => (m.id, m.displayName)).toList();
59 final isDefaultModelForServer =
60 settings.defaultModelServerId != null &&
61 settings.defaultModelServerId == defaultServerId;
62
63 final isDark = Theme.of(context).brightness == Brightness.dark;
64 final topPadding = MediaQuery.of(context).padding.top;
65
66 return Column(
67 children: [
68 Container(
69 padding: EdgeInsets.only(
70 left: 16,
71 right: 16,
72 top: topPadding + 8,
73 bottom: 16,
74 ),
75 decoration: BoxDecoration(
76 color: isDark ? const Color(0xFF0A0A0A) : const Color(0xFFFAFAFA),
77 border: Border(
78 bottom: BorderSide(
79 color: isDark
80 ? const Color(0xFF2A2A2A)
81 : const Color(0xFFE5E5E5),
82 ),
83 ),
84 ),
85 child: Row(
86 children: [
87 Builder(
88 builder: (context) => IconButton(
89 icon: const HugeIcon(icon: HugeIcons.strokeRoundedMenu01),
90 onPressed: () => Scaffold.maybeOf(context)?.openDrawer(),
91 ),
92 ),
93 const SizedBox(width: 8),
94 Text(
95 l10n.settings_title,
96 style: TextStyle(
97 fontSize: 20,
98 fontWeight: FontWeight.bold,
99 color: isDark ? Colors.white : Colors.black,
100 ),
101 ),
102 ],
103 ),
104 ),
105 Expanded(
106 child: LayoutBuilder(
107 builder: (context, constraints) {
108 final useTwoColumns = constraints.maxWidth >= 1080;
109 final contentMaxWidth = useTwoColumns ? 1120.0 : 720.0;
110 final horizontalPadding = constraints.maxWidth >= 720
111 ? 20.0
112 : 12.0;
113
114 final appearanceCard = _SettingsSectionCard(
115 title: l10n.settings_appearance,
116 icon: HugeIcons.strokeRoundedPaintBrush02,
117 accent: const Color(0xFF8B5CF6),
118 children: [
119 _ThemeToggle(ref: ref),
120 _LanguageSetting(
121 current: settings.localeCode,
122 onChanged: (value) => ref
123 .read(settingsProvider.notifier)
124 .setLocaleCode(value),
125 ),
126 _SliderSetting(
127 label: l10n.font_size,
128 value: settings.fontSize,
129 min: 12.0,
130 max: 24.0,
131 divisions: 12,
132 description: l10n.font_size_desc,
133 onChanged: (value) =>
134 ref.read(settingsProvider.notifier).setFontSize(value),
135 valueFormat: (value) => value.toStringAsFixed(0),
136 previewText: l10n.font_preview,
137 ),
138 _CodeThemeDropdown(
139 label: l10n.code_theme_dark,
140 current: settings.codeThemeDark,
141 onChanged: (value) => ref
142 .read(settingsProvider.notifier)
143 .setCodeThemeDark(value),
144 ),
145 _CodeThemeDropdown(
146 label: l10n.code_theme_light,
147 current: settings.codeThemeLight,
148 onChanged: (value) => ref
149 .read(settingsProvider.notifier)
150 .setCodeThemeLight(value),
151 ),
152 ],
153 );
154
155 final ttsCard = _SettingsSectionCard(
156 title: l10n.settings_tts,
157 icon: HugeIcons.strokeRoundedVoice,
158 accent: const Color(0xFF0EA5E9),
159 children: [
160 _EngineDropdown(
161 current: settings.ttsEngine,
162 onChanged: (value) =>
163 ref.read(settingsProvider.notifier).setTtsEngine(value),
164 ),
165 _SectionActionButton(
166 icon: HugeIcons.strokeRoundedVoice,
167 label: l10n.manage_tts_models,
168 onPressed: () => context.push(AppRoutes.ttsModels),
169 ),
170 if (settings.ttsEngine != EngineId.system) ...[
171 _VoiceSelector(
172 engine: settings.ttsEngine,
173 currentVoiceId: settings.ttsVoiceId,
174 onChanged: (voice) => ref
175 .read(settingsProvider.notifier)
176 .setTtsVoiceId(voice?.id),
177 ),
178 _SliderSetting(
179 label: l10n.tts_speed,
180 value: settings.ttsSpeed,
181 min: 0.5,
182 max: 2.0,
183 divisions: 15,
184 description: l10n.tts_speed_desc,
185 onChanged: (value) => ref
186 .read(settingsProvider.notifier)
187 .setTtsSpeed(value),
188 valueFormat: (value) => '${value.toStringAsFixed(2)}x',
189 ),
190 ],
191 _ToggleSetting(
192 label: l10n.tts_process_markdown,
193 description: l10n.tts_process_markdown_desc,
194 value: settings.ttsProcessMarkdown,
195 onChanged: (value) => ref
196 .read(settingsProvider.notifier)
197 .setTtsProcessMarkdown(value),
198 ),
199 _ToggleSetting(
200 label: l10n.settings_concise_voice_responses,
201 description: l10n.settings_concise_voice_responses_desc,
202 value: settings.conciseVoiceResponsesEnabled,
203 onChanged: (value) => ref
204 .read(settingsProvider.notifier)
205 .setConciseVoiceResponsesEnabled(value),
206 ),
207 _TtsSkipSecondsSetting(
208 value: settings.ttsSkipSeconds,
209 onChanged: (value) => ref
210 .read(settingsProvider.notifier)
211 .setTtsSkipSeconds(value),
212 ),
213 ],
214 );
215
216 final assistantCard = assistantService.isSupportedPlatform
217 ? _SettingsSectionCard(
218 title: l10n.settings_android_assistant,
219 icon: HugeIcons.strokeRoundedVoice,
220 accent: const Color(0xFF6366F1),
221 badges: [_FeatureBadge(label: l10n.beta_label)],
222 children: const [_AndroidAssistantSetting()],
223 )
224 : null;
225
226 final behaviorCard = _SettingsSectionCard(
227 title: l10n.settings_behavior,
228 icon: HugeIcons.strokeRoundedSlidersHorizontal,
229 accent: const Color(0xFF22C55E),
230 children: [
231 _ToggleSetting(
232 label: l10n.streaming_responses,
233 value: settings.streamingEnabled,
234 onChanged: (value) => ref
235 .read(settingsProvider.notifier)
236 .setStreamingEnabled(value),
237 ),
238 _ToggleSetting(
239 label: l10n.auto_generate_titles,
240 value: settings.autoGenerateTitle,
241 onChanged: (value) => ref
242 .read(settingsProvider.notifier)
243 .setAutoGenerateTitle(value),
244 ),
245 _ToggleSetting(
246 label: l10n.send_on_enter,
247 value: settings.sendOnEnter,
248 onChanged: (value) => ref
249 .read(settingsProvider.notifier)
250 .setSendOnEnter(value),
251 ),
252 _ToggleSetting(
253 label: l10n.show_system_messages,
254 description: l10n.show_system_messages_desc,
255 value: settings.showSystemMessages,
256 onChanged: (value) => ref
257 .read(settingsProvider.notifier)
258 .setShowSystemMessages(value),
259 ),
260 _ToggleSetting(
261 label: l10n.show_system_messages_in_chat,
262 description: l10n.show_system_messages_in_chat_desc,
263 value: settings.showSystemMessagesInChat,
264 onChanged: (value) => ref
265 .read(settingsProvider.notifier)
266 .setShowSystemMessagesInChat(value),
267 ),
268 _ToggleSetting(
269 label: l10n.auto_collapse_thinking,
270 description: l10n.auto_collapse_thinking_desc,
271 value: settings.autoCollapseThinking,
272 onChanged: (value) => ref
273 .read(settingsProvider.notifier)
274 .setAutoCollapseThinking(value),
275 ),
276 _ToggleSetting(
277 label: l10n.send_temperature_to_api,
278 description: l10n.send_sampling_params_desc,
279 value: settings.sendTemperature,
280 onChanged: (value) => ref
281 .read(settingsProvider.notifier)
282 .setSendTemperature(value),
283 ),
284 _ToggleSetting(
285 label: l10n.send_top_p_to_api,
286 description: l10n.send_sampling_params_desc,
287 value: settings.sendTopP,
288 onChanged: (value) =>
289 ref.read(settingsProvider.notifier).setSendTopP(value),
290 ),
291 _DefaultSystemPromptSetting(
292 currentPrompt: settings.defaultSystemPrompt,
293 onSave: (value) => ref
294 .read(settingsProvider.notifier)
295 .setDefaultSystemPrompt(value),
296 ),
297 _ToggleSetting(
298 label: l10n.haptic_feedback,
299 value: settings.hapticFeedbackEnabled,
300 onChanged: (value) {
301 ref
302 .read(settingsProvider.notifier)
303 .setHapticFeedback(value);
304 if (value) ref.read(appHapticsProvider).medium();
305 },
306 ),
307 _ToggleSetting(
308 label: l10n.temp_chat_keyboard_incognito,
309 description: l10n.temp_chat_keyboard_incognito_desc,
310 value: settings.tempChatKeyboardIncognito,
311 onChanged: (value) => ref
312 .read(settingsProvider.notifier)
313 .setTempChatKeyboardIncognito(value),
314 ),
315 _ToggleSetting(
316 label: l10n.resume_last_chat,
317 description: l10n.resume_last_chat_desc,
318 value: settings.resumeLastChat,
319 onChanged: (value) => ref
320 .read(settingsProvider.notifier)
321 .setResumeLastChat(value),
322 ),
323 _ToggleSetting(
324 label: l10n.keep_persona_on_new_chat,
325 description: l10n.keep_persona_on_new_chat_desc,
326 value: settings.keepPersonaOnNewChat,
327 onChanged: (value) => ref
328 .read(settingsProvider.notifier)
329 .setKeepPersonaOnNewChat(value),
330 ),
331 _ToggleSetting(
332 label: l10n.role_swap_button_enabled,
333 description: l10n.role_swap_button_enabled_desc,
334 value: settings.roleSwapButtonEnabled,
335 onChanged: (value) => ref
336 .read(settingsProvider.notifier)
337 .setRoleSwapButtonEnabled(value),
338 ),
339 _ToggleSetting(
340 label: l10n.enable_image_compression,
341 description: l10n.enable_image_compression_desc,
342 value: settings.imageCompressionEnabled,
343 onChanged: (value) => ref
344 .read(settingsProvider.notifier)
345 .setImageCompressionEnabled(value),
346 ),
347 if (settings.imageCompressionEnabled)
348 _ImageCompressionLevelSetting(
349 value: settings.imageCompressionLevel,
350 onChanged: (value) => ref
351 .read(settingsProvider.notifier)
352 .setImageCompressionLevel(value),
353 ),
354 ],
355 );
356
357 final onDeviceCard = _SettingsSectionCard(
358 title: l10n.settings_on_device,
359 icon: HugeIcons.strokeRoundedCpu,
360 accent: const Color(0xFFF97316),
361 children: [
362 _ToggleSetting(
363 label: l10n.enable_smart_reply,
364 value: settings.smartReplyEnabled,
365 onChanged: (value) => ref
366 .read(settingsProvider.notifier)
367 .setSmartReplyEnabled(value),
368 ),
369 if (settings.smartReplyEnabled)
370 _ToggleSetting(
371 label: l10n.smart_replies_use_persona,
372 description: l10n.smart_replies_use_persona_desc,
373 value: settings.smartRepliesUsePersona,
374 onChanged: (value) => ref
375 .read(settingsProvider.notifier)
376 .setSmartRepliesUsePersona(value),
377 ),
378 _ToggleSetting(
379 label: l10n.ai_user_response_enabled,
380 description: l10n.ai_user_response_enabled_desc,
381 value: settings.aiUserResponseEnabled,
382 badges: [_FeatureBadge(label: l10n.experimental_label)],
383 onChanged: (value) => ref
384 .read(settingsProvider.notifier)
385 .setAiUserResponseEnabled(value),
386 ),
387 _HuggingFaceTokenSetting(
388 currentToken: settings.huggingFaceToken,
389 onSave: (value) {
390 ref
391 .read(settingsProvider.notifier)
392 .setHuggingFaceToken(value);
393 ScaffoldMessenger.maybeOf(context)?.showSnackBar(
394 SnackBar(
395 content: Text(
396 value == null || value.isEmpty
397 ? l10n.settings_huggingface_token_cleared
398 : l10n.settings_huggingface_token_set,
399 ),
400 ),
401 );
402 },
403 ),
404 _SectionActionButton(
405 icon: HugeIcons.strokeRoundedSmartPhone01,
406 label: l10n.manage_on_device_models,
407 onPressed: () => context.push(AppRoutes.onDeviceModels),
408 ),
409 _ToggleSetting(
410 label: l10n.unload_models_before_load,
411 value: settings.unloadModelsBeforeLoad,
412 onChanged: (value) => ref
413 .read(settingsProvider.notifier)
414 .setUnloadModelsBeforeLoad(value),
415 ),
416 const _OnDeviceEngineStatusCard(),
417 ],
418 );
419
420 final defaultsCard = _SettingsSectionCard(
421 title: l10n.settings_default_server,
422 icon: HugeIcons.strokeRoundedShare01,
423 accent: const Color(0xFF06B6D4),
424 children: [
425 _DropdownSetting(
426 label: l10n.settings_default_server,
427 currentValue: settings.defaultServerId,
428 items: servers,
429 onChanged: (value) => ref
430 .read(settingsProvider.notifier)
431 .setDefaultServer(value),
432 icon: HugeIcons.strokeRoundedComputer,
433 ),
434 _DropdownSetting(
435 label: l10n.settings_default_persona,
436 currentValue: settings.defaultPersonaId,
437 items: personas,
438 onChanged: (value) => ref
439 .read(settingsProvider.notifier)
440 .setDefaultPersona(value),
441 icon: HugeIcons.strokeRoundedRobot01,
442 ),
443 _DropdownSetting(
444 label: l10n.settings_default_model,
445 description: l10n.settings_default_model_desc,
446 currentValue: isDefaultModelForServer
447 ? settings.defaultModelId
448 : null,
449 items: models,
450 onChanged: (value) => ref
451 .read(settingsProvider.notifier)
452 .setDefaultModel(
453 serverId: defaultServerId,
454 modelId: value,
455 ),
456 icon: HugeIcons.strokeRoundedSmartPhone01,
457 ),
458 const SizedBox(height: 8),
459 _SectionActionButton(
460 icon: HugeIcons.strokeRoundedRefresh,
461 label: l10n.restore_builtin_personas,
462 onPressed: () async {
463 await ref
464 .read(personasNotifierProvider.notifier)
465 .restoreBuiltInPersonas();
466 if (context.mounted) {
467 ScaffoldMessenger.maybeOf(context)?.showSnackBar(
468 SnackBar(
469 content: Text(
470 l10n.restore_builtin_personas_success,
471 ),
472 ),
473 );
474 }
475 },
476 ),
477 ],
478 );
479
480 final privacyCard = _SettingsSectionCard(
481 title: l10n.settings_privacy,
482 icon: HugeIcons.strokeRoundedLock,
483 accent: const Color(0xFF14B8A6),
484 children: [
485 _ToggleSetting(
486 label: l10n.show_data_indicator,
487 value: settings.showDataIndicator,
488 onChanged: (value) => ref
489 .read(settingsProvider.notifier)
490 .setShowDataIndicator(value),
491 ),
492 _MutedCallout(message: l10n.privacy_info),
493 ],
494 );
495
496 final dataCard = _SettingsSectionCard(
497 title: l10n.settings_data_management,
498 icon: HugeIcons.strokeRoundedRefresh,
499 accent: const Color(0xFFEF4444),
500 children: [
501 _SectionActionButton(
502 icon: HugeIcons.strokeRoundedCloud,
503 label: l10n.cloud_sync,
504 onPressed: () => context.push(AppRoutes.cloudSync),
505 ),
506 const SizedBox(height: 8),
507 const DataBackupActions(),
508 const SizedBox(height: 8),
509 _DangerousAction(
510 label: l10n.delete_all_conversations,
511 icon: HugeIcons.strokeRoundedDelete01,
512 onConfirm: () async {
513 await ref
514 .read(chatProvider.notifier)
515 .cancelAllGenerations();
516 await ref
517 .read(conversationsProvider.notifier)
518 .deleteAll();
519 },
520 ),
521 _DangerousAction(
522 label: l10n.reset_settings_defaults,
523 icon: HugeIcons.strokeRoundedRefresh,
524 onConfirm: () =>
525 ref.read(settingsProvider.notifier).resetToDefaults(),
526 ),
527 ],
528 );
529
530 final aboutCard = _SettingsSectionCard(
531 title: l10n.settings_about,
532 icon: HugeIcons.strokeRoundedInformationCircle,
533 accent: const Color(0xFFF59E0B),
534 children: [
535 _AboutPanel(
536 title:
537 '${l10n.app_name} v${packageInfo.value?.version ?? l10n.app_version} (${packageInfo.value?.buildNumber ?? ''})',
538 subtitle: l10n.onboarding_connect_desc.replaceAll(
539 '\n',
540 ' ',
541 ),
542 providers: [
543 l10n.server_type_lm_studio,
544 l10n.server_type_ollama,
545 l10n.server_type_ollama_cloud,
546 l10n.server_type_openrouter,
547 l10n.server_type_requesty,
548 ],
549 highlights: [
550 l10n.settings_on_device,
551 l10n.streaming_responses,
552 l10n.nav_personas,
553 ],
554 stack: const ['Flutter', 'Riverpod', 'shadcn_ui'],
555 openSource: l10n.open_source_desc,
556 ),
557 _SectionActionButton(
558 icon: HugeIcons.strokeRoundedAlertCircle,
559 label: l10n.report_a_problem,
560 onPressed: () => _openFeedbackIssue(context),
561 ),
562 ],
563 );
564
565 return ListView(
566 padding: EdgeInsets.fromLTRB(
567 horizontalPadding,
568 12,
569 horizontalPadding,
570 24 + systemBottomInset,
571 ),
572 children: [
573 Center(
574 child: ConstrainedBox(
575 constraints: BoxConstraints(maxWidth: contentMaxWidth),
576 child: Column(
577 crossAxisAlignment: CrossAxisAlignment.stretch,
578 children: [
579 _SettingsHero(
580 settings: settings,
581 themeType: currentTheme,
582 ),
583 const SizedBox(height: 16),
584 if (useTwoColumns)
585 Row(
586 crossAxisAlignment: CrossAxisAlignment.start,
587 children: [
588 Expanded(
589 child: _SettingsSectionColumn(
590 children: [
591 appearanceCard,
592 behaviorCard,
593 defaultsCard,
594 privacyCard,
595 ],
596 ),
597 ),
598 const SizedBox(width: 20),
599 Expanded(
600 child: _SettingsSectionColumn(
601 children: [
602 ttsCard,
603 ?assistantCard,
604 onDeviceCard,
605 dataCard,
606 aboutCard,
607 ],
608 ),
609 ),
610 ],
611 )
612 else
613 _SettingsSectionColumn(
614 children: [
615 appearanceCard,
616 ttsCard,
617 ?assistantCard,
618 behaviorCard,
619 onDeviceCard,
620 defaultsCard,
621 privacyCard,
622 dataCard,
623 aboutCard,
624 ],
625 ),
626 const SizedBox(height: 24),
627 _PrivacyPolicyLink(),
628 ],
629 ),
630 ),
631 ),
632 ],
633 );
634 },
635 ),
636 ),
637 ],
638 );
639 }
640 }
641
642 class _SettingsSectionColumn extends StatelessWidget {
643 const _SettingsSectionColumn({required this.children});
644
645 final List<Widget> children;
646
647 @override
648 Widget build(BuildContext context) {
649 return Column(
650 crossAxisAlignment: CrossAxisAlignment.stretch,
651 children: _withVerticalSpacing(children, gap: 16),
652 );
653 }
654 }
655
656 class _AndroidAssistantSetting extends ConsumerStatefulWidget {
657 const _AndroidAssistantSetting();
658
659 @override
660 ConsumerState<_AndroidAssistantSetting> createState() =>
661 _AndroidAssistantSettingState();
662 }
663
664 class _AndroidAssistantSettingState
665 extends ConsumerState<_AndroidAssistantSetting>
666 with WidgetsBindingObserver {
667 AndroidAssistantStatus? _status;
668 bool _busy = false;
669
670 @override
671 void initState() {
672 super.initState();
673 WidgetsBinding.instance.addObserver(this);
674 _refreshStatus();
675 }
676
677 @override
678 void didChangeAppLifecycleState(AppLifecycleState state) {
679 if (state == AppLifecycleState.resumed) {
680 _refreshStatus();
681 }
682 }
683
684 Future<void> _refreshStatus() async {
685 final status = await ref.read(androidAssistantServiceProvider).getStatus();
686 if (mounted) setState(() => _status = status);
687 }
688
689 Future<void> _handleAction() async {
690 if (_busy) return;
691 setState(() => _busy = true);
692
693 final service = ref.read(androidAssistantServiceProvider);
694 try {
695 if (_status == AndroidAssistantStatus.available) {
696 await service.requestRole();
697 } else {
698 await service.openSettings();
699 }
700 await _refreshStatus();
701 } on PlatformException catch (e) {
702 if (mounted) {
703 ScaffoldMessenger.maybeOf(context)?.showSnackBar(
704 SnackBar(
705 content: Text(
706 AppLocalizations.of(context)!.assistant_error(e.toString()),
707 ),
708 ),
709 );
710 }
711 } finally {
712 if (mounted) setState(() => _busy = false);
713 }
714 }
715
716 @override
717 void dispose() {
718 WidgetsBinding.instance.removeObserver(this);
719 super.dispose();
720 }
721
722 @override
723 Widget build(BuildContext context) {
724 final l10n = AppLocalizations.of(context)!;
725 final theme = Theme.of(context);
726 final status = _status ?? AndroidAssistantStatus.unknown;
727 final statusLabel = switch (status) {
728 AndroidAssistantStatus.active => l10n.assistant_status_active,
729 AndroidAssistantStatus.available => l10n.assistant_status_available,
730 AndroidAssistantStatus.manual => l10n.assistant_status_manual,
731 AndroidAssistantStatus.unsupported => l10n.assistant_status_unsupported,
732 AndroidAssistantStatus.unknown => l10n.assistant_status_checking,
733 };
734 final actionLabel = status == AndroidAssistantStatus.available
735 ? l10n.assistant_set_default
736 : l10n.assistant_open_settings;
737
738 return _SettingPanel(
739 child: Column(
740 crossAxisAlignment: CrossAxisAlignment.start,
741 children: [
742 Row(
743 children: [
744 Expanded(
745 child: Text(
746 l10n.assistant_default_title,
747 style: theme.textTheme.titleSmall?.copyWith(
748 fontWeight: FontWeight.w700,
749 ),
750 ),
751 ),
752 Container(
753 padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
754 decoration: BoxDecoration(
755 color: theme.colorScheme.primary.withValues(alpha: 0.09),
756 borderRadius: BorderRadius.circular(999),
757 ),
758 child: Text(
759 statusLabel,
760 style: theme.textTheme.labelSmall?.copyWith(
761 color: theme.colorScheme.primary,
762 fontWeight: FontWeight.w700,
763 ),
764 ),
765 ),
766 ],
767 ),
768 const SizedBox(height: 4),
769 Text(
770 l10n.assistant_default_description,
771 style: theme.textTheme.bodySmall?.copyWith(
772 color: theme.colorScheme.onSurfaceVariant,
773 ),
774 ),
775 const SizedBox(height: 12),
776 ShadButton.outline(
777 onPressed: _busy || status == AndroidAssistantStatus.unsupported
778 ? null
779 : _handleAction,
780 width: double.infinity,
781 leading: _busy
782 ? const SizedBox.square(
783 dimension: 16,
784 child: CircularProgressIndicator(strokeWidth: 2),
785 )
786 : const HugeIcon(
787 icon: HugeIcons.strokeRoundedSmartPhone01,
788 size: 16,
789 ),
790 child: Align(
791 alignment: Alignment.centerLeft,
792 child: Text(actionLabel),
793 ),
794 ),
795 ],
796 ),
797 );
798 }
799 }
800
801 class _SettingsHero extends StatelessWidget {
802 const _SettingsHero({required this.settings, required this.themeType});
803
804 final AppSettings settings;
805 final AppThemeType themeType;
806
807 @override
808 Widget build(BuildContext context) {
809 final l10n = AppLocalizations.of(context)!;
810 final theme = Theme.of(context);
811 final primary = _primaryColor(context);
812
813 return Cue.onChange(
814 value: themeType,
815 motion: .smooth(),
816 child: Container(
817 decoration: BoxDecoration(
818 color: _surfaceColor(context),
819 borderRadius: BorderRadius.circular(20),
820 border: Border.all(color: _outlineColor(context, alpha: 0.9)),
821 ),
822 child: Padding(
823 padding: const EdgeInsets.all(16),
824 child: Column(
825 crossAxisAlignment: CrossAxisAlignment.start,
826 children: [
827 Actor(
828 acts: [.fadeIn(), .slideY(from: 0.05)],
829 child: Row(
830 children: [
831 Container(
832 width: 36,
833 height: 36,
834 decoration: BoxDecoration(
835 color: primary.withValues(alpha: 0.10),
836 borderRadius: BorderRadius.circular(12),
837 ),
838 child: Center(
839 child: HugeIcon(
840 icon: HugeIcons.strokeRoundedSettings01,
841 size: 18,
842 color: primary,
843 ),
844 ),
845 ),
846 const SizedBox(width: 10),
847 Expanded(
848 child: Text(
849 l10n.settings_title,
850 style: theme.textTheme.headlineSmall?.copyWith(
851 fontWeight: FontWeight.w700,
852 ),
853 ),
854 ),
855 ],
856 ),
857 ),
858 const SizedBox(height: 6),
859 Actor(
860 delay: 60.ms,
861 acts: [.fadeIn(), .slideY(from: 0.05)],
862 child: Text(
863 l10n.app_tagline,
864 style: theme.textTheme.bodyMedium?.copyWith(
865 color: _mutedColor(context),
866 height: 1.35,
867 ),
868 ),
869 ),
870 const SizedBox(height: 14),
871 Wrap(
872 spacing: 8,
873 runSpacing: 8,
874 children: [
875 Actor(
876 delay: 120.ms,
877 acts: [.fadeIn(), .slideY(from: 0.08), .scale(from: 0.96)],
878 child: _HeroStat(
879 label: l10n.theme,
880 value: _themeLabel(themeType, l10n),
881 icon: HugeIcons.strokeRoundedSparkles,
882 ),
883 ),
884 Actor(
885 delay: 180.ms,
886 acts: [.fadeIn(), .slideY(from: 0.08), .scale(from: 0.96)],
887 child: _HeroStat(
888 label: l10n.settings_language,
889 value: _languageLabel(settings.localeCode, l10n),
890 icon: HugeIcons.strokeRoundedGlobe,
891 ),
892 ),
893 Actor(
894 delay: 240.ms,
895 acts: [.fadeIn(), .slideY(from: 0.08), .scale(from: 0.96)],
896 child: _HeroStat(
897 label: l10n.tts_engine,
898 value: _engineLabel(settings.ttsEngine, l10n),
899 icon: HugeIcons.strokeRoundedVoice,
900 ),
901 ),
902 ],
903 ),
904 ],
905 ),
906 ),
907 ),
908 );
909 }
910 }
911
912 class _HeroStat extends StatelessWidget {
913 const _HeroStat({
914 required this.label,
915 required this.value,
916 required this.icon,
917 });
918
919 final String label;
920 final String value;
921 final List<List<dynamic>> icon;
922
923 @override
924 Widget build(BuildContext context) {
925 final theme = Theme.of(context);
926 final primary = _primaryColor(context);
927
928 return Container(
929 padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
930 decoration: BoxDecoration(
931 color: _panelColor(context),
932 borderRadius: BorderRadius.circular(12),
933 border: Border.all(color: _outlineColor(context)),
934 ),
935 child: Row(
936 mainAxisSize: MainAxisSize.min,
937 children: [
938 HugeIcon(icon: icon, size: 14, color: primary),
939 const SizedBox(width: 8),
940 Text(
941 '$label: ',
942 style: theme.textTheme.labelMedium?.copyWith(
943 color: _mutedColor(context),
944 fontWeight: FontWeight.w600,
945 ),
946 ),
947 Flexible(
948 child: Text(
949 value,
950 overflow: TextOverflow.ellipsis,
951 style: theme.textTheme.labelLarge?.copyWith(
952 fontWeight: FontWeight.w700,
953 ),
954 ),
955 ),
956 ],
957 ),
958 );
959 }
960 }
961
962 class _SettingsSectionCard extends StatelessWidget {
963 const _SettingsSectionCard({
964 required this.title,
965 required this.icon,
966 required this.accent,
967 required this.children,
968 this.badges = const [],
969 });
970
971 final String title;
972 final List<List<dynamic>> icon;
973 final Color accent;
974 final List<Widget> children;
975 final List<Widget> badges;
976
977 @override
978 Widget build(BuildContext context) {
979 return Container(
980 decoration: BoxDecoration(
981 color: _surfaceColor(context),
982 borderRadius: BorderRadius.circular(18),
983 border: Border.all(color: _outlineColor(context, alpha: 0.9)),
984 ),
985 child: Padding(
986 padding: const EdgeInsets.all(16),
987 child: Column(
988 crossAxisAlignment: CrossAxisAlignment.start,
989 children: [
990 Row(
991 children: [
992 Container(
993 width: 34,
994 height: 34,
995 decoration: BoxDecoration(
996 color: accent.withValues(alpha: 0.10),
997 borderRadius: BorderRadius.circular(10),
998 ),
999 child: Center(
1000 child: HugeIcon(icon: icon, color: accent, size: 16),
1001 ),
1002 ),
1003 const SizedBox(width: 10),
1004 Expanded(
1005 child: Text(
1006 title,
1007 style: Theme.of(context).textTheme.titleMedium?.copyWith(
1008 fontWeight: FontWeight.w700,
1009 ),
1010 ),
1011 ),
1012 if (badges.isNotEmpty) ...[const SizedBox(width: 8), ...badges],
1013 ],
1014 ),
1015 const SizedBox(height: 12),
1016 ..._withVerticalSpacing(children, gap: 10),
1017 ],
1018 ),
1019 ),
1020 );
1021 }
1022 }
1023
1024 class _SettingPanel extends StatelessWidget {
1025 const _SettingPanel({required this.child});
1026
1027 final Widget child;
1028
1029 @override
1030 Widget build(BuildContext context) {
1031 return Container(
1032 padding: const EdgeInsets.all(12),
1033 decoration: BoxDecoration(
1034 color: _panelColor(context),
1035 borderRadius: BorderRadius.circular(14),
1036 border: Border.all(color: _outlineColor(context)),
1037 ),
1038 child: child,
1039 );
1040 }
1041 }
1042
1043 class _MutedCallout extends StatelessWidget {
1044 const _MutedCallout({required this.message});
1045
1046 final String message;
1047
1048 @override
1049 Widget build(BuildContext context) {
1050 return _SettingPanel(
1051 child: Row(
1052 crossAxisAlignment: CrossAxisAlignment.start,
1053 children: [
1054 HugeIcon(
1055 icon: HugeIcons.strokeRoundedMoon02,
1056 size: 18,
1057 color: _mutedColor(context),
1058 ),
1059 const SizedBox(width: 10),
1060 Expanded(
1061 child: Text(
1062 message,
1063 style: Theme.of(context).textTheme.bodyMedium?.copyWith(
1064 color: _mutedColor(context),
1065 height: 1.35,
1066 ),
1067 ),
1068 ),
1069 ],
1070 ),
1071 );
1072 }
1073 }
1074
1075 class _AboutPanel extends StatelessWidget {
1076 const _AboutPanel({
1077 required this.title,
1078 required this.subtitle,
1079 required this.providers,
1080 required this.highlights,
1081 required this.stack,
1082 required this.openSource,
1083 });
1084
1085 final String title;
1086 final String subtitle;
1087 final List<String> providers;
1088 final List<String> highlights;
1089 final List<String> stack;
1090 final String openSource;
1091
1092 @override
1093 Widget build(BuildContext context) {
1094 final theme = Theme.of(context);
1095 final l10n = AppLocalizations.of(context)!;
1096
1097 return _SettingPanel(
1098 child: Column(
1099 crossAxisAlignment: CrossAxisAlignment.start,
1100 children: [
1101 Text(
1102 title,
1103 style: theme.textTheme.titleMedium?.copyWith(
1104 fontWeight: FontWeight.w700,
1105 ),
1106 ),
1107 const SizedBox(height: 6),
1108 Text(
1109 subtitle,
1110 style: theme.textTheme.bodyMedium?.copyWith(
1111 color: _mutedColor(context),
1112 height: 1.35,
1113 ),
1114 ),
1115 const SizedBox(height: 12),
1116 _AboutLabel(label: l10n.app_tagline),
1117 const SizedBox(height: 8),
1118 Wrap(
1119 spacing: 6,
1120 runSpacing: 6,
1121 children: providers
1122 .map(
1123 (provider) => _AboutChip(
1124 label: provider,
1125 icon: HugeIcons.strokeRoundedShare01,
1126 ),
1127 )
1128 .toList(),
1129 ),
1130 const SizedBox(height: 12),
1131 _AboutLabel(label: l10n.highlights_label),
1132 const SizedBox(height: 8),
1133 Wrap(
1134 spacing: 6,
1135 runSpacing: 6,
1136 children: highlights
1137 .map(
1138 (highlight) => _AboutChip(
1139 label: highlight,
1140 icon: HugeIcons.strokeRoundedCheckmarkCircle01,
1141 ),
1142 )
1143 .toList(),
1144 ),
1145 const SizedBox(height: 12),
1146 _AboutLabel(label: l10n.built_with_label),
1147 const SizedBox(height: 8),
1148 Wrap(
1149 spacing: 6,
1150 runSpacing: 6,
1151 children: stack
1152 .map(
1153 (item) => _AboutChip(
1154 label: item,
1155 icon: HugeIcons.strokeRoundedCode,
1156 ),
1157 )
1158 .toList(),
1159 ),
1160 const SizedBox(height: 12),
1161 Container(
1162 width: double.infinity,
1163 padding: const EdgeInsets.all(10),
1164 decoration: BoxDecoration(
1165 color: _surfaceColor(context),
1166 borderRadius: BorderRadius.circular(10),
1167 border: Border.all(color: _outlineColor(context, alpha: 0.8)),
1168 ),
1169 child: Row(
1170 crossAxisAlignment: CrossAxisAlignment.start,
1171 children: [
1172 HugeIcon(
1173 icon: HugeIcons.strokeRoundedShare01,
1174 size: 15,
1175 color: _mutedColor(context),
1176 ),
1177 const SizedBox(width: 8),
1178 Expanded(
1179 child: Text(
1180 openSource,
1181 style: theme.textTheme.bodySmall?.copyWith(
1182 color: _mutedColor(context),
1183 height: 1.35,
1184 ),
1185 ),
1186 ),
1187 ],
1188 ),
1189 ),
1190 ],
1191 ),
1192 );
1193 }
1194 }
1195
1196 class _AboutLabel extends StatelessWidget {
1197 const _AboutLabel({required this.label});
1198
1199 final String label;
1200
1201 @override
1202 Widget build(BuildContext context) {
1203 return Text(
1204 label,
1205 style: Theme.of(context).textTheme.labelMedium?.copyWith(
1206 color: _mutedColor(context),
1207 fontWeight: FontWeight.w700,
1208 ),
1209 );
1210 }
1211 }
1212
1213 class _AboutChip extends StatelessWidget {
1214 const _AboutChip({required this.label, required this.icon});
1215
1216 final String label;
1217 final List<List<dynamic>> icon;
1218
1219 @override
1220 Widget build(BuildContext context) {
1221 return Container(
1222 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
1223 decoration: BoxDecoration(
1224 color: _surfaceColor(context),
1225 borderRadius: BorderRadius.circular(10),
1226 border: Border.all(color: _outlineColor(context, alpha: 0.75)),
1227 ),
1228 child: Row(
1229 mainAxisSize: MainAxisSize.min,
1230 children: [
1231 HugeIcon(icon: icon, size: 13, color: _mutedColor(context)),
1232 const SizedBox(width: 6),
1233 Text(
1234 label,
1235 style: Theme.of(
1236 context,
1237 ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
1238 ),
1239 ],
1240 ),
1241 );
1242 }
1243 }
1244
1245 class _LanguageSetting extends StatelessWidget {
1246 const _LanguageSetting({required this.current, required this.onChanged});
1247
1248 static const localeItems = <(String, String, String, String)>[
1249 ('en', 'English', 'assets/images/flag_us.png', '🇺🇸'),
1250 ('ja', '日本語', 'assets/images/flag_jp.png', '🇯🇵'),
1251 ('it', 'Italiano', 'assets/images/flag_it.png', '🇮🇹'),
1252 ('es', 'Español', 'assets/images/flag_es.png', '🇪🇸'),
1253 ('zh', '简体中文', 'assets/images/flag_cn.png', '🇨🇳'),
1254 ('zh_TW', '繁體中文', 'assets/images/flag_tw.png', '🇹🇼'),
1255 ('ar', 'العربية', 'assets/images/flag_sa.png', '🇸🇦'),
1256 ('bn', 'বাংলা', 'assets/images/flag_bd.png', '🇧🇩'),
1257 ('hi', 'हिन्दी', 'assets/images/flag_in.png', '🇮🇳'),
1258 ('ru', 'Русский', 'assets/images/flag_ru.png', '🇷🇺'),
1259 ('tr', 'Türkçe', 'assets/images/flag_tr.png', '🇹🇷'),
1260 ('fr', 'Français', 'assets/images/flag_fr.png', '🇫🇷'),
1261 ('de', 'Deutsch', 'assets/images/flag_de.png', '🇩🇪'),
1262 ('pt', 'Português', 'assets/images/flag_br.png', '🇧🇷'),
1263 ('ko', '한국어', 'assets/images/flag_kr.png', '🇰🇷'),
1264 ];
1265
1266 final String? current;
1267 final ValueChanged<String?> onChanged;
1268
1269 @override
1270 Widget build(BuildContext context) {
1271 final l10n = AppLocalizations.of(context)!;
1272 final theme = Theme.of(context);
1273
1274 return _SettingPanel(
1275 child: Column(
1276 crossAxisAlignment: CrossAxisAlignment.start,
1277 children: [
1278 _SettingHeader(label: l10n.settings_language),
1279 const SizedBox(height: 10),
1280 _InputShell(
1281 child: DropdownButtonHideUnderline(
1282 child: DropdownButton<String?>(
1283 value: current,
1284 isExpanded: true,
1285 padding: const EdgeInsets.symmetric(horizontal: 14),
1286 borderRadius: BorderRadius.circular(16),
1287 dropdownColor: theme.colorScheme.surface,
1288 items: [
1289 DropdownMenuItem<String?>(
1290 value: null,
1291 child: Row(
1292 children: [
1293 HugeIcon(
1294 icon: HugeIcons.strokeRoundedSettings01,
1295 size: 18,
1296 color: _mutedColor(context),
1297 ),
1298 const SizedBox(width: 10),
1299 Flexible(
1300 child: Text(
1301 l10n.language_system_default,
1302 overflow: TextOverflow.ellipsis,
1303 ),
1304 ),
1305 ],
1306 ),
1307 ),
1308 ...localeItems.map(
1309 (item) => DropdownMenuItem<String?>(
1310 value: item.$1,
1311 child: Row(
1312 children: [
1313 Container(
1314 width: 24,
1315 height: 16,
1316 decoration: BoxDecoration(
1317 borderRadius: BorderRadius.circular(4),
1318 border: Border.all(
1319 color: _outlineColor(context, alpha: 0.8),
1320 width: 0.6,
1321 ),
1322 ),
1323 child: ClipRRect(
1324 borderRadius: BorderRadius.circular(4),
1325 child: Image.asset(
1326 item.$3,
1327 fit: BoxFit.cover,
1328 errorBuilder: (context, error, stackTrace) {
1329 return Center(
1330 child: Text(
1331 item.$4,
1332 style: const TextStyle(fontSize: 12),
1333 ),
1334 );
1335 },
1336 ),
1337 ),
1338 ),
1339 const SizedBox(width: 10),
1340 Flexible(
1341 child: Text(
1342 item.$2,
1343 overflow: TextOverflow.ellipsis,
1344 ),
1345 ),
1346 ],
1347 ),
1348 ),
1349 ),
1350 ],
1351 onChanged: onChanged,
1352 ),
1353 ),
1354 ),
1355 ],
1356 ),
1357 );
1358 }
1359 }
1360
1361 class _SliderSetting extends StatelessWidget {
1362 const _SliderSetting({
1363 required this.label,
1364 required this.value,
1365 required this.min,
1366 required this.max,
1367 required this.divisions,
1368 required this.description,
1369 required this.onChanged,
1370 this.valueFormat,
1371 this.previewText,
1372 });
1373
1374 final String label;
1375 final double value;
1376 final double min;
1377 final double max;
1378 final int divisions;
1379 final String description;
1380 final ValueChanged<double> onChanged;
1381 final String Function(double)? valueFormat;
1382 final String? previewText;
1383
1384 @override
1385 Widget build(BuildContext context) {
1386 final theme = Theme.of(context);
1387 final primary = theme.colorScheme.primary;
1388 final displayValue = valueFormat != null
1389 ? valueFormat!(value)
1390 : value.toStringAsFixed(2);
1391
1392 return _SettingPanel(
1393 child: Column(
1394 crossAxisAlignment: CrossAxisAlignment.start,
1395 children: [
1396 Row(
1397 children: [
1398 Expanded(
1399 child: Text(
1400 label,
1401 style: theme.textTheme.titleSmall?.copyWith(
1402 fontWeight: FontWeight.w700,
1403 ),
1404 ),
1405 ),
1406 Container(
1407 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
1408 decoration: BoxDecoration(
1409 color: primary.withValues(alpha: 0.10),
1410 borderRadius: BorderRadius.circular(999),
1411 ),
1412 child: Text(
1413 displayValue,
1414 style: theme.textTheme.labelLarge?.copyWith(
1415 color: primary,
1416 fontWeight: FontWeight.w700,
1417 ),
1418 ),
1419 ),
1420 ],
1421 ),
1422 const SizedBox(height: 8),
1423 SliderTheme(
1424 data: SliderTheme.of(context).copyWith(
1425 trackHeight: 4,
1426 thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
1427 overlayShape: const RoundSliderOverlayShape(overlayRadius: 18),
1428 activeTrackColor: primary,
1429 thumbColor: primary,
1430 overlayColor: primary.withValues(alpha: 0.12),
1431 inactiveTrackColor: _outlineColor(context, alpha: 0.6),
1432 ),
1433 child: Slider(
1434 value: value.clamp(min, max),
1435 min: min,
1436 max: max,
1437 divisions: divisions,
1438 onChanged: onChanged,
1439 ),
1440 ),
1441 Text(
1442 description,
1443 style: theme.textTheme.bodySmall?.copyWith(
1444 color: _mutedColor(context),
1445 height: 1.4,
1446 ),
1447 ),
1448 if (previewText != null) ...[
1449 const SizedBox(height: 8),
1450 Container(
1451 width: double.infinity,
1452 padding: const EdgeInsets.all(14),
1453 decoration: BoxDecoration(
1454 color: theme.scaffoldBackgroundColor.withValues(alpha: 0.65),
1455 borderRadius: BorderRadius.circular(12),
1456 border: Border.all(color: _outlineColor(context, alpha: 0.75)),
1457 ),
1458 child: Text(
1459 previewText!,
1460 style: theme.textTheme.bodyMedium?.copyWith(
1461 fontSize: value.clamp(12.0, 24.0),
1462 color: _mutedColor(context),
1463 ),
1464 ),
1465 ),
1466 ],
1467 ],
1468 ),
1469 );
1470 }
1471 }
1472
1473 class _ToggleSetting extends StatelessWidget {
1474 const _ToggleSetting({
1475 required this.label,
1476 required this.value,
1477 required this.onChanged,
1478 this.description,
1479 this.badges = const [],
1480 });
1481
1482 final String label;
1483 final String? description;
1484 final bool value;
1485 final ValueChanged<bool> onChanged;
1486 final List<Widget> badges;
1487
1488 @override
1489 Widget build(BuildContext context) {
1490 final theme = Theme.of(context);
1491
1492 return _SettingPanel(
1493 child: Row(
1494 crossAxisAlignment: CrossAxisAlignment.center,
1495 children: [
1496 Expanded(
1497 child: Column(
1498 crossAxisAlignment: CrossAxisAlignment.start,
1499 children: [
1500 Wrap(
1501 spacing: 8,
1502 runSpacing: 6,
1503 crossAxisAlignment: WrapCrossAlignment.center,
1504 children: [
1505 Text(
1506 label,
1507 style: theme.textTheme.titleSmall?.copyWith(
1508 fontWeight: FontWeight.w700,
1509 ),
1510 ),
1511 ...badges,
1512 ],
1513 ),
1514 if (description != null) ...[
1515 const SizedBox(height: 4),
1516 Text(
1517 description!,
1518 style: theme.textTheme.bodySmall?.copyWith(
1519 color: theme.colorScheme.onSurfaceVariant,
1520 ),
1521 ),
1522 ],
1523 ],
1524 ),
1525 ),
1526 const SizedBox(width: 8),
1527 Switch.adaptive(value: value, onChanged: onChanged),
1528 ],
1529 ),
1530 );
1531 }
1532 }
1533
1534 class _ImageCompressionLevelSetting extends StatelessWidget {
1535 const _ImageCompressionLevelSetting({
1536 required this.value,
1537 required this.onChanged,
1538 });
1539
1540 final ImageCompressionLevel value;
1541 final ValueChanged<ImageCompressionLevel> onChanged;
1542
1543 @override
1544 Widget build(BuildContext context) {
1545 final l10n = AppLocalizations.of(context)!;
1546 final theme = Theme.of(context);
1547
1548 String labelFor(ImageCompressionLevel level) {
1549 switch (level) {
1550 case ImageCompressionLevel.low:
1551 return l10n.image_compression_level_low;
1552 case ImageCompressionLevel.medium:
1553 return l10n.image_compression_level_medium;
1554 case ImageCompressionLevel.high:
1555 return l10n.image_compression_level_high;
1556 }
1557 }
1558
1559 return _SettingPanel(
1560 child: Column(
1561 crossAxisAlignment: CrossAxisAlignment.start,
1562 children: [
1563 Text(
1564 l10n.image_compression_level,
1565 style: theme.textTheme.titleSmall?.copyWith(
1566 fontWeight: FontWeight.w700,
1567 ),
1568 ),
1569 const SizedBox(height: 4),
1570 Text(
1571 l10n.image_compression_level_desc,
1572 style: theme.textTheme.bodySmall?.copyWith(
1573 color: theme.colorScheme.onSurfaceVariant,
1574 ),
1575 ),
1576 const SizedBox(height: 10),
1577 Wrap(
1578 spacing: 8,
1579 runSpacing: 8,
1580 children: ImageCompressionLevel.values.map((level) {
1581 final selected = value == level;
1582 return FilterChip(
1583 label: Text(labelFor(level)),
1584 selected: selected,
1585 onSelected: (_) => onChanged(level),
1586 showCheckmark: false,
1587 );
1588 }).toList(),
1589 ),
1590 ],
1591 ),
1592 );
1593 }
1594 }
1595
1596 class _TtsSkipSecondsSetting extends StatelessWidget {
1597 const _TtsSkipSecondsSetting({required this.value, required this.onChanged});
1598
1599 final int value;
1600 final ValueChanged<int> onChanged;
1601
1602 static const _options = [5, 10, 15, 30];
1603
1604 @override
1605 Widget build(BuildContext context) {
1606 final l10n = AppLocalizations.of(context)!;
1607 final theme = Theme.of(context);
1608
1609 return _SettingPanel(
1610 child: Column(
1611 crossAxisAlignment: CrossAxisAlignment.start,
1612 children: [
1613 Text(
1614 l10n.tts_skip_seconds,
1615 style: theme.textTheme.titleSmall?.copyWith(
1616 fontWeight: FontWeight.w700,
1617 ),
1618 ),
1619 const SizedBox(height: 4),
1620 Text(
1621 l10n.tts_skip_seconds_desc,
1622 style: theme.textTheme.bodySmall?.copyWith(
1623 color: theme.colorScheme.onSurfaceVariant,
1624 ),
1625 ),
1626 const SizedBox(height: 10),
1627 Wrap(
1628 spacing: 8,
1629 runSpacing: 8,
1630 children: _options.map((seconds) {
1631 final selected = value == seconds;
1632 return FilterChip(
1633 label: Text(l10n.tts_skip_seconds_value(seconds)),
1634 selected: selected,
1635 onSelected: (_) => onChanged(seconds),
1636 showCheckmark: false,
1637 );
1638 }).toList(),
1639 ),
1640 ],
1641 ),
1642 );
1643 }
1644 }
1645
1646 class _FeatureBadge extends StatelessWidget {
1647 const _FeatureBadge({required this.label});
1648
1649 final String label;
1650
1651 @override
1652 Widget build(BuildContext context) {
1653 return Container(
1654 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
1655 decoration: BoxDecoration(
1656 color: Colors.amber.withValues(alpha: 0.10),
1657 borderRadius: BorderRadius.circular(999),
1658 border: Border.all(color: Colors.amber.withValues(alpha: 0.22)),
1659 ),
1660 child: Text(
1661 label.toUpperCase(),
1662 style: Theme.of(context).textTheme.labelSmall?.copyWith(
1663 color: const Color(0xFFB45309),
1664 fontWeight: FontWeight.w800,
1665 letterSpacing: 0.45,
1666 ),
1667 ),
1668 );
1669 }
1670 }
1671
1672 class _ThemeToggle extends StatelessWidget {
1673 const _ThemeToggle({required this.ref});
1674
1675 final WidgetRef ref;
1676
1677 @override
1678 Widget build(BuildContext context) {
1679 final l10n = AppLocalizations.of(context)!;
1680 final currentTheme = ref.watch(themeModeProvider);
1681
1682 return _SettingPanel(
1683 child: Column(
1684 crossAxisAlignment: CrossAxisAlignment.start,
1685 children: [
1686 _SettingHeader(label: l10n.theme),
1687 const SizedBox(height: 8),
1688 LayoutBuilder(
1689 builder: (context, constraints) {
1690 final spacing = 8.0;
1691 final columns = constraints.maxWidth >= 700
1692 ? 4
1693 : constraints.maxWidth >= 420
1694 ? 2
1695 : 1;
1696 final itemWidth =
1697 (constraints.maxWidth - (spacing * (columns - 1))) / columns;
1698
1699 return Wrap(
1700 spacing: spacing,
1701 runSpacing: spacing,
1702 children: [
1703 SizedBox(
1704 width: itemWidth,
1705 child: _ThemeOption(
1706 label: l10n.theme_system,
1707 icon: HugeIcons.strokeRoundedSun01,
1708 isSelected: currentTheme == AppThemeType.system,
1709 onTap: () => ref
1710 .read(themeModeProvider.notifier)
1711 .setThemeMode(AppThemeType.system),
1712 ),
1713 ),
1714 SizedBox(
1715 width: itemWidth,
1716 child: _ThemeOption(
1717 label: l10n.theme_light,
1718 icon: HugeIcons.strokeRoundedSun01,
1719 isSelected: currentTheme == AppThemeType.light,
1720 onTap: () => ref
1721 .read(themeModeProvider.notifier)
1722 .setThemeMode(AppThemeType.light),
1723 ),
1724 ),
1725 SizedBox(
1726 width: itemWidth,
1727 child: _ThemeOption(
1728 label: l10n.theme_dark,
1729 icon: HugeIcons.strokeRoundedMoon02,
1730 isSelected: currentTheme == AppThemeType.dark,
1731 onTap: () => ref
1732 .read(themeModeProvider.notifier)
1733 .setThemeMode(AppThemeType.dark),
1734 ),
1735 ),
1736 SizedBox(
1737 width: itemWidth,
1738 child: _ThemeOption(
1739 label: l10n.theme_claude,
1740 icon: HugeIcons.strokeRoundedSparkles,
1741 isSelected: currentTheme == AppThemeType.claude,
1742 onTap: () => ref
1743 .read(themeModeProvider.notifier)
1744 .setThemeMode(AppThemeType.claude),
1745 ),
1746 ),
1747 ],
1748 );
1749 },
1750 ),
1751 ],
1752 ),
1753 );
1754 }
1755 }
1756
1757 class _ThemeOption extends StatelessWidget {
1758 const _ThemeOption({
1759 required this.label,
1760 required this.icon,
1761 required this.isSelected,
1762 required this.onTap,
1763 });
1764
1765 final String label;
1766 final List<List<dynamic>> icon;
1767 final bool isSelected;
1768 final VoidCallback onTap;
1769
1770 @override
1771 Widget build(BuildContext context) {
1772 final theme = Theme.of(context);
1773 final primary = theme.colorScheme.primary;
1774
1775 return Material(
1776 color: Colors.transparent,
1777 child: InkWell(
1778 onTap: onTap,
1779 borderRadius: BorderRadius.circular(14),
1780 child: Container(
1781 padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
1782 decoration: BoxDecoration(
1783 color: isSelected
1784 ? primary.withValues(alpha: 0.10)
1785 : theme.scaffoldBackgroundColor.withValues(alpha: 0.55),
1786 borderRadius: BorderRadius.circular(14),
1787 border: Border.all(
1788 color: isSelected ? primary : _outlineColor(context, alpha: 0.8),
1789 ),
1790 ),
1791 child: Row(
1792 children: [
1793 Container(
1794 width: 30,
1795 height: 30,
1796 decoration: BoxDecoration(
1797 color: isSelected
1798 ? primary.withValues(alpha: 0.14)
1799 : _panelColor(context),
1800 borderRadius: BorderRadius.circular(10),
1801 ),
1802 child: Center(
1803 child: HugeIcon(
1804 icon: icon,
1805 size: 16,
1806 color: isSelected ? primary : _mutedColor(context),
1807 ),
1808 ),
1809 ),
1810 const SizedBox(width: 10),
1811 Expanded(
1812 child: Text(
1813 label,
1814 style: theme.textTheme.titleSmall?.copyWith(
1815 fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
1816 color: isSelected ? primary : null,
1817 ),
1818 ),
1819 ),
1820 HugeIcon(
1821 icon: isSelected
1822 ? HugeIcons.strokeRoundedCheckmarkCircle01
1823 : HugeIcons.strokeRoundedCircle,
1824 size: 16,
1825 color: isSelected ? primary : _mutedColor(context),
1826 ),
1827 ],
1828 ),
1829 ),
1830 ),
1831 );
1832 }
1833 }
1834
1835 class _DropdownSetting extends StatelessWidget {
1836 const _DropdownSetting({
1837 required this.label,
1838 required this.currentValue,
1839 required this.items,
1840 required this.onChanged,
1841 required this.icon,
1842 this.description,
1843 });
1844
1845 final String label;
1846 final String? currentValue;
1847 final List<(String id, String name)> items;
1848 final ValueChanged<String?> onChanged;
1849 final List<List<dynamic>> icon;
1850 final String? description;
1851
1852 @override
1853 Widget build(BuildContext context) {
1854 final l10n = AppLocalizations.of(context)!;
1855 final theme = Theme.of(context);
1856
1857 return _SettingPanel(
1858 child: Column(
1859 crossAxisAlignment: CrossAxisAlignment.start,
1860 children: [
1861 _SettingHeader(label: label),
1862 const SizedBox(height: 10),
1863 _InputShell(
1864 child: DropdownButtonHideUnderline(
1865 child: DropdownButton<String?>(
1866 value: currentValue,
1867 isExpanded: true,
1868 padding: const EdgeInsets.symmetric(horizontal: 14),
1869 borderRadius: BorderRadius.circular(16),
1870 dropdownColor: theme.colorScheme.surface,
1871 hint: Text(
1872 l10n.none_selected,
1873 style: theme.textTheme.bodyMedium?.copyWith(
1874 color: _mutedColor(context),
1875 ),
1876 ),
1877 items: [
1878 DropdownMenuItem<String?>(
1879 value: null,
1880 child: Text(l10n.none),
1881 ),
1882 ...items.map(
1883 (item) => DropdownMenuItem<String?>(
1884 value: item.$1,
1885 child: Row(
1886 children: [
1887 HugeIcon(
1888 icon: icon,
1889 size: 16,
1890 color: _mutedColor(context),
1891 ),
1892 const SizedBox(width: 8),
1893 Flexible(
1894 child: Text(
1895 item.$2,
1896 overflow: TextOverflow.ellipsis,
1897 ),
1898 ),
1899 ],
1900 ),
1901 ),
1902 ),
1903 ],
1904 onChanged: onChanged,
1905 ),
1906 ),
1907 ),
1908 if (description != null) ...[
1909 const SizedBox(height: 8),
1910 Text(
1911 description!,
1912 style: theme.textTheme.bodySmall?.copyWith(
1913 color: _mutedColor(context),
1914 ),
1915 ),
1916 ],
1917 ],
1918 ),
1919 );
1920 }
1921 }
1922
1923 class _CodeThemeDropdown extends StatelessWidget {
1924 const _CodeThemeDropdown({
1925 required this.label,
1926 required this.current,
1927 required this.onChanged,
1928 });
1929
1930 final String label;
1931 final SyntaxThemeName current;
1932 final ValueChanged<SyntaxThemeName> onChanged;
1933
1934 @override
1935 Widget build(BuildContext context) {
1936 final l10n = AppLocalizations.of(context)!;
1937 final theme = Theme.of(context);
1938
1939 return _SettingPanel(
1940 child: Column(
1941 crossAxisAlignment: CrossAxisAlignment.start,
1942 children: [
1943 _SettingHeader(label: label),
1944 const SizedBox(height: 10),
1945 _InputShell(
1946 child: DropdownButtonHideUnderline(
1947 child: DropdownButton<SyntaxThemeName>(
1948 value: current,
1949 isExpanded: true,
1950 padding: const EdgeInsets.symmetric(horizontal: 14),
1951 borderRadius: BorderRadius.circular(16),
1952 dropdownColor: theme.colorScheme.surface,
1953 items: SyntaxThemeName.values.map((codeTheme) {
1954 final displayName = switch (codeTheme) {
1955 SyntaxThemeName.light => l10n.theme_light,
1956 SyntaxThemeName.dark => l10n.theme_dark,
1957 };
1958
1959 return DropdownMenuItem<SyntaxThemeName>(
1960 value: codeTheme,
1961 child: Text(displayName),
1962 );
1963 }).toList(),
1964 onChanged: (value) {
1965 if (value != null) {
1966 onChanged(value);
1967 }
1968 },
1969 ),
1970 ),
1971 ),
1972 const SizedBox(height: 8),
1973 Text(
1974 l10n.code_theme_desc,
1975 style: theme.textTheme.bodySmall?.copyWith(
1976 color: _mutedColor(context),
1977 ),
1978 ),
1979 ],
1980 ),
1981 );
1982 }
1983 }
1984
1985 class _DangerousAction extends StatelessWidget {
1986 const _DangerousAction({
1987 required this.label,
1988 required this.icon,
1989 required this.onConfirm,
1990 });
1991
1992 final String label;
1993 final List<List<dynamic>> icon;
1994 final VoidCallback onConfirm;
1995
1996 @override
1997 Widget build(BuildContext context) {
1998 final l10n = AppLocalizations.of(context)!;
1999
2000 return OutlinedButton.icon(
2001 onPressed: () {
2002 showDialog<void>(
2003 context: context,
2004 builder: (dialogContext) {
2005 final dialogL10n = AppLocalizations.of(dialogContext)!;
2006 return AlertDialog(
2007 title: Text(label),
2008 content: Text(dialogL10n.cannot_undo),
2009 actions: [
2010 TextButton(
2011 onPressed: () => Navigator.pop(dialogContext),
2012 child: Text(dialogL10n.cancel),
2013 ),
2014 TextButton(
2015 onPressed: () {
2016 Navigator.pop(dialogContext);
2017 onConfirm();
2018 ScaffoldMessenger.maybeOf(context)?.showSnackBar(
2019 SnackBar(content: Text(l10n.label_completed(label))),
2020 );
2021 },
2022 style: TextButton.styleFrom(foregroundColor: Colors.red),
2023 child: Text(dialogL10n.confirm),
2024 ),
2025 ],
2026 );
2027 },
2028 );
2029 },
2030 icon: HugeIcon(icon: icon, color: Colors.red, size: 16),
2031 label: Align(
2032 alignment: Alignment.centerLeft,
2033 child: Text(label, style: const TextStyle(color: Colors.red)),
2034 ),
2035 style: OutlinedButton.styleFrom(
2036 foregroundColor: Colors.red,
2037 side: BorderSide(color: Colors.red.withValues(alpha: 0.55)),
2038 minimumSize: const Size(double.infinity, 44),
2039 alignment: Alignment.centerLeft,
2040 padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
2041 shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
2042 ),
2043 );
2044 }
2045 }
2046
2047 class _SectionActionButton extends StatelessWidget {
2048 const _SectionActionButton({
2049 required this.icon,
2050 required this.label,
2051 required this.onPressed,
2052 });
2053
2054 final List<List<dynamic>> icon;
2055 final String label;
2056 final VoidCallback onPressed;
2057
2058 @override
2059 Widget build(BuildContext context) {
2060 return ShadButton.outline(
2061 onPressed: onPressed,
2062 width: double.infinity,
2063 leading: HugeIcon(icon: icon, size: 16),
2064 child: Align(alignment: Alignment.centerLeft, child: Text(label)),
2065 );
2066 }
2067 }
2068
2069 class _EngineDropdown extends StatelessWidget {
2070 const _EngineDropdown({required this.current, required this.onChanged});
2071
2072 final EngineId current;
2073 final ValueChanged<EngineId> onChanged;
2074
2075 @override
2076 Widget build(BuildContext context) {
2077 final l10n = AppLocalizations.of(context)!;
2078 final theme = Theme.of(context);
2079
2080 return _SettingPanel(
2081 child: Column(
2082 crossAxisAlignment: CrossAxisAlignment.start,
2083 children: [
2084 _SettingHeader(label: l10n.tts_engine),
2085 const SizedBox(height: 10),
2086 _InputShell(
2087 child: DropdownButtonHideUnderline(
2088 child: DropdownButton<EngineId>(
2089 value: current,
2090 isExpanded: true,
2091 padding: const EdgeInsets.symmetric(horizontal: 14),
2092 borderRadius: BorderRadius.circular(16),
2093 dropdownColor: theme.colorScheme.surface,
2094 items: [
2095 _engineItem(
2096 context,
2097 EngineId.system,
2098 l10n.tts_engine_system,
2099 HugeIcons.strokeRoundedVoice,
2100 ),
2101 _engineItem(
2102 context,
2103 EngineId.kitten,
2104 l10n.tts_engine_kitten,
2105 HugeIcons.strokeRoundedSparkles,
2106 ),
2107 _engineItem(
2108 context,
2109 EngineId.piper,
2110 EngineMeta.piper.name,
2111 HugeIcons.strokeRoundedCpu,
2112 ),
2113 ],
2114 onChanged: (value) {
2115 if (value != null) {
2116 onChanged(value);
2117 }
2118 },
2119 ),
2120 ),
2121 ),
2122 ],
2123 ),
2124 );
2125 }
2126
2127 DropdownMenuItem<EngineId> _engineItem(
2128 BuildContext context,
2129 EngineId id,
2130 String label,
2131 List<List<dynamic>> icon,
2132 ) {
2133 final meta = EngineMeta.forEngine(id);
2134 return DropdownMenuItem<EngineId>(
2135 value: id,
2136 child: Row(
2137 children: [
2138 HugeIcon(icon: icon, size: 18, color: Color(meta.accentColor)),
2139 const SizedBox(width: 10),
2140 Flexible(child: Text(label, overflow: TextOverflow.ellipsis)),
2141 ],
2142 ),
2143 );
2144 }
2145 }
2146
2147 class _VoiceSelector extends StatelessWidget {
2148 const _VoiceSelector({
2149 required this.engine,
2150 required this.currentVoiceId,
2151 required this.onChanged,
2152 });
2153
2154 final EngineId engine;
2155 final String? currentVoiceId;
2156 final ValueChanged<Voice?> onChanged;
2157
2158 @override
2159 Widget build(BuildContext context) {
2160 final l10n = AppLocalizations.of(context)!;
2161 final voices = voicesForEngine(engine);
2162 final resolvedCurrentVoiceId = voiceFromSettings(
2163 currentVoiceId,
2164 engine,
2165 )?.id;
2166
2167 if (voices.isEmpty) {
2168 return const SizedBox.shrink();
2169 }
2170
2171 final femaleVoices = voices.where((voice) => voice.gender == 'f').toList();
2172 final maleVoices = voices.where((voice) => voice.gender == 'm').toList();
2173 final otherVoices = voices
2174 .where((voice) => voice.gender != 'f' && voice.gender != 'm')
2175 .toList();
2176
2177 return _SettingPanel(
2178 child: Column(
2179 crossAxisAlignment: CrossAxisAlignment.start,
2180 children: [
2181 _SettingHeader(label: l10n.voice),
2182 if (femaleVoices.isNotEmpty) ...[
2183 const SizedBox(height: 8),
2184 _VoiceGroup(
2185 title: l10n.voice_female,
2186 voices: femaleVoices,
2187 selectedVoiceId: resolvedCurrentVoiceId,
2188 onChanged: onChanged,
2189 ),
2190 ],
2191 if (maleVoices.isNotEmpty) ...[
2192 const SizedBox(height: 12),
2193 _VoiceGroup(
2194 title: l10n.voice_male,
2195 voices: maleVoices,
2196 selectedVoiceId: resolvedCurrentVoiceId,
2197 onChanged: onChanged,
2198 ),
2199 ],
2200 if (otherVoices.isNotEmpty) ...[
2201 const SizedBox(height: 12),
2202 _VoiceGroup(
2203 title: l10n.voice_other,
2204 voices: otherVoices,
2205 selectedVoiceId: resolvedCurrentVoiceId,
2206 onChanged: onChanged,
2207 ),
2208 ],
2209 ],
2210 ),
2211 );
2212 }
2213 }
2214
2215 class _VoiceGroup extends StatelessWidget {
2216 const _VoiceGroup({
2217 required this.title,
2218 required this.voices,
2219 required this.selectedVoiceId,
2220 required this.onChanged,
2221 });
2222
2223 final String title;
2224 final List<Voice> voices;
2225 final String? selectedVoiceId;
2226 final ValueChanged<Voice?> onChanged;
2227
2228 @override
2229 Widget build(BuildContext context) {
2230 return Column(
2231 crossAxisAlignment: CrossAxisAlignment.start,
2232 children: [
2233 Text(
2234 title.toUpperCase(),
2235 style: Theme.of(context).textTheme.labelSmall?.copyWith(
2236 color: _mutedColor(context),
2237 fontWeight: FontWeight.w700,
2238 letterSpacing: 0.5,
2239 ),
2240 ),
2241 const SizedBox(height: 6),
2242 Wrap(
2243 spacing: 8,
2244 runSpacing: 8,
2245 children: voices
2246 .map(
2247 (voice) => _VoiceChip(
2248 voice: voice,
2249 selected: voice.id == selectedVoiceId,
2250 onTap: () => onChanged(voice),
2251 ),
2252 )
2253 .toList(),
2254 ),
2255 ],
2256 );
2257 }
2258 }
2259
2260 class _VoiceChip extends StatelessWidget {
2261 const _VoiceChip({
2262 required this.voice,
2263 required this.selected,
2264 required this.onTap,
2265 });
2266
2267 final Voice voice;
2268 final bool selected;
2269 final VoidCallback onTap;
2270
2271 @override
2272 Widget build(BuildContext context) {
2273 final theme = Theme.of(context);
2274 final primary = theme.colorScheme.primary;
2275
2276 return Material(
2277 color: Colors.transparent,
2278 child: InkWell(
2279 onTap: onTap,
2280 borderRadius: BorderRadius.circular(12),
2281 child: Container(
2282 padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
2283 decoration: BoxDecoration(
2284 color: selected
2285 ? primary.withValues(alpha: 0.10)
2286 : theme.scaffoldBackgroundColor.withValues(alpha: 0.5),
2287 borderRadius: BorderRadius.circular(12),
2288 border: Border.all(
2289 color: selected ? primary : _outlineColor(context, alpha: 0.8),
2290 ),
2291 ),
2292 child: Row(
2293 mainAxisSize: MainAxisSize.min,
2294 children: [
2295 HugeIcon(
2296 icon: selected
2297 ? HugeIcons.strokeRoundedCheckmarkCircle01
2298 : HugeIcons.strokeRoundedCircle,
2299 size: 16,
2300 color: selected ? primary : _mutedColor(context),
2301 ),
2302 const SizedBox(width: 8),
2303 Text(
2304 voice.name,
2305 style: theme.textTheme.bodyMedium?.copyWith(
2306 fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
2307 color: selected ? primary : null,
2308 ),
2309 ),
2310 if (voice.language != null) ...[
2311 const SizedBox(width: 6),
2312 Container(
2313 padding: const EdgeInsets.symmetric(
2314 horizontal: 5,
2315 vertical: 2,
2316 ),
2317 decoration: BoxDecoration(
2318 color: _panelColor(context),
2319 borderRadius: BorderRadius.circular(999),
2320 ),
2321 child: Text(
2322 voice.language!,
2323 style: theme.textTheme.labelSmall?.copyWith(
2324 color: _mutedColor(context),
2325 fontWeight: FontWeight.w700,
2326 ),
2327 ),
2328 ),
2329 ],
2330 ],
2331 ),
2332 ),
2333 ),
2334 );
2335 }
2336 }
2337
2338 class _OnDeviceEngineStatusCard extends ConsumerWidget {
2339 const _OnDeviceEngineStatusCard();
2340
2341 @override
2342 Widget build(BuildContext context, WidgetRef ref) {
2343 final l10n = AppLocalizations.of(context)!;
2344 final engineState = ref.watch(onDeviceEngineProvider);
2345
2346 final (
2347 Color accent,
2348 List<List<dynamic>> icon,
2349 String message,
2350 ) = switch (engineState.status) {
2351 OnDeviceEngineStatus.loaded => (
2352 Colors.green,
2353 HugeIcons.strokeRoundedCheckmarkCircle01,
2354 l10n.model_loaded(
2355 engineState.loadedModelId ?? 'unknown',
2356 engineState.backend?.name ?? 'CPU',
2357 ),
2358 ),
2359 OnDeviceEngineStatus.loading => (
2360 Colors.blue,
2361 HugeIcons.strokeRoundedClock01,
2362 l10n.loading,
2363 ),
2364 OnDeviceEngineStatus.error => (
2365 Colors.red,
2366 HugeIcons.strokeRoundedAlertCircle,
2367 l10n.error_with_message(engineState.error ?? l10n.unknown_error),
2368 ),
2369 OnDeviceEngineStatus.notLoaded => (
2370 Colors.grey,
2371 HugeIcons.strokeRoundedInformationCircle,
2372 l10n.no_model_loaded,
2373 ),
2374 };
2375
2376 return Container(
2377 padding: const EdgeInsets.all(14),
2378 decoration: BoxDecoration(
2379 color: accent.withValues(alpha: 0.08),
2380 borderRadius: BorderRadius.circular(14),
2381 border: Border.all(color: accent.withValues(alpha: 0.25)),
2382 ),
2383 child: Row(
2384 crossAxisAlignment: CrossAxisAlignment.start,
2385 children: [
2386 HugeIcon(icon: icon, size: 18, color: accent),
2387 const SizedBox(width: 8),
2388 Expanded(
2389 child: Text(
2390 message,
2391 style: Theme.of(context).textTheme.bodyMedium?.copyWith(
2392 color: accent,
2393 fontWeight: FontWeight.w600,
2394 height: 1.3,
2395 ),
2396 ),
2397 ),
2398 ],
2399 ),
2400 );
2401 }
2402 }
2403
2404 class _SettingHeader extends StatelessWidget {
2405 const _SettingHeader({required this.label});
2406
2407 final String label;
2408
2409 @override
2410 Widget build(BuildContext context) {
2411 return Text(
2412 label,
2413 style: Theme.of(
2414 context,
2415 ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
2416 );
2417 }
2418 }
2419
2420 class _InputShell extends StatelessWidget {
2421 const _InputShell({required this.child});
2422
2423 final Widget child;
2424
2425 @override
2426 Widget build(BuildContext context) {
2427 return Container(
2428 decoration: BoxDecoration(
2429 color: _surfaceColor(context),
2430 borderRadius: BorderRadius.circular(12),
2431 border: Border.all(color: _outlineColor(context, alpha: 0.8)),
2432 ),
2433 child: child,
2434 );
2435 }
2436 }
2437
2438 class _HuggingFaceTokenSetting extends StatelessWidget {
2439 const _HuggingFaceTokenSetting({
2440 required this.currentToken,
2441 required this.onSave,
2442 });
2443
2444 final String? currentToken;
2445 final ValueChanged<String?> onSave;
2446
2447 bool get _hasToken => currentToken != null && currentToken!.isNotEmpty;
2448
2449 String _maskedToken(String token) {
2450 if (token.length <= 6) return '••••••';
2451 return '${token.substring(0, 4)}••••••${token.substring(token.length - 4)}';
2452 }
2453
2454 Future<void> _editToken(BuildContext context) async {
2455 final l10n = AppLocalizations.of(context)!;
2456 final controller = TextEditingController(text: currentToken ?? '');
2457 final result = await showDialog<String?>(
2458 context: context,
2459 builder: (ctx) {
2460 return AlertDialog(
2461 title: Text(l10n.edit_huggingface_token_dialog_title),
2462 content: TextField(
2463 controller: controller,
2464 obscureText: true,
2465 autofocus: true,
2466 decoration: InputDecoration(
2467 hintText: l10n.huggingface_token_dialog_hint,
2468 border: const OutlineInputBorder(),
2469 ),
2470 onSubmitted: (v) => Navigator.of(ctx).pop(v.trim()),
2471 ),
2472 actions: [
2473 TextButton(
2474 onPressed: () => Navigator.of(ctx).pop(),
2475 child: Text(l10n.cancel),
2476 ),
2477 TextButton(
2478 onPressed: () => Navigator.of(ctx).pop(''),
2479 child: Text(l10n.clear_huggingface_token),
2480 ),
2481 TextButton(
2482 onPressed: () => Navigator.of(ctx).pop(controller.text.trim()),
2483 child: Text(l10n.save),
2484 ),
2485 ],
2486 );
2487 },
2488 );
2489
2490 controller.dispose();
2491 if (result == null) return;
2492 onSave(result.isEmpty ? null : result);
2493 }
2494
2495 @override
2496 Widget build(BuildContext context) {
2497 final l10n = AppLocalizations.of(context)!;
2498 final theme = Theme.of(context);
2499
2500 return _SettingPanel(
2501 child: Column(
2502 crossAxisAlignment: CrossAxisAlignment.start,
2503 children: [
2504 _SettingHeader(label: l10n.settings_huggingface_token),
2505 const SizedBox(height: 6),
2506 Text(
2507 l10n.settings_huggingface_token_desc,
2508 style: theme.textTheme.bodySmall?.copyWith(
2509 color: _mutedColor(context),
2510 height: 1.4,
2511 ),
2512 ),
2513 const SizedBox(height: 10),
2514 Row(
2515 children: [
2516 Expanded(
2517 child: _InputShell(
2518 child: Padding(
2519 padding: const EdgeInsets.symmetric(
2520 horizontal: 14,
2521 vertical: 12,
2522 ),
2523 child: Text(
2524 _hasToken ? _maskedToken(currentToken!) : '—',
2525 style: theme.textTheme.bodyMedium?.copyWith(
2526 fontFamily: 'monospace',
2527 color: _hasToken
2528 ? theme.colorScheme.onSurface
2529 : _mutedColor(context),
2530 ),
2531 ),
2532 ),
2533 ),
2534 ),
2535 const SizedBox(width: 10),
2536 ShadButton.outline(
2537 onPressed: () => _editToken(context),
2538 child: Text(_hasToken ? l10n.edit : l10n.set_huggingface_token),
2539 ),
2540 if (_hasToken) ...[
2541 const SizedBox(width: 6),
2542 ShadButton.outline(
2543 onPressed: () => onSave(null),
2544 child: Text(l10n.clear_huggingface_token),
2545 ),
2546 ],
2547 ],
2548 ),
2549 ],
2550 ),
2551 );
2552 }
2553 }
2554
2555 class _DefaultSystemPromptSetting extends StatelessWidget {
2556 const _DefaultSystemPromptSetting({
2557 required this.currentPrompt,
2558 required this.onSave,
2559 });
2560
2561 final String currentPrompt;
2562 final ValueChanged<String> onSave;
2563
2564 Future<void> _editPrompt(BuildContext context) async {
2565 final l10n = AppLocalizations.of(context)!;
2566 final controller = TextEditingController(text: currentPrompt);
2567 final result = await showDialog<String?>(
2568 context: context,
2569 builder: (ctx) {
2570 return AlertDialog(
2571 title: Text(l10n.default_system_prompt),
2572 content: TextField(
2573 controller: controller,
2574 autofocus: true,
2575 minLines: 4,
2576 maxLines: 10,
2577 decoration: InputDecoration(
2578 hintText: l10n.default_system_prompt_hint,
2579 border: const OutlineInputBorder(),
2580 ),
2581 ),
2582 actions: [
2583 TextButton(
2584 onPressed: () => Navigator.of(ctx).pop(),
2585 child: Text(l10n.cancel),
2586 ),
2587 TextButton(
2588 onPressed: () => Navigator.of(ctx).pop(controller.text),
2589 child: Text(l10n.save),
2590 ),
2591 ],
2592 );
2593 },
2594 );
2595
2596 controller.dispose();
2597 if (result == null) return;
2598 onSave(result);
2599 }
2600
2601 @override
2602 Widget build(BuildContext context) {
2603 final l10n = AppLocalizations.of(context)!;
2604 final theme = Theme.of(context);
2605 final hasPrompt = currentPrompt.trim().isNotEmpty;
2606
2607 return _SettingPanel(
2608 child: Column(
2609 crossAxisAlignment: CrossAxisAlignment.start,
2610 children: [
2611 _SettingHeader(label: l10n.default_system_prompt),
2612 const SizedBox(height: 6),
2613 Text(
2614 l10n.default_system_prompt_desc,
2615 style: theme.textTheme.bodySmall?.copyWith(
2616 color: _mutedColor(context),
2617 height: 1.4,
2618 ),
2619 ),
2620 const SizedBox(height: 10),
2621 Row(
2622 children: [
2623 Expanded(
2624 child: _InputShell(
2625 child: Padding(
2626 padding: const EdgeInsets.symmetric(
2627 horizontal: 14,
2628 vertical: 12,
2629 ),
2630 child: Text(
2631 hasPrompt ? currentPrompt : '—',
2632 maxLines: 2,
2633 overflow: TextOverflow.ellipsis,
2634 style: theme.textTheme.bodyMedium?.copyWith(
2635 color: hasPrompt
2636 ? theme.colorScheme.onSurface
2637 : _mutedColor(context),
2638 ),
2639 ),
2640 ),
2641 ),
2642 ),
2643 const SizedBox(width: 10),
2644 ShadButton.outline(
2645 onPressed: () => _editPrompt(context),
2646 child: Text(l10n.edit),
2647 ),
2648 if (hasPrompt) ...[
2649 const SizedBox(width: 6),
2650 ShadButton.outline(
2651 onPressed: () => onSave(''),
2652 child: Text(l10n.clear),
2653 ),
2654 ],
2655 ],
2656 ),
2657 ],
2658 ),
2659 );
2660 }
2661 }
2662
2663 class _PrivacyPolicyLink extends StatelessWidget {
2664 static final Uri _privacyUrl = Uri.parse(
2665 'https://momin.pro/privacy-policy-localmind/',
2666 );
2667
2668 Future<void> _openPrivacyPolicy(BuildContext context) async {
2669 final messenger = ScaffoldMessenger.maybeOf(context);
2670 final l10n = AppLocalizations.of(context);
2671 try {
2672 final ok = await launchUrl(
2673 _privacyUrl,
2674 mode: LaunchMode.externalApplication,
2675 );
2676 if (!ok && messenger != null) {
2677 messenger.showSnackBar(
2678 SnackBar(
2679 content: Text(
2680 l10n?.failed_to_open_url(_privacyUrl.toString()) ??
2681 'Failed to open URL: $_privacyUrl',
2682 ),
2683 ),
2684 );
2685 }
2686 } catch (e) {
2687 if (messenger != null) {
2688 messenger.showSnackBar(
2689 SnackBar(
2690 content: Text(
2691 l10n?.failed_to_open_url(e.toString()) ??
2692 'Failed to open URL: $e',
2693 ),
2694 ),
2695 );
2696 }
2697 }
2698 }
2699
2700 @override
2701 Widget build(BuildContext context) {
2702 final l10n = AppLocalizations.of(context)!;
2703 final theme = Theme.of(context);
2704 final colorScheme = theme.colorScheme;
2705
2706 return Center(
2707 child: InkWell(
2708 onTap: () => _openPrivacyPolicy(context),
2709 borderRadius: BorderRadius.circular(8),
2710 child: Padding(
2711 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
2712 child: Row(
2713 mainAxisSize: MainAxisSize.min,
2714 children: [
2715 HugeIcon(
2716 icon: HugeIcons.strokeRoundedShield01,
2717 size: 16,
2718 color: colorScheme.onSurfaceVariant,
2719 ),
2720 const SizedBox(width: 8),
2721 Text(
2722 l10n.privacy_policy,
2723 style: theme.textTheme.bodySmall?.copyWith(
2724 color: colorScheme.onSurfaceVariant,
2725 fontWeight: FontWeight.w500,
2726 ),
2727 ),
2728 const SizedBox(width: 6),
2729 HugeIcon(
2730 icon: HugeIcons.strokeRoundedShare01,
2731 size: 14,
2732 color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
2733 ),
2734 ],
2735 ),
2736 ),
2737 ),
2738 );
2739 }
2740 }
2741
2742 List<Widget> _withVerticalSpacing(List<Widget> children, {double gap = 12}) {
2743 if (children.isEmpty) {
2744 return const [];
2745 }
2746
2747 return [
2748 for (var index = 0; index < children.length; index++) ...[
2749 children[index],
2750 if (index != children.length - 1) SizedBox(height: gap),
2751 ],
2752 ];
2753 }
2754
2755 Color _panelColor(BuildContext context) {
2756 final scheme = ShadTheme.of(context).colorScheme;
2757 return scheme.secondary;
2758 }
2759
2760 Color _surfaceColor(BuildContext context) {
2761 return ShadTheme.of(context).colorScheme.card;
2762 }
2763
2764 Color _primaryColor(BuildContext context) {
2765 return ShadTheme.of(context).colorScheme.primary;
2766 }
2767
2768 Color _outlineColor(BuildContext context, {double alpha = 0.6}) {
2769 return ShadTheme.of(context).colorScheme.border.withValues(alpha: alpha);
2770 }
2771
2772 Color _mutedColor(BuildContext context) {
2773 return ShadTheme.of(context).colorScheme.mutedForeground;
2774 }
2775
2776 String _themeLabel(AppThemeType themeType, AppLocalizations l10n) {
2777 return switch (themeType) {
2778 AppThemeType.system => l10n.theme_system,
2779 AppThemeType.light => l10n.theme_light,
2780 AppThemeType.dark => l10n.theme_dark,
2781 AppThemeType.claude => l10n.theme_claude,
2782 };
2783 }
2784
2785 String _engineLabel(EngineId engine, AppLocalizations l10n) {
2786 return switch (engine) {
2787 EngineId.system => l10n.tts_engine_system,
2788 EngineId.kitten => l10n.tts_engine_kitten,
2789 EngineId.piper => EngineMeta.piper.name,
2790 };
2791 }
2792
2793 String _languageLabel(String? localeCode, AppLocalizations l10n) {
2794 if (localeCode == null) {
2795 return l10n.language_system_default;
2796 }
2797
2798 for (final item in _LanguageSetting.localeItems) {
2799 if (item.$1 == localeCode) {
2800 return item.$2;
2801 }
2802 }
2803
2804 return localeCode.toUpperCase();
2805 }
2806
2807 Future<void> _openFeedbackIssue(BuildContext context) async {
2808 final uri = CrashReportService.instance.buildFeedbackIssueUrl();
2809 final messenger = ScaffoldMessenger.maybeOf(context);
2810 final l10n = AppLocalizations.of(context);
2811 try {
2812 final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
2813 if (!ok && messenger != null) {
2814 messenger.showSnackBar(
2815 SnackBar(
2816 content: Text(
2817 l10n?.could_not_open_github ??
2818 'Could not open GitHub. Please try again later.',
2819 ),
2820 ),
2821 );
2822 }
2823 } catch (e) {
2824 if (messenger != null) {
2825 messenger.showSnackBar(
2826 SnackBar(
2827 content: Text(
2828 l10n?.failed_to_open_url(e.toString()) ?? 'Failed to open URL: $e',
2829 ),
2830 ),
2831 );
2832 }
2833 }
2834 }