package soy.iko.opencode.ui.screens import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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.filled.Lock import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester 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.text.style.TextAlign import androidx.compose.ui.unit.dp import soy.iko.opencode.core.Event import soy.iko.opencode.core.UiState @Composable fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) { val focusRequester = remember { FocusRequester() } var passwordVisible by remember { mutableStateOf(false) } // Focus the server URL field when the screen opens, but only when it's empty // so we don't steal focus (and pop the keyboard) on a return visit. LaunchedEffect(Unit) { if (state.serverUrl.isEmpty()) focusRequester.requestFocus() } Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .imePadding() .padding(horizontal = 28.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( Icons.Filled.Terminal, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(72.dp), ) Spacer(Modifier.height(16.dp)) Text( text = "opencode", style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.primary, ) Text( text = "Connect to your opencode server", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) Spacer(Modifier.height(32.dp)) OutlinedTextField( value = state.serverUrl, onValueChange = { dispatch(Event.ServerUrlChanged(it)) }, label = { Text("Server URL") }, placeholder = { Text("http://192.168.1.10:4096") }, singleLine = true, modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Uri, imeAction = if (state.authRequired) ImeAction.Next else ImeAction.Go, ), keyboardActions = KeyboardActions(onGo = { dispatch(Event.Connect) }), enabled = !state.loading, ) // Point first-time users at the expected address format. Hidden once they // start typing so it doesn't compete with the field's own contents. if (state.serverUrl.isEmpty()) { Spacer(Modifier.height(6.dp)) Text( text = "Enter the URL of your opencode server (e.g. http://192.168.1.10:4096)", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) } // Nudge the user if the URL looks like it's missing a scheme — a bare host:port // won't connect. Informational (tertiary), not an error, since it's a heuristic. if (state.serverUrl.isNotBlank() && "://" !in state.serverUrl && !state.serverUrl.startsWith("http")) { Spacer(Modifier.height(6.dp)) Text( text = "Tip: Add http:// if connecting to a local server", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.tertiary, textAlign = TextAlign.Center, ) } if (state.authRequired) { Spacer(Modifier.height(12.dp)) OutlinedTextField( value = state.username, onValueChange = { dispatch(Event.UsernameChanged(it)) }, label = { Text("Username") }, singleLine = true, leadingIcon = { Icon(Icons.Filled.Person, contentDescription = null) }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), enabled = !state.loading, ) Spacer(Modifier.height(12.dp)) OutlinedTextField( value = state.password, onValueChange = { dispatch(Event.PasswordChanged(it)) }, label = { Text("Password") }, singleLine = true, visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), leadingIcon = { Icon(Icons.Filled.Lock, contentDescription = null) }, trailingIcon = { IconButton(onClick = { passwordVisible = !passwordVisible }) { Icon( imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, contentDescription = if (passwordVisible) "Hide password" else "Show password", ) } }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go), keyboardActions = KeyboardActions(onGo = { dispatch(Event.Connect) }), enabled = !state.loading, ) Spacer(Modifier.height(4.dp)) Text( text = "This server requires authentication.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Spacer(Modifier.height(24.dp)) Button( onClick = { dispatch(Event.Connect) }, enabled = !state.loading && state.serverUrl.isNotBlank(), modifier = Modifier.fillMaxWidth(), ) { if (state.loading) { CircularProgressIndicator( modifier = Modifier.height(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary, ) } else { Text(if (state.authRequired) "Sign in" else "Connect") } } // Surface the error inline as well, so it stays visible while the user // corrects the form (the snackbar is transient). if (state.error != null && !state.loading) { Spacer(Modifier.height(8.dp)) Text( text = state.error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center, ) } } }