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 var passwordVisible by rememberSaveable { mutableStateOf(false) }
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.
94 state.isDirty -> showDiscardConfirm = true
99 BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
100 if (!state.saving) showDiscardConfirm = true
103 val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
107 title = { Text(stringResource(R.string.server_settings)) },
109 IconButton(onClick = ::safeExit) {
110 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
114 var showDeleteConfirm by remember { mutableStateOf(false) }
115 IconButton(onClick = { showDeleteConfirm = true }) {
116 Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete_server))
118 if (showDeleteConfirm) {
119 val displayName = state.label.takeIf { it.isNotBlank() } ?: state.baseUrl
121 onDismissRequest = { showDeleteConfirm = false },
122 title = { Text(stringResource(R.string.delete_server)) },
123 text = { Text(stringResource(R.string.remove_server_text, displayName)) },
125 TextButton(onClick = {
126 showDeleteConfirm = false
128 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
131 TextButton(onClick = { showDeleteConfirm = false }) { Text(stringResource(R.string.cancel)) }
136 scrollBehavior = scrollBehavior,
141 val loadingLabel = stringResource(R.string.loading)
142 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
143 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
148 passwordVisible = passwordVisible,
149 onTogglePassword = { passwordVisible = !passwordVisible },
151 scrollBehavior = scrollBehavior,
158 if (showDiscardConfirm) {
160 onDismissRequest = { showDiscardConfirm = false },
161 title = { Text(stringResource(R.string.discard_title)) },
162 text = { Text(stringResource(R.string.discard_text)) },
164 TextButton(onClick = {
165 showDiscardConfirm = false
167 }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
170 TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
176@OptIn(ExperimentalMaterial3Api::class)
178private 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,
187 val haptics = LocalHapticFeedback.current
188 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
189 val urlValid = remember(state.baseUrl) { isValidUrl(state.baseUrl) }
195 .wrapContentWidth(Alignment.CenterHorizontally)
196 .widthIn(max = 600.dp)
198 .nestedScroll(scrollBehavior.nestedScrollConnection)
199 .verticalScroll(rememberScrollState()),
200 verticalArrangement = Arrangement.spacedBy(12.dp),
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)) },
208 isError = state.baseUrl.isNotBlank() && !urlValid,
210 if (state.baseUrl.isNotBlank() && !urlValid) {
211 Text(stringResource(R.string.invalid_url))
214 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
215 modifier = Modifier.fillMaxWidth().testTag("server_url"),
217 AnimatedVisibility(visible = state.authFieldsVisible) {
220 passwordVisible = passwordVisible,
221 onTogglePassword = onTogglePassword,
222 onUpdate = vm::update,
223 onTestCredentials = vm::testCredentials,
224 onImeDone = { if (canSave && !state.saving) vm.save(onDone) },
229 haptics.performHapticFeedback(HapticFeedbackType.LongPress)
232 enabled = canSave && !state.saving,
233 modifier = Modifier.fillMaxWidth().testTag("server_save"),
236 CircularProgressIndicator(
237 modifier = Modifier.size(20.dp),
238 color = MaterialTheme.colorScheme.onPrimary,
241 Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
243 Text(stringResource(R.string.save))
246 state.error?.let { message ->
249 color = MaterialTheme.colorScheme.error,
250 style = MaterialTheme.typography.bodySmall,
251 modifier = Modifier.fillMaxWidth(),
256 onValueChange = { v -> vm.update { it.copy(label = v) } },
257 label = { Text(stringResource(R.string.label_optional)) },
259 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
260 modifier = Modifier.fillMaxWidth().testTag("server_label"),
262 AdvancedSection(state = state, onUpdate = vm::update)
267private fun AuthFields(
268 state: ServerEditState,
269 passwordVisible: Boolean,
270 onTogglePassword: () -> Unit,
271 onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
272 onTestCredentials: () -> Unit,
273 onImeDone: () -> Unit,
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() }
283 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
285 stringResource(R.string.auth_required),
286 style = MaterialTheme.typography.bodySmall,
287 color = MaterialTheme.colorScheme.error,
290 value = state.username,
291 onValueChange = { v -> onUpdate { it.copy(username = v) } },
292 label = { Text(stringResource(R.string.username_optional)) },
294 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
295 keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
298 .focusRequester(usernameFocus)
299 .then(autofillModifier(AutofillHint.Username) { v -> onUpdate { it.copy(username = v) } })
300 .testTag("server_username"),
303 value = state.password,
304 onValueChange = { v -> onUpdate { it.copy(password = v) } },
305 label = { Text(stringResource(R.string.password_optional)) },
307 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
309 IconButton(onClick = onTogglePassword) {
311 if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
312 contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
316 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
317 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
320 .focusRequester(passwordFocus)
321 .then(autofillModifier(AutofillHint.Password) { v -> onUpdate { it.copy(password = v) } })
322 .testTag("server_password"),
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"),
330 if (state.testingCredentials) {
331 CircularProgressIndicator(
332 modifier = Modifier.size(20.dp),
335 Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
337 Text(stringResource(R.string.test_credentials))
340 state.credentialsResult?.let { ok ->
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,
351private fun AdvancedSection(
352 state: ServerEditState,
353 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
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()) {
359 onClick = { expanded = !expanded },
360 modifier = Modifier.semantics { stateDescription = stateLabel },
361 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
364 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
365 contentDescription = null,
366 modifier = Modifier.size(18.dp),
368 Text(stringResource(R.string.server_security), modifier = Modifier.padding(start = 8.dp))
370 AnimatedVisibility(visible = expanded) {
371 SecurityFields(state = state, onUpdate = onUpdate)
377private fun SecurityFields(
378 state: ServerEditState,
379 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
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)
386 stringResource(R.string.require_https_desc),
387 style = MaterialTheme.typography.bodySmall,
388 color = MaterialTheme.colorScheme.onSurfaceVariant,
392 checked = state.requireHttps,
393 onCheckedChange = { v -> onUpdate { it.copy(requireHttps = v) } },
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)) },
402 isError = !state.certPinValid,
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,
411 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
412 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