@@ -19,6 +19,8 @@ import androidx.compose.foundation.text.KeyboardOptions
1919import androidx.compose.foundation.verticalScroll
2020import androidx.compose.material.icons.Icons
2121import androidx.compose.material.icons.automirrored.filled.ArrowBack
22+import androidx.compose.material.icons.filled.ExpandLess
23+import androidx.compose.material.icons.filled.ExpandMore
2224import androidx.compose.material.icons.filled.Visibility
2325import androidx.compose.material.icons.filled.VisibilityOff
2426import androidx.compose.material.icons.filled.Wifi
@@ -151,9 +153,9 @@ private fun ServerEditForm(
151153 onDone: () -> Unit,
152154) {
153155 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 —
155− // on the common half-typed URL). Memoize on baseUrl so it runs once per edit instead of
156− // being re-parsed at each of the enabled= call sites on every keystroke's recomposition.
156+ // canSave parses the base URL with OkHttp's parser (which allocates on the common
157+ // half-typed URL). Memoize on baseUrl so it runs once per edit instead of being
158+ // re-parsed at each of the enabled= call sites on every keystroke's recomposition.
157159 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
158160 Column(
159161 modifier = Modifier
@@ -164,17 +166,10 @@ private fun ServerEditForm(
164166 .verticalScroll(rememberScrollState()),
165167 verticalArrangement = Arrangement.spacedBy(12.dp),
166168 ) {
167− OutlinedTextField(
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− )
169+ // Base URL is the only required field, so it leads the form and takes focus.
175170 OutlinedTextField(
176171 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) } },
178173 label = { Text(stringResource(R.string.base_url)) },
179174 placeholder = { Text(stringResource(R.string.base_url_hint)) },
180175 singleLine = true,
@@ -186,7 +181,7 @@ private fun ServerEditForm(
186181 val suggestion = suggestUrlScheme(state.baseUrl)
187182 if (suggestion != null) {
188183 TextButton(
189− onClick = { vm.update { it.copy(baseUrl = suggestion, probeReachable = null) } },
184+ onClick = { vm.update { it.copy(baseUrl = suggestion) } },
190185 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
191186 ) {
192187 Text(stringResource(R.string.suggest_scheme))
@@ -199,43 +194,15 @@ private fun ServerEditForm(
199194 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
200195 modifier = Modifier.fillMaxWidth().testTag("server_url"),
201196 )
202− OutlinedButton(
203− onClick = { vm.probe() },
204− enabled = canSave && !state.probing && !state.saving,
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− }
197+ // Auto-expand LAN discovery only on a fresh form (no URL yet) so tapping a found
198+ // server is the primary path; on edit the populated URL keeps it (and its multicast)
199+ // collapsed.
228200 DiscoverySection(
229− onPick = { url -> vm.update { it.copy(baseUrl = url, error = null, probeReachable = null) } },
201+ initiallyExpanded = state.baseUrl.isBlank(),
202+ onPick = { url -> vm.update { it.copy(baseUrl = url) } },
230203 )
231− if (!state.authFieldsVisible) {
232− Text(
233− stringResource(R.string.auth_help),
234− style = MaterialTheme.typography.bodySmall,
235− color = MaterialTheme.colorScheme.onSurfaceVariant,
236− modifier = Modifier.fillMaxWidth(),
237− )
238− }
204+ // Auth fields appear only once a connect attempt learns the server requires them
205+ // (or an authed profile is loaded) — the common no-auth server never sees them.
239206 AnimatedVisibility(visible = state.authFieldsVisible) {
240207 AuthFields(
241208 state = state,
@@ -243,44 +210,41 @@ private fun ServerEditForm(
243210 onTogglePassword = onTogglePassword,
244211 onUpdate = vm::update,
245212 onTestCredentials = vm::testCredentials,
246− onImeDone = { if (canSave && !state.saving) vm.saveAndConnect(onDone) },
213+ onImeDone = { if (canSave && !state.saving) vm.connect(onDone) },
247214 )
248215 }
249− SecurityFields(state = state, onUpdate = vm::update)
250− Row(
251− modifier = Modifier.fillMaxWidth(),
252− horizontalArrangement = Arrangement.spacedBy(12.dp),
216+ // Single primary action: probe-then-save-and-connect. Replaces the former
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,
224+ modifier = Modifier.fillMaxWidth().testTag("server_connect"),
253225 ) {
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) {
263− CircularProgressIndicator(
264− modifier = Modifier.size(20.dp),
265− color = MaterialTheme.colorScheme.primary,
266− strokeWidth = 2.dp,
267− )
268− Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
269− } 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))
226+ if (state.saving) {
227+ CircularProgressIndicator(
228+ modifier = Modifier.size(20.dp),
229+ color = MaterialTheme.colorScheme.onPrimary,
230+ strokeWidth = 2.dp,
231+ )
232+ Text(stringResource(R.string.connecting), modifier = Modifier.padding(start = 8.dp))
233+ } else {
234+ Text(stringResource(R.string.connect))
282235 }
283236 }
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+ }
284248 state.error?.let { message ->
285249 Text(
286250 message,
@@ -289,6 +253,17 @@ private fun ServerEditForm(
289253 modifier = Modifier.fillMaxWidth(),
290254 )
291255 }
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)
292267 }
293268}
294269
@@ -366,20 +341,44 @@ private fun AuthFields(
366341 }
367342}
368343
369−/** Optional TLS hardening for this server: force HTTPS (upgrade cleartext) and pin the
370− * server certificate. Both are off by default so the common localhost/HTTP setup is
371− * unaffected. */
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
348+ * section). Contains: force HTTPS (upgrade cleartext) and pin the server certificate — both
349+ * off by default.
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. */
372376@Composable
373377private fun SecurityFields(
374378 state: ServerEditState,
375379 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
376380) {
377381 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− )
383382 Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
384383 Column(modifier = Modifier.weight(1f)) {
385384 Text(stringResource(R.string.require_https), style = MaterialTheme.typography.bodyLarge)
@@ -417,14 +416,15 @@ private fun SecurityFields(
417416
418417/**
419418 * 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
421− * its multicast traffic) only runs while the section is expanded — the collection stops when
422− * the section is collapsed or the screen leaves composition.
419+ * offers each as a one-tap fill for the base URL. Discovery (and its multicast traffic) only
420+ * runs while the section is expanded — the collection stops when the section is collapsed or
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.
423423 */
424424@Composable
425−private fun DiscoverySection(onPick: (String) -> Unit) {
425+private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolean = false) {
426426 val context = LocalContext.current
427− var expanded by rememberSaveable { mutableStateOf(false) }
427+ var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) }
428428 Column(modifier = Modifier.fillMaxWidth()) {
429429 TextButton(
430430 onClick = { expanded = !expanded },