@@ -1,9 +1,11 @@
1package soy.iko.opencode.ui.server
1package soy.iko.opencode.ui.server
3+import androidx.activity.compose.BackHandler
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Column
8+import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.imePadding
11import androidx.compose.foundation.layout.imePadding
@@ -16,6 +18,9 @@ import androidx.compose.foundation.text.KeyboardOptions
16import androidx.compose.foundation.verticalScroll
18import androidx.compose.foundation.verticalScroll
17import androidx.compose.material.icons.Icons
19import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.automirrored.filled.ArrowBack
20import 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
19import androidx.compose.material3.Button
24import androidx.compose.material3.Button
20import androidx.compose.material3.CircularProgressIndicator
25import androidx.compose.material3.CircularProgressIndicator
21import androidx.compose.material3.ExperimentalMaterial3Api
26import androidx.compose.material3.ExperimentalMaterial3Api
@@ -26,9 +31,14 @@ import androidx.compose.material3.OutlinedButton
26import androidx.compose.material3.OutlinedTextField
31import androidx.compose.material3.OutlinedTextField
27import androidx.compose.material3.Scaffold
32import androidx.compose.material3.Scaffold
28import androidx.compose.material3.Text
33import androidx.compose.material3.Text
34+import androidx.compose.material3.TextButton
29import androidx.compose.material3.TopAppBar
35import androidx.compose.material3.TopAppBar
30import androidx.compose.runtime.Composable
36import androidx.compose.runtime.Composable
31import androidx.compose.runtime.getValue
37import 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
32import androidx.compose.ui.Alignment
42import androidx.compose.ui.Alignment
33import androidx.compose.ui.Modifier
43import androidx.compose.ui.Modifier
34import androidx.compose.ui.platform.testTag
44import androidx.compose.ui.platform.testTag
@@ -37,6 +47,7 @@ import androidx.compose.ui.semantics.semantics
37import androidx.compose.ui.text.input.ImeAction
47import androidx.compose.ui.text.input.ImeAction
38import androidx.compose.ui.text.input.KeyboardType
48import androidx.compose.ui.text.input.KeyboardType
39import androidx.compose.ui.text.input.PasswordVisualTransformation
49import androidx.compose.ui.text.input.PasswordVisualTransformation
50+import androidx.compose.ui.text.input.VisualTransformation
40import androidx.compose.ui.res.stringResource
51import androidx.compose.ui.res.stringResource
41import androidx.compose.ui.unit.dp
52import androidx.compose.ui.unit.dp
42import androidx.lifecycle.compose.collectAsStateWithLifecycle
53import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -55,13 +66,29 @@ fun ServerEditScreen(
55 val vm: ServerEditViewModel =
66 val vm: ServerEditViewModel =
56 viewModel(factory = vmFactory { ServerEditViewModel(container, profileId) })
67 viewModel(factory = vmFactory { ServerEditViewModel(container, profileId) })
57 val state by vm.state.collectAsStateWithLifecycle()
68 val state by vm.state.collectAsStateWithLifecycle()
69+ var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
70+ var passwordVisible by rememberSaveable { mutableStateOf(false) }
73+ if (state.isDirty && !state.saving) {
74+ showDiscardConfirm = true
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
60 topBar = {
87 topBar = {
61 TopAppBar(
88 TopAppBar(
62 title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
89 title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
63 navigationIcon = {
90 navigationIcon = {
64− IconButton(onClick = onDone) {
91+ IconButton(onClick = ::safeExit) {
65 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
92 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
@@ -74,98 +101,117 @@ fun ServerEditScreen(
74 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
101 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
77− Column(
104+ ServerEditForm(
78− modifier = Modifier
105+ state = state,
79− .padding(padding)
106+ passwordVisible = passwordVisible,
80− .imePadding()
107+ onTogglePassword = { passwordVisible = !passwordVisible },
81− .widthIn(max = 600.dp)
108+ padding = padding,
82− .padding(16.dp)
109+ vm = vm,
83− .verticalScroll(rememberScrollState()),
110+ onDone = onDone,
84− verticalArrangement = Arrangement.spacedBy(12.dp),
88− onValueChange = { v -> vm.update { it.copy(label = v) } },
89− label = { Text(stringResource(R.string.label_optional)) },
91− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
92− modifier = Modifier.fillMaxWidth().testTag("server_label"),
94− OutlinedTextField(
112+ }
115+ if (showDiscardConfirm) {
117+ onDismissRequest = { showDiscardConfirm = false },
118+ title = { Text(stringResource(R.string.discard_title)) },
119+ text = { Text(stringResource(R.string.discard_text)) },
121+ TextButton(onClick = {
122+ showDiscardConfirm = false
124+ }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
127+ TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
134+private fun ServerEditForm(
135+ state: ServerEditState,
136+ passwordVisible: Boolean,
137+ onTogglePassword: () -> Unit,
138+ padding: androidx.compose.foundation.layout.PaddingValues,
139+ vm: ServerEditViewModel,
146+ .widthIn(max = 600.dp)
148+ .verticalScroll(rememberScrollState()),
149+ verticalArrangement = Arrangement.spacedBy(12.dp),
95− value = state.baseUrl,
152+ value = state.label,
153+ onValueChange = { v -> vm.update { it.copy(label = v) } },
154+ label = { Text(stringResource(R.string.label_optional)) },
156+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
157+ modifier = Modifier.fillMaxWidth().testTag("server_label"),
160+ value = state.baseUrl,
96− onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null) } },
161+ onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null) } },
97− label = { Text(stringResource(R.string.base_url)) },
162+ label = { Text(stringResource(R.string.base_url)) },
98− placeholder = { Text(stringResource(R.string.base_url_hint)) },
163+ placeholder = { Text(stringResource(R.string.base_url_hint)) },
99− singleLine = true,
164+ singleLine = true,
100− isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
165+ isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
101− supportingText = {
166+ supportingText = {
102− if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
167+ if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
103− Text(stringResource(R.string.invalid_url))
168+ Text(stringResource(R.string.invalid_url))
106− keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
107− modifier = Modifier.fillMaxWidth().testTag("server_url"),
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).
113− onClick = { vm.probe() },
114− enabled = state.canSave && !state.probing && !state.saving,
115− modifier = Modifier.fillMaxWidth().testTag("server_probe"),
117− if (state.probing) {
118− CircularProgressIndicator(
119− modifier = Modifier.size(20.dp),
122− Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
124− Text(stringResource(R.string.check_connectivity))
127− // Guide users through the probe-for-auth flow: auth fields only appear
171+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
128− // after a probe detects they're required, so a user who doesn't know to
172+ modifier = Modifier.fillMaxWidth().testTag("server_url"),
129− // tap "Check connectivity" would otherwise be confused.
173+ )
130− if (!state.authFieldsVisible) {
174+ OutlinedButton(
131− Text(
175+ onClick = { vm.probe() },
132− stringResource(R.string.auth_help),
176+ enabled = state.canSave && !state.probing && !state.saving,
133− style = MaterialTheme.typography.bodySmall,
177+ modifier = Modifier.fillMaxWidth().testTag("server_probe"),
134− color = MaterialTheme.colorScheme.onSurfaceVariant,
178+ ) {
135− modifier = Modifier.fillMaxWidth(),
179+ if (state.probing) {
180+ CircularProgressIndicator(
181+ modifier = Modifier.size(20.dp),
184+ Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
186+ Text(stringResource(R.string.check_connectivity))
138− AnimatedVisibility(visible = state.authFieldsVisible) {
189+ if (!state.authFieldsVisible) {
139− Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
141− stringResource(R.string.auth_required),
191+ stringResource(R.string.auth_help),
142− style = MaterialTheme.typography.bodySmall,
192+ style = MaterialTheme.typography.bodySmall,
143− color = MaterialTheme.colorScheme.error,
193+ color = MaterialTheme.colorScheme.onSurfaceVariant,
144− )
194+ modifier = Modifier.fillMaxWidth(),
145− OutlinedTextField(
195+ )
146− value = state.username,
196+ }
147− onValueChange = { v -> vm.update { it.copy(username = v) } },
197+ AnimatedVisibility(visible = state.authFieldsVisible) {
148− label = { Text(stringResource(R.string.username_optional)) },
198+ AuthFields(
149− singleLine = true,
199+ state = state,
150− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
200+ passwordVisible = passwordVisible,
151− modifier = Modifier.fillMaxWidth().testTag("server_username"),
201+ onTogglePassword = onTogglePassword,
152− )
202+ onUpdate = vm::update,
153− OutlinedTextField(
203+ onTestCredentials = vm::testCredentials,
154− value = state.password,
155− onValueChange = { v -> vm.update { it.copy(password = v) } },
156− label = { Text(stringResource(R.string.password_optional)) },
158− visualTransformation = PasswordVisualTransformation(),
159− keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
160− keyboardActions = KeyboardActions(onDone = { if (state.canSave && !state.saving) vm.save(onDone) }),
204+ onImeDone = { if (state.canSave && !state.saving) vm.saveAndConnect(onDone) },
161− modifier = Modifier.fillMaxWidth().testTag("server_password"),
208+ modifier = Modifier.fillMaxWidth(),
209+ horizontalArrangement = Arrangement.spacedBy(12.dp),
166 onClick = { vm.save(onDone) },
212 onClick = { vm.save(onDone) },
167 enabled = state.canSave && !state.saving,
213 enabled = state.canSave && !state.saving,
168− modifier = Modifier.fillMaxWidth(),
214+ modifier = Modifier.weight(1f),
170 if (state.saving) {
216 if (state.saving) {
171 CircularProgressIndicator(
217 CircularProgressIndicator(
@@ -178,15 +224,89 @@ fun ServerEditScreen(
178 Text(stringResource(R.string.save))
224 Text(stringResource(R.string.save))
228+ onClick = { vm.saveAndConnect(onDone) },
229+ enabled = state.canSave && !state.saving,
230+ modifier = Modifier.weight(1f),
232+ Text(stringResource(R.string.save_and_connect))
181− state.error?.let { message ->
235+ state.error?.let { message ->
183− message,
237+ message,
184− color = androidx.compose.material3.MaterialTheme.colorScheme.error,
238+ color = MaterialTheme.colorScheme.error,
239+ style = MaterialTheme.typography.bodySmall,
240+ modifier = Modifier.fillMaxWidth(),
247+private fun AuthFields(
248+ state: ServerEditState,
249+ passwordVisible: Boolean,
250+ onTogglePassword: () -> Unit,
251+ onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
252+ onTestCredentials: () -> Unit,
253+ onImeDone: () -> Unit,
255+ val showPasswordLabel = stringResource(R.string.show_password)
256+ val hidePasswordLabel = stringResource(R.string.hide_password)
257+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
259+ stringResource(R.string.auth_required),
185− style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
260+ style = MaterialTheme.typography.bodySmall,
261+ color = MaterialTheme.colorScheme.error,
264+ value = state.username,
265+ onValueChange = { v -> onUpdate { it.copy(username = v) } },
266+ label = { Text(stringResource(R.string.username_optional)) },
268+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
269+ modifier = Modifier.fillMaxWidth().testTag("server_username"),
272+ value = state.password,
273+ onValueChange = { v -> onUpdate { it.copy(password = v) } },
274+ label = { Text(stringResource(R.string.password_optional)) },
276+ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
278+ IconButton(onClick = onTogglePassword) {
280+ if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
281+ contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
285+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
286+ keyboardActions = KeyboardActions(onDone = { onImeDone() }),
186− modifier = Modifier.fillMaxWidth(),
287+ modifier = Modifier.fillMaxWidth().testTag("server_password"),
290+ onClick = onTestCredentials,
291+ enabled = state.canSave && !state.testingCredentials && !state.saving,
292+ modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
294+ if (state.testingCredentials) {
295+ CircularProgressIndicator(
296+ modifier = Modifier.size(20.dp),
299+ Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
301+ Text(stringResource(R.string.test_credentials))
304+ state.credentialsResult?.let { ok ->
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,