git.iko.soy/opencode-android
on a0ce9ce help sign in
415 lines 18 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 var passwordVisible by rememberSaveable { mutableStateOf(false) }
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 }
99 BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
100 if (!state.saving) showDiscardConfirm = true
101 }
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 }
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 }
176@OptIn(ExperimentalMaterial3Api::class)
177@Composable
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,
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 }
266@Composable
267private 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 }
350@Composable
351private 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 }
376@Composable
377private 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 }