28 files changed
+859
-107
lib/features/onboarding/screens/onboarding_server_setup_screen.dart
+34
-15
@@ -13,6 +13,7 @@ import '../../../core/routes/app_routes.dart';
13
import '../../../l10n/app_localizations.dart';
14
import '../../servers/data/models/server.dart';
15
import '../../servers/providers/server_providers.dart';
16
+import '../../servers/views/components/https_scheme_hint.dart';
17
18
class OnboardingServerSetupScreen extends ConsumerStatefulWidget {
19
final ServerType selectedType;
@@ -49,7 +50,7 @@ class _OnboardingServerSetupScreenState
50
defaultPort = AppConstants.lmStudioDefaultPort.toString();
51
break;
52
case ServerType.openAICompatible:
52
- defaultName = 'Local AI';
53
+ defaultName = 'OpenAI Compatible';
54
defaultPort = AppConstants.openAICompatibleDefaultPort.toString();
55
break;
56
case ServerType.ollama:
@@ -104,6 +105,31 @@ class _OnboardingServerSetupScreenState
105
);
106
}
107
108
+ String? _validateHost(String? value) {
109
+ if (widget.selectedType == ServerType.openRouter) return null;
110
+ final l10n = AppLocalizations.of(context)!;
111
+ if (value == null || value.trim().isEmpty) {
112
+ return l10n.host_required;
113
+ }
114
+ if (parseServerAddressInput(value) == null) {
115
+ return l10n.host_valid;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ String? _validatePort(String? value) {
121
+ if (widget.selectedType == ServerType.openRouter) return null;
122
+ final l10n = AppLocalizations.of(context)!;
123
+ if (value == null || value.trim().isEmpty) {
124
+ return l10n.port_required;
125
+ }
126
+ final port = int.tryParse(value.trim());
127
+ if (port == null || port < 1 || port > 65535) {
128
+ return l10n.port_invalid;
129
+ }
130
+ return null;
131
+ }
132
+
133
Future<void> _testConnection() async {
134
if (!_formKey.currentState!.validate()) return;
135
@@ -125,6 +151,9 @@ class _OnboardingServerSetupScreenState
151
? l10n.connection_successful
152
: l10n.connection_failed;
153
});
154
+ if (isConnected) {
155
+ invalidateAvailableModelsCache(testServer.id);
156
+ }
157
} catch (e) {
158
setState(() {
159
_testSuccess = false;
@@ -148,6 +177,7 @@ class _OnboardingServerSetupScreenState
177
final server = _buildServer();
178
await ref.read(serversProvider.notifier).addServer(server);
179
await ref.read(serversProvider.notifier).setDefault(server.id);
180
+ invalidateAvailableModelsCache(server.id);
181
ref.read(activeServerProvider.notifier).setActiveServer(server);
182
183
if (mounted) {
@@ -234,10 +264,9 @@ class _OnboardingServerSetupScreenState
264
filled: true,
265
fillColor: theme.colorScheme.surface,
266
),
237
- validator: (val) => val == null || val.trim().isEmpty
238
- ? l10n.host_required
239
- : null,
267
+ validator: _validateHost,
268
),
269
+ HttpsSchemeHint(controller: _hostController),
270
const SizedBox(height: 16),
271
TextFormField(
272
controller: _portController,
@@ -246,17 +275,7 @@ class _OnboardingServerSetupScreenState
275
filled: true,
276
fillColor: theme.colorScheme.surface,
277
),
249
- validator: (val) {
250
- if (val == null || val.trim().isEmpty) {
251
- return l10n.port_required;
252
- } else {
253
- if (int.tryParse(val) == null) {
254
- return l10n.port_invalid;
255
- } else {
256
- return null;
257
- }
258
- }
259
- },
278
+ validator: _validatePort,
279
),
280
const SizedBox(height: 16),
281
],
lib/features/onboarding/screens/onboarding_server_type_screen.dart
+238
-71
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
4
import 'package:hugeicons/hugeicons.dart';
5
import 'package:shadcn_ui/shadcn_ui.dart';
6
import 'package:cue/cue.dart';
7
+import 'package:url_launcher/url_launcher.dart';
8
9
import '../../../core/models/enums.dart';
10
import '../../../core/providers/app_providers.dart';
@@ -21,6 +22,9 @@ class OnboardingServerTypeScreen extends ConsumerStatefulWidget {
22
class _OnboardingServerTypeScreenState
23
extends ConsumerState<OnboardingServerTypeScreen> {
24
ServerType? _selectedType;
25
+ static final Uri _repoUrl = Uri.parse(
26
+ 'https://github.com/abdulmominsakib/localmind',
27
+ );
28
29
@override
30
void initState() {
@@ -28,6 +32,28 @@ class _OnboardingServerTypeScreenState
32
_selectedType = ServerType.lmStudio;
33
}
34
35
+ Future<void> _openRepoUrl() async {
36
+ final messenger = ScaffoldMessenger.of(context);
37
+ final l10n = AppLocalizations.of(context)!;
38
+ try {
39
+ final launched = await launchUrl(
40
+ _repoUrl,
41
+ mode: LaunchMode.externalApplication,
42
+ );
43
+ if (!launched && mounted) {
44
+ messenger.showSnackBar(
45
+ SnackBar(content: Text(l10n.could_not_open_github)),
46
+ );
47
+ }
48
+ } catch (_) {
49
+ if (mounted) {
50
+ messenger.showSnackBar(
51
+ SnackBar(content: Text(l10n.could_not_open_github)),
52
+ );
53
+ }
54
+ }
55
+ }
56
+
57
@override
58
Widget build(BuildContext context) {
59
final l10n = AppLocalizations.of(context)!;
@@ -107,62 +133,90 @@ class _OnboardingServerTypeScreenState
133
.fadeIn(),
134
.slideY(from: 0.08),
135
],
110
- child: Column(
111
- crossAxisAlignment: CrossAxisAlignment.stretch,
112
- children: [
113
- const SizedBox(height: 24),
114
- _buildServerCard(
115
- type: ServerType.onDevice,
116
- title: l10n.server_type_on_device,
117
- subtitle: l10n.server_type_on_device_sub,
118
- iconWidget: Icon(
119
- Icons.phone_android_rounded,
120
- color: _selectedType == ServerType.onDevice
121
- ? theme.colorScheme.primary
122
- : theme.colorScheme.onSurface,
123
- ),
124
- theme: theme,
125
- ),
126
- const SizedBox(height: 16),
127
- _buildServerCard(
128
- type: ServerType.lmStudio,
129
- title: l10n.server_type_lm_studio,
130
- subtitle: l10n.server_type_lm_studio_sub,
131
- iconWidget: Icon(
132
- Icons.terminal_rounded,
133
- color: _selectedType == ServerType.lmStudio
134
- ? theme.colorScheme.primary
135
- : theme.colorScheme.onSurface,
136
- ),
137
- theme: theme,
138
- ),
139
- const SizedBox(height: 16),
140
- _buildServerCard(
141
- type: ServerType.ollama,
142
- title: l10n.server_type_ollama,
143
- subtitle: l10n.server_type_ollama_sub,
144
- iconWidget: Icon(
145
- Icons.smart_toy_rounded,
146
- color: _selectedType == ServerType.ollama
147
- ? theme.colorScheme.primary
148
- : theme.colorScheme.onSurface,
149
- ),
150
- theme: theme,
151
- ),
152
- const SizedBox(height: 16),
153
- _buildServerCard(
154
- type: ServerType.openRouter,
155
- title: l10n.server_type_openrouter,
156
- subtitle: l10n.server_type_openrouter_sub,
157
- iconWidget: HugeIcon(
158
- icon: HugeIcons.strokeRoundedCloudServer,
159
- color: _selectedType == ServerType.openRouter
160
- ? theme.colorScheme.primary
161
- : theme.colorScheme.onSurface,
162
- ),
163
- theme: theme,
164
- ),
165
- ],
136
+ child: LayoutBuilder(
137
+ builder: (context, constraints) {
138
+ final crossAxisCount =
139
+ constraints.maxWidth >= 560 ? 3 : 2;
140
+
141
+ return GridView.count(
142
+ shrinkWrap: true,
143
+ physics: const NeverScrollableScrollPhysics(),
144
+ padding: const EdgeInsets.only(top: 24),
145
+ crossAxisCount: crossAxisCount,
146
+ mainAxisSpacing: 12,
147
+ crossAxisSpacing: 12,
148
+ mainAxisExtent: 144,
149
+ children: [
150
+ _buildServerCard(
151
+ type: ServerType.onDevice,
152
+ title: l10n.server_type_on_device,
153
+ subtitle: l10n.server_type_on_device_sub,
154
+ iconWidget: Icon(
155
+ Icons.phone_android_rounded,
156
+ color: _selectedType == ServerType.onDevice
157
+ ? theme.colorScheme.primary
158
+ : theme.colorScheme.onSurface,
159
+ ),
160
+ theme: theme,
161
+ ),
162
+ _buildServerCard(
163
+ type: ServerType.lmStudio,
164
+ title: l10n.server_type_lm_studio,
165
+ subtitle: l10n.server_type_lm_studio_sub,
166
+ iconWidget: Icon(
167
+ Icons.terminal_rounded,
168
+ color: _selectedType == ServerType.lmStudio
169
+ ? theme.colorScheme.primary
170
+ : theme.colorScheme.onSurface,
171
+ ),
172
+ theme: theme,
173
+ ),
174
+ _buildServerCard(
175
+ type: ServerType.openAICompatible,
176
+ title: l10n.server_type_openai_display,
177
+ subtitle: l10n.openai_compatible_api,
178
+ iconWidget: HugeIcon(
179
+ icon: HugeIcons.strokeRoundedApi,
180
+ color:
181
+ _selectedType ==
182
+ ServerType.openAICompatible
183
+ ? theme.colorScheme.primary
184
+ : theme.colorScheme.onSurface,
185
+ ),
186
+ theme: theme,
187
+ ),
188
+ _buildServerCard(
189
+ type: ServerType.ollama,
190
+ title: l10n.server_type_ollama,
191
+ subtitle: l10n.server_type_ollama_sub,
192
+ iconWidget: Icon(
193
+ Icons.smart_toy_rounded,
194
+ color: _selectedType == ServerType.ollama
195
+ ? theme.colorScheme.primary
196
+ : theme.colorScheme.onSurface,
197
+ ),
198
+ theme: theme,
199
+ ),
200
+ _buildServerCard(
201
+ type: ServerType.openRouter,
202
+ title: l10n.server_type_openrouter,
203
+ subtitle: l10n.server_type_openrouter_sub,
204
+ iconWidget: HugeIcon(
205
+ icon: HugeIcons.strokeRoundedCloudServer,
206
+ color: _selectedType == ServerType.openRouter
207
+ ? theme.colorScheme.primary
208
+ : theme.colorScheme.onSurface,
209
+ ),
210
+ theme: theme,
211
+ ),
212
+ _buildMoreCard(
213
+ theme: theme,
214
+ title: l10n.add_more,
215
+ subtitle: l10n.on_github,
216
+ ),
217
+ ],
218
+ );
219
+ },
220
),
221
),
222
const SizedBox(height: 32),
@@ -277,46 +331,57 @@ class _OnboardingServerTypeScreenState
331
});
332
},
333
child: Container(
280
- padding: const EdgeInsets.all(20),
334
+ padding: const EdgeInsets.all(16),
335
decoration: BoxDecoration(
336
color: disabled
337
? theme.colorScheme.surface.withValues(alpha: 0.5)
284
- : theme.colorScheme.surface,
285
- borderRadius: BorderRadius.circular(16),
338
+ : theme.colorScheme.surface,
339
+ borderRadius: BorderRadius.circular(14),
340
border: Border.all(
341
color: isSelected && !disabled
342
? theme.colorScheme.primary
343
: theme.colorScheme.outline.withValues(alpha: 0.3),
290
- width: isSelected && !disabled ? 2 : 1,
344
+ width: isSelected && !disabled ? 1.5 : 1,
345
),
346
),
293
- child: Column(
294
- crossAxisAlignment: CrossAxisAlignment.start,
295
- children: [
296
- Row(
297
- children: [
347
+ child: Column(
348
+ crossAxisAlignment: CrossAxisAlignment.start,
349
+ children: [
350
+ Row(
351
+ children: [
352
Container(
299
- padding: const EdgeInsets.all(12),
353
+ padding: const EdgeInsets.all(8),
354
decoration: BoxDecoration(
355
color: theme.scaffoldBackgroundColor,
302
- borderRadius: BorderRadius.circular(12),
356
+ borderRadius: BorderRadius.circular(11),
357
+ ),
358
+ child: IconTheme(
359
+ data: IconThemeData(
360
+ size: 18,
361
+ color: disabled
362
+ ? theme.colorScheme.onSurface.withValues(alpha: 0.5)
363
+ : theme.colorScheme.onSurface,
364
+ ),
365
+ child: iconWidget,
366
),
304
- child: iconWidget,
367
),
368
const Spacer(),
369
if (disabled)
370
const Icon(
371
Icons.lock_outline_rounded,
310
- size: 20,
372
+ size: 18,
373
color: Colors.orange,
374
),
375
],
376
),
315
- const SizedBox(height: 16),
377
+ const SizedBox(height: 10),
378
Text(
379
title,
380
+ maxLines: 1,
381
+ overflow: TextOverflow.ellipsis,
382
style: theme.textTheme.titleMedium?.copyWith(
383
fontWeight: FontWeight.bold,
384
+ fontSize: 14.5,
385
color: disabled
386
? theme.colorScheme.onSurface.withValues(alpha: 0.5)
387
: null,
@@ -325,8 +390,11 @@ class _OnboardingServerTypeScreenState
390
const SizedBox(height: 4),
391
Text(
392
subtitle,
393
+ maxLines: 1,
394
+ overflow: TextOverflow.ellipsis,
395
style: theme.textTheme.labelSmall?.copyWith(
329
- letterSpacing: 2,
396
+ letterSpacing: 1.0,
397
+ fontSize: 9.5,
398
color: disabled
399
? Colors.orange.withValues(alpha: 0.7)
400
: theme.colorScheme.onSurface.withValues(alpha: 0.5),
@@ -338,6 +406,105 @@ class _OnboardingServerTypeScreenState
406
);
407
}
408
409
+ Widget _buildMoreCard({
410
+ required ThemeData theme,
411
+ required String title,
412
+ required String subtitle,
413
+ }) {
414
+ return Material(
415
+ color: Colors.transparent,
416
+ child: InkWell(
417
+ borderRadius: BorderRadius.circular(14),
418
+ onTap: _openRepoUrl,
419
+ child: ClipRRect(
420
+ borderRadius: BorderRadius.circular(14),
421
+ child: Stack(
422
+ children: [
423
+ Container(
424
+ padding: const EdgeInsets.all(16),
425
+ decoration: BoxDecoration(
426
+ gradient: LinearGradient(
427
+ begin: Alignment.topLeft,
428
+ end: Alignment.bottomRight,
429
+ colors: [
430
+ theme.colorScheme.surface,
431
+ theme.colorScheme.surfaceContainerHighest.withValues(
432
+ alpha: 0.68,
433
+ ),
434
+ ],
435
+ ),
436
+ borderRadius: BorderRadius.circular(14),
437
+ border: Border.all(
438
+ color: theme.colorScheme.outline.withValues(alpha: 0.18),
439
+ ),
440
+ ),
441
+ child: Column(
442
+ crossAxisAlignment: CrossAxisAlignment.start,
443
+ children: [
444
+ Row(
445
+ children: [
446
+ Container(
447
+ padding: const EdgeInsets.all(8),
448
+ decoration: BoxDecoration(
449
+ color: theme.colorScheme.primary.withValues(
450
+ alpha: 0.12,
451
+ ),
452
+ borderRadius: BorderRadius.circular(11),
453
+ ),
454
+ child: Icon(
455
+ Icons.add_rounded,
456
+ size: 18,
457
+ color: theme.colorScheme.primary,
458
+ ),
459
+ ),
460
+ const Spacer(),
461
+ ],
462
+ ),
463
+ const Spacer(),
464
+ Text(
465
+ title,
466
+ maxLines: 1,
467
+ overflow: TextOverflow.ellipsis,
468
+ style: theme.textTheme.titleMedium?.copyWith(
469
+ fontWeight: FontWeight.bold,
470
+ fontSize: 14.5,
471
+ ),
472
+ ),
473
+ const SizedBox(height: 4),
474
+ Text(
475
+ subtitle,
476
+ maxLines: 1,
477
+ overflow: TextOverflow.ellipsis,
478
+ style: theme.textTheme.labelSmall?.copyWith(
479
+ letterSpacing: 1.0,
480
+ fontSize: 9.5,
481
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.55),
482
+ ),
483
+ ),
484
+ ],
485
+ ),
486
+ ),
487
+ PositionedDirectional(
488
+ end: 4,
489
+ bottom: 2,
490
+ child: IgnorePointer(
491
+ child: Opacity(
492
+ opacity: 0.06,
493
+ child: HugeIcon(
494
+ icon: HugeIcons.strokeRoundedGithub,
495
+ size: 54,
496
+ color: theme.colorScheme.onSurface,
497
+ ),
498
+ ),
499
+ ),
500
+ ),
501
+ ],
502
+ ),
503
+ ),
504
+ ),
505
+ );
506
+ }
507
+
508
Future<void> _completeOnboarding() async {
509
final settings = ref.read(settingsProvider);
510
await ref
lib/features/servers/data/models/server.dart
+89
-12
@@ -1,5 +1,91 @@
1
import '../../../../core/models/enums.dart';
2
3
+bool _hasExplicitHttpScheme(String input) {
4
+ final trimmed = input.trim().toLowerCase();
5
+ return trimmed.startsWith('http://') || trimmed.startsWith('https://');
6
+}
7
+
8
+Uri? parseServerAddressInput(String input) {
9
+ final trimmed = input.trim();
10
+ if (trimmed.isEmpty) return null;
11
+
12
+ final candidate = _hasExplicitHttpScheme(trimmed) ? trimmed : 'http://$trimmed';
13
+ final uri = Uri.tryParse(candidate);
14
+ if (uri == null || uri.host.isEmpty) return null;
15
+ if (uri.scheme != 'http' && uri.scheme != 'https') return null;
16
+ if (uri.path.isNotEmpty && uri.path != '/') return null;
17
+ return uri;
18
+}
19
+
20
+bool isHttpsAddressInput(String input) {
21
+ return input.trim().toLowerCase().startsWith('https://');
22
+}
23
+
24
+Uri _normalizeNetworkUri(Uri uri, {int? port}) {
25
+ return Uri(
26
+ scheme: uri.scheme,
27
+ userInfo: uri.userInfo,
28
+ host: uri.host,
29
+ port: port ?? (uri.hasPort ? uri.port : null),
30
+ );
31
+}
32
+
33
+String displayServerAddress(String host, int port, ServerType type) {
34
+ if (type == ServerType.openRouter) {
35
+ return 'openrouter.ai';
36
+ }
37
+ if (type == ServerType.onDevice) {
38
+ return 'Local inference';
39
+ }
40
+
41
+ final trimmedHost = host.trim();
42
+ if (trimmedHost.isEmpty) {
43
+ return port > 0 ? 'localhost:$port' : 'localhost';
44
+ }
45
+
46
+ if (_hasExplicitHttpScheme(trimmedHost)) {
47
+ final uri = parseServerAddressInput(trimmedHost);
48
+ if (uri != null) {
49
+ return _normalizeNetworkUri(
50
+ uri,
51
+ port: uri.hasPort || port <= 0 ? null : port,
52
+ ).toString();
53
+ }
54
+ return trimmedHost;
55
+ }
56
+
57
+ if (trimmedHost.contains(':')) {
58
+ return trimmedHost;
59
+ }
60
+
61
+ return port > 0 ? '$trimmedHost:$port' : trimmedHost;
62
+}
63
+
64
+String buildServerBaseUrl(String host, int port, ServerType type) {
65
+ if (type == ServerType.openRouter) {
66
+ return 'https://openrouter.ai/api/v1';
67
+ }
68
+ if (type == ServerType.onDevice) {
69
+ return 'on-device';
70
+ }
71
+
72
+ final trimmedHost = host.trim();
73
+ final uri = parseServerAddressInput(trimmedHost);
74
+ if (uri != null) {
75
+ return _normalizeNetworkUri(
76
+ uri,
77
+ port: uri.hasPort || port <= 0 ? null : port,
78
+ ).toString();
79
+ }
80
+
81
+ if (trimmedHost.isEmpty) {
82
+ return '';
83
+ }
84
+
85
+ final protocol = (port == 443) ? 'https' : 'http';
86
+ return '$protocol://$trimmedHost:$port';
87
+}
88
+
89
class Server {
90
final String id;
91
final String name;
@@ -28,18 +114,7 @@ class Server {
114
});
115
116
String get baseUrl {
31
- if (type == ServerType.openRouter) {
32
- return 'https://openrouter.ai/api/v1';
33
- }
34
- if (type == ServerType.onDevice) {
35
- return 'on-device';
36
- }
37
- final trimmedHost = host.trim();
38
- if (trimmedHost.startsWith('http://') || trimmedHost.startsWith('https://')) {
39
- return '$trimmedHost:$port';
40
- }
41
- final protocol = (port == 443) ? 'https' : 'http';
42
- return '$protocol://$trimmedHost:$port';
117
+ return buildServerBaseUrl(host, port, type);
118
}
119
120
String get chatEndpoint {
@@ -119,6 +194,8 @@ class Server {
194
195
bool get isOnDevice => type == ServerType.onDevice;
196
197
+ String get displayAddress => displayServerAddress(host, port, type);
198
+
199
Server copyWith({
200
String? id,
201
String? name,
lib/features/servers/providers/server_providers.dart
+13
@@ -16,6 +16,14 @@ final _modelCache = ModelCache();
16
17
bool _onDeviceServerEnsured = false;
18
19
+void invalidateAvailableModelsCache(String serverId) {
20
+ _modelCache.invalidate(serverId);
21
+}
22
+
23
+void invalidateAllAvailableModelsCache() {
24
+ _modelCache.invalidateAll();
25
+}
26
+
27
final ensureOnDeviceServerProvider = FutureProvider<void>((ref) async {
28
if (_onDeviceServerEnsured) return;
29
@@ -157,6 +165,7 @@ class ServersNotifier extends AsyncNotifier<List<Server>> {
165
Future<void> addServer(Server server) async {
166
final db = ref.read(databaseProvider);
167
db.serverBox.put(ServerEntity.fromDomain(server));
168
+ invalidateAvailableModelsCache(server.id);
169
state = AsyncData(await _loadAll());
170
}
171
@@ -173,6 +182,7 @@ class ServersNotifier extends AsyncNotifier<List<Server>> {
182
entity.internalId = existing.internalId;
183
}
184
db.serverBox.put(entity);
185
+ invalidateAvailableModelsCache(server.id);
186
state = AsyncData(await _loadAll());
187
}
188
@@ -181,6 +191,7 @@ class ServersNotifier extends AsyncNotifier<List<Server>> {
191
final query = db.serverBox.query(ServerEntity_.id.equals(serverId)).build();
192
db.serverBox.removeMany(query.findIds());
193
query.close();
194
+ invalidateAvailableModelsCache(serverId);
195
state = AsyncData(await _loadAll());
196
}
197
@@ -203,6 +214,7 @@ class ServersNotifier extends AsyncNotifier<List<Server>> {
214
entity.internalId = existing.internalId;
215
}
216
db.serverBox.put(entity);
217
+ invalidateAvailableModelsCache(server.id);
218
}
219
state = AsyncData(await _loadAll());
220
}
@@ -235,6 +247,7 @@ class ServersNotifier extends AsyncNotifier<List<Server>> {
247
entity.internalId = existing.internalId;
248
}
249
db.serverBox.put(entity);
250
+ invalidateAvailableModelsCache(updatedServer.id);
251
252
state = AsyncData(await _loadAll());
253
return status;
lib/features/servers/views/add_server_screen.dart
+9
-5
@@ -10,6 +10,7 @@ import '../../../core/models/enums.dart';
10
import '../../../core/providers/service_providers.dart';
11
import '../../../core/utils/system_insets.dart';
12
import '../providers/server_providers.dart';
13
+import 'components/https_scheme_hint.dart';
14
import 'components/server_icon_picker.dart';
15
import 'components/server_type_selector.dart';
16
@@ -100,6 +101,9 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
101
? AppLocalizations.of(context)!.connection_successful
102
: AppLocalizations.of(context)!.connection_failed;
103
});
104
+ if (isConnected) {
105
+ invalidateAvailableModelsCache(testServer.id);
106
+ }
107
} catch (e) {
108
setState(() {
109
_testResult = AppLocalizations.of(
@@ -150,6 +154,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
154
} else {
155
await ref.read(serversProvider.notifier).addServer(server);
156
}
157
+ invalidateAvailableModelsCache(server.id);
158
159
if (mounted) {
160
final l10n = AppLocalizations.of(context)!;
@@ -200,10 +205,8 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
205
if (value == null || value.trim().isEmpty) {
206
return l10n.host_required;
207
}
203
- // Clean any protocol prefixes (http:// or https://) for validation
204
- final cleaned = value.trim().replaceFirst(RegExp(r'^https?://'), '');
205
- final hostPattern = RegExp(r'^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$');
206
- if (!hostPattern.hasMatch(cleaned)) {
208
+ final parsed = parseServerAddressInput(value);
209
+ if (parsed == null) {
210
return l10n.host_valid;
211
}
212
return null;
@@ -395,6 +398,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
398
keyboardType: TextInputType.text,
399
textInputAction: TextInputAction.next,
400
),
401
+ HttpsSchemeHint(controller: _hostController),
402
const SizedBox(height: 12),
403
TextFormField(
404
controller: _portController,
@@ -704,7 +708,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
708
final l10n = AppLocalizations.of(context)!;
709
return switch (_selectedType) {
710
ServerType.lmStudio => l10n.server_type_lm_studio,
707
- ServerType.openAICompatible => 'OpenAI',
711
+ ServerType.openAICompatible => l10n.server_type_openai_display,
712
ServerType.ollama => l10n.server_type_ollama,
713
ServerType.openRouter => l10n.server_type_openrouter,
714
ServerType.onDevice => l10n.server_type_on_device_display,
lib/features/servers/views/components/https_scheme_hint.dart
new
+81
@@ -0,0 +1,81 @@
1
+import 'package:flutter/material.dart';
2
+
3
+import '../../../../l10n/app_localizations.dart';
4
+import '../../data/models/server.dart';
5
+
6
+class HttpsSchemeHint extends StatelessWidget {
7
+ final TextEditingController controller;
8
+
9
+ const HttpsSchemeHint({
10
+ super.key,
11
+ required this.controller,
12
+ });
13
+
14
+ @override
15
+ Widget build(BuildContext context) {
16
+ final theme = Theme.of(context);
17
+ final colorScheme = theme.colorScheme;
18
+ final l10n = AppLocalizations.of(context)!;
19
+
20
+ return ValueListenableBuilder<TextEditingValue>(
21
+ valueListenable: controller,
22
+ builder: (context, value, child) {
23
+ if (!isHttpsAddressInput(value.text)) {
24
+ return const SizedBox.shrink();
25
+ }
26
+
27
+ return AnimatedSize(
28
+ duration: const Duration(milliseconds: 160),
29
+ curve: Curves.easeOut,
30
+ alignment: Alignment.topCenter,
31
+ child: Container(
32
+ margin: const EdgeInsets.only(top: 12),
33
+ padding: const EdgeInsets.all(12),
34
+ decoration: BoxDecoration(
35
+ color: colorScheme.tertiaryContainer.withValues(alpha: 0.35),
36
+ borderRadius: BorderRadius.circular(14),
37
+ border: Border.all(
38
+ color: colorScheme.tertiary.withValues(alpha: 0.18),
39
+ ),
40
+ ),
41
+ child: Row(
42
+ crossAxisAlignment: CrossAxisAlignment.start,
43
+ children: [
44
+ Icon(
45
+ Icons.lock_outline_rounded,
46
+ size: 18,
47
+ color: colorScheme.tertiary,
48
+ ),
49
+ const SizedBox(width: 10),
50
+ Expanded(
51
+ child: Column(
52
+ crossAxisAlignment: CrossAxisAlignment.start,
53
+ children: [
54
+ Text(
55
+ l10n.https_requires_ssl,
56
+ style: theme.textTheme.labelLarge?.copyWith(
57
+ fontWeight: FontWeight.w700,
58
+ color: colorScheme.onTertiaryContainer,
59
+ ),
60
+ ),
61
+ const SizedBox(height: 2),
62
+ Text(
63
+ l10n.most_local_setups_use_http,
64
+ style: theme.textTheme.bodySmall?.copyWith(
65
+ color: colorScheme.onTertiaryContainer.withValues(
66
+ alpha: 0.82,
67
+ ),
68
+ height: 1.25,
69
+ ),
70
+ ),
71
+ ],
72
+ ),
73
+ ),
74
+ ],
75
+ ),
76
+ ),
77
+ );
78
+ },
79
+ );
80
+ }
81
+}
lib/features/servers/views/components/server_card.dart
+1
-1
@@ -53,7 +53,7 @@ class ServerCard extends StatelessWidget {
53
if (server.type == ServerType.onDevice) {
54
return l10n.server_address_on_device;
55
}
56
- return l10n.server_address_format(server.host, server.port.toString());
56
+ return server.displayAddress;
57
}
58
59
@override
lib/features/servers/views/components/server_type_selector.dart
+1
-1
@@ -28,7 +28,7 @@ class ServerTypeSelector extends StatelessWidget {
28
),
29
(
30
ServerType.openAICompatible,
31
- 'OpenAI',
31
+ l10n.server_type_openai_display,
32
HugeIcons.strokeRoundedApi,
33
Colors.green,
34
),
lib/l10n/app_ar.arb
+12
@@ -213,6 +213,18 @@
213
214
"star_on_github": "نجمة على GitHub",
215
216
+ "add_more": "أضف المزيد",
217
+
218
+ "on_github": "على GitHub",
219
+
220
+ "could_not_open_github": "تعذر فتح GitHub.",
221
+
222
+ "openai_compatible_api": "واجهة متوافقة مع OpenAI",
223
+
224
+ "https_requires_ssl": "يتطلب HTTPS استخدام SSL",
225
+
226
+ "most_local_setups_use_http": "تستخدم معظم الإعدادات المحلية http://",
227
+
228
"settings_title": "الإعدادات",
229
230
"settings_appearance": "المظهر",
lib/l10n/app_bn.arb
+6
@@ -254,6 +254,12 @@
254
"online": "অনলাইন",
255
"open_source": "ওপেন সোর্স",
256
"open_source_desc": "LocalMind একটি ওপেন সোর্স প্রকল্প। গিটহাবে (GitHub) আমাদের অগ্রগতি অনুসরণ করুন বা অবদান রাখুন।",
257
+ "add_more": "আরও যোগ করুন",
258
+ "on_github": "GitHub-এ",
259
+ "could_not_open_github": "GitHub খুলতে পারিনি।",
260
+ "openai_compatible_api": "OpenAI-সামঞ্জস্যপূর্ণ API",
261
+ "https_requires_ssl": "HTTPS-এর জন্য SSL প্রয়োজন",
262
+ "most_local_setups_use_http": "বেশিরভাগ লোকাল সেটআপে http:// ব্যবহৃত হয়",
263
"options_tooltip": "বিকল্পসমূহ",
264
"parameters_tab": "প্যারামিটারসমূহ",
265
"pause": "বিরতি দিন",
lib/l10n/app_en.arb
+30
@@ -615,6 +615,21 @@
615
"description": "Button to star repo on GitHub"
616
},
617
618
+ "add_more": "Add more",
619
+ "@add_more": {
620
+ "description": "Label for the decorative GitHub tile on onboarding"
621
+ },
622
+
623
+ "on_github": "on GitHub",
624
+ "@on_github": {
625
+ "description": "Secondary label for the decorative GitHub tile on onboarding"
626
+ },
627
+
628
+ "could_not_open_github": "Could not open GitHub.",
629
+ "@could_not_open_github": {
630
+ "description": "Snackbar text shown when opening the GitHub repo fails"
631
+ },
632
+
633
"settings_title": "Settings",
634
"@settings_title": {
635
"description": "Settings screen title"
@@ -1344,6 +1359,21 @@
1359
"description": "Onboarding server connection description"
1360
},
1361
1362
+ "openai_compatible_api": "OpenAI-compatible API",
1363
+ "@openai_compatible_api": {
1364
+ "description": "Subtitle for the OpenAI compatible server card in onboarding"
1365
+ },
1366
+
1367
+ "https_requires_ssl": "HTTPS requires SSL",
1368
+ "@https_requires_ssl": {
1369
+ "description": "Hint shown when a user enters an https URL for a local server"
1370
+ },
1371
+
1372
+ "most_local_setups_use_http": "Most local setups use http://",
1373
+ "@most_local_setups_use_http": {
1374
+ "description": "Hint shown with the HTTPS warning chip"
1375
+ },
1376
+
1377
"onboarding_welcome": "Welcome to LocalMind",
1378
"@onboarding_welcome": {
1379
"description": "Onboarding welcome message"
lib/l10n/app_es.arb
+6
@@ -254,6 +254,12 @@
254
"online": "En línea",
255
"open_source": "Código abierto",
256
"open_source_desc": "LocalMind es de código abierto. Siga nuestro progreso o contribuya en GitHub.",
257
+ "add_more": "Añadir más",
258
+ "on_github": "en GitHub",
259
+ "could_not_open_github": "No se pudo abrir GitHub.",
260
+ "openai_compatible_api": "API compatible con OpenAI",
261
+ "https_requires_ssl": "HTTPS requiere SSL",
262
+ "most_local_setups_use_http": "La mayoría de las configuraciones locales usan http://",
263
"options_tooltip": "Opciones",
264
"parameters_tab": "Parámetros",
265
"pause": "Pausar",
lib/l10n/app_hi.arb
+6
@@ -254,6 +254,12 @@
254
"online": "ऑनलाइन",
255
"open_source": "ओपन सोर्स",
256
"open_source_desc": "LocalMind ओपन सोर्स है। गिटहब (GitHub) पर हमारी प्रगति का अनुसरण करें या योगदान दें।",
257
+ "add_more": "और जोड़ें",
258
+ "on_github": "GitHub पर",
259
+ "could_not_open_github": "GitHub नहीं खोल सका।",
260
+ "openai_compatible_api": "OpenAI-संगत API",
261
+ "https_requires_ssl": "HTTPS के लिए SSL आवश्यक है",
262
+ "most_local_setups_use_http": "ज़्यादातर स्थानीय सेटअप में http:// इस्तेमाल होता है",
263
"options_tooltip": "विकल्प",
264
"parameters_tab": "पैरामीटर",
265
"pause": "विराम दें",
lib/l10n/app_it.arb
+6
@@ -108,6 +108,12 @@
108
"open_source": "Open Source",
109
"open_source_desc": "LocalMind è open source. Segui i nostri progressi o contribuisci su GitHub.",
110
"star_on_github": "Metti in evidenza su GitHub",
111
+ "add_more": "Aggiungi altro",
112
+ "on_github": "su GitHub",
113
+ "could_not_open_github": "Impossibile aprire GitHub.",
114
+ "openai_compatible_api": "API compatibile con OpenAI",
115
+ "https_requires_ssl": "HTTPS richiede SSL",
116
+ "most_local_setups_use_http": "La maggior parte delle configurazioni locali usa http://",
117
"settings_title": "Impostazioni",
118
"settings_appearance": "Aspetto",
119
"settings_language": "Lingua",
lib/l10n/app_ja.arb
+7
-1
@@ -106,6 +106,12 @@
106
"open_source": "オープンソース",
107
"open_source_desc": "LocalMind はオープンソースです。GitHub で開発状況の確認や貢献ができます。",
108
"star_on_github": "GitHub でスターを付ける",
109
+ "add_more": "さらに追加",
110
+ "on_github": "GitHub で",
111
+ "could_not_open_github": "GitHub を開けませんでした。",
112
+ "openai_compatible_api": "OpenAI 互換 API",
113
+ "https_requires_ssl": "HTTPS には SSL が必要です",
114
+ "most_local_setups_use_http": "ほとんどのローカル設定では http:// を使います",
115
"settings_title": "設定",
116
"settings_appearance": "外観",
117
"settings_language": "言語",
@@ -498,4 +504,4 @@
504
"select_model_hint": "モデルを選択してチャットを開始",
505
"test_notification_title": "テスト通知",
506
"test_notification_body": "これはモデルのダウンロード進捗用のテスト通知です。"
501
-}
\ No newline at end of file
507
+}
lib/l10n/app_localizations.dart
+36
@@ -746,6 +746,24 @@ abstract class AppLocalizations {
746
/// **'Star on GitHub'**
747
String get star_on_github;
748
749
+ /// Label for the decorative GitHub tile on onboarding
750
+ ///
751
+ /// In en, this message translates to:
752
+ /// **'Add more'**
753
+ String get add_more;
754
+
755
+ /// Secondary label for the decorative GitHub tile on onboarding
756
+ ///
757
+ /// In en, this message translates to:
758
+ /// **'on GitHub'**
759
+ String get on_github;
760
+
761
+ /// Snackbar text shown when opening the GitHub repo fails
762
+ ///
763
+ /// In en, this message translates to:
764
+ /// **'Could not open GitHub.'**
765
+ String get could_not_open_github;
766
+
767
/// Settings screen title
768
///
769
/// In en, this message translates to:
@@ -1568,6 +1586,24 @@ abstract class AppLocalizations {
1586
/// **'Connect to LM Studio, Ollama, or\nOpenRouter to start your private AI\nexperience.'**
1587
String get onboarding_connect_desc;
1588
1589
+ /// Subtitle for the OpenAI compatible server card in onboarding
1590
+ ///
1591
+ /// In en, this message translates to:
1592
+ /// **'OpenAI-compatible API'**
1593
+ String get openai_compatible_api;
1594
+
1595
+ /// Hint shown when a user enters an https URL for a local server
1596
+ ///
1597
+ /// In en, this message translates to:
1598
+ /// **'HTTPS requires SSL'**
1599
+ String get https_requires_ssl;
1600
+
1601
+ /// Hint shown with the HTTPS warning chip
1602
+ ///
1603
+ /// In en, this message translates to:
1604
+ /// **'Most local setups use http://'**
1605
+ String get most_local_setups_use_http;
1606
+
1607
/// Onboarding welcome message
1608
///
1609
/// In en, this message translates to:
lib/l10n/app_localizations_ar.dart
+19
@@ -353,6 +353,15 @@ class AppLocalizationsAr extends AppLocalizations {
353
@override
354
String get star_on_github => 'نجمة على GitHub';
355
356
+ @override
357
+ String get add_more => 'أضف المزيد';
358
+
359
+ @override
360
+ String get on_github => 'على GitHub';
361
+
362
+ @override
363
+ String get could_not_open_github => 'تعذر فتح GitHub.';
364
+
365
@override
366
String get settings_title => 'الإعدادات';
367
@@ -784,6 +793,16 @@ class AppLocalizationsAr extends AppLocalizations {
793
String get onboarding_connect_desc =>
794
'اتصل بـ LM Studio أو Ollama أو\nOpenRouter لبدء تجربة الذكاء\nالاصطناعي الخاصة بك.';
795
796
+ @override
797
+ String get openai_compatible_api => 'واجهة متوافقة مع OpenAI';
798
+
799
+ @override
800
+ String get https_requires_ssl => 'يتطلب HTTPS استخدام SSL';
801
+
802
+ @override
803
+ String get most_local_setups_use_http =>
804
+ 'تستخدم معظم الإعدادات المحلية http://';
805
+
806
@override
807
String get onboarding_welcome => 'مرحباً بك في LocalMind';
808
lib/l10n/app_localizations_bn.dart
+19
@@ -354,6 +354,15 @@ class AppLocalizationsBn extends AppLocalizations {
354
@override
355
String get star_on_github => 'গিটহাবে স্টার দিন';
356
357
+ @override
358
+ String get add_more => 'আরও যোগ করুন';
359
+
360
+ @override
361
+ String get on_github => 'GitHub-এ';
362
+
363
+ @override
364
+ String get could_not_open_github => 'GitHub খুলতে পারিনি।';
365
+
366
@override
367
String get settings_title => 'সেটিংস';
368
@@ -788,6 +797,16 @@ class AppLocalizationsBn extends AppLocalizations {
797
String get onboarding_connect_desc =>
798
'আপনার নিজস্ব ব্যক্তিগত এআই অভিজ্ঞতা শুরু করতে\nLM Studio, Ollama, অথবা OpenRouter-এর সাথে\nসংযোগ করুন।';
799
800
+ @override
801
+ String get openai_compatible_api => 'OpenAI-সামঞ্জস্যপূর্ণ API';
802
+
803
+ @override
804
+ String get https_requires_ssl => 'HTTPS-এর জন্য SSL প্রয়োজন';
805
+
806
+ @override
807
+ String get most_local_setups_use_http =>
808
+ 'বেশিরভাগ লোকাল সেটআপে http:// ব্যবহৃত হয়';
809
+
810
@override
811
String get onboarding_welcome => 'LocalMind-এ স্বাগতম';
812
lib/l10n/app_localizations_en.dart
+18
@@ -353,6 +353,15 @@ class AppLocalizationsEn extends AppLocalizations {
353
@override
354
String get star_on_github => 'Star on GitHub';
355
356
+ @override
357
+ String get add_more => 'Add more';
358
+
359
+ @override
360
+ String get on_github => 'on GitHub';
361
+
362
+ @override
363
+ String get could_not_open_github => 'Could not open GitHub.';
364
+
365
@override
366
String get settings_title => 'Settings';
367
@@ -785,6 +794,15 @@ class AppLocalizationsEn extends AppLocalizations {
794
String get onboarding_connect_desc =>
795
'Connect to LM Studio, Ollama, or\nOpenRouter to start your private AI\nexperience.';
796
797
+ @override
798
+ String get openai_compatible_api => 'OpenAI-compatible API';
799
+
800
+ @override
801
+ String get https_requires_ssl => 'HTTPS requires SSL';
802
+
803
+ @override
804
+ String get most_local_setups_use_http => 'Most local setups use http://';
805
+
806
@override
807
String get onboarding_welcome => 'Welcome to LocalMind';
808
lib/l10n/app_localizations_es.dart
+19
@@ -353,6 +353,15 @@ class AppLocalizationsEs extends AppLocalizations {
353
@override
354
String get star_on_github => 'Dar estrella en GitHub';
355
356
+ @override
357
+ String get add_more => 'Añadir más';
358
+
359
+ @override
360
+ String get on_github => 'en GitHub';
361
+
362
+ @override
363
+ String get could_not_open_github => 'No se pudo abrir GitHub.';
364
+
365
@override
366
String get settings_title => 'Ajustes';
367
@@ -788,6 +797,16 @@ class AppLocalizationsEs extends AppLocalizations {
797
String get onboarding_connect_desc =>
798
'Conéctese a LM Studio, Ollama o\nOpenRouter para comenzar su experiencia\nprivada de IA.';
799
800
+ @override
801
+ String get openai_compatible_api => 'API compatible con OpenAI';
802
+
803
+ @override
804
+ String get https_requires_ssl => 'HTTPS requiere SSL';
805
+
806
+ @override
807
+ String get most_local_setups_use_http =>
808
+ 'La mayoría de las configuraciones locales usan http://';
809
+
810
@override
811
String get onboarding_welcome => 'Bienvenido a LocalMind';
812
lib/l10n/app_localizations_hi.dart
+19
@@ -354,6 +354,15 @@ class AppLocalizationsHi extends AppLocalizations {
354
@override
355
String get star_on_github => 'GitHub पर स्टार दें';
356
357
+ @override
358
+ String get add_more => 'और जोड़ें';
359
+
360
+ @override
361
+ String get on_github => 'GitHub पर';
362
+
363
+ @override
364
+ String get could_not_open_github => 'GitHub नहीं खोल सका।';
365
+
366
@override
367
String get settings_title => 'सेटिंग्स';
368
@@ -787,6 +796,16 @@ class AppLocalizationsHi extends AppLocalizations {
796
String get onboarding_connect_desc =>
797
'अपना निजी AI अनुभव शुरू करने के लिए\nLM Studio, Ollama, या OpenRouter से\nकनेक्ट करें।';
798
799
+ @override
800
+ String get openai_compatible_api => 'OpenAI-संगत API';
801
+
802
+ @override
803
+ String get https_requires_ssl => 'HTTPS के लिए SSL आवश्यक है';
804
+
805
+ @override
806
+ String get most_local_setups_use_http =>
807
+ 'ज़्यादातर स्थानीय सेटअप में http:// इस्तेमाल होता है';
808
+
809
@override
810
String get onboarding_welcome => 'LocalMind में आपका स्वागत है';
811
lib/l10n/app_localizations_it.dart
+19
@@ -355,6 +355,15 @@ class AppLocalizationsIt extends AppLocalizations {
355
@override
356
String get star_on_github => 'Metti in evidenza su GitHub';
357
358
+ @override
359
+ String get add_more => 'Aggiungi altro';
360
+
361
+ @override
362
+ String get on_github => 'su GitHub';
363
+
364
+ @override
365
+ String get could_not_open_github => 'Impossibile aprire GitHub.';
366
+
367
@override
368
String get settings_title => 'Impostazioni';
369
@@ -788,6 +797,16 @@ class AppLocalizationsIt extends AppLocalizations {
797
String get onboarding_connect_desc =>
798
'Connettiti a LM Studio, Ollama o\nOpenRouter per avviare la tua IA privata\nesperienza.';
799
800
+ @override
801
+ String get openai_compatible_api => 'API compatibile con OpenAI';
802
+
803
+ @override
804
+ String get https_requires_ssl => 'HTTPS richiede SSL';
805
+
806
+ @override
807
+ String get most_local_setups_use_http =>
808
+ 'La maggior parte delle configurazioni locali usa http://';
809
+
810
@override
811
String get onboarding_welcome => 'Benvenuto in LocalMind';
812
lib/l10n/app_localizations_ja.dart
+18
@@ -352,6 +352,15 @@ class AppLocalizationsJa extends AppLocalizations {
352
@override
353
String get star_on_github => 'GitHub でスターを付ける';
354
355
+ @override
356
+ String get add_more => 'さらに追加';
357
+
358
+ @override
359
+ String get on_github => 'GitHub で';
360
+
361
+ @override
362
+ String get could_not_open_github => 'GitHub を開けませんでした。';
363
+
364
@override
365
String get settings_title => '設定';
366
@@ -777,6 +786,15 @@ class AppLocalizationsJa extends AppLocalizations {
786
String get onboarding_connect_desc =>
787
'LM Studio、Ollama、またはOpenRouterに接続して、プライベートAI体験を開始しましょう。';
788
789
+ @override
790
+ String get openai_compatible_api => 'OpenAI 互換 API';
791
+
792
+ @override
793
+ String get https_requires_ssl => 'HTTPS には SSL が必要です';
794
+
795
+ @override
796
+ String get most_local_setups_use_http => 'ほとんどのローカル設定では http:// を使います';
797
+
798
@override
799
String get onboarding_welcome => 'Welcome to LocalMind';
800
lib/l10n/app_localizations_zh.dart
+18
@@ -350,6 +350,15 @@ class AppLocalizationsZh extends AppLocalizations {
350
@override
351
String get star_on_github => '在 GitHub 上点亮 Star';
352
353
+ @override
354
+ String get add_more => '添加更多';
355
+
356
+ @override
357
+ String get on_github => '在 GitHub 上';
358
+
359
+ @override
360
+ String get could_not_open_github => '无法打开 GitHub。';
361
+
362
@override
363
String get settings_title => '设置';
364
@@ -774,6 +783,15 @@ class AppLocalizationsZh extends AppLocalizations {
783
String get onboarding_connect_desc =>
784
'连接到 LM Studio、Ollama 或\nOpenRouter,开启您的私有 AI\n体验。';
785
786
+ @override
787
+ String get openai_compatible_api => 'OpenAI 兼容 API';
788
+
789
+ @override
790
+ String get https_requires_ssl => 'HTTPS 需要 SSL';
791
+
792
+ @override
793
+ String get most_local_setups_use_http => '大多数本地配置使用 http://';
794
+
795
@override
796
String get onboarding_welcome => '欢迎使用 LocalMind';
797
lib/l10n/app_zh.arb
+6
@@ -254,6 +254,12 @@
254
"online": "在线",
255
"open_source": "开源",
256
"open_source_desc": "LocalMind 是开源项目。可在 GitHub 上关注我们的进展或参与贡献。",
257
+ "add_more": "添加更多",
258
+ "on_github": "在 GitHub 上",
259
+ "could_not_open_github": "无法打开 GitHub。",
260
+ "openai_compatible_api": "OpenAI 兼容 API",
261
+ "https_requires_ssl": "HTTPS 需要 SSL",
262
+ "most_local_setups_use_http": "大多数本地配置使用 http://",
263
"options_tooltip": "选项",
264
"parameters_tab": "参数",
265
"pause": "暂停",
pubspec.yaml
+1
-1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
# In Windows, build-name is used as the major, minor, and patch parts
18
# of the product and file versions while build-number is used as the build suffix.
19
-version: 1.3.7+39
19
+version: 1.3.8+40
20
21
environment:
22
sdk: ^3.11.0
test/features/servers/server_address_test.dart
new
+66
@@ -0,0 +1,66 @@
1
+import 'package:flutter_test/flutter_test.dart';
2
+import 'package:localmind/core/models/enums.dart';
3
+import 'package:localmind/features/servers/data/models/server.dart';
4
+
5
+void main() {
6
+ group('Server address parsing', () {
7
+ test('builds baseUrl for a bare host and port', () {
8
+ final server = Server(
9
+ id: '1',
10
+ name: 'Local',
11
+ type: ServerType.openAICompatible,
12
+ host: 'localhost',
13
+ port: 8080,
14
+ createdAt: DateTime(2026, 1, 1),
15
+ lastConnectedAt: DateTime(2026, 1, 1),
16
+ );
17
+
18
+ expect(server.baseUrl, 'http://localhost:8080');
19
+ });
20
+
21
+ test('preserves an explicit http URL without doubling the port', () {
22
+ final server = Server(
23
+ id: '1',
24
+ name: 'Local',
25
+ type: ServerType.openAICompatible,
26
+ host: 'http://localhost:8080',
27
+ port: 1234,
28
+ createdAt: DateTime(2026, 1, 1),
29
+ lastConnectedAt: DateTime(2026, 1, 1),
30
+ );
31
+
32
+ expect(server.baseUrl, 'http://localhost:8080');
33
+ expect(server.displayAddress, 'http://localhost:8080');
34
+ });
35
+
36
+ test('accepts https URLs and keeps the configured port when missing', () {
37
+ final server = Server(
38
+ id: '1',
39
+ name: 'Secure',
40
+ type: ServerType.openAICompatible,
41
+ host: 'https://localhost',
42
+ port: 8443,
43
+ createdAt: DateTime(2026, 1, 1),
44
+ lastConnectedAt: DateTime(2026, 1, 1),
45
+ );
46
+
47
+ expect(server.baseUrl, 'https://localhost:8443');
48
+ expect(server.displayAddress, 'https://localhost:8443');
49
+ });
50
+
51
+ test('keeps a host that already includes a port', () {
52
+ final server = Server(
53
+ id: '1',
54
+ name: 'Local',
55
+ type: ServerType.openAICompatible,
56
+ host: 'localhost:8080',
57
+ port: 1234,
58
+ createdAt: DateTime(2026, 1, 1),
59
+ lastConnectedAt: DateTime(2026, 1, 1),
60
+ );
61
+
62
+ expect(server.baseUrl, 'http://localhost:8080');
63
+ expect(server.displayAddress, 'localhost:8080');
64
+ });
65
+ });
66
+}
test/features/servers/server_setup_ui_test.dart
new
+62
@@ -0,0 +1,62 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:flutter_test/flutter_test.dart';
4
+import 'package:localmind/core/theme/app_theme.dart';
5
+import 'package:localmind/features/onboarding/screens/onboarding_server_type_screen.dart';
6
+import 'package:localmind/features/servers/views/components/https_scheme_hint.dart';
7
+import 'package:localmind/l10n/app_localizations.dart';
8
+import 'package:shadcn_ui/shadcn_ui.dart';
9
+
10
+Widget _wrapWithApp(Widget child) {
11
+ return ProviderScope(
12
+ child: ShadTheme(
13
+ data: AppTheme.lightShadTheme,
14
+ child: MaterialApp(
15
+ locale: const Locale('en'),
16
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
17
+ supportedLocales: AppLocalizations.supportedLocales,
18
+ home: child,
19
+ ),
20
+ ),
21
+ );
22
+}
23
+
24
+void main() {
25
+ testWidgets('shows OpenAI Compatible on the onboarding server picker',
26
+ (tester) async {
27
+ await tester.pumpWidget(_wrapWithApp(const OnboardingServerTypeScreen()));
28
+ await tester.pumpAndSettle();
29
+
30
+ expect(find.text('OpenAI Compatible'), findsWidgets);
31
+ expect(find.text('Add more'), findsOneWidget);
32
+ expect(find.text('on GitHub'), findsOneWidget);
33
+ });
34
+
35
+ testWidgets('shows the HTTPS hint only for https hosts', (tester) async {
36
+ final controller = TextEditingController();
37
+ addTearDown(controller.dispose);
38
+
39
+ await tester.pumpWidget(
40
+ _wrapWithApp(
41
+ Material(
42
+ child: HttpsSchemeHint(controller: controller),
43
+ ),
44
+ ),
45
+ );
46
+
47
+ expect(find.text('HTTPS requires SSL'), findsNothing);
48
+ expect(find.text('Most local setups use http://'), findsNothing);
49
+
50
+ controller.text = 'https://localhost:1234';
51
+ await tester.pump();
52
+
53
+ expect(find.text('HTTPS requires SSL'), findsOneWidget);
54
+ expect(find.text('Most local setups use http://'), findsOneWidget);
55
+
56
+ controller.text = 'http://localhost:1234';
57
+ await tester.pump();
58
+
59
+ expect(find.text('HTTPS requires SSL'), findsNothing);
60
+ expect(find.text('Most local setups use http://'), findsNothing);
61
+ });
62
+}