git.iko.soy/opencode-android
on d518ef7 help sign in
428 lines 19 kB history raw
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)
76@Composable
77fun 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 // 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) }
92 fun safeExit() {
93 when {
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.
97 state.saving -> Unit
98 state.isDirty -> showDiscardConfirm = true
99 else -> onDone()
100 }
101 }
103 BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
104 if (!state.saving) showDiscardConfirm = true
105 }
107 val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
108 Scaffold(
109 topBar = {
110 TopAppBar(
111 title = { Text(stringResource(R.string.server_settings)) },
112 navigationIcon = {
113 IconButton(onClick = ::safeExit) {
114 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
115 }
116 },
117 actions = {
118 var showDeleteConfirm by remember { mutableStateOf(false) }
119 IconButton(onClick = { showDeleteConfirm = true }) {
120 Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete_server))
121 }
122 if (showDeleteConfirm) {
123 val displayName = state.label.takeIf { it.isNotBlank() } ?: state.baseUrl
124 AlertDialog(
125 onDismissRequest = { showDeleteConfirm = false },
126 title = { Text(stringResource(R.string.delete_server)) },
127 text = { Text(stringResource(R.string.remove_server_text, displayName)) },
128 confirmButton = {
129 TextButton(onClick = {
130 showDeleteConfirm = false
131 vm.delete(onDone)
132 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
133 },
134 dismissButton = {
135 TextButton(onClick = { showDeleteConfirm = false }) { Text(stringResource(R.string.cancel)) }
136 },
137 )
138 }
139 },
140 scrollBehavior = scrollBehavior,
141 )
142 },
143 ) { padding ->
144 if (!state.loaded) {
145 val loadingLabel = stringResource(R.string.loading)
146 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
147 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
148 }
149 } else {
150 ServerSettingsForm(
151 state = state,
152 passwordVisible = passwordVisible,
153 onTogglePassword = { passwordVisible = !passwordVisible },
154 padding = padding,
155 scrollBehavior = scrollBehavior,
156 vm = vm,
157 onDone = onDone,
158 )
159 }
160 }
162 if (showDiscardConfirm) {
163 AlertDialog(
164 onDismissRequest = { showDiscardConfirm = false },
165 title = { Text(stringResource(R.string.discard_title)) },
166 text = { Text(stringResource(R.string.discard_text)) },
167 confirmButton = {
168 TextButton(onClick = {
169 showDiscardConfirm = false
170 onDone()
171 }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
172 },
173 dismissButton = {
174 TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
175 },
176 )
177 }
180@OptIn(ExperimentalMaterial3Api::class)
181@Composable
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,
189 onDone: () -> Unit,
190) {
191 val haptics = LocalHapticFeedback.current
192 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
193 val urlValid = remember(state.baseUrl) { isValidUrl(state.baseUrl) }
194 Column(
195 modifier = Modifier
196 .imePadding()
197 .padding(padding)
198 .fillMaxWidth()
199 .wrapContentWidth(Alignment.CenterHorizontally)
200 .widthIn(max = 600.dp)
201 .padding(16.dp)
202 .nestedScroll(scrollBehavior.nestedScrollConnection)
203 .verticalScroll(rememberScrollState()),
204 verticalArrangement = Arrangement.spacedBy(12.dp),
205 ) {
206 OutlinedTextField(
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)) },
211 singleLine = true,
212 isError = state.baseUrl.isNotBlank() && !urlValid,
213 supportingText = {
214 if (state.baseUrl.isNotBlank() && !urlValid) {
215 Text(stringResource(R.string.invalid_url))
216 }
217 },
218 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
219 modifier = Modifier.fillMaxWidth().testTag("server_url"),
220 )
221 AnimatedVisibility(visible = state.authFieldsVisible) {
222 AuthFields(
223 state = state,
224 passwordVisible = passwordVisible,
225 onTogglePassword = onTogglePassword,
226 onUpdate = vm::update,
227 onTestCredentials = vm::testCredentials,
228 onImeDone = { if (canSave && !state.saving) vm.save(onDone) },
229 )
230 }
231 // Warn when credentials will travel over cleartext HTTP (mirrors ServerEditScreen).
232 if (state.baseUrl.lowercase().startsWith("http://") && state.username.isNotBlank()) {
233 Text(
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),
238 )
239 }
240 Button(
241 onClick = {
242 haptics.performHapticFeedback(HapticFeedbackType.LongPress)
243 vm.save(onDone)
244 },
245 enabled = canSave && !state.saving,
246 modifier = Modifier.fillMaxWidth().testTag("server_save"),
247 ) {
248 if (state.saving) {
249 CircularProgressIndicator(
250 modifier = Modifier.size(20.dp),
251 color = MaterialTheme.colorScheme.onPrimary,
252 strokeWidth = 2.dp,
253 )
254 Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
255 } else {
256 Text(stringResource(R.string.save))
257 }
258 }
259 state.error?.let { message ->
260 Text(
261 message,
262 color = MaterialTheme.colorScheme.error,
263 style = MaterialTheme.typography.bodySmall,
264 modifier = Modifier.fillMaxWidth(),
265 )
266 }
267 OutlinedTextField(
268 value = state.label,
269 onValueChange = { v -> vm.update { it.copy(label = v) } },
270 label = { Text(stringResource(R.string.label_optional)) },
271 singleLine = true,
272 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
273 modifier = Modifier.fillMaxWidth().testTag("server_label"),
274 )
275 AdvancedSection(state = state, onUpdate = vm::update)
276 }
279@Composable
280private fun AuthFields(
281 state: ServerEditState,
282 passwordVisible: Boolean,
283 onTogglePassword: () -> Unit,
284 onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
285 onTestCredentials: () -> Unit,
286 onImeDone: () -> Unit,
287) {
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() }
295 }
296 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
297 Text(
298 stringResource(R.string.auth_required),
299 style = MaterialTheme.typography.bodySmall,
300 color = MaterialTheme.colorScheme.error,
301 )
302 OutlinedTextField(
303 value = state.username,
304 onValueChange = { v -> onUpdate { it.copy(username = v) } },
305 label = { Text(stringResource(R.string.username_optional)) },
306 singleLine = true,
307 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
308 keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
309 modifier = Modifier
310 .fillMaxWidth()
311 .focusRequester(usernameFocus)
312 .then(autofillModifier(AutofillHint.Username) { v -> onUpdate { it.copy(username = v) } })
313 .testTag("server_username"),
314 )
315 OutlinedTextField(
316 value = state.password,
317 onValueChange = { v -> onUpdate { it.copy(password = v) } },
318 label = { Text(stringResource(R.string.password_optional)) },
319 singleLine = true,
320 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
321 trailingIcon = {
322 IconButton(onClick = onTogglePassword) {
323 Icon(
324 if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
325 contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
326 )
327 }
328 },
329 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
330 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
331 modifier = Modifier
332 .fillMaxWidth()
333 .focusRequester(passwordFocus)
334 .then(autofillModifier(AutofillHint.Password) { v -> onUpdate { it.copy(password = v) } })
335 .testTag("server_password"),
336 )
337 OutlinedButton(
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"),
342 ) {
343 if (state.testingCredentials) {
344 CircularProgressIndicator(
345 modifier = Modifier.size(20.dp),
346 strokeWidth = 2.dp,
347 )
348 Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
349 } else {
350 Text(stringResource(R.string.test_credentials))
351 }
352 }
353 state.credentialsResult?.let { ok ->
354 Text(
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,
358 )
359 }
360 }
363@Composable
364private fun AdvancedSection(
365 state: ServerEditState,
366 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
367) {
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()) {
371 TextButton(
372 onClick = { expanded = !expanded },
373 modifier = Modifier.semantics { stateDescription = stateLabel },
374 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
375 ) {
376 Icon(
377 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
378 contentDescription = null,
379 modifier = Modifier.size(18.dp),
380 )
381 Text(stringResource(R.string.server_security), modifier = Modifier.padding(start = 8.dp))
382 }
383 AnimatedVisibility(visible = expanded) {
384 SecurityFields(state = state, onUpdate = onUpdate)
385 }
386 }
389@Composable
390private fun SecurityFields(
391 state: ServerEditState,
392 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
393) {
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)
398 Text(
399 stringResource(R.string.require_https_desc),
400 style = MaterialTheme.typography.bodySmall,
401 color = MaterialTheme.colorScheme.onSurfaceVariant,
402 )
403 }
404 Switch(
405 checked = state.requireHttps,
406 onCheckedChange = { v -> onUpdate { it.copy(requireHttps = v) } },
407 )
408 }
409 OutlinedTextField(
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)) },
414 singleLine = true,
415 isError = !state.certPinValid,
416 supportingText = {
417 Text(
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,
422 )
423 },
424 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
425 modifier = Modifier.fillMaxWidth().testTag("server_cert_pin"),
426 )
427 }