1package soy.iko.opencode.ui.server
3import androidx.activity.compose.BackHandler
4import androidx.compose.animation.AnimatedVisibility
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.imePadding
12import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.layout.widthIn
15import androidx.compose.foundation.layout.wrapContentWidth
16import androidx.compose.foundation.rememberScrollState
17import androidx.compose.foundation.text.KeyboardActions
18import androidx.compose.foundation.text.KeyboardOptions
19import androidx.compose.foundation.verticalScroll
20import androidx.compose.material.icons.Icons
21import androidx.compose.material.icons.automirrored.filled.ArrowBack
22import androidx.compose.material.icons.filled.Delete
23import androidx.compose.material.icons.filled.ExpandLess
24import androidx.compose.material.icons.filled.ExpandMore
25import androidx.compose.material.icons.filled.Visibility
26import androidx.compose.material.icons.filled.VisibilityOff
27import androidx.compose.material3.AlertDialog
28import androidx.compose.material3.Button
29import androidx.compose.material3.CircularProgressIndicator
30import androidx.compose.material3.ExperimentalMaterial3Api
31import androidx.compose.material3.Icon
32import androidx.compose.material3.IconButton
33import androidx.compose.material3.MaterialTheme
34import androidx.compose.material3.OutlinedButton
35import androidx.compose.material3.OutlinedTextField
36import androidx.compose.material3.Scaffold
37import androidx.compose.material3.Switch
38import androidx.compose.material3.Text
39import androidx.compose.material3.TextButton
40import androidx.compose.material3.TopAppBar
41import androidx.compose.material3.TopAppBarDefaults
42import androidx.compose.runtime.Composable
43import androidx.compose.runtime.LaunchedEffect
44import androidx.compose.runtime.getValue
45import androidx.compose.runtime.mutableStateOf
46import androidx.compose.runtime.remember
47import androidx.compose.runtime.saveable.rememberSaveable
48import androidx.compose.runtime.setValue
49import androidx.compose.ui.Alignment
50import androidx.compose.ui.Modifier
51import androidx.compose.ui.focus.FocusRequester
52import androidx.compose.ui.focus.focusRequester
53import androidx.compose.ui.hapticfeedback.HapticFeedbackType
54import androidx.compose.ui.input.nestedscroll.nestedScroll
55import androidx.compose.ui.platform.LocalHapticFeedback
56import androidx.compose.ui.platform.LocalSoftwareKeyboardController
57import androidx.compose.ui.platform.testTag
58import androidx.compose.ui.res.stringResource
59import androidx.compose.ui.semantics.contentDescription
60import androidx.compose.ui.semantics.semantics
61import androidx.compose.ui.semantics.stateDescription
62import androidx.compose.ui.text.input.ImeAction
63import androidx.compose.ui.text.input.KeyboardType
64import androidx.compose.ui.text.input.PasswordVisualTransformation
65import androidx.compose.ui.text.input.VisualTransformation
66import androidx.compose.ui.unit.dp
67import androidx.lifecycle.compose.collectAsStateWithLifecycle
68import androidx.lifecycle.viewmodel.compose.viewModel
69import soy.iko.opencode.R
70import soy.iko.opencode.di.AppContainer
71import soy.iko.opencode.ui.components.AutofillHint
72import soy.iko.opencode.ui.components.autofillModifier
73import soy.iko.opencode.ui.vmFactory
75@OptIn(ExperimentalMaterial3Api::class)
77fun ServerSettingsScreen(
78 container: AppContainer,
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 // Keyed on profileId so the password-reveal flag resets when a different profile is loaded
87 // into the same back-stack entry. Without the key, revealing server A's password and then
88 // viewing server B (which reuses this entry via nav back-and-forth) would show B's password
89 // in cleartext without the user tapping the eye icon — a minor credential info-leak.
90 var passwordVisible by rememberSaveable(profileId) { mutableStateOf(false) }
94 // A save is in flight: ignore the back gesture (matching the BackHandler guard
95 // below). Popping here would cancel the save's viewModelScope coroutine and
96 // silently abandon the write; the VM calls onDone itself once the save lands.
98 state.isDirty -> showDiscardConfirm = true
103 BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
104 if (!state.saving) showDiscardConfirm = true
107 val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
111 title = { Text(stringResource(R.string.server_settings)) },
113 IconButton(onClick = ::safeExit) {
114 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
118 var showDeleteConfirm by remember { mutableStateOf(false) }
119 IconButton(onClick = { showDeleteConfirm = true }) {
120 Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete_server))
122 if (showDeleteConfirm) {
123 val displayName = state.label.takeIf { it.isNotBlank() } ?: state.baseUrl
125 onDismissRequest = { showDeleteConfirm = false },
126 title = { Text(stringResource(R.string.delete_server)) },
127 text = { Text(stringResource(R.string.remove_server_text, displayName)) },
129 TextButton(onClick = {
130 showDeleteConfirm = false
132 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
135 TextButton(onClick = { showDeleteConfirm = false }) { Text(stringResource(R.string.cancel)) }
140 scrollBehavior = scrollBehavior,
145 val loadingLabel = stringResource(R.string.loading)
146 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
147 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
152 passwordVisible = passwordVisible,
153 onTogglePassword = { passwordVisible = !passwordVisible },
155 scrollBehavior = scrollBehavior,
162 if (showDiscardConfirm) {
164 onDismissRequest = { showDiscardConfirm = false },
165 title = { Text(stringResource(R.string.discard_title)) },
166 text = { Text(stringResource(R.string.discard_text)) },
168 TextButton(onClick = {
169 showDiscardConfirm = false
171 }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
174 TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
180@OptIn(ExperimentalMaterial3Api::class)
182private fun ServerSettingsForm(
183 state: ServerEditState,
184 passwordVisible: Boolean,
185 onTogglePassword: () -> Unit,
186 padding: androidx.compose.foundation.layout.PaddingValues,
187 scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
188 vm: ServerSettingsViewModel,
191 val haptics = LocalHapticFeedback.current
192 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
193 val urlValid = remember(state.baseUrl) { isValidUrl(state.baseUrl) }
199 .wrapContentWidth(Alignment.CenterHorizontally)
200 .widthIn(max = 600.dp)
202 .nestedScroll(scrollBehavior.nestedScrollConnection)
203 .verticalScroll(rememberScrollState()),
204 verticalArrangement = Arrangement.spacedBy(12.dp),
207 value = state.baseUrl,
208 onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
209 label = { Text(stringResource(R.string.base_url)) },
210 placeholder = { Text(stringResource(R.string.base_url_hint)) },
212 isError = state.baseUrl.isNotBlank() && !urlValid,
214 if (state.baseUrl.isNotBlank() && !urlValid) {
215 Text(stringResource(R.string.invalid_url))
218 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
219 modifier = Modifier.fillMaxWidth().testTag("server_url"),
221 AnimatedVisibility(visible = state.authFieldsVisible) {
224 passwordVisible = passwordVisible,
225 onTogglePassword = onTogglePassword,
226 onUpdate = vm::update,
227 onTestCredentials = vm::testCredentials,
228 onImeDone = { if (canSave && !state.saving) vm.save(onDone) },
231 // Warn when credentials will travel over cleartext HTTP (mirrors ServerEditScreen).
232 if (state.baseUrl.lowercase().startsWith("http://") && state.username.isNotBlank()) {
234 stringResource(R.string.cleartext_credentials_warning),
235 color = MaterialTheme.colorScheme.error,
236 style = MaterialTheme.typography.bodySmall,
237 modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
242 haptics.performHapticFeedback(HapticFeedbackType.LongPress)
245 enabled = canSave && !state.saving,
246 modifier = Modifier.fillMaxWidth().testTag("server_save"),
249 CircularProgressIndicator(
250 modifier = Modifier.size(20.dp),
251 color = MaterialTheme.colorScheme.onPrimary,
254 Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
256 Text(stringResource(R.string.save))
259 state.error?.let { message ->
262 color = MaterialTheme.colorScheme.error,
263 style = MaterialTheme.typography.bodySmall,
264 modifier = Modifier.fillMaxWidth(),
269 onValueChange = { v -> vm.update { it.copy(label = v) } },
270 label = { Text(stringResource(R.string.label_optional)) },
272 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
273 modifier = Modifier.fillMaxWidth().testTag("server_label"),
275 AdvancedSection(state = state, onUpdate = vm::update)
280private fun AuthFields(
281 state: ServerEditState,
282 passwordVisible: Boolean,
283 onTogglePassword: () -> Unit,
284 onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
285 onTestCredentials: () -> Unit,
286 onImeDone: () -> Unit,
288 val showPasswordLabel = stringResource(R.string.show_password)
289 val hidePasswordLabel = stringResource(R.string.hide_password)
290 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
291 val usernameFocus = remember { FocusRequester() }
292 val passwordFocus = remember { FocusRequester() }
293 LaunchedEffect(Unit) {
294 runCatching { usernameFocus.requestFocus() }
296 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
298 stringResource(R.string.auth_required),
299 style = MaterialTheme.typography.bodySmall,
300 color = MaterialTheme.colorScheme.error,
303 value = state.username,
304 onValueChange = { v -> onUpdate { it.copy(username = v) } },
305 label = { Text(stringResource(R.string.username_optional)) },
307 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
308 keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
311 .focusRequester(usernameFocus)
312 .then(autofillModifier(AutofillHint.Username) { v -> onUpdate { it.copy(username = v) } })
313 .testTag("server_username"),
316 value = state.password,
317 onValueChange = { v -> onUpdate { it.copy(password = v) } },
318 label = { Text(stringResource(R.string.password_optional)) },
320 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
322 IconButton(onClick = onTogglePassword) {
324 if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
325 contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
329 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
330 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
333 .focusRequester(passwordFocus)
334 .then(autofillModifier(AutofillHint.Password) { v -> onUpdate { it.copy(password = v) } })
335 .testTag("server_password"),
338 onClick = onTestCredentials,
339 enabled = canSave && !state.testingCredentials && !state.saving &&
340 (state.username.isNotBlank() || state.password.isNotBlank()),
341 modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
343 if (state.testingCredentials) {
344 CircularProgressIndicator(
345 modifier = Modifier.size(20.dp),
348 Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
350 Text(stringResource(R.string.test_credentials))
353 state.credentialsResult?.let { ok ->
355 stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
356 style = MaterialTheme.typography.bodySmall,
357 color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
364private fun AdvancedSection(
365 state: ServerEditState,
366 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
368 var expanded by rememberSaveable { mutableStateOf(state.requireHttps || state.certPin.isNotBlank()) }
369 val stateLabel = stringResource(if (expanded) R.string.state_expanded else R.string.state_collapsed)
370 Column(modifier = Modifier.fillMaxWidth()) {
372 onClick = { expanded = !expanded },
373 modifier = Modifier.semantics { stateDescription = stateLabel },
374 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
377 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
378 contentDescription = null,
379 modifier = Modifier.size(18.dp),
381 Text(stringResource(R.string.server_security), modifier = Modifier.padding(start = 8.dp))
383 AnimatedVisibility(visible = expanded) {
384 SecurityFields(state = state, onUpdate = onUpdate)
390private fun SecurityFields(
391 state: ServerEditState,
392 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
394 Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
395 Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
396 Column(modifier = Modifier.weight(1f)) {
397 Text(stringResource(R.string.require_https), style = MaterialTheme.typography.bodyLarge)
399 stringResource(R.string.require_https_desc),
400 style = MaterialTheme.typography.bodySmall,
401 color = MaterialTheme.colorScheme.onSurfaceVariant,
405 checked = state.requireHttps,
406 onCheckedChange = { v -> onUpdate { it.copy(requireHttps = v) } },
410 value = state.certPin,
411 onValueChange = { v -> onUpdate { it.copy(certPin = v) } },
412 label = { Text(stringResource(R.string.cert_pin)) },
413 placeholder = { Text(stringResource(R.string.cert_pin_hint)) },
415 isError = !state.certPinValid,
418 stringResource(if (state.certPinValid) R.string.cert_pin_desc else R.string.cert_pin_invalid),
419 style = MaterialTheme.typography.bodySmall,
420 color = if (state.certPinValid) MaterialTheme.colorScheme.onSurfaceVariant
421 else MaterialTheme.colorScheme.error,
424 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
425 modifier = Modifier.fillMaxWidth().testTag("server_cert_pin"),
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list