main
dart 433 lines 13.1 KB
Raw
1 import 'package:flutter/material.dart';
2 import 'package:hugeicons/hugeicons.dart';
3 import 'package:localmind/l10n/app_localizations.dart';
4 import '../../../../core/components/server_logo.dart';
5 import '../../../../core/models/enums.dart';
6 import '../../data/models/server.dart';
7 import 'connection_status_indicator.dart';
8 import 'server_icon_picker.dart';
9
10 class ServerCard extends StatelessWidget {
11 final Server server;
12 final bool isActive;
13 final VoidCallback? onTap;
14 final VoidCallback? onEdit;
15 final VoidCallback? onDelete;
16 final VoidCallback? onSetDefault;
17
18 const ServerCard({
19 super.key,
20 required this.server,
21 this.isActive = false,
22 this.onTap,
23 this.onEdit,
24 this.onDelete,
25 this.onSetDefault,
26 });
27
28 static const _accent = Color(0xFF22C55E);
29 static const _warning = Color(0xFFF59E0B);
30 static const _error = Color(0xFFEF4444);
31 static const _neutral = Color(0xFF71717A);
32
33 String _serverTypeName(AppLocalizations l10n) {
34 switch (server.type) {
35 case ServerType.lmStudio:
36 return l10n.server_type_lm_studio_display;
37 case ServerType.openAICompatible:
38 return l10n.server_type_openai_display;
39 case ServerType.ollama:
40 return l10n.server_type_ollama_display;
41 case ServerType.ollamaCloud:
42 return l10n.server_type_ollama_cloud_display;
43 case ServerType.openRouter:
44 return l10n.server_type_openrouter_display;
45 case ServerType.requesty:
46 return l10n.server_type_requesty_display;
47 case ServerType.onDevice:
48 return l10n.server_type_on_device_display;
49 }
50 }
51
52 String _serverAddress(AppLocalizations l10n) {
53 if (server.type == ServerType.openRouter) {
54 return l10n.server_address_openrouter;
55 }
56 if (server.type == ServerType.requesty) {
57 return l10n.server_address_requesty;
58 }
59 if (server.type == ServerType.ollamaCloud) {
60 return l10n.server_address_ollama_cloud;
61 }
62 if (server.isOnDevice) {
63 return l10n.server_address_on_device;
64 }
65 return server.displayAddress;
66 }
67
68 String _statusLabel(AppLocalizations l10n) {
69 switch (server.status) {
70 case ConnectionStatus.connected:
71 return l10n.connected;
72 case ConnectionStatus.disconnected:
73 return l10n.offline;
74 case ConnectionStatus.checking:
75 return l10n.checking;
76 case ConnectionStatus.error:
77 return l10n.error;
78 }
79 }
80
81 Color _statusColor() {
82 switch (server.status) {
83 case ConnectionStatus.connected:
84 return _accent;
85 case ConnectionStatus.error:
86 return _error;
87 case ConnectionStatus.checking:
88 return _warning;
89 case ConnectionStatus.disconnected:
90 return _neutral;
91 }
92 }
93
94 List<List<dynamic>> _typeIcon() {
95 switch (server.type) {
96 case ServerType.lmStudio:
97 return HugeIcons.strokeRoundedComputerTerminal01;
98 case ServerType.openAICompatible:
99 return HugeIcons.strokeRoundedAiChat01;
100 case ServerType.ollama:
101 return HugeIcons.strokeRoundedRobot01;
102 case ServerType.ollamaCloud:
103 return HugeIcons.strokeRoundedAiCloud;
104 case ServerType.openRouter:
105 return HugeIcons.strokeRoundedCloud;
106 case ServerType.requesty:
107 return HugeIcons.strokeRoundedAiNetwork;
108 case ServerType.onDevice:
109 return HugeIcons.strokeRoundedHardDrive;
110 }
111 }
112
113 @override
114 Widget build(BuildContext context) {
115 final l10n = AppLocalizations.of(context)!;
116 final theme = Theme.of(context);
117 final isDark = theme.brightness == Brightness.dark;
118 final statusColor = _statusColor();
119
120 return AnimatedContainer(
121 duration: const Duration(milliseconds: 280),
122 curve: Curves.easeOutCubic,
123 decoration: BoxDecoration(
124 color: isActive
125 ? (isDark
126 ? _accent.withValues(alpha: 0.10)
127 : _accent.withValues(alpha: 0.06))
128 : theme.cardColor,
129 borderRadius: BorderRadius.circular(14),
130 border: Border.all(
131 color: isActive
132 ? _accent.withValues(alpha: 0.45)
133 : theme.dividerColor.withValues(alpha: isDark ? 0.12 : 0.08),
134 width: isActive ? 1.5 : 1,
135 ),
136 boxShadow: isActive
137 ? [
138 BoxShadow(
139 color: _accent.withValues(alpha: 0.08),
140 blurRadius: 12,
141 offset: const Offset(0, 4),
142 ),
143 ]
144 : null,
145 ),
146 clipBehavior: Clip.antiAlias,
147 child: Material(
148 color: Colors.transparent,
149 child: InkWell(
150 onTap: onTap,
151 child: Padding(
152 padding: const EdgeInsets.fromLTRB(14, 14, 6, 14),
153 child: Row(
154 crossAxisAlignment: CrossAxisAlignment.start,
155 children: [
156 _buildIcon(theme, isDark),
157 const SizedBox(width: 14),
158 Expanded(
159 child: Column(
160 crossAxisAlignment: CrossAxisAlignment.start,
161 children: [
162 _buildTitleRow(theme, isDark, l10n),
163 const SizedBox(height: 6),
164 _buildTypeChip(theme, isDark, l10n),
165 const SizedBox(height: 6),
166 _buildAddressRow(theme, l10n),
167 const SizedBox(height: 10),
168 _buildStatusRow(statusColor, l10n),
169 ],
170 ),
171 ),
172 _buildMenu(theme, l10n),
173 ],
174 ),
175 ),
176 ),
177 ),
178 );
179 }
180
181 Widget _buildIcon(ThemeData theme, bool isDark) {
182 return AnimatedContainer(
183 duration: const Duration(milliseconds: 280),
184 curve: Curves.easeOutCubic,
185 width: 44,
186 height: 44,
187 decoration: BoxDecoration(
188 gradient: isActive
189 ? LinearGradient(
190 begin: Alignment.topLeft,
191 end: Alignment.bottomRight,
192 colors: [
193 _accent.withValues(alpha: 0.20),
194 _accent.withValues(alpha: 0.06),
195 ],
196 )
197 : LinearGradient(
198 begin: Alignment.topLeft,
199 end: Alignment.bottomRight,
200 colors: [
201 isDark
202 ? Colors.white.withValues(alpha: 0.06)
203 : Colors.black.withValues(alpha: 0.04),
204 isDark
205 ? Colors.white.withValues(alpha: 0.02)
206 : Colors.black.withValues(alpha: 0.01),
207 ],
208 ),
209 borderRadius: BorderRadius.circular(11),
210 border: Border.all(
211 color: isActive
212 ? _accent.withValues(alpha: 0.30)
213 : theme.dividerColor.withValues(alpha: isDark ? 0.15 : 0.10),
214 width: 1,
215 ),
216 ),
217 child: Center(
218 child: server.iconName != null
219 ? HugeIcon(
220 icon:
221 (getHugeIconByName(server.iconName)?.icon ??
222 getDefaultServerIcon(server.type.name)!.icon),
223 size: 22,
224 color: isActive ? _accent : theme.colorScheme.onSurfaceVariant,
225 )
226 : ServerLogo(
227 type: server.type,
228 size: server.type.hasLogo ? 26 : 22,
229 borderRadius: 6,
230 iconColor: isActive
231 ? _accent
232 : theme.colorScheme.onSurfaceVariant,
233 ),
234 ),
235 );
236 }
237
238 Widget _buildTitleRow(ThemeData theme, bool isDark, AppLocalizations l10n) {
239 return Row(
240 crossAxisAlignment: CrossAxisAlignment.center,
241 children: [
242 Flexible(
243 child: Text(
244 server.name,
245 style: theme.textTheme.titleMedium?.copyWith(
246 fontWeight: FontWeight.w600,
247 letterSpacing: -0.2,
248 height: 1.25,
249 color: isActive
250 ? (isDark ? Colors.white : const Color(0xFF0A0A0A))
251 : theme.colorScheme.onSurface,
252 ),
253 overflow: TextOverflow.ellipsis,
254 maxLines: 1,
255 ),
256 ),
257 if (server.isDefault) ...[
258 const SizedBox(width: 6),
259 Tooltip(
260 message: l10n.default_badge,
261 child: const HugeIcon(
262 icon: HugeIcons.strokeRoundedStar,
263 size: 14,
264 color: _warning,
265 ),
266 ),
267 ],
268 ],
269 );
270 }
271
272 Widget _buildTypeChip(ThemeData theme, bool isDark, AppLocalizations l10n) {
273 return Align(
274 alignment: AlignmentDirectional.centerStart,
275 child: Container(
276 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
277 decoration: BoxDecoration(
278 color: isDark
279 ? Colors.white.withValues(alpha: 0.06)
280 : Colors.black.withValues(alpha: 0.04),
281 borderRadius: BorderRadius.circular(6),
282 ),
283 child: Row(
284 mainAxisSize: MainAxisSize.min,
285 children: [
286 if (server.type.hasLogo) ...[
287 ServerLogo(type: server.type, size: 13, borderRadius: 2.5),
288 const SizedBox(width: 5),
289 ] else ...[
290 HugeIcon(
291 icon: _typeIcon(),
292 size: 12,
293 color: theme.colorScheme.onSurfaceVariant,
294 ),
295 const SizedBox(width: 5),
296 ],
297 Text(
298 _serverTypeName(l10n),
299 style: theme.textTheme.labelSmall?.copyWith(
300 color: theme.colorScheme.onSurfaceVariant,
301 fontWeight: FontWeight.w500,
302 fontSize: 11,
303 letterSpacing: 0.1,
304 ),
305 ),
306 ],
307 ),
308 ),
309 );
310 }
311
312 Widget _buildAddressRow(ThemeData theme, AppLocalizations l10n) {
313 return Row(
314 children: [
315 HugeIcon(
316 icon: HugeIcons.strokeRoundedLink01,
317 size: 12,
318 color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
319 ),
320 const SizedBox(width: 5),
321 Flexible(
322 child: Text(
323 _serverAddress(l10n),
324 style: theme.textTheme.bodySmall?.copyWith(
325 color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.85),
326 fontFeatures: const [FontFeature.tabularFigures()],
327 letterSpacing: -0.1,
328 ),
329 overflow: TextOverflow.ellipsis,
330 maxLines: 1,
331 ),
332 ),
333 ],
334 );
335 }
336
337 Widget _buildStatusRow(Color statusColor, AppLocalizations l10n) {
338 return Align(
339 alignment: AlignmentDirectional.centerStart,
340 child: Container(
341 padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
342 decoration: BoxDecoration(
343 color: statusColor.withValues(alpha: 0.12),
344 borderRadius: BorderRadius.circular(6),
345 ),
346 child: Row(
347 mainAxisSize: MainAxisSize.min,
348 children: [
349 ConnectionStatusIndicator(status: server.status, size: 8),
350 const SizedBox(width: 5),
351 Text(
352 _statusLabel(l10n),
353 style: TextStyle(
354 fontSize: 11,
355 fontWeight: FontWeight.w600,
356 color: statusColor,
357 letterSpacing: 0.1,
358 ),
359 ),
360 ],
361 ),
362 ),
363 );
364 }
365
366 Widget _buildMenu(ThemeData theme, AppLocalizations l10n) {
367 return SizedBox(
368 width: 32,
369 child: PopupMenuButton<String>(
370 padding: EdgeInsets.zero,
371 iconSize: 18,
372 onSelected: (value) {
373 switch (value) {
374 case 'edit':
375 onEdit?.call();
376 break;
377 case 'delete':
378 onDelete?.call();
379 break;
380 case 'setDefault':
381 onSetDefault?.call();
382 break;
383 }
384 },
385 itemBuilder: (context) => [
386 PopupMenuItem(
387 value: 'edit',
388 child: Row(
389 children: [
390 HugeIcon(
391 icon: HugeIcons.strokeRoundedPencilEdit02,
392 size: 18,
393 color: theme.colorScheme.onSurface,
394 ),
395 const SizedBox(width: 8),
396 Text(l10n.edit),
397 ],
398 ),
399 ),
400 if (!server.isDefault)
401 PopupMenuItem(
402 value: 'setDefault',
403 child: Row(
404 children: [
405 HugeIcon(
406 icon: HugeIcons.strokeRoundedStar,
407 size: 18,
408 color: theme.colorScheme.onSurface,
409 ),
410 const SizedBox(width: 8),
411 Text(l10n.set_as_default),
412 ],
413 ),
414 ),
415 PopupMenuItem(
416 value: 'delete',
417 child: Row(
418 children: [
419 const HugeIcon(
420 icon: HugeIcons.strokeRoundedDelete01,
421 size: 18,
422 color: Colors.red,
423 ),
424 const SizedBox(width: 8),
425 Text(l10n.delete, style: const TextStyle(color: Colors.red)),
426 ],
427 ),
428 ),
429 ],
430 ),
431 );
432 }
433 }