@@ -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,
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 }
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),
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)) },
172− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
173− modifier = Modifier.fillMaxWidth().testTag("server_label"),
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),
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"),
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"),
207− if (state.probing) {
208− CircularProgressIndicator(
209− modifier = Modifier.size(20.dp),
212− Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
214− Text(stringResource(R.string.check_connectivity))
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) {
222− stringResource(R.string.server_reachable),
223− style = MaterialTheme.typography.bodySmall,
224− color = MaterialTheme.colorScheme.primary,
225− modifier = Modifier.fillMaxWidth(),
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) } },
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(),
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) },
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.
220+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
223+ enabled = canSave && !state.saving,
251− modifier = Modifier.fillMaxWidth(),
224+ modifier = Modifier.fillMaxWidth().testTag("server_connect"),
252− horizontalArrangement = Arrangement.spacedBy(12.dp),
256− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
259− enabled = canSave && !state.saving,
260− modifier = Modifier.weight(1f),
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,
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))
275− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
276− vm.saveAndConnect(onDone)
278− enabled = canSave && !state.saving,
279− modifier = Modifier.weight(1f),
281− Text(stringResource(R.string.save_and_connect))
234+ Text(stringResource(R.string.connect))
237+ // Secondary, low-emphasis: persist without connecting (offline setup).
240+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
243+ enabled = canSave && !state.saving,
244+ modifier = Modifier.fillMaxWidth().testTag("server_save"),
246+ Text(stringResource(R.string.save_without_connecting))
284 state.error?.let { message ->
248 state.error?.let { message ->
@@ -289,6 +253,17 @@ private fun ServerEditForm(
289 modifier = Modifier.fillMaxWidth(),
253 modifier = Modifier.fillMaxWidth(),
256+ // De-emphasized optional label at the bottom: displayLabel already falls back to the
257+ // URL, so most servers don't need one.
259+ value = state.label,
260+ onValueChange = { v -> vm.update { it.copy(label = v) } },
261+ label = { Text(stringResource(R.string.label_optional)) },
263+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
264+ modifier = Modifier.fillMaxWidth().testTag("server_label"),
266+ AdvancedSection(state = state, onUpdate = vm::update)
@@ -366,20 +341,44 @@ private fun AuthFields(
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+ */
352+private fun AdvancedSection(
353+ state: ServerEditState,
354+ onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
356+ var expanded by rememberSaveable { mutableStateOf(state.requireHttps || state.certPin.isNotBlank()) }
357+ Column(modifier = Modifier.fillMaxWidth()) {
359+ onClick = { expanded = !expanded },
360+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
363+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
364+ contentDescription = null,
365+ modifier = Modifier.size(18.dp),
367+ Text(stringResource(R.string.server_security), modifier = Modifier.padding(start = 8.dp))
369+ AnimatedVisibility(visible = expanded) {
370+ SecurityFields(state = state, onUpdate = onUpdate)
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,
377 Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
381 Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
379− stringResource(R.string.server_security),
380− style = MaterialTheme.typography.titleSmall,
381− color = MaterialTheme.colorScheme.primary,
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(
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.
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 },