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