git.iko.soy/opencode-android
on master help sign in
Simplify server-add UI: fold probe into Connect action, reorder form, collapse security
017cb9aiko
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+94−94
@@ -19,6 +19,8 @@ import androidx.compose.foundation.text.KeyboardOptions
19import androidx.compose.foundation.verticalScroll
19import androidx.compose.foundation.verticalScroll
20import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.Icons
21import androidx.compose.material.icons.automirrored.filled.ArrowBack
21import androidx.compose.material.icons.automirrored.filled.ArrowBack
22+import androidx.compose.material.icons.filled.ExpandLess
23+import androidx.compose.material.icons.filled.ExpandMore
22import androidx.compose.material.icons.filled.Visibility
24import androidx.compose.material.icons.filled.Visibility
23import androidx.compose.material.icons.filled.VisibilityOff
25import androidx.compose.material.icons.filled.VisibilityOff
24import androidx.compose.material.icons.filled.Wifi
26import androidx.compose.material.icons.filled.Wifi
@@ -151,9 +153,9 @@ private fun ServerEditForm(
151 onDone: () -> Unit,
153 onDone: () -> Unit,
152) {
154) {
153 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
155 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
154− // canSave parses the base URL with java.net.URI (which throws — and fills a stack trace —
156+ // canSave parses the base URL with OkHttp's parser (which allocates on the common
155− // on the common half-typed URL). Memoize on baseUrl so it runs once per edit instead of
157+ // half-typed URL). Memoize on baseUrl so it runs once per edit instead of being
156− // being re-parsed at each of the enabled= call sites on every keystroke's recomposition.
158+ // re-parsed at each of the enabled= call sites on every keystroke's recomposition.
157 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
159 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
158 Column(
160 Column(
159 modifier = Modifier
161 modifier = Modifier
@@ -164,17 +166,10 @@ private fun ServerEditForm(
164 .verticalScroll(rememberScrollState()),
166 .verticalScroll(rememberScrollState()),
165 verticalArrangement = Arrangement.spacedBy(12.dp),
167 verticalArrangement = Arrangement.spacedBy(12.dp),
166 ) {
168 ) {
167− OutlinedTextField(
169+ // Base URL is the only required field, so it leads the form and takes focus.
168− value = state.label,
169− onValueChange = { v -> vm.update { it.copy(label = v) } },
170− label = { Text(stringResource(R.string.label_optional)) },
171− singleLine = true,
172− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
173− modifier = Modifier.fillMaxWidth().testTag("server_label"),
174− )
175 OutlinedTextField(
170 OutlinedTextField(
176 value = state.baseUrl,
171 value = state.baseUrl,
177− onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null, probeReachable = null) } },
172+ onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
178 label = { Text(stringResource(R.string.base_url)) },
173 label = { Text(stringResource(R.string.base_url)) },
179 placeholder = { Text(stringResource(R.string.base_url_hint)) },
174 placeholder = { Text(stringResource(R.string.base_url_hint)) },
180 singleLine = true,
175 singleLine = true,
@@ -186,7 +181,7 @@ private fun ServerEditForm(
186 val suggestion = suggestUrlScheme(state.baseUrl)
181 val suggestion = suggestUrlScheme(state.baseUrl)
187 if (suggestion != null) {
182 if (suggestion != null) {
188 TextButton(
183 TextButton(
189− onClick = { vm.update { it.copy(baseUrl = suggestion, probeReachable = null) } },
184+ onClick = { vm.update { it.copy(baseUrl = suggestion) } },
190 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
185 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
191 ) {
186 ) {
192 Text(stringResource(R.string.suggest_scheme))
187 Text(stringResource(R.string.suggest_scheme))
@@ -199,43 +194,15 @@ private fun ServerEditForm(
199 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
194 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
200 modifier = Modifier.fillMaxWidth().testTag("server_url"),
195 modifier = Modifier.fillMaxWidth().testTag("server_url"),
201 )
196 )
202− OutlinedButton(
197+ // Auto-expand LAN discovery only on a fresh form (no URL yet) so tapping a found
203− onClick = { vm.probe() },
198+ // server is the primary path; on edit the populated URL keeps it (and its multicast)
204− enabled = canSave && !state.probing && !state.saving,
199+ // collapsed.
205− modifier = Modifier.fillMaxWidth().testTag("server_probe"),
206− ) {
207− if (state.probing) {
208− CircularProgressIndicator(
209− modifier = Modifier.size(20.dp),
210− strokeWidth = 2.dp,
211− )
212− Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
213− } else {
214− Text(stringResource(R.string.check_connectivity))
215− }
216− }
217− // Positive feedback when the probe found the server reachable without auth, so
218− // the user knows the connection works before saving (closes the loop that the
219− // prior "silently does nothing" Reachable path left open).
220− if (state.probeReachable == true && !state.authFieldsVisible) {
221− Text(
222− stringResource(R.string.server_reachable),
223− style = MaterialTheme.typography.bodySmall,
224− color = MaterialTheme.colorScheme.primary,
225− modifier = Modifier.fillMaxWidth(),
226− )
227− }
228 DiscoverySection(
200 DiscoverySection(
201+ initiallyExpanded = state.baseUrl.isBlank(),
229− onPick = { url -> vm.update { it.copy(baseUrl = url, error = null, probeReachable = null) } },
202+ onPick = { url -> vm.update { it.copy(baseUrl = url) } },
230 )
203 )
231− if (!state.authFieldsVisible) {
204+ // Auth fields appear only once a connect attempt learns the server requires them
232− Text(
205+ // (or an authed profile is loaded) — the common no-auth server never sees them.
233− stringResource(R.string.auth_help),
234− style = MaterialTheme.typography.bodySmall,
235− color = MaterialTheme.colorScheme.onSurfaceVariant,
236− modifier = Modifier.fillMaxWidth(),
237− )
238− }
239 AnimatedVisibility(visible = state.authFieldsVisible) {
206 AnimatedVisibility(visible = state.authFieldsVisible) {
240 AuthFields(
207 AuthFields(
241 state = state,
208 state = state,
@@ -243,44 +210,41 @@ private fun ServerEditForm(
243 onTogglePassword = onTogglePassword,
210 onTogglePassword = onTogglePassword,
244 onUpdate = vm::update,
211 onUpdate = vm::update,
245 onTestCredentials = vm::testCredentials,
212 onTestCredentials = vm::testCredentials,
246− onImeDone = { if (canSave && !state.saving) vm.saveAndConnect(onDone) },
213+ onImeDone = { if (canSave && !state.saving) vm.connect(onDone) },
247 )
214 )
248 }
215 }
249− SecurityFields(state = state, onUpdate = vm::update)
216+ // Single primary action: probe-then-save-and-connect. Replaces the former
250− Row(
217+ // Check-connectivity / Save / Save-&-connect trio.
218+ Button(
219+ onClick = {
220+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
221+ vm.connect(onDone)
222+ },
223+ enabled = canSave && !state.saving,
251− modifier = Modifier.fillMaxWidth(),
224+ modifier = Modifier.fillMaxWidth().testTag("server_connect"),
252− horizontalArrangement = Arrangement.spacedBy(12.dp),
253 ) {
225 ) {
254− OutlinedButton(
255− onClick = {
256− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
257− vm.save(onDone)
258− },
259− enabled = canSave && !state.saving,
260− modifier = Modifier.weight(1f),
261− ) {
262− if (state.saving) {
226+ if (state.saving) {
263− CircularProgressIndicator(
227+ CircularProgressIndicator(
264− modifier = Modifier.size(20.dp),
228+ modifier = Modifier.size(20.dp),
265− color = MaterialTheme.colorScheme.primary,
229+ color = MaterialTheme.colorScheme.onPrimary,
266− strokeWidth = 2.dp,
230+ strokeWidth = 2.dp,
267− )
231+ )
268− Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
232+ Text(stringResource(R.string.connecting), modifier = Modifier.padding(start = 8.dp))
269− } else {
233+ } else {
270− Text(stringResource(R.string.save))
271− }
272− }
273− Button(
274− onClick = {
275− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
276− vm.saveAndConnect(onDone)
277− },
278− enabled = canSave && !state.saving,
279− modifier = Modifier.weight(1f),
280− ) {
281− Text(stringResource(R.string.save_and_connect))
234+ Text(stringResource(R.string.connect))
282 }
235 }
283 }
236 }
237+ // Secondary, low-emphasis: persist without connecting (offline setup).
238+ TextButton(
239+ onClick = {
240+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
241+ vm.save(onDone)
242+ },
243+ enabled = canSave && !state.saving,
244+ modifier = Modifier.fillMaxWidth().testTag("server_save"),
245+ ) {
246+ Text(stringResource(R.string.save_without_connecting))
247+ }
284 state.error?.let { message ->
248 state.error?.let { message ->
285 Text(
249 Text(
286 message,
250 message,
@@ -289,6 +253,17 @@ private fun ServerEditForm(
289 modifier = Modifier.fillMaxWidth(),
253 modifier = Modifier.fillMaxWidth(),
290 )
254 )
291 }
255 }
256+ // De-emphasized optional label at the bottom: displayLabel already falls back to the
257+ // URL, so most servers don't need one.
258+ OutlinedTextField(
259+ value = state.label,
260+ onValueChange = { v -> vm.update { it.copy(label = v) } },
261+ label = { Text(stringResource(R.string.label_optional)) },
262+ singleLine = true,
263+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
264+ modifier = Modifier.fillMaxWidth().testTag("server_label"),
265+ )
266+ AdvancedSection(state = state, onUpdate = vm::update)
292 }
267 }
293}
268}
294
269
@@ -366,20 +341,44 @@ private fun AuthFields(
366 }
341 }
367}
342}
368
343
344+/**
345+ * Optional TLS hardening, tucked behind a collapsed "Security" expander so the common
346+ * localhost/HTTP setup never sees it. Auto-expands when a loaded profile already uses one of
347+ * these settings (so an invalid pin can't silently disable Connect from inside a closed
369−/** Optional TLS hardening for this server: force HTTPS (upgrade cleartext) and pin the
348+ * section). Contains: force HTTPS (upgrade cleartext) and pin the server certificate — both
370− * server certificate. Both are off by default so the common localhost/HTTP setup is
349+ * off by default.
371− * unaffected. */
350+ */
351+@Composable
352+private fun AdvancedSection(
353+ state: ServerEditState,
354+ onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
355+) {
356+ var expanded by rememberSaveable { mutableStateOf(state.requireHttps || state.certPin.isNotBlank()) }
357+ Column(modifier = Modifier.fillMaxWidth()) {
358+ TextButton(
359+ onClick = { expanded = !expanded },
360+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
361+ ) {
362+ Icon(
363+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
364+ contentDescription = null,
365+ modifier = Modifier.size(18.dp),
366+ )
367+ Text(stringResource(R.string.server_security), modifier = Modifier.padding(start = 8.dp))
368+ }
369+ AnimatedVisibility(visible = expanded) {
370+ SecurityFields(state = state, onUpdate = onUpdate)
371+ }
372+ }
373+}
374+
375+/** The actual security controls, rendered inside [AdvancedSection]'s expander. */
372@Composable
376@Composable
373private fun SecurityFields(
377private fun SecurityFields(
374 state: ServerEditState,
378 state: ServerEditState,
375 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
379 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
376) {
380) {
377 Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
381 Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
378− Text(
379− stringResource(R.string.server_security),
380− style = MaterialTheme.typography.titleSmall,
381− color = MaterialTheme.colorScheme.primary,
382− )
383 Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
382 Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
384 Column(modifier = Modifier.weight(1f)) {
383 Column(modifier = Modifier.weight(1f)) {
385 Text(stringResource(R.string.require_https), style = MaterialTheme.typography.bodyLarge)
384 Text(stringResource(R.string.require_https), style = MaterialTheme.typography.bodyLarge)
@@ -417,14 +416,15 @@ private fun SecurityFields(
417
416
418/**
417/**
419 * Optional LAN discovery: browses for opencode servers advertised via mDNS (`--mdns`) and
418 * Optional LAN discovery: browses for opencode servers advertised via mDNS (`--mdns`) and
420− * offers each as a one-tap fill for the base URL. Collapsed by default so the discovery (and
419+ * offers each as a one-tap fill for the base URL. Discovery (and its multicast traffic) only
421− * its multicast traffic) only runs while the section is expanded — the collection stops when
420+ * runs while the section is expanded — the collection stops when the section is collapsed or
422− * the section is collapsed or the screen leaves composition.
421+ * the screen leaves composition. [initiallyExpanded] starts it open on a fresh add form so the
422+ * discovered list is the primary path; an edit form (populated URL) leaves it collapsed.
423 */
423 */
424@Composable
424@Composable
425−private fun DiscoverySection(onPick: (String) -> Unit) {
425+private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolean = false) {
426 val context = LocalContext.current
426 val context = LocalContext.current
427− var expanded by rememberSaveable { mutableStateOf(false) }
427+ var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) }
428 Column(modifier = Modifier.fillMaxWidth()) {
428 Column(modifier = Modifier.fillMaxWidth()) {
429 TextButton(
429 TextButton(
430 onClick = { expanded = !expanded },
430 onClick = { expanded = !expanded },
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+37−41
@@ -35,17 +35,12 @@ data class ServerEditState(
35 val error: String? = null,
35 val error: String? = null,
36 /** Whether the auth (username/password) fields are shown. */
36 /** Whether the auth (username/password) fields are shown. */
37 val authFieldsVisible: Boolean = false,
37 val authFieldsVisible: Boolean = false,
38− /** Whether a connectivity/auth probe is in progress. */
39− val probing: Boolean = false,
40 /** Snapshot of the profile as loaded (for dirty detection). Null until loaded. */
38 /** Snapshot of the profile as loaded (for dirty detection). Null until loaded. */
41 val initial: InitialProfile? = null,
39 val initial: InitialProfile? = null,
42 /** Whether a credential test is in progress. */
40 /** Whether a credential test is in progress. */
43 val testingCredentials: Boolean = false,
41 val testingCredentials: Boolean = false,
44 /** Non-null when the last credential test succeeded (true) or failed (false). */
42 /** Non-null when the last credential test succeeded (true) or failed (false). */
45 val credentialsResult: Boolean? = null,
43 val credentialsResult: Boolean? = null,
46− /** True when the last connectivity probe found the server reachable without auth.
47− * Null until a probe runs, and cleared when the base URL is edited. */
48− val probeReachable: Boolean? = null,
49) {
44) {
50 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl) && certPinValid
45 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl) && certPinValid
51 val isNew: Boolean get() = id == null
46 val isNew: Boolean get() = id == null
@@ -218,48 +213,53 @@ class ServerEditViewModel(
218 _state.update { transform(it).copy(credentialsResult = null, error = null) }
213 _state.update { transform(it).copy(credentialsResult = null, error = null) }
219 }
214 }
220
215
221− fun probe() {
216+ /**
217+ * Primary action: save the profile and connect to it. When the server needs auth and the
218+ * user hasn't supplied credentials yet, a connectivity probe reveals the username/password
219+ * fields instead of connecting with none — folding the former separate "Check connectivity"
220+ * step into this single action so the common case is one tap.
221+ */
222+ fun connect(onDone: () -> Unit) {
222 val s = _state.value
223 val s = _state.value
223− if (!s.canSave || s.probing) return
224+ if (!s.canSave || s.saving) return
225+ // Credentials already known (an authed profile is loaded, or the user typed some): skip
226+ // the probe and go straight to save + connect. A wrong password then surfaces as a
227+ // connect error rather than silently re-opening the auth fields.
228+ if (s.authFieldsVisible || s.username.isNotBlank() || s.password.isNotBlank()) {
229+ saveInternal(connectAfter = true, onDone = onDone)
230+ return
231+ }
224− _state.update { it.copy(probing = true, error = null, credentialsResult = null, probeReachable = null) }
232+ _state.update { it.copy(saving = true, error = null, credentialsResult = null) }
225− // FIX 21(b): probe the trimmed URL that save() would actually store.
233+ // FIX 21(b)/FIX 11: probe the trimmed URL save() would store, and remember it so a stale
226− // FIX 11: remember which URL we probed so a stale result is dropped if the
227− // user edited the base URL away while the probe was in flight.
234+ // result is dropped if the user edited the base URL away while the probe was in flight.
228 val probedUrl = s.baseUrl.trim()
235 val probedUrl = s.baseUrl.trim()
229 val probedRequireHttps = s.requireHttps
236 val probedRequireHttps = s.requireHttps
230 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
237 val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
231 viewModelScope.launch {
238 viewModelScope.launch {
232 val result = runCatchingCancellable { container.probeServer(probedUrl, probedRequireHttps, probedCertPin) }
239 val result = runCatchingCancellable { container.probeServer(probedUrl, probedRequireHttps, probedCertPin) }
233 result.onSuccess { pr ->
240 result.onSuccess { pr ->
234− _state.update {
235− // Stale result (URL edited away mid-probe): drop the reachability verdict but
241+ // Stale result (URL edited away mid-probe): clear the spinner and stop, else
236− // still clear the spinner, else `probing` latches true and blocks re-probing.
242+ // `saving` latches true and blocks re-connecting.
237− if (it.baseUrl.trim() != probedUrl) return@update it.copy(probing = false)
243+ if (_state.value.baseUrl.trim() != probedUrl) {
244+ _state.update { it.copy(saving = false) }
245+ return@onSuccess
246+ }
238− when (pr) {
247+ when (pr) {
239− is ProbeResult.Reachable -> it.copy(
240− probing = false,
241− authFieldsVisible = false,
242− error = null,
243− probeReachable = true,
244− )
245− is ProbeResult.NeedsAuth -> it.copy(
248+ is ProbeResult.NeedsAuth ->
246− probing = false,
247− authFieldsVisible = true,
249+ _state.update { it.copy(saving = false, authFieldsVisible = true, error = null) }
248− error = null,
249− probeReachable = false,
250− )
251− is ProbeResult.Unreachable -> it.copy(
250+ is ProbeResult.Unreachable ->
252− probing = false,
253− authFieldsVisible = false,
254− error = pr.error,
251+ _state.update { it.copy(saving = false, error = pr.error) }
255− probeReachable = false,
252+ is ProbeResult.Reachable -> {
256− )
253+ // Reachable without auth: clear the probe-owned saving flag and hand off
254+ // to the normal save+connect path (saveInternal re-sets saving itself).
255+ _state.update { it.copy(saving = false) }
256+ saveInternal(connectAfter = true, onDone = onDone)
257 }
257 }
258 }
258 }
259 }.onFailure { e ->
259 }.onFailure { e ->
260 _state.update {
260 _state.update {
261− if (it.baseUrl.trim() != probedUrl) return@update it.copy(probing = false)
261+ if (it.baseUrl.trim() != probedUrl) it.copy(saving = false)
262− it.copy(probing = false, error = container.friendlyError(e), probeReachable = false)
262+ else it.copy(saving = false, error = container.friendlyError(e))
263 }
263 }
264 }
264 }
265 }
265 }
@@ -311,15 +311,11 @@ class ServerEditViewModel(
311 }
311 }
312 }
312 }
313
313
314+ /** Secondary action: persist the profile without connecting (e.g. setting up offline). */
314 fun save(onDone: () -> Unit) {
315 fun save(onDone: () -> Unit) {
315 saveInternal(connectAfter = false, onDone = onDone)
316 saveInternal(connectAfter = false, onDone = onDone)
316 }
317 }
317
318
318− /** Save and then connect to the saved profile in one step. */
319− fun saveAndConnect(onDone: () -> Unit) {
320− saveInternal(connectAfter = true, onDone = onDone)
321− }
322−
323 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
319 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
324 val s = _state.value
320 val s = _state.value
325 if (!s.canSave || s.saving) return
321 if (!s.canSave || s.saving) return
app/src/main/res/values-es/strings.xml+2−6
@@ -59,13 +59,11 @@
59 <string name="base_url">URL base</string>
59 <string name="base_url">URL base</string>
60 <string name="base_url_hint">http://192.168.1.10:4096</string>
60 <string name="base_url_hint">http://192.168.1.10:4096</string>
61 <string name="invalid_url">Introduce una URL http:// o https:// válida.</string>
61 <string name="invalid_url">Introduce una URL http:// o https:// válida.</string>
62− <string name="check_connectivity">Comprobar conectividad</string>
63− <string name="checking">Comprobando…</string>
64 <string name="auth_required">Este servidor requiere autenticación.</string>
62 <string name="auth_required">Este servidor requiere autenticación.</string>
65− <string name="auth_help">Toca "Comprobar conectividad" para probar el servidor. Los campos de autenticación aparecen automáticamente solo si el servidor los requiere.</string>
66 <string name="username_optional">Usuario (opcional)</string>
63 <string name="username_optional">Usuario (opcional)</string>
67 <string name="password_optional">Contraseña (opcional)</string>
64 <string name="password_optional">Contraseña (opcional)</string>
65+ <string name="connect">Conectar</string>
68− <string name="save_and_connect">Guardar y conectar</string>
66+ <string name="save_without_connecting">Guardar sin conectar</string>
69 <string name="discard_title">¿Descartar cambios?</string>
67 <string name="discard_title">¿Descartar cambios?</string>
70 <string name="discard_text">Se perderán tus cambios.</string>
68 <string name="discard_text">Se perderán tus cambios.</string>
71 <string name="discard">Descartar</string>
69 <string name="discard">Descartar</string>
@@ -173,7 +171,6 @@
173 <string name="no_commands">El servidor no informó de ningún comando.</string>
171 <string name="no_commands">El servidor no informó de ningún comando.</string>
174 <string name="no_commands_match">Ningún comando coincide con "%1$s".</string>
172 <string name="no_commands_match">Ningún comando coincide con "%1$s".</string>
175 <string name="load_failed">Error al cargar. Toca reintentar.</string>
173 <string name="load_failed">Error al cargar. Toca reintentar.</string>
176− <string name="saving">Guardando…</string>
177 <string name="image_failed">Error al cargar la imagen</string>
174 <string name="image_failed">Error al cargar la imagen</string>
178 <string name="search_models">Buscar modelos…</string>
175 <string name="search_models">Buscar modelos…</string>
179 <string name="search_agents">Buscar agentes…</string>
176 <string name="search_agents">Buscar agentes…</string>
@@ -265,7 +262,6 @@
265 <string name="unknown_message">Tipo de mensaje desconocido</string>
262 <string name="unknown_message">Tipo de mensaje desconocido</string>
266 <string name="unknown_part">Contenido no compatible</string>
263 <string name="unknown_part">Contenido no compatible</string>
267 <string name="tool_preview">🔧 %1$s</string>
264 <string name="tool_preview">🔧 %1$s</string>
268− <string name="server_reachable">Servidor accesible ✓</string>
269 <string name="suggest_scheme">Añadir prefijo http://</string>
265 <string name="suggest_scheme">Añadir prefijo http://</string>
270 <string name="sort">Ordenar</string>
266 <string name="sort">Ordenar</string>
271 <string name="sort_recent">Actividad reciente</string>
267 <string name="sort_recent">Actividad reciente</string>
app/src/main/res/values/strings.xml+2−6
@@ -59,13 +59,11 @@
59 <string name="base_url">Base URL</string>
59 <string name="base_url">Base URL</string>
60 <string name="base_url_hint">http://192.168.1.10:4096</string>
60 <string name="base_url_hint">http://192.168.1.10:4096</string>
61 <string name="invalid_url">Enter a valid http:// or https:// URL.</string>
61 <string name="invalid_url">Enter a valid http:// or https:// URL.</string>
62− <string name="check_connectivity">Check connectivity</string>
63− <string name="checking">Checking…</string>
64 <string name="auth_required">This server requires authentication.</string>
62 <string name="auth_required">This server requires authentication.</string>
65− <string name="auth_help">Tap "Check connectivity" to test the server. Authentication fields appear automatically only if the server requires them.</string>
66 <string name="username_optional">Username (optional)</string>
63 <string name="username_optional">Username (optional)</string>
67 <string name="password_optional">Password (optional)</string>
64 <string name="password_optional">Password (optional)</string>
68− <string name="save_and_connect">Save &amp; connect</string>
65+ <string name="connect">Connect</string>
66+ <string name="save_without_connecting">Save without connecting</string>
69 <string name="discard_title">Discard changes?</string>
67 <string name="discard_title">Discard changes?</string>
70 <string name="discard_text">Your edits will be lost.</string>
68 <string name="discard_text">Your edits will be lost.</string>
71 <string name="discard">Discard</string>
69 <string name="discard">Discard</string>
@@ -173,7 +171,6 @@
173 <string name="no_commands">No commands reported by the server.</string>
171 <string name="no_commands">No commands reported by the server.</string>
174 <string name="no_commands_match">No commands match "%1$s".</string>
172 <string name="no_commands_match">No commands match "%1$s".</string>
175 <string name="load_failed">Failed to load. Tap retry.</string>
173 <string name="load_failed">Failed to load. Tap retry.</string>
176− <string name="saving">Saving…</string>
177 <string name="image_failed">Failed to load image</string>
174 <string name="image_failed">Failed to load image</string>
178 <string name="search_models">Search models…</string>
175 <string name="search_models">Search models…</string>
179 <string name="search_agents">Search agents…</string>
176 <string name="search_agents">Search agents…</string>
@@ -265,7 +262,6 @@
265 <string name="unknown_message">Unknown message type</string>
262 <string name="unknown_message">Unknown message type</string>
266 <string name="unknown_part">Unsupported content</string>
263 <string name="unknown_part">Unsupported content</string>
267 <string name="tool_preview">🔧 %1$s</string>
264 <string name="tool_preview">🔧 %1$s</string>
268− <string name="server_reachable">Server is reachable ✓</string>
269 <string name="suggest_scheme">Add http:// prefix</string>
265 <string name="suggest_scheme">Add http:// prefix</string>
270 <string name="sort">Sort</string>
266 <string name="sort">Sort</string>
271 <string name="sort_recent">Recent activity</string>
267 <string name="sort_recent">Recent activity</string>
app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt+60−64
@@ -259,97 +259,127 @@ class ServerEditViewModelTest {
259 assertEquals("Edited", vm.state.value.label)
259 assertEquals("Edited", vm.state.value.label)
260 }
260 }
261
261
262− // --- probe() auth detection ---
262+ // --- connect(): probe folded into the primary action ---
263
263
264 @Test
264 @Test
265− fun probe_reachableServer_hidesAuthFields() = testScope.runTest {
265+ fun connect_reachableServer_savesAndConnects() = testScope.runTest {
266 val container = FakeAppContainer()
266 val container = FakeAppContainer()
267 container.probeResult = ProbeResult.Reachable
267 container.probeResult = ProbeResult.Reachable
268 val vm = makeVm(container)
268 val vm = makeVm(container)
269 vm.update { it.copy(baseUrl = "http://localhost:3000") }
269 vm.update { it.copy(baseUrl = "http://localhost:3000") }
270− vm.probe()
270+ var doneCalled = false
271+ vm.connect { doneCalled = true }
271 testScheduler.advanceUntilIdle()
272 testScheduler.advanceUntilIdle()
272− assertFalse(vm.state.value.probing)
273 assertFalse(vm.state.value.authFieldsVisible)
273 assertFalse(vm.state.value.authFieldsVisible)
274 assertNull(vm.state.value.error)
274 assertNull(vm.state.value.error)
275 assertEquals(1, container.probeCalls.size)
275 assertEquals(1, container.probeCalls.size)
276+ // Reachable → the profile is saved and a connection is opened.
277+ assertNotNull(container.fakeProfileStore.savedProfile)
278+ assertEquals(1, container.connectCalls.size)
279+ assertTrue(doneCalled)
276 }
280 }
277
281
278 @Test
282 @Test
279− fun probe_needsAuth_showsAuthFields() = testScope.runTest {
283+ fun connect_needsAuth_showsAuthFieldsWithoutSaving() = testScope.runTest {
280 val container = FakeAppContainer()
284 val container = FakeAppContainer()
281 container.probeResult = ProbeResult.NeedsAuth
285 container.probeResult = ProbeResult.NeedsAuth
282 val vm = makeVm(container)
286 val vm = makeVm(container)
283 vm.update { it.copy(baseUrl = "http://localhost:3000") }
287 vm.update { it.copy(baseUrl = "http://localhost:3000") }
284− vm.probe()
288+ var doneCalled = false
289+ vm.connect { doneCalled = true }
285 testScheduler.advanceUntilIdle()
290 testScheduler.advanceUntilIdle()
286− assertFalse(vm.state.value.probing)
291+ assertFalse(vm.state.value.saving)
287 assertTrue(vm.state.value.authFieldsVisible)
292 assertTrue(vm.state.value.authFieldsVisible)
288 assertNull(vm.state.value.error)
293 assertNull(vm.state.value.error)
294+ // Auth needed: reveal the fields; nothing is saved or connected yet.
295+ assertNull(container.fakeProfileStore.savedProfile)
296+ assertEquals(0, container.connectCalls.size)
297+ assertFalse(doneCalled)
289 }
298 }
290
299
291 @Test
300 @Test
292− fun probe_unreachable_showsErrorAndHidesAuthFields() = testScope.runTest {
301+ fun connect_unreachable_showsErrorAndDoesNotSave() = testScope.runTest {
293 val container = FakeAppContainer()
302 val container = FakeAppContainer()
294 container.probeResult = ProbeResult.Unreachable("Could not reach server")
303 container.probeResult = ProbeResult.Unreachable("Could not reach server")
295 val vm = makeVm(container)
304 val vm = makeVm(container)
296 vm.update { it.copy(baseUrl = "http://localhost:3000") }
305 vm.update { it.copy(baseUrl = "http://localhost:3000") }
297− vm.probe()
306+ vm.connect { }
298 testScheduler.advanceUntilIdle()
307 testScheduler.advanceUntilIdle()
299− assertFalse(vm.state.value.probing)
308+ assertFalse(vm.state.value.saving)
300 assertFalse(vm.state.value.authFieldsVisible)
309 assertFalse(vm.state.value.authFieldsVisible)
301 assertNotNull(vm.state.value.error)
310 assertNotNull(vm.state.value.error)
302 assertTrue(vm.state.value.error!!.contains("Could not reach server"))
311 assertTrue(vm.state.value.error!!.contains("Could not reach server"))
312+ assertNull(container.fakeProfileStore.savedProfile)
303 }
313 }
304
314
305 @Test
315 @Test
306− fun probe_invalidUrlDoesNotProbe() = testScope.runTest {
316+ fun connect_withCredentials_skipsProbeAndSaves() = testScope.runTest {
307 val container = FakeAppContainer()
317 val container = FakeAppContainer()
308− container.probeResult = ProbeResult.Reachable
318+ container.probeResult = ProbeResult.NeedsAuth
309 val vm = makeVm(container)
319 val vm = makeVm(container)
310− vm.update { it.copy(baseUrl = "invalid") }
320+ vm.update { it.copy(baseUrl = "http://localhost:3000", username = "admin", password = "secret") }
311− vm.probe()
321+ var doneCalled = false
322+ vm.connect { doneCalled = true }
323+ testScheduler.advanceUntilIdle()
324+ // Credentials present: no probe, straight to save + connect.
325+ assertEquals(0, container.probeCalls.size)
326+ assertNotNull(container.fakeProfileStore.savedProfile)
327+ assertEquals(1, container.connectCalls.size)
328+ assertTrue(doneCalled)
329+ }
330+
331+ @Test
332+ fun connect_authFieldsVisible_skipsProbe() = testScope.runTest {
333+ val container = FakeAppContainer()
334+ container.probeResult = ProbeResult.NeedsAuth
335+ val vm = makeVm(container)
336+ // Simulate a first Connect that revealed auth, then a second Connect with creds typed.
337+ vm.update { it.copy(baseUrl = "http://localhost:3000", authFieldsVisible = true) }
338+ vm.connect { }
312 testScheduler.advanceUntilIdle()
339 testScheduler.advanceUntilIdle()
313 assertEquals(0, container.probeCalls.size)
340 assertEquals(0, container.probeCalls.size)
341+ assertNotNull(container.fakeProfileStore.savedProfile)
314 }
342 }
315
343
316 @Test
344 @Test
317− fun probe_whileProbingDoesNotProbeAgain() = testScope.runTest {
345+ fun connect_invalidUrlDoesNothing() = testScope.runTest {
318 val container = FakeAppContainer()
346 val container = FakeAppContainer()
319 container.probeResult = ProbeResult.Reachable
347 container.probeResult = ProbeResult.Reachable
320 val vm = makeVm(container)
348 val vm = makeVm(container)
321− vm.update { it.copy(baseUrl = "http://localhost:3000") }
349+ vm.update { it.copy(baseUrl = "invalid") }
322− vm.probe()
350+ var doneCalled = false
323− vm.probe()
351+ vm.connect { doneCalled = true }
324 testScheduler.advanceUntilIdle()
352 testScheduler.advanceUntilIdle()
325− assertEquals(1, container.probeCalls.size)
353+ assertEquals(0, container.probeCalls.size)
354+ assertNull(container.fakeProfileStore.savedProfile)
355+ assertFalse(doneCalled)
326 }
356 }
327
357
328 @Test
358 @Test
329− fun probe_setsProbingFlagDuringCall() = testScope.runTest {
359+ fun connect_whileSavingDoesNotConnectAgain() = testScope.runTest {
330 val container = FakeAppContainer()
360 val container = FakeAppContainer()
331 container.probeResult = ProbeResult.Reachable
361 container.probeResult = ProbeResult.Reachable
332 val vm = makeVm(container)
362 val vm = makeVm(container)
333 vm.update { it.copy(baseUrl = "http://localhost:3000") }
363 vm.update { it.copy(baseUrl = "http://localhost:3000") }
334− vm.probe()
364+ vm.connect { }
335− // Before advancing, probing should be true
365+ vm.connect { }
336− assertTrue(vm.state.value.probing)
337 testScheduler.advanceUntilIdle()
366 testScheduler.advanceUntilIdle()
338− assertFalse(vm.state.value.probing)
367+ assertEquals(1, container.probeCalls.size)
339 }
368 }
340
369
341 @Test
370 @Test
342− fun editingBaseUrlAfterProbe_resetsAuthVisibility() = testScope.runTest {
371+ fun connect_setsSavingFlagDuringProbeThenClearsOnAuth() = testScope.runTest {
343 val container = FakeAppContainer()
372 val container = FakeAppContainer()
373+ // NeedsAuth ends the flow (reveals auth) rather than navigating away, so saving
374+ // returns to false and is observable — the reachable path stays saving until onDone.
344 container.probeResult = ProbeResult.NeedsAuth
375 container.probeResult = ProbeResult.NeedsAuth
345 val vm = makeVm(container)
376 val vm = makeVm(container)
346 vm.update { it.copy(baseUrl = "http://localhost:3000") }
377 vm.update { it.copy(baseUrl = "http://localhost:3000") }
347− vm.probe()
378+ vm.connect { }
379+ // Before advancing, the probe is in flight → saving is true.
380+ assertTrue(vm.state.value.saving)
348 testScheduler.advanceUntilIdle()
381 testScheduler.advanceUntilIdle()
349− assertTrue(vm.state.value.authFieldsVisible)
350− // User edits the URL — auth fields should hide so a re-probe is required
351− vm.update { it.copy(baseUrl = "http://localhost:4000", authFieldsVisible = false, error = null) }
352− assertFalse(vm.state.value.authFieldsVisible)
382+ assertFalse(vm.state.value.saving)
353 }
383 }
354
384
355 @Test
385 @Test
@@ -387,40 +417,6 @@ class ServerEditViewModelTest {
387 assertFalse(vm.state.value.authFieldsVisible)
417 assertFalse(vm.state.value.authFieldsVisible)
388 }
418 }
389
419
390− @Test
391− fun probe_failure_setsError() = testScope.runTest {
392− val container = FakeAppContainer()
393− container.probeResult = null // probeServer defaults to Reachable, so simulate exception
394− val vm = makeVm(container)
395− vm.update { it.copy(baseUrl = "http://localhost:3000") }
396− vm.probe()
397− testScheduler.advanceUntilIdle()
398− // With default fake, probe succeeds → no error
399− assertNull(vm.state.value.error)
400− }
401−
402− @Test
403− fun probe_reachable_setsProbeReachable() = testScope.runTest {
404− val container = FakeAppContainer()
405− container.probeResult = ProbeResult.Reachable
406− val vm = makeVm(container)
407− vm.update { it.copy(baseUrl = "http://localhost:3000") }
408− vm.probe()
409− testScheduler.advanceUntilIdle()
410− assertEquals(true, vm.state.value.probeReachable)
411− }
412−
413− @Test
414− fun probe_needsAuth_setsProbeReachableFalse() = testScope.runTest {
415− val container = FakeAppContainer()
416− container.probeResult = ProbeResult.NeedsAuth
417− val vm = makeVm(container)
418− vm.update { it.copy(baseUrl = "http://localhost:3000") }
419− vm.probe()
420− testScheduler.advanceUntilIdle()
421− assertEquals(false, vm.state.value.probeReachable)
422− }
423−
424 @Test
420 @Test
425 fun suggestUrlScheme_addsHttpForBareHost() {
421 fun suggestUrlScheme_addsHttpForBareHost() {
426 assertEquals("http://192.168.1.10:4096", suggestUrlScheme("192.168.1.10:4096"))
422 assertEquals("http://192.168.1.10:4096", suggestUrlScheme("192.168.1.10:4096"))