@tduncan / Cortana / commits / 46c1245

feat: add server address validation, HTTPS scheme hints, and localization updates

feat: add server address validation, HTTPS scheme hints, and localization updates

abdulmominsakib committed Jun 9, 2026 at 10:48 UTC 46c1245e79e5ec7e9b6417896580297dcff8c9d6
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 +}