@tduncan / Cortana / commits / a3505a3

feat: refactor server card UI with new status indicators and localization strings

abdulmominsakib committed Jul 27, 2026 at 14:57 UTC a3505a3c95ceff9601b1fd46d1001c5290da05ec
14 files changed +567 -195
lib/features/chat/providers/chat_notifier.dart
+3 -1
@@ -958,11 +958,13 @@ class ChatNotifier extends Notifier<ChatState> {
958 stateNeedsUpdate = true;
959 break;
960 case ChatResponseType.processing:
961 + final wasProcessing = streamingAssistantMessage.isProcessing;
962 streamingAssistantMessage = streamingAssistantMessage
963 .copyWith(isProcessing: true);
964 _latestStreamingMessage = streamingAssistantMessage;
965 stateNeedsUpdate = true;
965 - if (ref.read(settingsProvider).hapticFeedbackEnabled) {
966 + if (!wasProcessing &&
967 + ref.read(settingsProvider).hapticFeedbackEnabled) {
968 HapticFeedback.lightImpact();
969 }
970 break;
lib/features/servers/views/components/server_card.dart
+335 -194
@@ -24,6 +24,11 @@ class ServerCard extends StatelessWidget {
24 this.onSetDefault,
25 });
26
27 + static const _accent = Color(0xFF22C55E);
28 + static const _warning = Color(0xFFF59E0B);
29 + static const _error = Color(0xFFEF4444);
30 + static const _neutral = Color(0xFF71717A);
31 +
32 HugeIconData? get _serverIconData {
33 if (server.iconName != null) {
34 return getHugeIconByName(server.iconName);
@@ -56,222 +61,358 @@ class ServerCard extends StatelessWidget {
61 return server.displayAddress;
62 }
63
64 + String _statusLabel(AppLocalizations l10n) {
65 + switch (server.status) {
66 + case ConnectionStatus.connected:
67 + return l10n.connected;
68 + case ConnectionStatus.disconnected:
69 + return l10n.offline;
70 + case ConnectionStatus.checking:
71 + return l10n.checking;
72 + case ConnectionStatus.error:
73 + return l10n.error;
74 + }
75 + }
76 +
77 + Color _statusColor() {
78 + switch (server.status) {
79 + case ConnectionStatus.connected:
80 + return _accent;
81 + case ConnectionStatus.error:
82 + return _error;
83 + case ConnectionStatus.checking:
84 + return _warning;
85 + case ConnectionStatus.disconnected:
86 + return _neutral;
87 + }
88 + }
89 +
90 + List<List<dynamic>> _typeIcon() {
91 + switch (server.type) {
92 + case ServerType.lmStudio:
93 + return HugeIcons.strokeRoundedComputerTerminal01;
94 + case ServerType.openAICompatible:
95 + return HugeIcons.strokeRoundedAiChat01;
96 + case ServerType.ollama:
97 + return HugeIcons.strokeRoundedRobot01;
98 + case ServerType.openRouter:
99 + return HugeIcons.strokeRoundedCloud;
100 + case ServerType.onDevice:
101 + return HugeIcons.strokeRoundedHardDrive;
102 + }
103 + }
104 +
105 @override
106 Widget build(BuildContext context) {
107 final l10n = AppLocalizations.of(context)!;
108 final theme = Theme.of(context);
63 -
64 - final activeGreen = const Color(0xFF22C55E);
109 + final isDark = theme.brightness == Brightness.dark;
110 + final statusColor = _statusColor();
111
112 return AnimatedContainer(
67 - duration: const Duration(milliseconds: 300),
68 - curve: Curves.easeInOut,
113 + duration: const Duration(milliseconds: 280),
114 + curve: Curves.easeOutCubic,
115 decoration: BoxDecoration(
70 - color: isActive ? activeGreen.withValues(alpha: 0.08) : theme.cardColor,
71 - borderRadius: BorderRadius.circular(12),
116 + color: isActive
117 + ? (isDark
118 + ? _accent.withValues(alpha: 0.10)
119 + : _accent.withValues(alpha: 0.06))
120 + : theme.cardColor,
121 + borderRadius: BorderRadius.circular(14),
122 border: Border.all(
123 color: isActive
74 - ? activeGreen.withValues(alpha: 0.5)
75 - : theme.dividerColor.withValues(alpha: 0.1),
124 + ? _accent.withValues(alpha: 0.45)
125 + : theme.dividerColor.withValues(alpha: isDark ? 0.12 : 0.08),
126 width: isActive ? 1.5 : 1,
127 ),
128 + boxShadow: isActive
129 + ? [
130 + BoxShadow(
131 + color: _accent.withValues(alpha: 0.08),
132 + blurRadius: 12,
133 + offset: const Offset(0, 4),
134 + ),
135 + ]
136 + : null,
137 ),
138 clipBehavior: Clip.antiAlias,
80 - child: InkWell(
81 - onTap: onTap,
82 - child: Stack(
83 - children: [
84 - if (isActive)
85 - PositionedDirectional(
86 - start: 0,
87 - top: 0,
88 - bottom: 0,
89 - width: 4,
90 - child: Container(
91 - decoration: BoxDecoration(
92 - color: activeGreen,
93 - borderRadius: const BorderRadiusDirectional.only(
94 - topEnd: Radius.circular(4),
95 - bottomEnd: Radius.circular(4),
96 - ),
139 + child: Material(
140 + color: Colors.transparent,
141 + child: InkWell(
142 + onTap: onTap,
143 + child: Padding(
144 + padding: const EdgeInsets.fromLTRB(14, 14, 6, 14),
145 + child: Row(
146 + crossAxisAlignment: CrossAxisAlignment.start,
147 + children: [
148 + _buildIcon(theme, isDark),
149 + const SizedBox(width: 14),
150 + Expanded(
151 + child: Column(
152 + crossAxisAlignment: CrossAxisAlignment.start,
153 + children: [
154 + _buildTitleRow(theme, isDark, l10n),
155 + const SizedBox(height: 6),
156 + _buildTypeChip(theme, isDark, l10n),
157 + const SizedBox(height: 6),
158 + _buildAddressRow(theme, l10n),
159 + const SizedBox(height: 10),
160 + _buildStatusRow(statusColor, l10n),
161 + ],
162 ),
163 ),
164 + _buildMenu(theme, l10n),
165 + ],
166 + ),
167 + ),
168 + ),
169 + ),
170 + );
171 + }
172 +
173 + Widget _buildIcon(ThemeData theme, bool isDark) {
174 + return AnimatedContainer(
175 + duration: const Duration(milliseconds: 280),
176 + curve: Curves.easeOutCubic,
177 + width: 44,
178 + height: 44,
179 + decoration: BoxDecoration(
180 + gradient: isActive
181 + ? LinearGradient(
182 + begin: Alignment.topLeft,
183 + end: Alignment.bottomRight,
184 + colors: [
185 + _accent.withValues(alpha: 0.20),
186 + _accent.withValues(alpha: 0.06),
187 + ],
188 + )
189 + : LinearGradient(
190 + begin: Alignment.topLeft,
191 + end: Alignment.bottomRight,
192 + colors: [
193 + isDark
194 + ? Colors.white.withValues(alpha: 0.06)
195 + : Colors.black.withValues(alpha: 0.04),
196 + isDark
197 + ? Colors.white.withValues(alpha: 0.02)
198 + : Colors.black.withValues(alpha: 0.01),
199 + ],
200 + ),
201 + borderRadius: BorderRadius.circular(11),
202 + border: Border.all(
203 + color: isActive
204 + ? _accent.withValues(alpha: 0.30)
205 + : theme.dividerColor.withValues(alpha: isDark ? 0.15 : 0.10),
206 + width: 1,
207 + ),
208 + ),
209 + child: Center(
210 + child: _serverIconData != null
211 + ? HugeIcon(
212 + icon: _serverIconData!.icon,
213 + size: 22,
214 + color: isActive ? _accent : theme.colorScheme.onSurfaceVariant,
215 + )
216 + : HugeIcon(
217 + icon: HugeIcons.strokeRoundedDatabase,
218 + size: 22,
219 + color: isActive ? _accent : theme.colorScheme.onSurfaceVariant,
220 + ),
221 + ),
222 + );
223 + }
224 +
225 + Widget _buildTitleRow(ThemeData theme, bool isDark, AppLocalizations l10n) {
226 + return Row(
227 + crossAxisAlignment: CrossAxisAlignment.center,
228 + children: [
229 + Flexible(
230 + child: Text(
231 + server.name,
232 + style: theme.textTheme.titleMedium?.copyWith(
233 + fontWeight: FontWeight.w600,
234 + letterSpacing: -0.2,
235 + height: 1.25,
236 + color: isActive
237 + ? (isDark ? Colors.white : const Color(0xFF0A0A0A))
238 + : theme.colorScheme.onSurface,
239 + ),
240 + overflow: TextOverflow.ellipsis,
241 + maxLines: 1,
242 + ),
243 + ),
244 + if (server.isDefault) ...[
245 + const SizedBox(width: 6),
246 + Tooltip(
247 + message: l10n.default_badge,
248 + child: const HugeIcon(
249 + icon: HugeIcons.strokeRoundedStar,
250 + size: 14,
251 + color: _warning,
252 + ),
253 + ),
254 + ],
255 + ],
256 + );
257 + }
258 +
259 + Widget _buildTypeChip(ThemeData theme, bool isDark, AppLocalizations l10n) {
260 + return Align(
261 + alignment: AlignmentDirectional.centerStart,
262 + child: Container(
263 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
264 + decoration: BoxDecoration(
265 + color: isDark
266 + ? Colors.white.withValues(alpha: 0.06)
267 + : Colors.black.withValues(alpha: 0.04),
268 + borderRadius: BorderRadius.circular(6),
269 + ),
270 + child: Row(
271 + mainAxisSize: MainAxisSize.min,
272 + children: [
273 + HugeIcon(
274 + icon: _typeIcon(),
275 + size: 12,
276 + color: theme.colorScheme.onSurfaceVariant,
277 + ),
278 + const SizedBox(width: 5),
279 + Text(
280 + _serverTypeName(l10n),
281 + style: theme.textTheme.labelSmall?.copyWith(
282 + color: theme.colorScheme.onSurfaceVariant,
283 + fontWeight: FontWeight.w500,
284 + fontSize: 11,
285 + letterSpacing: 0.1,
286 ),
100 - Padding(
101 - padding: const EdgeInsets.all(16),
287 + ),
288 + ],
289 + ),
290 + ),
291 + );
292 + }
293 +
294 + Widget _buildAddressRow(ThemeData theme, AppLocalizations l10n) {
295 + return Row(
296 + children: [
297 + HugeIcon(
298 + icon: HugeIcons.strokeRoundedLink01,
299 + size: 12,
300 + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
301 + ),
302 + const SizedBox(width: 5),
303 + Flexible(
304 + child: Text(
305 + _serverAddress(l10n),
306 + style: theme.textTheme.bodySmall?.copyWith(
307 + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.85),
308 + fontFeatures: const [FontFeature.tabularFigures()],
309 + letterSpacing: -0.1,
310 + ),
311 + overflow: TextOverflow.ellipsis,
312 + maxLines: 1,
313 + ),
314 + ),
315 + ],
316 + );
317 + }
318 +
319 + Widget _buildStatusRow(Color statusColor, AppLocalizations l10n) {
320 + return Align(
321 + alignment: AlignmentDirectional.centerStart,
322 + child: Container(
323 + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
324 + decoration: BoxDecoration(
325 + color: statusColor.withValues(alpha: 0.12),
326 + borderRadius: BorderRadius.circular(6),
327 + ),
328 + child: Row(
329 + mainAxisSize: MainAxisSize.min,
330 + children: [
331 + ConnectionStatusIndicator(status: server.status, size: 8),
332 + const SizedBox(width: 5),
333 + Text(
334 + _statusLabel(l10n),
335 + style: TextStyle(
336 + fontSize: 11,
337 + fontWeight: FontWeight.w600,
338 + color: statusColor,
339 + letterSpacing: 0.1,
340 + ),
341 + ),
342 + ],
343 + ),
344 + ),
345 + );
346 + }
347 +
348 + Widget _buildMenu(ThemeData theme, AppLocalizations l10n) {
349 + return SizedBox(
350 + width: 32,
351 + child: PopupMenuButton<String>(
352 + padding: EdgeInsets.zero,
353 + iconSize: 18,
354 + onSelected: (value) {
355 + switch (value) {
356 + case 'edit':
357 + onEdit?.call();
358 + break;
359 + case 'delete':
360 + onDelete?.call();
361 + break;
362 + case 'setDefault':
363 + onSetDefault?.call();
364 + break;
365 + }
366 + },
367 + itemBuilder: (context) => [
368 + PopupMenuItem(
369 + value: 'edit',
370 + child: Row(
371 + children: [
372 + HugeIcon(
373 + icon: HugeIcons.strokeRoundedPencilEdit02,
374 + size: 18,
375 + color: theme.colorScheme.onSurface,
376 + ),
377 + const SizedBox(width: 8),
378 + Text(l10n.edit),
379 + ],
380 + ),
381 + ),
382 + if (!server.isDefault)
383 + PopupMenuItem(
384 + value: 'setDefault',
385 child: Row(
386 children: [
104 - AnimatedContainer(
105 - duration: const Duration(milliseconds: 300),
106 - width: 48,
107 - height: 48,
108 - decoration: BoxDecoration(
109 - color: isActive
110 - ? activeGreen.withValues(alpha: 0.1)
111 - : theme.colorScheme.surfaceContainerHighest
112 - .withValues(alpha: 0.3),
113 - borderRadius: BorderRadius.circular(12),
114 - border: Border.all(
115 - color: isActive
116 - ? activeGreen.withValues(alpha: 0.2)
117 - : Colors.transparent,
118 - ),
119 - ),
120 - child: _serverIconData != null
121 - ? HugeIcon(
122 - icon: _serverIconData!.icon,
123 - size: 24,
124 - color: isActive
125 - ? activeGreen
126 - : theme.colorScheme.onSurfaceVariant,
127 - )
128 - : HugeIcon(icon:
129 - HugeIcons.strokeRoundedDatabase,
130 - color: isActive
131 - ? activeGreen
132 - : theme.colorScheme.onSurfaceVariant,
133 - ),
134 - ),
135 - const SizedBox(width: 16),
136 - Expanded(
137 - child: Column(
138 - crossAxisAlignment: CrossAxisAlignment.start,
139 - children: [
140 - Row(
141 - children: [
142 - Expanded(
143 - child: Text(
144 - server.name,
145 - style: theme.textTheme.titleMedium?.copyWith(
146 - fontWeight: FontWeight.w600,
147 - ),
148 - overflow: TextOverflow.ellipsis,
149 - ),
150 - ),
151 - if (isActive)
152 - Container(
153 - padding: const EdgeInsets.symmetric(
154 - horizontal: 8,
155 - vertical: 2,
156 - ),
157 - margin: const EdgeInsetsDirectional.only(end: 4),
158 - decoration: BoxDecoration(
159 - color: activeGreen,
160 - borderRadius: BorderRadius.circular(4),
161 - ),
162 - child: Text(
163 - l10n.active,
164 - style: theme.textTheme.labelSmall?.copyWith(
165 - color: Colors.white,
166 - fontWeight: FontWeight.bold,
167 - ),
168 - ),
169 - ),
170 - if (server.isDefault)
171 - Container(
172 - padding: const EdgeInsets.symmetric(
173 - horizontal: 8,
174 - vertical: 2,
175 - ),
176 - decoration: BoxDecoration(
177 - color: isActive
178 - ? activeGreen.withValues(alpha: 0.05)
179 - : theme.colorScheme.outline.withValues(
180 - alpha: 0.1,
181 - ),
182 - borderRadius: BorderRadius.circular(4),
183 - border: Border.all(
184 - color: isActive
185 - ? activeGreen.withValues(alpha: 0.3)
186 - : theme.colorScheme.outline,
187 - ),
188 - ),
189 - child: Text(
190 - l10n.default_badge,
191 - style: theme.textTheme.labelSmall?.copyWith(
192 - color: isActive
193 - ? activeGreen
194 - : theme.colorScheme.outline,
195 - ),
196 - ),
197 - ),
198 - ],
199 - ),
200 - const SizedBox(height: 4),
201 - Row(
202 - children: [
203 - Text(
204 - _serverTypeName(l10n),
205 - style: theme.textTheme.bodySmall,
206 - ),
207 - const SizedBox(width: 8),
208 - Text('\u2022', style: theme.textTheme.bodySmall),
209 - const SizedBox(width: 8),
210 - Text(
211 - _serverAddress(l10n),
212 - style: theme.textTheme.bodySmall,
213 - ),
214 - ],
215 - ),
216 - ],
217 - ),
218 - ),
219 - const SizedBox(width: 12),
220 - ConnectionStatusIndicator(status: server.status),
221 - PopupMenuButton<String>(
222 - onSelected: (value) {
223 - switch (value) {
224 - case 'edit':
225 - onEdit?.call();
226 - break;
227 - case 'delete':
228 - onDelete?.call();
229 - break;
230 - case 'setDefault':
231 - onSetDefault?.call();
232 - break;
233 - }
234 - },
235 - itemBuilder: (context) => [
236 - PopupMenuItem(
237 - value: 'edit',
238 - child: Row(
239 - children: [
240 - const HugeIcon(icon: HugeIcons.strokeRoundedPencilEdit02, size: 18),
241 - const SizedBox(width: 8),
242 - Text(l10n.edit),
243 - ],
244 - ),
245 - ),
246 - if (!server.isDefault)
247 - PopupMenuItem(
248 - value: 'setDefault',
249 - child: Row(
250 - children: [
251 - const HugeIcon(icon: HugeIcons.strokeRoundedStar, size: 18),
252 - const SizedBox(width: 8),
253 - Text(l10n.set_as_default),
254 - ],
255 - ),
256 - ),
257 - PopupMenuItem(
258 - value: 'delete',
259 - child: Row(
260 - children: [
261 - const HugeIcon(icon: HugeIcons.strokeRoundedDelete01, size: 18, color: Colors.red),
262 - const SizedBox(width: 8),
263 - Text(l10n.delete, style: const TextStyle(color: Colors.red)),
264 - ],
265 - ),
266 - ),
267 - ],
387 + HugeIcon(
388 + icon: HugeIcons.strokeRoundedStar,
389 + size: 18,
390 + color: theme.colorScheme.onSurface,
391 ),
392 + const SizedBox(width: 8),
393 + Text(l10n.set_as_default),
394 ],
395 ),
396 ),
272 - ],
273 - ),
397 + PopupMenuItem(
398 + value: 'delete',
399 + child: Row(
400 + children: [
401 + const HugeIcon(
402 + icon: HugeIcons.strokeRoundedDelete01,
403 + size: 18,
404 + color: Colors.red,
405 + ),
406 + const SizedBox(width: 8),
407 + Text(
408 + l10n.delete,
409 + style: const TextStyle(color: Colors.red),
410 + ),
411 + ],
412 + ),
413 + ),
414 + ],
415 ),
416 );
417 }
277 -}
418 +}
\ No newline at end of file
lib/l10n/app_en.arb
+8
@@ -136,6 +136,14 @@
136 "@online": {
137 "description": "Connection status online"
138 },
139 + "connected": "Connected",
140 + "@connected": {
141 + "description": "Server connection status when actively connected"
142 + },
143 + "checking": "Checking",
144 + "@checking": {
145 + "description": "Server connection status while being tested"
146 + },
147 "offline": "Offline",
148 "@offline": {
149 "description": "Connection status offline"
lib/l10n/app_localizations.dart
+12
@@ -317,6 +317,18 @@ abstract class AppLocalizations {
317 /// **'Online'**
318 String get online;
319
320 + /// Server connection status when actively connected
321 + ///
322 + /// In en, this message translates to:
323 + /// **'Connected'**
324 + String get connected;
325 +
326 + /// Server connection status while being tested
327 + ///
328 + /// In en, this message translates to:
329 + /// **'Checking'**
330 + String get checking;
331 +
332 /// Connection status offline
333 ///
334 /// In en, this message translates to:
lib/l10n/app_localizations_ar.dart
+6
@@ -110,6 +110,12 @@ class AppLocalizationsAr extends AppLocalizations {
110 @override
111 String get online => 'متصل';
112
113 + @override
114 + String get connected => 'Connected';
115 +
116 + @override
117 + String get checking => 'Checking';
118 +
119 @override
120 String get offline => 'غير متصل';
121
lib/l10n/app_localizations_bn.dart
+6
@@ -110,6 +110,12 @@ class AppLocalizationsBn extends AppLocalizations {
110 @override
111 String get online => 'অনলাইন';
112
113 + @override
114 + String get connected => 'Connected';
115 +
116 + @override
117 + String get checking => 'Checking';
118 +
119 @override
120 String get offline => 'অফলাইন';
121
lib/l10n/app_localizations_en.dart
+6
@@ -110,6 +110,12 @@ class AppLocalizationsEn extends AppLocalizations {
110 @override
111 String get online => 'Online';
112
113 + @override
114 + String get connected => 'Connected';
115 +
116 + @override
117 + String get checking => 'Checking';
118 +
119 @override
120 String get offline => 'Offline';
121
lib/l10n/app_localizations_es.dart
+6
@@ -110,6 +110,12 @@ class AppLocalizationsEs extends AppLocalizations {
110 @override
111 String get online => 'En línea';
112
113 + @override
114 + String get connected => 'Connected';
115 +
116 + @override
117 + String get checking => 'Checking';
118 +
119 @override
120 String get offline => 'Sin conexión';
121
lib/l10n/app_localizations_hi.dart
+6
@@ -110,6 +110,12 @@ class AppLocalizationsHi extends AppLocalizations {
110 @override
111 String get online => 'ऑनलाइन';
112
113 + @override
114 + String get connected => 'Connected';
115 +
116 + @override
117 + String get checking => 'Checking';
118 +
119 @override
120 String get offline => 'ऑफ़लाइन';
121
lib/l10n/app_localizations_it.dart
+6
@@ -110,6 +110,12 @@ class AppLocalizationsIt extends AppLocalizations {
110 @override
111 String get online => 'Online';
112
113 + @override
114 + String get connected => 'Connected';
115 +
116 + @override
117 + String get checking => 'Checking';
118 +
119 @override
120 String get offline => 'Offline';
121
lib/l10n/app_localizations_ja.dart
+6
@@ -110,6 +110,12 @@ class AppLocalizationsJa extends AppLocalizations {
110 @override
111 String get online => 'オンライン';
112
113 + @override
114 + String get connected => 'Connected';
115 +
116 + @override
117 + String get checking => 'Checking';
118 +
119 @override
120 String get offline => 'オフライン';
121
lib/l10n/app_localizations_ru.dart
+6
@@ -110,6 +110,12 @@ class AppLocalizationsRu extends AppLocalizations {
110 @override
111 String get online => 'Онлайн';
112
113 + @override
114 + String get connected => 'Connected';
115 +
116 + @override
117 + String get checking => 'Checking';
118 +
119 @override
120 String get offline => 'Оффлайн';
121
lib/l10n/app_localizations_zh.dart
+6
@@ -110,6 +110,12 @@ class AppLocalizationsZh extends AppLocalizations {
110 @override
111 String get online => '在线';
112
113 + @override
114 + String get connected => 'Connected';
115 +
116 + @override
117 + String get checking => 'Checking';
118 +
119 @override
120 String get offline => '离线';
121
test/features/servers/server_card_test.dart new
+155
@@ -0,0 +1,155 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_localizations/flutter_localizations.dart';
3 +import 'package:flutter_test/flutter_test.dart';
4 +import 'package:localmind/core/models/enums.dart';
5 +import 'package:localmind/features/servers/data/models/server.dart';
6 +import 'package:localmind/features/servers/views/components/server_card.dart';
7 +import 'package:localmind/l10n/app_localizations.dart';
8 +
9 +Widget _wrapWithApp(Widget child) {
10 + return MaterialApp(
11 + locale: const Locale('en'),
12 + localizationsDelegates: const [
13 + AppLocalizations.delegate,
14 + GlobalMaterialLocalizations.delegate,
15 + GlobalWidgetsLocalizations.delegate,
16 + GlobalCupertinoLocalizations.delegate,
17 + ],
18 + supportedLocales: AppLocalizations.supportedLocales,
19 + home: Scaffold(body: child),
20 + );
21 +}
22 +
23 +Server _buildServer({
24 + String name = 'llama home',
25 + ServerType type = ServerType.ollama,
26 + String host = '192.168.0.210',
27 + int port = 11434,
28 + bool isDefault = false,
29 + ConnectionStatus status = ConnectionStatus.disconnected,
30 +}) {
31 + return Server(
32 + id: 'test-id',
33 + name: name,
34 + type: type,
35 + host: host,
36 + port: port,
37 + isDefault: isDefault,
38 + createdAt: DateTime(2026, 1, 1),
39 + lastConnectedAt: DateTime(2026, 1, 1),
40 + status: status,
41 + );
42 +}
43 +
44 +void main() {
45 + group('ServerCard', () {
46 + testWidgets('renders server name, type chip and address',
47 + (tester) async {
48 + await tester.pumpWidget(
49 + _wrapWithApp(
50 + ServerCard(server: _buildServer()),
51 + ),
52 + );
53 + await tester.pump();
54 +
55 + expect(find.text('llama home'), findsOneWidget);
56 + // Type chip should render the localized ollama display name.
57 + expect(find.text('Ollama'), findsOneWidget);
58 + // Address row should render the IP:port.
59 + expect(find.text('192.168.0.210:11434'), findsOneWidget);
60 + });
61 +
62 + testWidgets('shows the Offline status pill when disconnected',
63 + (tester) async {
64 + await tester.pumpWidget(
65 + _wrapWithApp(
66 + ServerCard(server: _buildServer()),
67 + ),
68 + );
69 + await tester.pump();
70 +
71 + expect(find.text('Offline'), findsOneWidget);
72 + });
73 +
74 + testWidgets('shows the Connected status pill when connected',
75 + (tester) async {
76 + await tester.pumpWidget(
77 + _wrapWithApp(
78 + ServerCard(
79 + server: _buildServer(status: ConnectionStatus.connected),
80 + ),
81 + ),
82 + );
83 + await tester.pump();
84 +
85 + expect(find.text('Connected'), findsOneWidget);
86 + });
87 +
88 + testWidgets('shows the Checking status pill while checking',
89 + (tester) async {
90 + await tester.pumpWidget(
91 + _wrapWithApp(
92 + ServerCard(
93 + server: _buildServer(status: ConnectionStatus.checking),
94 + ),
95 + ),
96 + );
97 + await tester.pump();
98 +
99 + expect(find.text('Checking'), findsOneWidget);
100 + });
101 +
102 + testWidgets('shows the default star indicator when server is default',
103 + (tester) async {
104 + await tester.pumpWidget(
105 + _wrapWithApp(
106 + ServerCard(server: _buildServer(isDefault: true)),
107 + ),
108 + );
109 + await tester.pump();
110 +
111 + // The star icon (Tooltip wraps the icon) should be present.
112 + expect(find.byTooltip('Default'), findsOneWidget);
113 + });
114 +
115 + testWidgets('invokes onTap when the card is tapped', (tester) async {
116 + var taps = 0;
117 + await tester.pumpWidget(
118 + _wrapWithApp(
119 + ServerCard(
120 + server: _buildServer(),
121 + onTap: () => taps++,
122 + ),
123 + ),
124 + );
125 + await tester.pump();
126 +
127 + await tester.tap(find.byType(ServerCard));
128 + await tester.pump();
129 +
130 + expect(taps, 1);
131 + });
132 +
133 + testWidgets('renders different content for on-device servers',
134 + (tester) async {
135 + await tester.pumpWidget(
136 + _wrapWithApp(
137 + ServerCard(
138 + server: _buildServer(
139 + name: 'On-Device',
140 + type: ServerType.onDevice,
141 + host: '',
142 + port: 0,
143 + ),
144 + ),
145 + ),
146 + );
147 + await tester.pump();
148 +
149 + // The name and the on-device type chip both render "On-Device".
150 + expect(find.text('On-Device'), findsWidgets);
151 + // On-device uses a localized placeholder for the address.
152 + expect(find.text('Local inference'), findsOneWidget);
153 + });
154 + });
155 +}
\ No newline at end of file