package soy.iko.opencode.ui.server import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.Wifi import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import soy.iko.opencode.data.network.NsdDiscovery import soy.iko.opencode.di.AppContainer import soy.iko.opencode.R import soy.iko.opencode.ui.vmFactory @OptIn(ExperimentalMaterial3Api::class) @Composable fun ServerEditScreen( container: AppContainer, profileId: String?, onDone: () -> Unit, sourceId: String? = null, ) { val vm: ServerEditViewModel = viewModel(factory = vmFactory { ServerEditViewModel(container, profileId, sourceId) }) val state by vm.state.collectAsStateWithLifecycle() var showDiscardConfirm by rememberSaveable { mutableStateOf(false) } var passwordVisible by rememberSaveable { mutableStateOf(false) } fun safeExit() { when { // A save is in flight: ignore the back gesture (matching the BackHandler guard // below). Popping here would cancel the save's viewModelScope coroutine and // silently abandon the write; the VM calls onDone itself once the save lands. state.saving -> Unit state.isDirty -> showDiscardConfirm = true else -> onDone() } } // Intercept the system back gesture when the form has unsaved changes so the user // gets a chance to keep editing instead of losing their work silently. Stays enabled // (as a no-op) while a save is in flight so the gesture can't propagate to the NavHost // and cancel the save's viewModelScope coroutine mid-write — matching safeExit()'s guard. BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) { if (!state.saving) showDiscardConfirm = true } Scaffold( topBar = { TopAppBar( title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) }, navigationIcon = { IconButton(onClick = ::safeExit) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, ) }, ) { padding -> if (!state.loaded) { val loadingLabel = stringResource(R.string.loading) Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel }) } } else { ServerEditForm( state = state, passwordVisible = passwordVisible, onTogglePassword = { passwordVisible = !passwordVisible }, padding = padding, vm = vm, onDone = onDone, ) } } if (showDiscardConfirm) { AlertDialog( onDismissRequest = { showDiscardConfirm = false }, title = { Text(stringResource(R.string.discard_title)) }, text = { Text(stringResource(R.string.discard_text)) }, confirmButton = { TextButton(onClick = { showDiscardConfirm = false onDone() }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) } }, ) } } @Composable private fun ServerEditForm( state: ServerEditState, passwordVisible: Boolean, onTogglePassword: () -> Unit, padding: androidx.compose.foundation.layout.PaddingValues, vm: ServerEditViewModel, onDone: () -> Unit, ) { val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current // canSave parses the base URL with java.net.URI (which throws — and fills a stack trace — // on the common half-typed URL). Memoize on baseUrl so it runs once per edit instead of // being re-parsed at each of the enabled= call sites on every keystroke's recomposition. val canSave = remember(state.baseUrl, state.certPin) { state.canSave } Column( modifier = Modifier .padding(padding) .imePadding() .widthIn(max = 600.dp) .padding(16.dp) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp), ) { OutlinedTextField( value = state.label, onValueChange = { v -> vm.update { it.copy(label = v) } }, label = { Text(stringResource(R.string.label_optional)) }, singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), modifier = Modifier.fillMaxWidth().testTag("server_label"), ) OutlinedTextField( value = state.baseUrl, onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null, probeReachable = null) } }, label = { Text(stringResource(R.string.base_url)) }, placeholder = { Text(stringResource(R.string.base_url_hint)) }, singleLine = true, isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl), supportingText = { if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) { // Offer a one-tap "http://" fix for bare host:port input instead of a // generic error, so the user doesn't have to know the URL needs a scheme. val suggestion = suggestUrlScheme(state.baseUrl) if (suggestion != null) { TextButton( onClick = { vm.update { it.copy(baseUrl = suggestion, probeReachable = null) } }, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp), ) { Text(stringResource(R.string.suggest_scheme)) } } else { Text(stringResource(R.string.invalid_url)) } } }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done), modifier = Modifier.fillMaxWidth().testTag("server_url"), ) OutlinedButton( onClick = { vm.probe() }, enabled = canSave && !state.probing && !state.saving, modifier = Modifier.fillMaxWidth().testTag("server_probe"), ) { if (state.probing) { CircularProgressIndicator( modifier = Modifier.size(20.dp), strokeWidth = 2.dp, ) Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp)) } else { Text(stringResource(R.string.check_connectivity)) } } // Positive feedback when the probe found the server reachable without auth, so // the user knows the connection works before saving (closes the loop that the // prior "silently does nothing" Reachable path left open). if (state.probeReachable == true && !state.authFieldsVisible) { Text( stringResource(R.string.server_reachable), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier.fillMaxWidth(), ) } DiscoverySection( onPick = { url -> vm.update { it.copy(baseUrl = url, error = null, probeReachable = null) } }, ) if (!state.authFieldsVisible) { Text( stringResource(R.string.auth_help), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth(), ) } AnimatedVisibility(visible = state.authFieldsVisible) { AuthFields( state = state, passwordVisible = passwordVisible, onTogglePassword = onTogglePassword, onUpdate = vm::update, onTestCredentials = vm::testCredentials, onImeDone = { if (canSave && !state.saving) vm.saveAndConnect(onDone) }, ) } SecurityFields(state = state, onUpdate = vm::update) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { OutlinedButton( onClick = { haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress) vm.save(onDone) }, enabled = canSave && !state.saving, modifier = Modifier.weight(1f), ) { if (state.saving) { CircularProgressIndicator( modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.primary, strokeWidth = 2.dp, ) Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp)) } else { Text(stringResource(R.string.save)) } } Button( onClick = { haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress) vm.saveAndConnect(onDone) }, enabled = canSave && !state.saving, modifier = Modifier.weight(1f), ) { Text(stringResource(R.string.save_and_connect)) } } state.error?.let { message -> Text( message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.fillMaxWidth(), ) } } } @Composable private fun AuthFields( state: ServerEditState, passwordVisible: Boolean, onTogglePassword: () -> Unit, onUpdate: ((ServerEditState) -> ServerEditState) -> Unit, onTestCredentials: () -> Unit, onImeDone: () -> Unit, ) { val showPasswordLabel = stringResource(R.string.show_password) val hidePasswordLabel = stringResource(R.string.hide_password) // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on // every keystroke's recomposition (see ServerEditForm). val canSave = remember(state.baseUrl, state.certPin) { state.canSave } Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( stringResource(R.string.auth_required), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) OutlinedTextField( value = state.username, onValueChange = { v -> onUpdate { it.copy(username = v) } }, label = { Text(stringResource(R.string.username_optional)) }, singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), modifier = Modifier.fillMaxWidth().testTag("server_username"), ) OutlinedTextField( value = state.password, onValueChange = { v -> onUpdate { it.copy(password = v) } }, label = { Text(stringResource(R.string.password_optional)) }, singleLine = true, visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), trailingIcon = { IconButton(onClick = onTogglePassword) { Icon( if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel, ) } }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { onImeDone() }), modifier = Modifier.fillMaxWidth().testTag("server_password"), ) OutlinedButton( onClick = onTestCredentials, // Require at least one credential field: testCredentials() no-ops when both are // blank, so without this the button is tappable but silently does nothing. enabled = canSave && !state.testingCredentials && !state.saving && (state.username.isNotBlank() || state.password.isNotBlank()), modifier = Modifier.fillMaxWidth().testTag("server_test_creds"), ) { if (state.testingCredentials) { CircularProgressIndicator( modifier = Modifier.size(20.dp), strokeWidth = 2.dp, ) Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp)) } else { Text(stringResource(R.string.test_credentials)) } } state.credentialsResult?.let { ok -> Text( stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected), style = MaterialTheme.typography.bodySmall, color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, ) } } } /** Optional TLS hardening for this server: force HTTPS (upgrade cleartext) and pin the * server certificate. Both are off by default so the common localhost/HTTP setup is * unaffected. */ @Composable private fun SecurityFields( state: ServerEditState, onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { Text( stringResource(R.string.server_security), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, ) Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { Text(stringResource(R.string.require_https), style = MaterialTheme.typography.bodyLarge) Text( stringResource(R.string.require_https_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Switch( checked = state.requireHttps, onCheckedChange = { v -> onUpdate { it.copy(requireHttps = v) } }, ) } OutlinedTextField( value = state.certPin, onValueChange = { v -> onUpdate { it.copy(certPin = v) } }, label = { Text(stringResource(R.string.cert_pin)) }, placeholder = { Text(stringResource(R.string.cert_pin_hint)) }, singleLine = true, isError = !state.certPinValid, supportingText = { Text( stringResource(if (state.certPinValid) R.string.cert_pin_desc else R.string.cert_pin_invalid), style = MaterialTheme.typography.bodySmall, color = if (state.certPinValid) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error, ) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), modifier = Modifier.fillMaxWidth().testTag("server_cert_pin"), ) } } /** * Optional LAN discovery: browses for opencode servers advertised via mDNS (`--mdns`) and * offers each as a one-tap fill for the base URL. Collapsed by default so the discovery (and * its multicast traffic) only runs while the section is expanded — the collection stops when * the section is collapsed or the screen leaves composition. */ @Composable private fun DiscoverySection(onPick: (String) -> Unit) { val context = LocalContext.current var expanded by rememberSaveable { mutableStateOf(false) } Column(modifier = Modifier.fillMaxWidth()) { TextButton( onClick = { expanded = !expanded }, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp), ) { Icon(Icons.Filled.Wifi, contentDescription = null, modifier = Modifier.size(18.dp)) Text(stringResource(R.string.discover_on_network), modifier = Modifier.padding(start = 8.dp)) } if (expanded) { val discovery = remember { NsdDiscovery(context) } val servers by remember(discovery) { discovery.discover() } .collectAsStateWithLifecycle(initialValue = emptyList()) if (servers.isEmpty()) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp), ) { CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) Text( stringResource(R.string.searching_network), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp), ) } } else { servers.forEach { server -> Column( modifier = Modifier .fillMaxWidth() .clickable { onPick(server.baseUrl) } .padding(vertical = 8.dp), ) { Text(server.name, style = MaterialTheme.typography.bodyMedium) Text( server.baseUrl, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } } }