main
dart 1,000 lines 34.8 KB
Raw
1 import 'package:flutter/material.dart';
2 import 'package:hugeicons/hugeicons.dart';
3 import 'package:flutter_riverpod/flutter_riverpod.dart';
4 import 'package:go_router/go_router.dart';
5 import 'package:shadcn_ui/shadcn_ui.dart';
6 import 'package:localmind/l10n/app_localizations.dart';
7 import '../../../core/components/server_logo.dart';
8 import '../../../core/constants/app_constants.dart';
9 import '../data/models/server.dart';
10 import '../../../core/models/enums.dart';
11 import '../../../core/providers/service_providers.dart';
12 import '../../../core/utils/system_insets.dart';
13 import '../providers/server_providers.dart';
14 import 'components/https_scheme_hint.dart';
15 import 'components/server_icon_picker.dart';
16 import 'components/server_type_selector.dart';
17
18 class AddServerScreen extends ConsumerStatefulWidget {
19 final Server? editServer;
20
21 const AddServerScreen({super.key, this.editServer});
22
23 @override
24 ConsumerState<AddServerScreen> createState() => _AddServerScreenState();
25 }
26
27 class _AddServerScreenState extends ConsumerState<AddServerScreen> {
28 final _formKey = GlobalKey<FormState>();
29 final _scrollController = ScrollController();
30 late TextEditingController _nameController;
31 late TextEditingController _hostController;
32 late TextEditingController _portController;
33 late TextEditingController _apiKeyController;
34 late TextEditingController _ramGbController;
35 late TextEditingController _vramGbController;
36
37 late ServerType _selectedType;
38 String? _selectedIconName;
39 bool _isTesting = false;
40 bool _isSaving = false;
41 String? _testResult;
42
43 bool get _isEditing => widget.editServer != null;
44 bool get _requiresEndpoint =>
45 _selectedType != ServerType.openRouter &&
46 _selectedType != ServerType.requesty &&
47 _selectedType != ServerType.ollamaCloud;
48 bool get _requiresMandatoryApiKey =>
49 _selectedType == ServerType.openRouter ||
50 _selectedType == ServerType.requesty ||
51 _selectedType == ServerType.ollamaCloud;
52
53 String _lastHostScheme = '';
54
55 int _defaultPortForType(ServerType type) {
56 return switch (type) {
57 ServerType.lmStudio => AppConstants.lmStudioDefaultPort,
58 ServerType.openAICompatible => AppConstants.openAICompatibleDefaultPort,
59 ServerType.ollama => AppConstants.ollamaDefaultPort,
60 ServerType.ollamaCloud => AppConstants.ollamaCloudDefaultPort,
61 ServerType.openRouter => 443,
62 ServerType.requesty => 443,
63 ServerType.onDevice => 0,
64 };
65 }
66
67 void _onHostChanged() {
68 final text = _hostController.text.trim();
69 final parsed = parseServerAddressInput(text);
70 if (parsed == null) return;
71
72 if (parsed.hasPort) {
73 _portController.text = parsed.port.toString();
74 } else if (parsed.scheme == 'https' && _lastHostScheme != 'https') {
75 final currentPort = int.tryParse(_portController.text.trim());
76 if (currentPort == _defaultPortForType(_selectedType)) {
77 _portController.text = '443';
78 }
79 } else if (parsed.scheme == 'http' && _lastHostScheme == 'https') {
80 final currentPort = int.tryParse(_portController.text.trim());
81 if (currentPort == 443) {
82 _portController.text = _defaultPortForType(_selectedType).toString();
83 }
84 }
85 _lastHostScheme = parsed.scheme;
86 }
87
88 @override
89 void initState() {
90 super.initState();
91 final server = widget.editServer;
92 _selectedType = server?.type ?? ServerType.lmStudio;
93 _selectedIconName = server?.iconName;
94 _nameController = TextEditingController(text: server?.name ?? '');
95 final initialHost = server != null
96 ? (server.pathPrefix != null && server.pathPrefix!.isNotEmpty
97 ? '${server.host}${server.pathPrefix}'
98 : server.host)
99 : '';
100 _hostController = TextEditingController(text: initialHost);
101 _portController = TextEditingController(
102 text:
103 server?.port.toString() ??
104 AppConstants.lmStudioDefaultPort.toString(),
105 );
106 _apiKeyController = TextEditingController(text: server?.apiKey ?? '');
107 _ramGbController = TextEditingController(
108 text: server?.availableRamGb?.toString() ?? '',
109 );
110 _vramGbController = TextEditingController(
111 text: server?.availableVramGb?.toString() ?? '',
112 );
113 _lastHostScheme = isHttpsAddressInput(initialHost) ? 'https' : 'http';
114 _hostController.addListener(_onHostChanged);
115 }
116
117 @override
118 void dispose() {
119 _hostController.removeListener(_onHostChanged);
120 _scrollController.dispose();
121 _nameController.dispose();
122 _hostController.dispose();
123 _portController.dispose();
124 _apiKeyController.dispose();
125 _ramGbController.dispose();
126 _vramGbController.dispose();
127 super.dispose();
128 }
129
130 void _onTypeChanged(ServerType type) {
131 setState(() {
132 _selectedType = type;
133 _testResult = null;
134
135 final isHttps = isHttpsAddressInput(_hostController.text);
136 if (type == ServerType.openRouter || type == ServerType.requesty) {
137 _portController.text = '443';
138 } else if (type == ServerType.ollamaCloud) {
139 _portController.text = AppConstants.ollamaCloudDefaultPort.toString();
140 _hostController.text = AppConstants.ollamaCloudBaseUrl;
141 } else if (isHttps) {
142 _portController.text = '443';
143 } else if (type == ServerType.lmStudio) {
144 _portController.text = AppConstants.lmStudioDefaultPort.toString();
145 } else if (type == ServerType.openAICompatible) {
146 _portController.text = AppConstants.openAICompatibleDefaultPort
147 .toString();
148 } else if (type == ServerType.ollama) {
149 _portController.text = AppConstants.ollamaDefaultPort.toString();
150 }
151 });
152 }
153
154 void _scrollToBottom() {
155 WidgetsBinding.instance.addPostFrameCallback((_) {
156 if (_scrollController.hasClients) {
157 _scrollController.animateTo(
158 _scrollController.position.maxScrollExtent,
159 duration: const Duration(milliseconds: 300),
160 curve: Curves.easeOut,
161 );
162 }
163 });
164 }
165
166 Future<void> _testConnection() async {
167 if (_formKey.currentState?.validate() != true) return;
168
169 // Defensive mounted check before any state mutation. See #65.
170 if (!mounted) return;
171 setState(() {
172 _isTesting = true;
173 _testResult = null;
174 });
175 _scrollToBottom();
176
177 final apiService = ref.read(serverApiServiceProvider);
178 final testServer = _buildServer();
179
180 try {
181 final isConnected = await apiService.testConnection(testServer);
182 if (!mounted) return;
183 setState(() {
184 _testResult = isConnected
185 ? AppLocalizations.of(context)?.connection_successful ??
186 'Connection successful'
187 : AppLocalizations.of(context)?.connection_failed ??
188 'Connection failed';
189 });
190 if (isConnected) {
191 invalidateAvailableModelsCache(testServer.id);
192 }
193 } catch (e) {
194 if (!mounted) return;
195 setState(() {
196 _testResult =
197 AppLocalizations.of(context)?.error_with_message(e.toString()) ??
198 'Error: $e';
199 });
200 } finally {
201 if (mounted) {
202 setState(() {
203 _isTesting = false;
204 });
205 _scrollToBottom();
206 }
207 }
208 }
209
210 int? _parseOptionalGb(String value) {
211 final trimmed = value.trim();
212 if (trimmed.isEmpty) return null;
213 return int.tryParse(trimmed);
214 }
215
216 Server _buildServer() {
217 final rawHost = _hostController.text.trim();
218 final rawPort = _portController.text.trim();
219 final defaultPort = _defaultPortForType(_selectedType);
220 String host;
221 int port;
222 String? pathPrefix;
223
224 if (_selectedType == ServerType.ollamaCloud) {
225 host = AppConstants.ollamaCloudBaseUrl;
226 port = AppConstants.ollamaCloudDefaultPort;
227 } else {
228 final parsed = parseServerAddressInput(rawHost);
229 if (parsed != null) {
230 final hasExplicitPort = parsed.hasPort;
231 host =
232 '${parsed.scheme}://${parsed.host}${hasExplicitPort ? ':${parsed.port}' : ''}';
233 final schemeDefaultPort = parsed.scheme == 'https' ? 443 : defaultPort;
234 port = hasExplicitPort
235 ? parsed.port
236 : (int.tryParse(rawPort) ?? schemeDefaultPort);
237 final prefix = extractPathPrefix(parsed);
238 pathPrefix = prefix.isNotEmpty ? prefix : null;
239 } else {
240 host = rawHost;
241 port = int.tryParse(rawPort) ?? defaultPort;
242 pathPrefix = null;
243 }
244 }
245
246 return Server(
247 id:
248 widget.editServer?.id ??
249 DateTime.now().millisecondsSinceEpoch.toString(),
250 name: _nameController.text.trim(),
251 type: _selectedType,
252 host: host,
253 port: port,
254 apiKey: _requiresMandatoryApiKey
255 ? _apiKeyController.text.trim()
256 : (_apiKeyController.text.trim().isNotEmpty
257 ? _apiKeyController.text.trim()
258 : null),
259 isDefault: widget.editServer?.isDefault ?? false,
260 createdAt: widget.editServer?.createdAt ?? DateTime.now(),
261 lastConnectedAt: widget.editServer?.lastConnectedAt ?? DateTime.now(),
262 status: widget.editServer?.status ?? ConnectionStatus.disconnected,
263 iconName: _selectedIconName,
264 pathPrefix: pathPrefix,
265 availableRamGb: _selectedType == ServerType.lmStudio
266 ? _parseOptionalGb(_ramGbController.text)
267 : widget.editServer?.availableRamGb,
268 availableVramGb: _selectedType == ServerType.lmStudio
269 ? _parseOptionalGb(_vramGbController.text)
270 : widget.editServer?.availableVramGb,
271 );
272 }
273
274 Future<void> _saveServer() async {
275 if (_formKey.currentState?.validate() != true) return;
276
277 // Defensive mounted check before any state mutation. See #65.
278 if (!mounted) return;
279 setState(() {
280 _isSaving = true;
281 });
282
283 var hasPopped = false;
284 try {
285 final server = _buildServer();
286
287 if (_isEditing) {
288 await ref.read(serversProvider.notifier).updateServer(server);
289 } else {
290 await ref.read(serversProvider.notifier).addServer(server);
291 }
292 // Guard every ref-touching statement post-await — the user can pop
293 // this screen after the provider mutation succeeds.
294 if (!mounted) return;
295 final apiService = ref.read(serverApiServiceProvider);
296 await ref
297 .read(serversProvider.notifier)
298 .testConnection(server.id, apiService);
299 if (!mounted) return;
300 invalidateAvailableModelsCache(server.id);
301
302 if (mounted) {
303 final l10n = AppLocalizations.of(context);
304 final messenger = ScaffoldMessenger.maybeOf(context);
305 hasPopped = true;
306 if (Navigator.canPop(context)) {
307 Navigator.pop(context);
308 } else {
309 context.pop();
310 }
311 if (l10n != null && messenger != null) {
312 messenger.showSnackBar(
313 SnackBar(
314 content: Text(
315 _isEditing ? l10n.server_updated : l10n.server_added,
316 ),
317 duration: const Duration(seconds: 2),
318 ),
319 );
320 }
321 }
322 } catch (e) {
323 hasPopped = false;
324 if (mounted) {
325 ScaffoldMessenger.maybeOf(context)?.showSnackBar(
326 SnackBar(
327 content: Text(
328 AppLocalizations.of(context)?.error_with_message(e.toString()) ??
329 'Error: $e',
330 ),
331 backgroundColor: Colors.red,
332 ),
333 );
334 }
335 } finally {
336 if (mounted && !hasPopped) {
337 setState(() {
338 _isSaving = false;
339 });
340 }
341 }
342 }
343
344 String? _validateName(String? value) {
345 final context = this.context;
346 final l10n = AppLocalizations.of(context)!;
347 if (value == null || value.trim().isEmpty) {
348 return l10n.name_required;
349 }
350 if (value.trim().length > 50) {
351 return l10n.name_length_validation;
352 }
353 return null;
354 }
355
356 String? _validateHost(String? value) {
357 if (!_requiresEndpoint) return null;
358 final l10n = AppLocalizations.of(context)!;
359
360 if (value == null || value.trim().isEmpty) {
361 return l10n.host_required;
362 }
363 final parsed = parseServerAddressInput(value);
364 if (parsed == null) {
365 return l10n.host_valid;
366 }
367 return null;
368 }
369
370 String? _validatePort(String? value) {
371 if (!_requiresEndpoint) return null;
372 final l10n = AppLocalizations.of(context)!;
373
374 if (value == null || value.trim().isEmpty) {
375 return l10n.port_required;
376 }
377 final port = int.tryParse(value.trim());
378 if (port == null || port < 1 || port > 65535) {
379 return l10n.port_range;
380 }
381 return null;
382 }
383
384 String? _validateApiKey(String? value) {
385 if (_requiresMandatoryApiKey) {
386 final l10n = AppLocalizations.of(context)!;
387 if (value == null || value.trim().isEmpty) {
388 return _selectedType == ServerType.ollamaCloud
389 ? l10n.api_key_required_ollama_cloud
390 : _selectedType == ServerType.requesty
391 ? l10n.api_key_required_requesty
392 : l10n.api_key_required_openrouter;
393 }
394 if (_selectedType == ServerType.openRouter &&
395 !value.trim().startsWith('sk-')) {
396 return l10n.api_key_format;
397 }
398 }
399 return null;
400 }
401
402 void _showIconPicker() {
403 showShadSheet(
404 context: context,
405 side: ShadSheetSide.bottom,
406 builder: (context) => ServerIconPicker(
407 selectedIconName: _selectedIconName,
408 onIconSelected: (iconName) {
409 setState(() {
410 _selectedIconName = iconName;
411 });
412 },
413 ),
414 );
415 }
416
417 @override
418 Widget build(BuildContext context) {
419 final l10n = AppLocalizations.of(context)!;
420 final theme = Theme.of(context);
421 final systemBottomInset = bottomSystemInset(context);
422 final colorScheme = theme.colorScheme;
423
424 return Scaffold(
425 appBar: AppBar(
426 title: Text(_isEditing ? l10n.edit_server : l10n.add_server_title),
427 leading: IconButton(
428 icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01),
429 onPressed: () => context.pop(),
430 ),
431 ),
432 floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
433 floatingActionButton: _buildFloatingActionBar(
434 context,
435 bottomInset: systemBottomInset,
436 ),
437 body: Form(
438 key: _formKey,
439 child: ListView(
440 controller: _scrollController,
441 padding: EdgeInsets.fromLTRB(16, 12, 16, 120 + systemBottomInset),
442 children: [
443 _buildHeaderCard(context),
444 const SizedBox(height: 14),
445 _buildSectionCard(
446 context,
447 title: l10n.server_type_label,
448 subtitle: l10n.server_type_help,
449 child: ServerTypeSelector(
450 selectedType: _selectedType,
451 onChanged: _onTypeChanged,
452 ),
453 ),
454 const SizedBox(height: 12),
455 _buildSectionCard(
456 context,
457 title: l10n.server_identity_title,
458 subtitle: l10n.server_identity_desc,
459 child: Column(
460 children: [
461 GestureDetector(
462 onTap: _showIconPicker,
463 child: Container(
464 padding: const EdgeInsets.all(14),
465 decoration: BoxDecoration(
466 color: colorScheme.surfaceContainerHighest.withValues(
467 alpha: 0.35,
468 ),
469 border: Border.all(
470 color: colorScheme.outline.withValues(alpha: 0.4),
471 ),
472 borderRadius: BorderRadius.circular(16),
473 ),
474 child: Row(
475 children: [
476 Container(
477 width: 48,
478 height: 48,
479 padding:
480 _selectedIconName == null &&
481 _selectedType.hasLogo
482 ? const EdgeInsets.all(6)
483 : EdgeInsets.zero,
484 decoration: BoxDecoration(
485 color: colorScheme.primary.withValues(
486 alpha: 0.12,
487 ),
488 borderRadius: BorderRadius.circular(14),
489 ),
490 child: Center(
491 child: _selectedIconName != null
492 ? HugeIcon(
493 icon:
494 (getHugeIconByName(
495 _selectedIconName,
496 )?.icon ??
497 getDefaultServerIcon(
498 _selectedType.name,
499 )!.icon),
500 size: 24,
501 color: colorScheme.primary,
502 )
503 : ServerLogo(
504 type: _selectedType,
505 size: _selectedType.hasLogo ? 32 : 24,
506 borderRadius: 7,
507 iconColor: colorScheme.primary,
508 ),
509 ),
510 ),
511 const SizedBox(width: 14),
512 Expanded(
513 child: Column(
514 crossAxisAlignment: CrossAxisAlignment.start,
515 children: [
516 Text(
517 l10n.server_icon_label,
518 style: theme.textTheme.labelLarge?.copyWith(
519 fontWeight: FontWeight.w600,
520 ),
521 ),
522 const SizedBox(height: 4),
523 Text(
524 _selectedIconName ?? l10n.default_icon,
525 style: theme.textTheme.bodyMedium?.copyWith(
526 color: colorScheme.onSurfaceVariant,
527 ),
528 ),
529 ],
530 ),
531 ),
532 HugeIcon(
533 icon: HugeIcons.strokeRoundedArrowRight01,
534 size: 16,
535 color: colorScheme.onSurfaceVariant,
536 ),
537 ],
538 ),
539 ),
540 ),
541 const SizedBox(height: 12),
542 TextFormField(
543 controller: _nameController,
544 decoration: InputDecoration(
545 labelText: l10n.name_label,
546 hintText: l10n.my_server_hint,
547 ),
548 validator: _validateName,
549 textInputAction: TextInputAction.next,
550 ),
551 ],
552 ),
553 ),
554 if (_requiresEndpoint) ...[
555 const SizedBox(height: 12),
556 _buildSectionCard(
557 context,
558 title: l10n.server_connection_title,
559 subtitle: l10n.server_connection_desc,
560 child: Column(
561 children: [
562 TextFormField(
563 controller: _hostController,
564 decoration: InputDecoration(
565 labelText: l10n.host_label,
566 hintText: '192.168.1.100',
567 ),
568 validator: _validateHost,
569 keyboardType: TextInputType.text,
570 textInputAction: TextInputAction.next,
571 ),
572 HttpsSchemeHint(controller: _hostController),
573 const SizedBox(height: 12),
574 TextFormField(
575 controller: _portController,
576 decoration: InputDecoration(
577 labelText: l10n.port_label,
578 hintText: _selectedType == ServerType.lmStudio
579 ? AppConstants.lmStudioDefaultPort.toString()
580 : AppConstants.ollamaDefaultPort.toString(),
581 ),
582 validator: _validatePort,
583 keyboardType: TextInputType.number,
584 textInputAction: TextInputAction.next,
585 ),
586 ],
587 ),
588 ),
589 ],
590 if (_selectedType == ServerType.lmStudio) ...[
591 const SizedBox(height: 12),
592 _buildSectionCard(
593 context,
594 title: l10n.lm_studio_memory_settings_title,
595 subtitle: l10n.lm_studio_memory_settings_desc,
596 child: Row(
597 children: [
598 Expanded(
599 child: TextFormField(
600 controller: _ramGbController,
601 decoration: InputDecoration(
602 labelText: l10n.lm_studio_available_ram_gb,
603 hintText: '32',
604 ),
605 keyboardType: TextInputType.number,
606 ),
607 ),
608 const SizedBox(width: 12),
609 Expanded(
610 child: TextFormField(
611 controller: _vramGbController,
612 decoration: InputDecoration(
613 labelText: l10n.lm_studio_available_vram_gb,
614 hintText: '6',
615 ),
616 keyboardType: TextInputType.number,
617 ),
618 ),
619 ],
620 ),
621 ),
622 ],
623 const SizedBox(height: 12),
624 _buildSectionCard(
625 context,
626 title: l10n.server_authentication_title,
627 subtitle: _requiresMandatoryApiKey
628 ? (_selectedType == ServerType.requesty
629 ? l10n.server_authentication_required_desc_requesty
630 : l10n.server_authentication_required_desc)
631 : l10n.server_authentication_optional_desc,
632 child: Column(
633 children: [
634 TextFormField(
635 controller: _apiKeyController,
636 decoration: InputDecoration(
637 labelText: _requiresMandatoryApiKey
638 ? l10n.api_key_required
639 : l10n.api_key_optional,
640 hintText: _requiresMandatoryApiKey
641 ? (_selectedType == ServerType.ollamaCloud
642 ? l10n.api_key_hint_ollama_cloud
643 : _selectedType == ServerType.requesty
644 ? l10n.api_key_hint_requesty
645 : l10n.api_key_hint_openrouter)
646 : l10n.api_key_hint_generic,
647 ),
648 validator: _validateApiKey,
649 obscureText: true,
650 textInputAction: TextInputAction.done,
651 ),
652 if (_requiresMandatoryApiKey) ...[
653 const SizedBox(height: 12),
654 Container(
655 padding: const EdgeInsets.all(12),
656 decoration: BoxDecoration(
657 color: theme.colorScheme.primary.withValues(
658 alpha: 0.08,
659 ),
660 borderRadius: BorderRadius.circular(8),
661 border: Border.all(
662 color: theme.colorScheme.primary.withValues(
663 alpha: 0.2,
664 ),
665 ),
666 ),
667 child: Row(
668 crossAxisAlignment: CrossAxisAlignment.start,
669 children: [
670 HugeIcon(
671 icon: HugeIcons.strokeRoundedShield01,
672 size: 18,
673 color: theme.colorScheme.primary,
674 ),
675 const SizedBox(width: 8),
676 Expanded(
677 child: Text(
678 _selectedType == ServerType.ollamaCloud
679 ? l10n.ollama_cloud_disclosure
680 : _selectedType == ServerType.requesty
681 ? l10n.requesty_disclosure
682 : l10n.openrouter_disclosure,
683 style: theme.textTheme.bodySmall?.copyWith(
684 color: theme.colorScheme.onSurface.withValues(
685 alpha: 0.8,
686 ),
687 height: 1.4,
688 ),
689 ),
690 ),
691 ],
692 ),
693 ),
694 ],
695 ],
696 ),
697 ),
698 if (_testResult != null) ...[
699 const SizedBox(height: 12),
700 _buildTestResultBanner(context),
701 ],
702 ],
703 ),
704 ),
705 );
706 }
707
708 Widget _buildHeaderCard(BuildContext context) {
709 final l10n = AppLocalizations.of(context)!;
710 final theme = Theme.of(context);
711 final colorScheme = theme.colorScheme;
712
713 return Container(
714 padding: const EdgeInsets.all(18),
715 decoration: BoxDecoration(
716 color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.38),
717 borderRadius: BorderRadius.circular(24),
718 border: Border.all(color: colorScheme.outline.withValues(alpha: 0.2)),
719 ),
720 child: Column(
721 crossAxisAlignment: CrossAxisAlignment.start,
722 children: [
723 Container(
724 width: 52,
725 height: 52,
726 padding: _selectedType.hasLogo
727 ? const EdgeInsets.all(7)
728 : EdgeInsets.zero,
729 decoration: BoxDecoration(
730 color: colorScheme.primary.withValues(alpha: 0.14),
731 borderRadius: BorderRadius.circular(16),
732 ),
733 child: Center(
734 child: ServerLogo(
735 type: _selectedType,
736 size: _selectedType.hasLogo ? 34 : 28,
737 borderRadius: 8,
738 iconColor: colorScheme.primary,
739 ),
740 ),
741 ),
742 const SizedBox(height: 12),
743 Text(
744 _isEditing ? l10n.edit_server : l10n.add_server_title,
745 style: theme.textTheme.headlineSmall?.copyWith(
746 fontWeight: FontWeight.w700,
747 ),
748 ),
749 const SizedBox(height: 6),
750 Text(
751 _addServerHeaderSubtitle(l10n),
752 style: theme.textTheme.bodyMedium?.copyWith(
753 color: colorScheme.onSurfaceVariant,
754 height: 1.4,
755 ),
756 ),
757 const SizedBox(height: 12),
758 Wrap(
759 spacing: 8,
760 runSpacing: 8,
761 children: [
762 _buildInfoPill(
763 context,
764 icon: HugeIcons.strokeRoundedShare01,
765 label: _serverTypeLabel(context),
766 ),
767 _buildInfoPill(
768 context,
769 icon: _requiresMandatoryApiKey
770 ? HugeIcons.strokeRoundedKey01
771 : HugeIcons.strokeRoundedDatabase,
772 label: _requiresMandatoryApiKey
773 ? l10n.api_key_required
774 : l10n.host_label,
775 ),
776 ],
777 ),
778 ],
779 ),
780 );
781 }
782
783 Widget _buildSectionCard(
784 BuildContext context, {
785 required String title,
786 required String subtitle,
787 required Widget child,
788 }) {
789 final theme = Theme.of(context);
790 final colorScheme = theme.colorScheme;
791
792 return Container(
793 padding: const EdgeInsets.all(16),
794 decoration: BoxDecoration(
795 color: colorScheme.surface,
796 borderRadius: BorderRadius.circular(20),
797 border: Border.all(color: colorScheme.outline.withValues(alpha: 0.16)),
798 ),
799 child: Column(
800 crossAxisAlignment: CrossAxisAlignment.start,
801 children: [
802 Text(
803 title,
804 style: theme.textTheme.titleMedium?.copyWith(
805 fontWeight: FontWeight.w700,
806 ),
807 ),
808 const SizedBox(height: 4),
809 Text(
810 subtitle,
811 style: theme.textTheme.bodySmall?.copyWith(
812 color: colorScheme.onSurfaceVariant,
813 height: 1.35,
814 ),
815 ),
816 const SizedBox(height: 12),
817 child,
818 ],
819 ),
820 );
821 }
822
823 Widget _buildTestResultBanner(BuildContext context) {
824 final l10n = AppLocalizations.of(context)!;
825 final theme = Theme.of(context);
826 final success = _testResult!.contains(
827 l10n.connection_successful.split('!')[0],
828 );
829 final accent = success ? Colors.green : theme.colorScheme.error;
830
831 return Container(
832 padding: const EdgeInsets.all(14),
833 decoration: BoxDecoration(
834 color: accent.withValues(alpha: 0.08),
835 borderRadius: BorderRadius.circular(18),
836 border: Border.all(color: accent.withValues(alpha: 0.3)),
837 ),
838 child: Row(
839 crossAxisAlignment: CrossAxisAlignment.start,
840 children: [
841 HugeIcon(
842 icon: success
843 ? HugeIcons.strokeRoundedCheckmarkCircle01
844 : HugeIcons.strokeRoundedAlertCircle,
845 color: accent,
846 ),
847 const SizedBox(width: 10),
848 Expanded(
849 child: Text(
850 _testResult!,
851 style: theme.textTheme.bodyMedium?.copyWith(
852 color: accent,
853 fontWeight: FontWeight.w600,
854 ),
855 ),
856 ),
857 ],
858 ),
859 );
860 }
861
862 Widget _buildFloatingActionBar(
863 BuildContext context, {
864 required double bottomInset,
865 }) {
866 final l10n = AppLocalizations.of(context)!;
867 final theme = Theme.of(context);
868 final colorScheme = theme.colorScheme;
869
870 return Padding(
871 padding: EdgeInsets.fromLTRB(16, 0, 16, bottomInset > 0 ? 4 : 8),
872 child: DecoratedBox(
873 decoration: BoxDecoration(
874 color: colorScheme.surface.withValues(alpha: 0.96),
875 borderRadius: BorderRadius.circular(24),
876 border: Border.all(
877 color: colorScheme.outline.withValues(alpha: 0.14),
878 ),
879 boxShadow: [
880 BoxShadow(
881 color: Colors.black.withValues(alpha: 0.08),
882 blurRadius: 24,
883 offset: const Offset(0, 12),
884 ),
885 ],
886 ),
887 child: Padding(
888 padding: const EdgeInsets.all(12),
889 child: Row(
890 children: [
891 Expanded(
892 child: OutlinedButton.icon(
893 onPressed: _isTesting ? null : _testConnection,
894 style: OutlinedButton.styleFrom(
895 minimumSize: const Size.fromHeight(52),
896 shape: RoundedRectangleBorder(
897 borderRadius: BorderRadius.circular(16),
898 ),
899 ),
900 icon: _isTesting
901 ? const SizedBox(
902 width: 16,
903 height: 16,
904 child: CircularProgressIndicator(strokeWidth: 2),
905 )
906 : const HugeIcon(icon: HugeIcons.strokeRoundedWifi01),
907 label: Text(_isTesting ? l10n.testing : l10n.test_connection),
908 ),
909 ),
910 const SizedBox(width: 12),
911 Expanded(
912 child: ElevatedButton.icon(
913 onPressed: _isSaving ? null : _saveServer,
914 style: ElevatedButton.styleFrom(
915 minimumSize: const Size.fromHeight(52),
916 shape: RoundedRectangleBorder(
917 borderRadius: BorderRadius.circular(16),
918 ),
919 ),
920 icon: _isSaving
921 ? const SizedBox(
922 width: 18,
923 height: 18,
924 child: CircularProgressIndicator(strokeWidth: 2),
925 )
926 : const HugeIcon(icon: HugeIcons.strokeRoundedFloppyDisk),
927 label: Text(
928 _isSaving
929 ? l10n.save
930 : (_isEditing ? l10n.update_server : l10n.save_server),
931 ),
932 ),
933 ),
934 ],
935 ),
936 ),
937 ),
938 );
939 }
940
941 Widget _buildInfoPill(
942 BuildContext context, {
943 required List<List<dynamic>> icon,
944 required String label,
945 }) {
946 final theme = Theme.of(context);
947 final colorScheme = theme.colorScheme;
948
949 return Container(
950 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
951 decoration: BoxDecoration(
952 color: colorScheme.surface.withValues(alpha: 0.7),
953 borderRadius: BorderRadius.circular(999),
954 border: Border.all(color: colorScheme.outline.withValues(alpha: 0.18)),
955 ),
956 child: Row(
957 mainAxisSize: MainAxisSize.min,
958 children: [
959 HugeIcon(icon: icon, size: 16, color: colorScheme.primary),
960 const SizedBox(width: 8),
961 Text(
962 label,
963 style: theme.textTheme.labelLarge?.copyWith(
964 fontWeight: FontWeight.w600,
965 ),
966 ),
967 ],
968 ),
969 );
970 }
971
972 String _serverTypeLabel(BuildContext context) {
973 final l10n = AppLocalizations.of(context)!;
974 return switch (_selectedType) {
975 ServerType.lmStudio => l10n.server_type_lm_studio,
976 ServerType.openAICompatible => l10n.server_type_openai_display,
977 ServerType.ollama => l10n.server_type_ollama,
978 ServerType.ollamaCloud => l10n.server_type_ollama_cloud,
979 ServerType.openRouter => l10n.server_type_openrouter,
980 ServerType.requesty => l10n.server_type_requesty,
981 ServerType.onDevice => l10n.server_type_on_device_display,
982 };
983 }
984
985 String _addServerHeaderSubtitle(AppLocalizations l10n) {
986 switch (_selectedType) {
987 case ServerType.lmStudio:
988 case ServerType.openAICompatible:
989 case ServerType.ollama:
990 case ServerType.onDevice:
991 return l10n.add_server_endpoint_subtitle;
992 case ServerType.ollamaCloud:
993 return l10n.add_server_ollama_cloud_subtitle;
994 case ServerType.openRouter:
995 return l10n.add_server_openrouter_subtitle;
996 case ServerType.requesty:
997 return l10n.add_server_requesty_subtitle;
998 }
999 }
1000 }