main
dart 908 lines 29.8 KB
Raw
1 import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:hugeicons/hugeicons.dart';
4 import 'package:localmind/l10n/app_localizations.dart';
5 import 'package:shadcn_ui/shadcn_ui.dart';
6
7 import '../../chat/data/mcp_server_manager.dart';
8 import '../../chat/data/tools/calendar_service.dart';
9 import '../../chat/data/tools/location_service.dart';
10 import '../../chat/data/tools/tool_definition.dart';
11 import '../../chat/providers/chat_mcp_providers.dart';
12 import '../../chat/providers/tooling_providers.dart';
13 import '../../../core/providers/app_providers.dart';
14 import '../../../core/theme/colors.dart';
15 import '../../../core/utils/system_insets.dart';
16
17 class McpToolsScreen extends ConsumerWidget {
18 const McpToolsScreen({super.key});
19
20 void _toggleExampleServer(WidgetRef ref) {
21 final manager = ref.read(mcpServerManagerProvider);
22 if (manager.hasExampleServer()) {
23 manager.removeServer(exampleMcpServerLabel);
24 } else {
25 manager.addExampleServer();
26 }
27 ref.invalidate(availableToolsProvider);
28 }
29
30 @override
31 Widget build(BuildContext context, WidgetRef ref) {
32 final l10n = AppLocalizations.of(context)!;
33 final settings = ref.watch(settingsProvider);
34 final manager = ref.watch(mcpServerManagerProvider);
35 final hasExampleServer = manager.hasExampleServer();
36 final toolsAsync = ref.watch(availableToolsProvider);
37 final isDark = Theme.of(context).brightness == Brightness.dark;
38 final topPadding = MediaQuery.of(context).padding.top;
39 final bottomInset = bottomSystemInset(context);
40
41 return Column(
42 children: [
43 Container(
44 padding: EdgeInsets.only(
45 left: 16,
46 right: 16,
47 top: topPadding + 8,
48 bottom: 16,
49 ),
50 decoration: BoxDecoration(
51 color: isDark ? const Color(0xFF0A0A0A) : const Color(0xFFFAFAFA),
52 border: Border(
53 bottom: BorderSide(
54 color: isDark
55 ? const Color(0xFF2A2A2A)
56 : const Color(0xFFE5E5E5),
57 ),
58 ),
59 ),
60 child: Row(
61 children: [
62 Builder(
63 builder: (context) => IconButton(
64 icon: const HugeIcon(icon: HugeIcons.strokeRoundedMenu01),
65 onPressed: () => Scaffold.maybeOf(context)?.openDrawer(),
66 ),
67 ),
68 const SizedBox(width: 8),
69 Text(
70 l10n.mcp_tools_title,
71 style: TextStyle(
72 fontSize: 20,
73 fontWeight: FontWeight.bold,
74 color: isDark ? Colors.white : Colors.black,
75 ),
76 ),
77 ],
78 ),
79 ),
80 Expanded(
81 child: LayoutBuilder(
82 builder: (context, constraints) {
83 final useTwoColumns = constraints.maxWidth >= 1080;
84 final contentMaxWidth = useTwoColumns ? 1120.0 : 720.0;
85 final horizontalPadding = constraints.maxWidth >= 720
86 ? 20.0
87 : 12.0;
88
89 final mcpCard = _McpSectionCard(
90 title: l10n.mcp_tools_title,
91 children: [
92 _McpToggleSetting(
93 label: l10n.enable_mcp,
94 value: settings.mcpEnabled,
95 badges: [_FeatureBadge(label: l10n.experimental_label)],
96 onChanged: (value) {
97 ref.read(settingsProvider.notifier).setMcpEnabled(value);
98 },
99 ),
100 if (settings.mcpEnabled) ...[
101 _McpToggleSetting(
102 label: l10n.new_chat_mcp_default,
103 value: settings.newChatMcpEnabled,
104 onChanged: (value) => ref
105 .read(settingsProvider.notifier)
106 .setNewChatMcpEnabled(value),
107 ),
108 _McpToggleSetting(
109 label: l10n.calendar_access,
110 description: l10n.calendar_access_desc,
111 value: settings.calendarToolsEnabled,
112 onChanged: (value) async {
113 if (value) {
114 final cal = CalendarService.instance;
115 final granted = await cal.requestAccess();
116 if (granted) {
117 ref
118 .read(settingsProvider.notifier)
119 .setCalendarToolsEnabled(true);
120 } else {
121 if (context.mounted) {
122 ScaffoldMessenger.maybeOf(context)?.showSnackBar(
123 SnackBar(
124 content: Text(
125 l10n.calendar_permission_denied,
126 ),
127 ),
128 );
129 }
130 }
131 } else {
132 ref
133 .read(settingsProvider.notifier)
134 .setCalendarToolsEnabled(false);
135 }
136 },
137 ),
138 _McpToggleSetting(
139 label: l10n.location_access,
140 description: l10n.location_access_desc,
141 value: settings.locationToolsEnabled,
142 onChanged: (value) async {
143 if (value) {
144 final location = LocationService.instance;
145 final granted = await location.requestAccess();
146 if (granted) {
147 ref
148 .read(settingsProvider.notifier)
149 .setLocationToolsEnabled(true);
150 } else {
151 if (context.mounted) {
152 ScaffoldMessenger.maybeOf(context)?.showSnackBar(
153 SnackBar(
154 content: Text(
155 l10n.location_permission_denied,
156 ),
157 ),
158 );
159 }
160 }
161 } else {
162 ref
163 .read(settingsProvider.notifier)
164 .setLocationToolsEnabled(false);
165 }
166 },
167 ),
168 _McpToggleSetting(
169 label: l10n.enable_example_server,
170 description: l10n.example_mcp_server_desc,
171 value: hasExampleServer,
172 onChanged: (_) => _toggleExampleServer(ref),
173 ),
174 ],
175 ],
176 );
177
178 final toolsCard = _McpSectionCard(
179 title: l10n.available_tools,
180 accent: const Color(0xFF22C55E),
181 icon: HugeIcons.strokeRoundedPuzzle,
182 children: [
183 _buildToolsContent(l10n, settings.mcpEnabled, toolsAsync),
184 ],
185 );
186
187 return ListView(
188 padding: EdgeInsets.fromLTRB(
189 horizontalPadding,
190 12,
191 horizontalPadding,
192 24 + bottomInset,
193 ),
194 children: [
195 Center(
196 child: ConstrainedBox(
197 constraints: BoxConstraints(maxWidth: contentMaxWidth),
198 child: Column(
199 crossAxisAlignment: CrossAxisAlignment.stretch,
200 children: [
201 mcpCard,
202 if (settings.mcpEnabled) ...[
203 const SizedBox(height: 16),
204 _ConfiguredMcpServersCard(
205 onServersChanged: () =>
206 ref.invalidate(availableToolsProvider),
207 ),
208 ],
209 const SizedBox(height: 16),
210 toolsCard,
211 ],
212 ),
213 ),
214 ),
215 ],
216 );
217 },
218 ),
219 ),
220 ],
221 );
222 }
223
224 Widget _buildToolsContent(
225 AppLocalizations l10n,
226 bool mcpEnabled,
227 AsyncValue<List<ToolDefinition>> toolsAsync,
228 ) {
229 if (!mcpEnabled) {
230 return _StatusPanel(
231 icon: HugeIcons.strokeRoundedAlertCircle,
232 title: l10n.mcp_disabled_warning,
233 body: l10n.no_tools_registered_desc,
234 );
235 }
236
237 return toolsAsync.when(
238 loading: () => _McpPanel(
239 child: const Padding(
240 padding: EdgeInsets.symmetric(vertical: 24),
241 child: Center(
242 child: SizedBox(
243 width: 22,
244 height: 22,
245 child: CircularProgressIndicator(strokeWidth: 2),
246 ),
247 ),
248 ),
249 ),
250 error: (error, _) => _StatusPanel(
251 icon: HugeIcons.strokeRoundedInformationCircle,
252 title: l10n.unable_load_tools,
253 body: error.toString(),
254 ),
255 data: (tools) {
256 if (tools.isEmpty) {
257 return _StatusPanel(
258 icon: HugeIcons.strokeRoundedPuzzle,
259 title: l10n.no_tools_registered,
260 body: l10n.no_tools_registered_desc,
261 );
262 }
263
264 return Column(
265 children: tools
266 .map((tool) => _ToolRow(tool: tool))
267 .toList(growable: false),
268 );
269 },
270 );
271 }
272 }
273
274 class _McpSectionCard extends StatelessWidget {
275 const _McpSectionCard({
276 required this.title,
277 required this.children,
278 this.icon = HugeIcons.strokeRoundedMcpServer,
279 this.accent = const Color(0xFF8B5CF6),
280 this.trailing,
281 });
282
283 final String title;
284 final List<List<dynamic>> icon;
285 final Color accent;
286 final List<Widget> children;
287 final Widget? trailing;
288
289 @override
290 Widget build(BuildContext context) {
291 return Container(
292 decoration: BoxDecoration(
293 color: _surfaceColor(context),
294 borderRadius: BorderRadius.circular(18),
295 border: Border.all(color: _outlineColor(context, alpha: 0.9)),
296 ),
297 child: Padding(
298 padding: const EdgeInsets.all(16),
299 child: Column(
300 crossAxisAlignment: CrossAxisAlignment.start,
301 children: [
302 Row(
303 children: [
304 Container(
305 width: 34,
306 height: 34,
307 decoration: BoxDecoration(
308 color: accent.withValues(alpha: 0.10),
309 borderRadius: BorderRadius.circular(10),
310 ),
311 child: Center(
312 child: HugeIcon(icon: icon, color: accent, size: 16),
313 ),
314 ),
315 const SizedBox(width: 10),
316 Expanded(
317 child: Text(
318 title,
319 style: Theme.of(context).textTheme.titleMedium?.copyWith(
320 fontWeight: FontWeight.w700,
321 ),
322 ),
323 ),
324 if (trailing != null) ...[const SizedBox(width: 8), trailing!],
325 ],
326 ),
327 const SizedBox(height: 12),
328 ..._withVerticalSpacing(children, gap: 10),
329 ],
330 ),
331 ),
332 );
333 }
334 }
335
336 class _McpPanel extends StatelessWidget {
337 const _McpPanel({required this.child});
338
339 final Widget child;
340
341 @override
342 Widget build(BuildContext context) {
343 return Container(
344 padding: const EdgeInsets.all(12),
345 decoration: BoxDecoration(
346 color: _panelColor(context),
347 borderRadius: BorderRadius.circular(14),
348 border: Border.all(color: _outlineColor(context)),
349 ),
350 child: child,
351 );
352 }
353 }
354
355 class _McpToggleSetting extends StatelessWidget {
356 const _McpToggleSetting({
357 required this.label,
358 required this.value,
359 required this.onChanged,
360 this.description,
361 this.badges = const [],
362 });
363
364 final String label;
365 final String? description;
366 final bool value;
367 final ValueChanged<bool> onChanged;
368 final List<Widget> badges;
369
370 @override
371 Widget build(BuildContext context) {
372 final theme = Theme.of(context);
373
374 return _McpPanel(
375 child: Row(
376 crossAxisAlignment: CrossAxisAlignment.center,
377 children: [
378 Expanded(
379 child: Column(
380 crossAxisAlignment: CrossAxisAlignment.start,
381 children: [
382 Wrap(
383 spacing: 8,
384 runSpacing: 6,
385 crossAxisAlignment: WrapCrossAlignment.center,
386 children: [
387 Text(
388 label,
389 style: theme.textTheme.titleSmall?.copyWith(
390 fontWeight: FontWeight.w700,
391 ),
392 ),
393 ...badges,
394 ],
395 ),
396 if (description != null) ...[
397 const SizedBox(height: 4),
398 Text(
399 description!,
400 style: theme.textTheme.bodySmall?.copyWith(
401 color: theme.colorScheme.onSurfaceVariant,
402 ),
403 ),
404 ],
405 ],
406 ),
407 ),
408 const SizedBox(width: 8),
409 Switch.adaptive(value: value, onChanged: onChanged),
410 ],
411 ),
412 );
413 }
414 }
415
416 class _ToolRow extends StatelessWidget {
417 const _ToolRow({required this.tool});
418
419 final ToolDefinition tool;
420
421 @override
422 Widget build(BuildContext context) {
423 final theme = Theme.of(context);
424 final l10n = AppLocalizations.of(context)!;
425 final isMcp = tool.providerType == ToolProviderType.mcp;
426
427 return Padding(
428 padding: const EdgeInsets.only(bottom: 10),
429 child: _McpPanel(
430 child: Row(
431 crossAxisAlignment: CrossAxisAlignment.start,
432 children: [
433 HugeIcon(
434 icon: isMcp
435 ? HugeIcons.strokeRoundedShare01
436 : (tool.name.startsWith('calendar.')
437 ? HugeIcons.strokeRoundedCalendar01
438 : (tool.name.startsWith('location.')
439 ? HugeIcons.strokeRoundedLocation01
440 : (tool.name.startsWith('sms.')
441 ? HugeIcons.strokeRoundedMessage01
442 : HugeIcons.strokeRoundedCalculate))),
443 size: 18,
444 color: isMcp
445 ? theme.colorScheme.primary
446 : theme.colorScheme.outline,
447 ),
448 const SizedBox(width: 12),
449 Expanded(
450 child: Column(
451 crossAxisAlignment: CrossAxisAlignment.start,
452 children: [
453 Text(
454 tool.name,
455 style: theme.textTheme.bodyLarge?.copyWith(
456 fontWeight: FontWeight.w700,
457 ),
458 ),
459 if (tool.description.isNotEmpty) ...[
460 const SizedBox(height: 4),
461 Text(
462 tool.description,
463 style: theme.textTheme.bodyMedium?.copyWith(
464 color: theme.colorScheme.onSurfaceVariant,
465 ),
466 ),
467 ],
468 const SizedBox(height: 8),
469 Wrap(
470 spacing: 8,
471 runSpacing: 8,
472 children: [
473 _ToolBadge(
474 label: isMcp ? 'MCP' : l10n.built_in_label,
475 color: isMcp
476 ? theme.colorScheme.primary
477 : theme.colorScheme.outline,
478 ),
479 if (tool.providerRef != null)
480 _ToolBadge(
481 label: tool.providerRef!,
482 color: theme.colorScheme.secondary,
483 ),
484 ],
485 ),
486 ],
487 ),
488 ),
489 ],
490 ),
491 ),
492 );
493 }
494 }
495
496 class _ToolBadge extends StatelessWidget {
497 const _ToolBadge({required this.label, required this.color});
498
499 final String label;
500 final Color color;
501
502 @override
503 Widget build(BuildContext context) {
504 return Container(
505 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
506 decoration: BoxDecoration(
507 color: color.withValues(alpha: 0.12),
508 borderRadius: BorderRadius.circular(6),
509 ),
510 child: Text(
511 label,
512 style: TextStyle(
513 color: color,
514 fontSize: 12,
515 fontWeight: FontWeight.w600,
516 ),
517 ),
518 );
519 }
520 }
521
522 class _StatusPanel extends StatelessWidget {
523 const _StatusPanel({
524 required this.icon,
525 required this.title,
526 required this.body,
527 });
528
529 final List<List<dynamic>> icon;
530 final String title;
531 final String body;
532
533 @override
534 Widget build(BuildContext context) {
535 final theme = Theme.of(context);
536 return _McpPanel(
537 child: Padding(
538 padding: const EdgeInsets.symmetric(vertical: 12),
539 child: Column(
540 children: [
541 HugeIcon(icon: icon, size: 28, color: theme.colorScheme.outline),
542 const SizedBox(height: 12),
543 Text(
544 title,
545 style: theme.textTheme.titleMedium?.copyWith(
546 fontWeight: FontWeight.w700,
547 ),
548 textAlign: TextAlign.center,
549 ),
550 const SizedBox(height: 6),
551 Text(
552 body,
553 style: theme.textTheme.bodyMedium?.copyWith(
554 color: theme.colorScheme.onSurfaceVariant,
555 ),
556 textAlign: TextAlign.center,
557 ),
558 ],
559 ),
560 ),
561 );
562 }
563 }
564
565 class _FeatureBadge extends StatelessWidget {
566 const _FeatureBadge({required this.label});
567
568 final String label;
569
570 @override
571 Widget build(BuildContext context) {
572 return Container(
573 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
574 decoration: BoxDecoration(
575 color: Colors.amber.withValues(alpha: 0.10),
576 borderRadius: BorderRadius.circular(999),
577 border: Border.all(color: Colors.amber.withValues(alpha: 0.22)),
578 ),
579 child: Text(
580 label.toUpperCase(),
581 style: Theme.of(context).textTheme.labelSmall?.copyWith(
582 color: const Color(0xFFB45309),
583 fontWeight: FontWeight.w800,
584 letterSpacing: 0.45,
585 ),
586 ),
587 );
588 }
589 }
590
591 // Visual helpers, kept in sync with settings_screen.dart to ensure the
592 // MCP screen matches the rest of the app's settings surfaces.
593 List<Widget> _withVerticalSpacing(List<Widget> children, {double gap = 12}) {
594 if (children.isEmpty) return const [];
595 return [
596 for (var index = 0; index < children.length; index++) ...[
597 children[index],
598 if (index != children.length - 1) SizedBox(height: gap),
599 ],
600 ];
601 }
602
603 Color _panelColor(BuildContext context) {
604 return ShadTheme.of(context).colorScheme.secondary;
605 }
606
607 Color _surfaceColor(BuildContext context) {
608 return ShadTheme.of(context).colorScheme.card;
609 }
610
611 Color _outlineColor(BuildContext context, {double alpha = 0.6}) {
612 return ShadTheme.of(context).colorScheme.border.withValues(alpha: alpha);
613 }
614
615 class _ConfiguredMcpServersCard extends ConsumerStatefulWidget {
616 const _ConfiguredMcpServersCard({required this.onServersChanged});
617
618 final VoidCallback onServersChanged;
619
620 @override
621 ConsumerState<_ConfiguredMcpServersCard> createState() =>
622 __ConfiguredMcpServersCardState();
623 }
624
625 class __ConfiguredMcpServersCardState
626 extends ConsumerState<_ConfiguredMcpServersCard> {
627 final _serverLabelController = TextEditingController();
628 final _serverUrlController = TextEditingController();
629
630 @override
631 void dispose() {
632 _serverLabelController.dispose();
633 _serverUrlController.dispose();
634 super.dispose();
635 }
636
637 void _addServer() {
638 final label = _serverLabelController.text.trim();
639 final url = _serverUrlController.text.trim();
640 if (label.isEmpty && url.isEmpty) return;
641
642 final McpIntegration integration;
643 if (label.contains('/') || url.contains('/')) {
644 final raw = label.isNotEmpty ? label : url;
645 final pluginId = raw.contains('/') ? raw : 'mcp/$raw';
646 integration = McpIntegration(
647 type: McpIntegrationType.plugin,
648 pluginId: pluginId,
649 serverLabel: label.isNotEmpty ? label : null,
650 );
651 } else {
652 if (label.isEmpty || url.isEmpty) return;
653 integration = McpIntegration(
654 type: McpIntegrationType.ephemeralMcp,
655 serverLabel: label,
656 serverUrl: url,
657 );
658 }
659
660 ref.read(settingsProvider.notifier).addSavedMcpIntegration(integration);
661 ref.read(chatMcpConfigProvider.notifier).addIntegration(integration);
662 _serverLabelController.clear();
663 _serverUrlController.clear();
664 widget.onServersChanged();
665 }
666
667 void _showImportJsonDialog(BuildContext context) {
668 final l10n = AppLocalizations.of(context)!;
669 final theme = Theme.of(context);
670 final isDark = theme.brightness == Brightness.dark;
671 final jsonController = TextEditingController();
672
673 showDialog<void>(
674 context: context,
675 builder: (dialogContext) {
676 return AlertDialog(
677 shape: RoundedRectangleBorder(
678 borderRadius: BorderRadius.circular(16),
679 ),
680 title: Row(
681 children: [
682 const HugeIcon(icon: HugeIcons.strokeRoundedFileImport, size: 22),
683 const SizedBox(width: 8),
684 Expanded(
685 child: Text(
686 l10n.import_mcp_json_dialog_title,
687 style: const TextStyle(
688 fontSize: 16,
689 fontWeight: FontWeight.bold,
690 ),
691 ),
692 ),
693 ],
694 ),
695 content: SingleChildScrollView(
696 padding: EdgeInsets.zero,
697 child: Column(
698 mainAxisSize: MainAxisSize.min,
699 crossAxisAlignment: CrossAxisAlignment.start,
700 children: [
701 Container(
702 padding: const EdgeInsets.all(12),
703 decoration: BoxDecoration(
704 color: isDark
705 ? AppColors.darkBackground
706 : AppColors.lightSurface,
707 borderRadius: BorderRadius.circular(8),
708 border: Border.all(
709 color: isDark
710 ? AppColors.darkSurfaceCard
711 : AppColors.lightBorder,
712 ),
713 ),
714 child: Text(
715 l10n.import_mcp_json_instructions,
716 style: TextStyle(
717 fontSize: 12,
718 color: isDark
719 ? AppColors.darkMutedText
720 : AppColors.lightMutedText,
721 ),
722 ),
723 ),
724 const SizedBox(height: 12),
725 ShadInput(
726 controller: jsonController,
727 maxLines: 8,
728 placeholder: Text(
729 l10n.import_mcp_json_placeholder,
730 style: const TextStyle(fontSize: 11),
731 ),
732 ),
733 ],
734 ),
735 ),
736 actions: [
737 ShadButton.ghost(
738 onPressed: () => Navigator.of(dialogContext).pop(),
739 child: Text(l10n.cancel),
740 ),
741 ShadButton(
742 onPressed: () {
743 final jsonStr = jsonController.text.trim();
744 if (jsonStr.isNotEmpty) {
745 final count = ref
746 .read(chatMcpConfigProvider.notifier)
747 .importIntegrationsFromJson(jsonStr);
748 Navigator.of(dialogContext).pop();
749 widget.onServersChanged();
750
751 ScaffoldMessenger.maybeOf(context)?.showSnackBar(
752 SnackBar(
753 content: Text(
754 count > 0
755 ? l10n.mcp_import_success(count)
756 : l10n.mcp_import_failed,
757 ),
758 backgroundColor: count > 0 ? Colors.green : Colors.red,
759 ),
760 );
761 }
762 },
763 child: Text(l10n.import_mcp_json),
764 ),
765 ],
766 );
767 },
768 );
769 }
770
771 @override
772 Widget build(BuildContext context) {
773 final l10n = AppLocalizations.of(context)!;
774 final settings = ref.watch(settingsProvider);
775 final savedIntegrations = settings.savedMcpIntegrations;
776 final isDark = Theme.of(context).brightness == Brightness.dark;
777
778 return _McpSectionCard(
779 title: l10n.active_integrations,
780 accent: const Color(0xFF3B82F6),
781 icon: HugeIcons.strokeRoundedMcpServer,
782 trailing: ShadButton.outline(
783 size: ShadButtonSize.sm,
784 onPressed: () => _showImportJsonDialog(context),
785 child: Row(
786 mainAxisSize: MainAxisSize.min,
787 children: [
788 const HugeIcon(icon: HugeIcons.strokeRoundedFileImport, size: 16),
789 const SizedBox(width: 4),
790 Text(l10n.import_mcp_json),
791 ],
792 ),
793 ),
794 children: [
795 if (savedIntegrations.isNotEmpty) ...[
796 ListView.builder(
797 shrinkWrap: true,
798 padding: EdgeInsets.zero,
799 physics: const NeverScrollableScrollPhysics(),
800 itemCount: savedIntegrations.length,
801 itemBuilder: (context, index) {
802 final integration = savedIntegrations[index];
803 return Container(
804 margin: const EdgeInsets.only(bottom: 8),
805 decoration: BoxDecoration(
806 color: isDark
807 ? AppColors.darkBackground
808 : AppColors.lightSurface,
809 borderRadius: BorderRadius.circular(12),
810 border: Border.all(
811 color: isDark
812 ? AppColors.darkSurfaceCard
813 : AppColors.lightBorder,
814 ),
815 ),
816 child: ListTile(
817 dense: true,
818 leading: HugeIcon(
819 icon: HugeIcons.strokeRoundedPuzzle,
820 size: 18,
821 color: integration.enabled ? null : Colors.grey,
822 ),
823 title: Text(
824 integration.serverLabel ?? integration.pluginId ?? '',
825 style: TextStyle(
826 fontWeight: FontWeight.w600,
827 decoration: integration.enabled
828 ? TextDecoration.none
829 : TextDecoration.lineThrough,
830 color: integration.enabled
831 ? null
832 : (isDark
833 ? AppColors.darkMutedText
834 : AppColors.lightMutedText),
835 ),
836 ),
837 subtitle: Text(
838 integration.type == McpIntegrationType.plugin
839 ? 'Plugin (${integration.pluginId})'
840 : (integration.serverUrl ?? ''),
841 style: const TextStyle(fontSize: 11),
842 ),
843 trailing: Row(
844 mainAxisSize: MainAxisSize.min,
845 children: [
846 ShadSwitch(
847 value: integration.enabled,
848 onChanged: (v) {
849 ref
850 .read(settingsProvider.notifier)
851 .toggleSavedMcpIntegration(index, v);
852 ref
853 .read(chatMcpConfigProvider.notifier)
854 .toggleIntegration(index, v);
855 },
856 ),
857 const SizedBox(width: 4),
858 ShadIconButton.ghost(
859 icon: const HugeIcon(
860 icon: HugeIcons.strokeRoundedDelete01,
861 size: 18,
862 color: Colors.red,
863 ),
864 onPressed: () {
865 ref
866 .read(settingsProvider.notifier)
867 .removeSavedMcpIntegration(index);
868 ref
869 .read(chatMcpConfigProvider.notifier)
870 .removeIntegration(index);
871 widget.onServersChanged();
872 },
873 ),
874 ],
875 ),
876 ),
877 );
878 },
879 ),
880 ],
881 Row(
882 children: [
883 Expanded(
884 child: ShadInput(
885 controller: _serverLabelController,
886 placeholder: Text(l10n.mcp_label_placeholder),
887 ),
888 ),
889 const SizedBox(width: 8),
890 Expanded(
891 flex: 2,
892 child: ShadInput(
893 controller: _serverUrlController,
894 placeholder: Text(l10n.mcp_url_placeholder),
895 keyboardType: TextInputType.url,
896 ),
897 ),
898 const SizedBox(width: 8),
899 ShadIconButton(
900 icon: const HugeIcon(icon: HugeIcons.strokeRoundedAdd01),
901 onPressed: _addServer,
902 ),
903 ],
904 ),
905 ],
906 );
907 }
908 }