@@ -1,9 +1,11 @@
11package soy.iko.opencode.ui.server
22
3+import androidx.activity.compose.BackHandler
34import androidx.compose.animation.AnimatedVisibility
45import androidx.compose.foundation.layout.Arrangement
56import androidx.compose.foundation.layout.Box
67import androidx.compose.foundation.layout.Column
8+import androidx.compose.foundation.layout.Row
79import androidx.compose.foundation.layout.fillMaxSize
810import androidx.compose.foundation.layout.fillMaxWidth
911import androidx.compose.foundation.layout.imePadding
@@ -16,6 +18,9 @@ import androidx.compose.foundation.text.KeyboardOptions
1618import androidx.compose.foundation.verticalScroll
1719import androidx.compose.material.icons.Icons
1820import androidx.compose.material.icons.automirrored.filled.ArrowBack
21+import androidx.compose.material.icons.filled.Visibility
22+import androidx.compose.material.icons.filled.VisibilityOff
23+import androidx.compose.material3.AlertDialog
1924import androidx.compose.material3.Button
2025import androidx.compose.material3.CircularProgressIndicator
2126import androidx.compose.material3.ExperimentalMaterial3Api
@@ -26,9 +31,14 @@ import androidx.compose.material3.OutlinedButton
2631import androidx.compose.material3.OutlinedTextField
2732import androidx.compose.material3.Scaffold
2833import androidx.compose.material3.Text
34+import androidx.compose.material3.TextButton
2935import androidx.compose.material3.TopAppBar
3036import androidx.compose.runtime.Composable
3137import androidx.compose.runtime.getValue
38+import androidx.compose.runtime.mutableStateOf
39+import androidx.compose.runtime.remember
40+import androidx.compose.runtime.saveable.rememberSaveable
41+import androidx.compose.runtime.setValue
3242import androidx.compose.ui.Alignment
3343import androidx.compose.ui.Modifier
3444import androidx.compose.ui.platform.testTag
@@ -37,6 +47,7 @@ import androidx.compose.ui.semantics.semantics
3747import androidx.compose.ui.text.input.ImeAction
3848import androidx.compose.ui.text.input.KeyboardType
3949import androidx.compose.ui.text.input.PasswordVisualTransformation
50+import androidx.compose.ui.text.input.VisualTransformation
4051import androidx.compose.ui.res.stringResource
4152import androidx.compose.ui.unit.dp
4253import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -55,13 +66,29 @@ fun ServerEditScreen(
5566 val vm: ServerEditViewModel =
5667 viewModel(factory = vmFactory { ServerEditViewModel(container, profileId) })
5768 val state by vm.state.collectAsStateWithLifecycle()
69+ var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
70+ var passwordVisible by rememberSaveable { mutableStateOf(false) }
71+
72+ fun safeExit() {
73+ if (state.isDirty && !state.saving) {
74+ showDiscardConfirm = true
75+ } else {
76+ onDone()
77+ }
78+ }
79+
80+ // Intercept the system back gesture when the form has unsaved changes so the user
81+ // gets a chance to keep editing instead of losing their work silently.
82+ BackHandler(enabled = state.isDirty && !state.saving && !showDiscardConfirm) {
83+ showDiscardConfirm = true
84+ }
5885
5986 Scaffold(
6087 topBar = {
6188 TopAppBar(
6289 title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
6390 navigationIcon = {
64− IconButton(onClick = onDone) {
91+ IconButton(onClick = ::safeExit) {
6592 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
6693 }
6794 },
@@ -74,98 +101,117 @@ fun ServerEditScreen(
74101 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
75102 }
76103 } else {
77− Column(
78− modifier = Modifier
79− .padding(padding)
80− .imePadding()
81− .widthIn(max = 600.dp)
82− .padding(16.dp)
83− .verticalScroll(rememberScrollState()),
84− verticalArrangement = Arrangement.spacedBy(12.dp),
85− ) {
86− OutlinedTextField(
87− value = state.label,
88− onValueChange = { v -> vm.update { it.copy(label = v) } },
89− label = { Text(stringResource(R.string.label_optional)) },
90− singleLine = true,
91− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
92− modifier = Modifier.fillMaxWidth().testTag("server_label"),
104+ ServerEditForm(
105+ state = state,
106+ passwordVisible = passwordVisible,
107+ onTogglePassword = { passwordVisible = !passwordVisible },
108+ padding = padding,
109+ vm = vm,
110+ onDone = onDone,
93111 )
94− OutlinedTextField(
95− value = state.baseUrl,
96− onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null) } },
97− label = { Text(stringResource(R.string.base_url)) },
98− placeholder = { Text(stringResource(R.string.base_url_hint)) },
99− singleLine = true,
100− isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
101− supportingText = {
102− if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
103− Text(stringResource(R.string.invalid_url))
104− }
105− },
106− keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
107− modifier = Modifier.fillMaxWidth().testTag("server_url"),
108− )
109− // Connectivity check — probes the server without credentials to detect
110− // whether auth is required. Auth fields are only revealed afterwards (or
111− // when editing a profile that already has saved credentials).
112− OutlinedButton(
113− onClick = { vm.probe() },
114− enabled = state.canSave && !state.probing && !state.saving,
115− modifier = Modifier.fillMaxWidth().testTag("server_probe"),
116− ) {
117− if (state.probing) {
118− CircularProgressIndicator(
119− modifier = Modifier.size(20.dp),
120− strokeWidth = 2.dp,
121− )
122− Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
123− } else {
124− Text(stringResource(R.string.check_connectivity))
112+ }
113+ }
114+
115+ if (showDiscardConfirm) {
116+ AlertDialog(
117+ onDismissRequest = { showDiscardConfirm = false },
118+ title = { Text(stringResource(R.string.discard_title)) },
119+ text = { Text(stringResource(R.string.discard_text)) },
120+ confirmButton = {
121+ TextButton(onClick = {
122+ showDiscardConfirm = false
123+ onDone()
124+ }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
125+ },
126+ dismissButton = {
127+ TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
128+ },
129+ )
130+ }
131+}
132+
133+@Composable
134+private fun ServerEditForm(
135+ state: ServerEditState,
136+ passwordVisible: Boolean,
137+ onTogglePassword: () -> Unit,
138+ padding: androidx.compose.foundation.layout.PaddingValues,
139+ vm: ServerEditViewModel,
140+ onDone: () -> Unit,
141+) {
142+ Column(
143+ modifier = Modifier
144+ .padding(padding)
145+ .imePadding()
146+ .widthIn(max = 600.dp)
147+ .padding(16.dp)
148+ .verticalScroll(rememberScrollState()),
149+ verticalArrangement = Arrangement.spacedBy(12.dp),
150+ ) {
151+ OutlinedTextField(
152+ value = state.label,
153+ onValueChange = { v -> vm.update { it.copy(label = v) } },
154+ label = { Text(stringResource(R.string.label_optional)) },
155+ singleLine = true,
156+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
157+ modifier = Modifier.fillMaxWidth().testTag("server_label"),
158+ )
159+ OutlinedTextField(
160+ value = state.baseUrl,
161+ onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null) } },
162+ label = { Text(stringResource(R.string.base_url)) },
163+ placeholder = { Text(stringResource(R.string.base_url_hint)) },
164+ singleLine = true,
165+ isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
166+ supportingText = {
167+ if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
168+ Text(stringResource(R.string.invalid_url))
125169 }
126− }
127− // Guide users through the probe-for-auth flow: auth fields only appear
128− // after a probe detects they're required, so a user who doesn't know to
129− // tap "Check connectivity" would otherwise be confused.
130− if (!state.authFieldsVisible) {
131− Text(
132− stringResource(R.string.auth_help),
133− style = MaterialTheme.typography.bodySmall,
134− color = MaterialTheme.colorScheme.onSurfaceVariant,
135− modifier = Modifier.fillMaxWidth(),
170+ },
171+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
172+ modifier = Modifier.fillMaxWidth().testTag("server_url"),
173+ )
174+ OutlinedButton(
175+ onClick = { vm.probe() },
176+ enabled = state.canSave && !state.probing && !state.saving,
177+ modifier = Modifier.fillMaxWidth().testTag("server_probe"),
178+ ) {
179+ if (state.probing) {
180+ CircularProgressIndicator(
181+ modifier = Modifier.size(20.dp),
182+ strokeWidth = 2.dp,
136183 )
184+ Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
185+ } else {
186+ Text(stringResource(R.string.check_connectivity))
137187 }
138− AnimatedVisibility(visible = state.authFieldsVisible) {
139− Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
140− Text(
141− stringResource(R.string.auth_required),
142− style = MaterialTheme.typography.bodySmall,
143− color = MaterialTheme.colorScheme.error,
144− )
145− OutlinedTextField(
146− value = state.username,
147− onValueChange = { v -> vm.update { it.copy(username = v) } },
148− label = { Text(stringResource(R.string.username_optional)) },
149− singleLine = true,
150− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
151− modifier = Modifier.fillMaxWidth().testTag("server_username"),
152− )
153− OutlinedTextField(
154− value = state.password,
155− onValueChange = { v -> vm.update { it.copy(password = v) } },
156− label = { Text(stringResource(R.string.password_optional)) },
157− singleLine = true,
158− visualTransformation = PasswordVisualTransformation(),
159− keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
160− keyboardActions = KeyboardActions(onDone = { if (state.canSave && !state.saving) vm.save(onDone) }),
161− modifier = Modifier.fillMaxWidth().testTag("server_password"),
162− )
163− }
164− }
188+ }
189+ if (!state.authFieldsVisible) {
190+ Text(
191+ stringResource(R.string.auth_help),
192+ style = MaterialTheme.typography.bodySmall,
193+ color = MaterialTheme.colorScheme.onSurfaceVariant,
194+ modifier = Modifier.fillMaxWidth(),
195+ )
196+ }
197+ AnimatedVisibility(visible = state.authFieldsVisible) {
198+ AuthFields(
199+ state = state,
200+ passwordVisible = passwordVisible,
201+ onTogglePassword = onTogglePassword,
202+ onUpdate = vm::update,
203+ onTestCredentials = vm::testCredentials,
204+ onImeDone = { if (state.canSave && !state.saving) vm.saveAndConnect(onDone) },
205+ )
206+ }
207+ Row(
208+ modifier = Modifier.fillMaxWidth(),
209+ horizontalArrangement = Arrangement.spacedBy(12.dp),
210+ ) {
165211 Button(
166212 onClick = { vm.save(onDone) },
167213 enabled = state.canSave && !state.saving,
168− modifier = Modifier.fillMaxWidth(),
214+ modifier = Modifier.weight(1f),
169215 ) {
170216 if (state.saving) {
171217 CircularProgressIndicator(
@@ -178,15 +224,89 @@ fun ServerEditScreen(
178224 Text(stringResource(R.string.save))
179225 }
180226 }
181− state.error?.let { message ->
182− Text(
183− message,
184− color = androidx.compose.material3.MaterialTheme.colorScheme.error,
185− style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
186− modifier = Modifier.fillMaxWidth(),
227+ Button(
228+ onClick = { vm.saveAndConnect(onDone) },
229+ enabled = state.canSave && !state.saving,
230+ modifier = Modifier.weight(1f),
231+ ) {
232+ Text(stringResource(R.string.save_and_connect))
233+ }
234+ }
235+ state.error?.let { message ->
236+ Text(
237+ message,
238+ color = MaterialTheme.colorScheme.error,
239+ style = MaterialTheme.typography.bodySmall,
240+ modifier = Modifier.fillMaxWidth(),
241+ )
242+ }
243+ }
244+}
245+
246+@Composable
247+private fun AuthFields(
248+ state: ServerEditState,
249+ passwordVisible: Boolean,
250+ onTogglePassword: () -> Unit,
251+ onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
252+ onTestCredentials: () -> Unit,
253+ onImeDone: () -> Unit,
254+) {
255+ val showPasswordLabel = stringResource(R.string.show_password)
256+ val hidePasswordLabel = stringResource(R.string.hide_password)
257+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
258+ Text(
259+ stringResource(R.string.auth_required),
260+ style = MaterialTheme.typography.bodySmall,
261+ color = MaterialTheme.colorScheme.error,
262+ )
263+ OutlinedTextField(
264+ value = state.username,
265+ onValueChange = { v -> onUpdate { it.copy(username = v) } },
266+ label = { Text(stringResource(R.string.username_optional)) },
267+ singleLine = true,
268+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
269+ modifier = Modifier.fillMaxWidth().testTag("server_username"),
270+ )
271+ OutlinedTextField(
272+ value = state.password,
273+ onValueChange = { v -> onUpdate { it.copy(password = v) } },
274+ label = { Text(stringResource(R.string.password_optional)) },
275+ singleLine = true,
276+ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
277+ trailingIcon = {
278+ IconButton(onClick = onTogglePassword) {
279+ Icon(
280+ if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
281+ contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
282+ )
283+ }
284+ },
285+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
286+ keyboardActions = KeyboardActions(onDone = { onImeDone() }),
287+ modifier = Modifier.fillMaxWidth().testTag("server_password"),
288+ )
289+ OutlinedButton(
290+ onClick = onTestCredentials,
291+ enabled = state.canSave && !state.testingCredentials && !state.saving,
292+ modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
293+ ) {
294+ if (state.testingCredentials) {
295+ CircularProgressIndicator(
296+ modifier = Modifier.size(20.dp),
297+ strokeWidth = 2.dp,
187298 )
299+ Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
300+ } else {
301+ Text(stringResource(R.string.test_credentials))
188302 }
189303 }
304+ state.credentialsResult?.let { ok ->
305+ Text(
306+ stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
307+ style = MaterialTheme.typography.bodySmall,
308+ color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
309+ )
190310 }
191311 }
192312}