@@ -0,0 +1,415 @@
1+package soy.iko.opencode.ui.server
2+
3+import androidx.activity.compose.BackHandler
4+import androidx.compose.animation.AnimatedVisibility
5+import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Box
7+import androidx.compose.foundation.layout.Column
8+import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.fillMaxSize
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.imePadding
12+import androidx.compose.foundation.layout.padding
13+import androidx.compose.foundation.layout.size
14+import androidx.compose.foundation.layout.widthIn
15+import androidx.compose.foundation.layout.wrapContentWidth
16+import androidx.compose.foundation.rememberScrollState
17+import androidx.compose.foundation.text.KeyboardActions
18+import androidx.compose.foundation.text.KeyboardOptions
19+import androidx.compose.foundation.verticalScroll
20+import androidx.compose.material.icons.Icons
21+import androidx.compose.material.icons.automirrored.filled.ArrowBack
22+import androidx.compose.material.icons.filled.Delete
23+import androidx.compose.material.icons.filled.ExpandLess
24+import androidx.compose.material.icons.filled.ExpandMore
25+import androidx.compose.material.icons.filled.Visibility
26+import androidx.compose.material.icons.filled.VisibilityOff
27+import androidx.compose.material3.AlertDialog
28+import androidx.compose.material3.Button
29+import androidx.compose.material3.CircularProgressIndicator
30+import androidx.compose.material3.ExperimentalMaterial3Api
31+import androidx.compose.material3.Icon
32+import androidx.compose.material3.IconButton
33+import androidx.compose.material3.MaterialTheme
34+import androidx.compose.material3.OutlinedButton
35+import androidx.compose.material3.OutlinedTextField
36+import androidx.compose.material3.Scaffold
37+import androidx.compose.material3.Switch
38+import androidx.compose.material3.Text
39+import androidx.compose.material3.TextButton
40+import androidx.compose.material3.TopAppBar
41+import androidx.compose.material3.TopAppBarDefaults
42+import androidx.compose.runtime.Composable
43+import androidx.compose.runtime.LaunchedEffect
44+import androidx.compose.runtime.getValue
45+import androidx.compose.runtime.mutableStateOf
46+import androidx.compose.runtime.remember
47+import androidx.compose.runtime.saveable.rememberSaveable
48+import androidx.compose.runtime.setValue
49+import androidx.compose.ui.Alignment
50+import androidx.compose.ui.Modifier
51+import androidx.compose.ui.focus.FocusRequester
52+import androidx.compose.ui.focus.focusRequester
53+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
54+import androidx.compose.ui.input.nestedscroll.nestedScroll
55+import androidx.compose.ui.platform.LocalHapticFeedback
56+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
57+import androidx.compose.ui.platform.testTag
58+import androidx.compose.ui.res.stringResource
59+import androidx.compose.ui.semantics.contentDescription
60+import androidx.compose.ui.semantics.semantics
61+import androidx.compose.ui.semantics.stateDescription
62+import androidx.compose.ui.text.input.ImeAction
63+import androidx.compose.ui.text.input.KeyboardType
64+import androidx.compose.ui.text.input.PasswordVisualTransformation
65+import androidx.compose.ui.text.input.VisualTransformation
66+import androidx.compose.ui.unit.dp
67+import androidx.lifecycle.compose.collectAsStateWithLifecycle
68+import androidx.lifecycle.viewmodel.compose.viewModel
69+import soy.iko.opencode.R
70+import soy.iko.opencode.di.AppContainer
71+import soy.iko.opencode.ui.components.AutofillHint
72+import soy.iko.opencode.ui.components.autofillModifier
73+import soy.iko.opencode.ui.vmFactory
74+
75+@OptIn(ExperimentalMaterial3Api::class)
76+@Composable
77+fun ServerSettingsScreen(
78+ container: AppContainer,
79+ profileId: String,
80+ onDone: () -> Unit,
81+) {
82+ val vm: ServerSettingsViewModel =
83+ viewModel(factory = vmFactory { ServerSettingsViewModel(container, profileId) })
84+ val state by vm.state.collectAsStateWithLifecycle()
85+ var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
86+ var passwordVisible by rememberSaveable { mutableStateOf(false) }
87+
88+ fun safeExit() {
89+ when {
90+ // A save is in flight: ignore the back gesture (matching the BackHandler guard
91+ // below). Popping here would cancel the save's viewModelScope coroutine and
92+ // silently abandon the write; the VM calls onDone itself once the save lands.
93+ state.saving -> Unit
94+ state.isDirty -> showDiscardConfirm = true
95+ else -> onDone()
96+ }
97+ }
98+
99+ BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
100+ if (!state.saving) showDiscardConfirm = true
101+ }
102+
103+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
104+ Scaffold(
105+ topBar = {
106+ TopAppBar(
107+ title = { Text(stringResource(R.string.server_settings)) },
108+ navigationIcon = {
109+ IconButton(onClick = ::safeExit) {
110+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
111+ }
112+ },
113+ actions = {
114+ var showDeleteConfirm by remember { mutableStateOf(false) }
115+ IconButton(onClick = { showDeleteConfirm = true }) {
116+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete_server))
117+ }
118+ if (showDeleteConfirm) {
119+ val displayName = state.label.takeIf { it.isNotBlank() } ?: state.baseUrl
120+ AlertDialog(
121+ onDismissRequest = { showDeleteConfirm = false },
122+ title = { Text(stringResource(R.string.delete_server)) },
123+ text = { Text(stringResource(R.string.remove_server_text, displayName)) },
124+ confirmButton = {
125+ TextButton(onClick = {
126+ showDeleteConfirm = false
127+ vm.delete(onDone)
128+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
129+ },
130+ dismissButton = {
131+ TextButton(onClick = { showDeleteConfirm = false }) { Text(stringResource(R.string.cancel)) }
132+ },
133+ )
134+ }
135+ },
136+ scrollBehavior = scrollBehavior,
137+ )
138+ },
139+ ) { padding ->
140+ if (!state.loaded) {
141+ val loadingLabel = stringResource(R.string.loading)
142+ Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
143+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
144+ }
145+ } else {
146+ ServerSettingsForm(
147+ state = state,
148+ passwordVisible = passwordVisible,
149+ onTogglePassword = { passwordVisible = !passwordVisible },
150+ padding = padding,
151+ scrollBehavior = scrollBehavior,
152+ vm = vm,
153+ onDone = onDone,
154+ )
155+ }
156+ }
157+
158+ if (showDiscardConfirm) {
159+ AlertDialog(
160+ onDismissRequest = { showDiscardConfirm = false },
161+ title = { Text(stringResource(R.string.discard_title)) },
162+ text = { Text(stringResource(R.string.discard_text)) },
163+ confirmButton = {
164+ TextButton(onClick = {
165+ showDiscardConfirm = false
166+ onDone()
167+ }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
168+ },
169+ dismissButton = {
170+ TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
171+ },
172+ )
173+ }
174+}
175+
176+@OptIn(ExperimentalMaterial3Api::class)
177+@Composable
178+private fun ServerSettingsForm(
179+ state: ServerEditState,
180+ passwordVisible: Boolean,
181+ onTogglePassword: () -> Unit,
182+ padding: androidx.compose.foundation.layout.PaddingValues,
183+ scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
184+ vm: ServerSettingsViewModel,
185+ onDone: () -> Unit,
186+) {
187+ val haptics = LocalHapticFeedback.current
188+ val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
189+ val urlValid = remember(state.baseUrl) { isValidUrl(state.baseUrl) }
190+ Column(
191+ modifier = Modifier
192+ .imePadding()
193+ .padding(padding)
194+ .fillMaxWidth()
195+ .wrapContentWidth(Alignment.CenterHorizontally)
196+ .widthIn(max = 600.dp)
197+ .padding(16.dp)
198+ .nestedScroll(scrollBehavior.nestedScrollConnection)
199+ .verticalScroll(rememberScrollState()),
200+ verticalArrangement = Arrangement.spacedBy(12.dp),
201+ ) {
202+ OutlinedTextField(
203+ value = state.baseUrl,
204+ onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
205+ label = { Text(stringResource(R.string.base_url)) },
206+ placeholder = { Text(stringResource(R.string.base_url_hint)) },
207+ singleLine = true,
208+ isError = state.baseUrl.isNotBlank() && !urlValid,
209+ supportingText = {
210+ if (state.baseUrl.isNotBlank() && !urlValid) {
211+ Text(stringResource(R.string.invalid_url))
212+ }
213+ },
214+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
215+ modifier = Modifier.fillMaxWidth().testTag("server_url"),
216+ )
217+ AnimatedVisibility(visible = state.authFieldsVisible) {
218+ AuthFields(
219+ state = state,
220+ passwordVisible = passwordVisible,
221+ onTogglePassword = onTogglePassword,
222+ onUpdate = vm::update,
223+ onTestCredentials = vm::testCredentials,
224+ onImeDone = { if (canSave && !state.saving) vm.save(onDone) },
225+ )
226+ }
227+ Button(
228+ onClick = {
229+ haptics.performHapticFeedback(HapticFeedbackType.LongPress)
230+ vm.save(onDone)
231+ },
232+ enabled = canSave && !state.saving,
233+ modifier = Modifier.fillMaxWidth().testTag("server_save"),
234+ ) {
235+ if (state.saving) {
236+ CircularProgressIndicator(
237+ modifier = Modifier.size(20.dp),
238+ color = MaterialTheme.colorScheme.onPrimary,
239+ strokeWidth = 2.dp,
240+ )
241+ Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
242+ } else {
243+ Text(stringResource(R.string.save))
244+ }
245+ }
246+ state.error?.let { message ->
247+ Text(
248+ message,
249+ color = MaterialTheme.colorScheme.error,
250+ style = MaterialTheme.typography.bodySmall,
251+ modifier = Modifier.fillMaxWidth(),
252+ )
253+ }
254+ OutlinedTextField(
255+ value = state.label,
256+ onValueChange = { v -> vm.update { it.copy(label = v) } },
257+ label = { Text(stringResource(R.string.label_optional)) },
258+ singleLine = true,
259+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
260+ modifier = Modifier.fillMaxWidth().testTag("server_label"),
261+ )
262+ AdvancedSection(state = state, onUpdate = vm::update)
263+ }
264+}
265+
266+@Composable
267+private fun AuthFields(
268+ state: ServerEditState,
269+ passwordVisible: Boolean,
270+ onTogglePassword: () -> Unit,
271+ onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
272+ onTestCredentials: () -> Unit,
273+ onImeDone: () -> Unit,
274+) {
275+ val showPasswordLabel = stringResource(R.string.show_password)
276+ val hidePasswordLabel = stringResource(R.string.hide_password)
277+ val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
278+ val usernameFocus = remember { FocusRequester() }
279+ val passwordFocus = remember { FocusRequester() }
280+ LaunchedEffect(Unit) {
281+ runCatching { usernameFocus.requestFocus() }
282+ }
283+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
284+ Text(
285+ stringResource(R.string.auth_required),
286+ style = MaterialTheme.typography.bodySmall,
287+ color = MaterialTheme.colorScheme.error,
288+ )
289+ OutlinedTextField(
290+ value = state.username,
291+ onValueChange = { v -> onUpdate { it.copy(username = v) } },
292+ label = { Text(stringResource(R.string.username_optional)) },
293+ singleLine = true,
294+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
295+ keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
296+ modifier = Modifier
297+ .fillMaxWidth()
298+ .focusRequester(usernameFocus)
299+ .then(autofillModifier(AutofillHint.Username) { v -> onUpdate { it.copy(username = v) } })
300+ .testTag("server_username"),
301+ )
302+ OutlinedTextField(
303+ value = state.password,
304+ onValueChange = { v -> onUpdate { it.copy(password = v) } },
305+ label = { Text(stringResource(R.string.password_optional)) },
306+ singleLine = true,
307+ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
308+ trailingIcon = {
309+ IconButton(onClick = onTogglePassword) {
310+ Icon(
311+ if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
312+ contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
313+ )
314+ }
315+ },
316+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
317+ keyboardActions = KeyboardActions(onDone = { onImeDone() }),
318+ modifier = Modifier
319+ .fillMaxWidth()
320+ .focusRequester(passwordFocus)
321+ .then(autofillModifier(AutofillHint.Password) { v -> onUpdate { it.copy(password = v) } })
322+ .testTag("server_password"),
323+ )
324+ OutlinedButton(
325+ onClick = onTestCredentials,
326+ enabled = canSave && !state.testingCredentials && !state.saving &&
327+ (state.username.isNotBlank() || state.password.isNotBlank()),
328+ modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
329+ ) {
330+ if (state.testingCredentials) {
331+ CircularProgressIndicator(
332+ modifier = Modifier.size(20.dp),
333+ strokeWidth = 2.dp,
334+ )
335+ Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
336+ } else {
337+ Text(stringResource(R.string.test_credentials))
338+ }
339+ }
340+ state.credentialsResult?.let { ok ->
341+ Text(
342+ stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
343+ style = MaterialTheme.typography.bodySmall,
344+ color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
345+ )
346+ }
347+ }
348+}
349+
350+@Composable
351+private fun AdvancedSection(
352+ state: ServerEditState,
353+ onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
354+) {
355+ var expanded by rememberSaveable { mutableStateOf(state.requireHttps || state.certPin.isNotBlank()) }
356+ val stateLabel = stringResource(if (expanded) R.string.state_expanded else R.string.state_collapsed)
357+ Column(modifier = Modifier.fillMaxWidth()) {
358+ TextButton(
359+ onClick = { expanded = !expanded },
360+ modifier = Modifier.semantics { stateDescription = stateLabel },
361+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
362+ ) {
363+ Icon(
364+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
365+ contentDescription = null,
366+ modifier = Modifier.size(18.dp),
367+ )
368+ Text(stringResource(R.string.server_security), modifier = Modifier.padding(start = 8.dp))
369+ }
370+ AnimatedVisibility(visible = expanded) {
371+ SecurityFields(state = state, onUpdate = onUpdate)
372+ }
373+ }
374+}
375+
376+@Composable
377+private fun SecurityFields(
378+ state: ServerEditState,
379+ onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
380+) {
381+ Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
382+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
383+ Column(modifier = Modifier.weight(1f)) {
384+ Text(stringResource(R.string.require_https), style = MaterialTheme.typography.bodyLarge)
385+ Text(
386+ stringResource(R.string.require_https_desc),
387+ style = MaterialTheme.typography.bodySmall,
388+ color = MaterialTheme.colorScheme.onSurfaceVariant,
389+ )
390+ }
391+ Switch(
392+ checked = state.requireHttps,
393+ onCheckedChange = { v -> onUpdate { it.copy(requireHttps = v) } },
394+ )
395+ }
396+ OutlinedTextField(
397+ value = state.certPin,
398+ onValueChange = { v -> onUpdate { it.copy(certPin = v) } },
399+ label = { Text(stringResource(R.string.cert_pin)) },
400+ placeholder = { Text(stringResource(R.string.cert_pin_hint)) },
401+ singleLine = true,
402+ isError = !state.certPinValid,
403+ supportingText = {
404+ Text(
405+ stringResource(if (state.certPinValid) R.string.cert_pin_desc else R.string.cert_pin_invalid),
406+ style = MaterialTheme.typography.bodySmall,
407+ color = if (state.certPinValid) MaterialTheme.colorScheme.onSurfaceVariant
408+ else MaterialTheme.colorScheme.error,
409+ )
410+ },
411+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
412+ modifier = Modifier.fillMaxWidth().testTag("server_cert_pin"),
413+ )
414+ }
415+}