git.iko.soy/opencode-android
on 2794fda help sign in
578 lines 28 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.input.nestedscroll.nestedScroll
54import androidx.compose.ui.platform.LocalSoftwareKeyboardController
55import androidx.compose.ui.platform.testTag
56import androidx.compose.ui.semantics.Role
57import androidx.compose.ui.semantics.contentDescription
58import androidx.compose.ui.semantics.role
59import androidx.compose.ui.semantics.semantics
60import androidx.compose.ui.semantics.stateDescription
61import androidx.compose.ui.text.input.ImeAction
62import androidx.compose.ui.text.input.KeyboardType
63import androidx.compose.ui.text.input.PasswordVisualTransformation
64import androidx.compose.ui.text.input.VisualTransformation
65import androidx.compose.ui.res.stringResource
66import androidx.compose.ui.unit.dp
67import androidx.lifecycle.compose.collectAsStateWithLifecycle
68import androidx.lifecycle.viewmodel.compose.viewModel
69import soy.iko.opencode.di.AppContainer
70import soy.iko.opencode.R
71import soy.iko.opencode.ui.components.autofillModifier
72import soy.iko.opencode.ui.components.AutofillHint
73import soy.iko.opencode.ui.vmFactory
75@OptIn(ExperimentalMaterial3Api::class)
76@Composable
77fun ServerEditScreen(
78 container: AppContainer,
79 profileId: String?,
80 onDone: () -> Unit,
81 sourceId: String? = null,
82) {
83 val vm: ServerEditViewModel =
84 viewModel(factory = vmFactory { ServerEditViewModel(container, profileId, sourceId) })
85 val state by vm.state.collectAsStateWithLifecycle()
86 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
87 var passwordVisible by rememberSaveable { mutableStateOf(false) }
89 fun safeExit() {
90 when {
91 // A save is in flight: ignore the back gesture (matching the BackHandler guard
92 // below). Popping here would cancel the save's viewModelScope coroutine and
93 // silently abandon the write; the VM calls onDone itself once the save lands.
94 state.saving -> Unit
95 state.isDirty -> showDiscardConfirm = true
96 else -> onDone()
97 }
98 }
100 // Intercept the system back gesture when the form has unsaved changes so the user
101 // gets a chance to keep editing instead of losing their work silently. Stays enabled
102 // (as a no-op) while a save is in flight so the gesture can't propagate to the NavHost
103 // and cancel the save's viewModelScope coroutine mid-write — matching safeExit()'s guard.
104 BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
105 if (!state.saving) showDiscardConfirm = true
106 }
108 // Collapsing/lifting top bar so the long form scrolls under it, matching Settings and
109 // Diagnostics (the form can be tall: Base URL + Auth + Test + Connect + Save +
110 // Label + Security).
111 val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
112 Scaffold(
113 topBar = {
114 TopAppBar(
115 title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
116 navigationIcon = {
117 IconButton(onClick = ::safeExit) {
118 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
119 }
120 },
121 actions = {
122 // Delete action in the overflow, only for an existing (saved) profile.
123 // The active profile is guarded in the ViewModel (delete disconnects it
124 // if it's the active one). A confirmation dialog gates the destructive action.
125 if (!state.isNew) {
126 var showDeleteConfirm by remember { mutableStateOf(false) }
127 IconButton(onClick = { showDeleteConfirm = true }) {
128 Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete_server))
129 }
130 if (showDeleteConfirm) {
131 // Name the server (matching the server list's delete dialog, which
132 // includes displayLabel) so the user can verify which profile they're
133 // deleting before confirming. displayLabel falls back to baseUrl when
134 // no label is set, so there's always an identifiable name.
135 val displayName = state.label.takeIf { it.isNotBlank() } ?: state.baseUrl
136 AlertDialog(
137 onDismissRequest = { showDeleteConfirm = false },
138 title = { Text(stringResource(R.string.delete_server)) },
139 text = { Text(stringResource(R.string.remove_server_text, displayName)) },
140 confirmButton = {
141 TextButton(onClick = {
142 showDeleteConfirm = false
143 vm.delete(onDone)
144 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
145 },
146 dismissButton = {
147 TextButton(onClick = { showDeleteConfirm = false }) { Text(stringResource(R.string.cancel)) }
148 },
149 )
150 }
151 }
152 },
153 scrollBehavior = scrollBehavior,
154 )
155 },
156 ) { padding ->
157 if (!state.loaded) {
158 val loadingLabel = stringResource(R.string.loading)
159 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
160 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
161 }
162 } else {
163 ServerEditForm(
164 state = state,
165 passwordVisible = passwordVisible,
166 onTogglePassword = { passwordVisible = !passwordVisible },
167 padding = padding,
168 scrollBehavior = scrollBehavior,
169 vm = vm,
170 onDone = onDone,
171 )
172 }
173 }
175 if (showDiscardConfirm) {
176 AlertDialog(
177 onDismissRequest = { showDiscardConfirm = false },
178 title = { Text(stringResource(R.string.discard_title)) },
179 text = { Text(stringResource(R.string.discard_text)) },
180 confirmButton = {
181 TextButton(onClick = {
182 showDiscardConfirm = false
183 onDone()
184 }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
185 },
186 dismissButton = {
187 TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
188 },
189 )
190 }
193@OptIn(ExperimentalMaterial3Api::class)
194@Composable
195private fun ServerEditForm(
196 state: ServerEditState,
197 passwordVisible: Boolean,
198 onTogglePassword: () -> Unit,
199 padding: androidx.compose.foundation.layout.PaddingValues,
200 scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
201 vm: ServerEditViewModel,
202 onDone: () -> Unit,
203) {
204 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
205 // canSave parses the base URL with OkHttp's parser (which allocates on the common
206 // half-typed URL). Memoize on baseUrl so it runs once per edit instead of being
207 // re-parsed at each of the enabled= call sites on every keystroke's recomposition.
208 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
209 // isValidUrl parses with OkHttp too; memoize on baseUrl so it runs once per edit instead
210 // of re-parsing at each isError/supportingText call site on every keystroke's recomposition.
211 val urlValid = remember(state.baseUrl) { isValidUrl(state.baseUrl) }
212 // Focus the Base URL field (and raise the keyboard) on a fresh Add-server form so the
213 // user can start typing the required field immediately.
214 val baseUrlFocus = remember { FocusRequester() }
215 val keyboard = LocalSoftwareKeyboardController.current
216 LaunchedEffect(Unit) {
217 if (state.isNew && state.baseUrl.isBlank()) {
218 baseUrlFocus.requestFocus()
219 keyboard?.show()
220 }
221 }
222 Column(
223 modifier = Modifier
224 .padding(padding)
225 .imePadding()
226 .fillMaxWidth()
227 .wrapContentWidth(Alignment.CenterHorizontally)
228 .widthIn(max = 600.dp)
229 .padding(16.dp)
230 .nestedScroll(scrollBehavior.nestedScrollConnection)
231 .verticalScroll(rememberScrollState()),
232 verticalArrangement = Arrangement.spacedBy(12.dp),
233 ) {
234 // Base URL is the only required field, so it leads the form and takes focus on a
235 // fresh add form (via baseUrlFocus above).
236 OutlinedTextField(
237 value = state.baseUrl,
238 onValueChange = { v ->
239 // A paste of a full QR/JSON config into the URL field populates the whole form
240 // (so a user can paste a shared server config instead of scanning an image).
241 // Falls through to a normal URL edit when it isn't a recognized payload.
242 if (!vm.applyQrPayload(v)) vm.update { it.copy(baseUrl = v) }
243 },
244 label = { Text(stringResource(R.string.base_url)) },
245 placeholder = { Text(stringResource(R.string.base_url_hint)) },
246 singleLine = true,
247 isError = state.baseUrl.isNotBlank() && !urlValid,
248 supportingText = {
249 if (state.baseUrl.isNotBlank() && !urlValid) {
250 // Offer a one-tap scheme fix for bare host:port input instead of a
251 // generic error, so the user doesn't have to know the URL needs a scheme.
252 // suggestUrlScheme() picks https:// for TLS-shaped hosts (port 443, public
253 // domains) so the quick-fix doesn't nudge users onto cleartext.
254 val suggestion = suggestUrlScheme(state.baseUrl)
255 if (suggestion != null) {
256 val labelRes = if (suggestion.startsWith("https://")) R.string.suggest_scheme_https else R.string.suggest_scheme
257 // Mark the supporting-text fix as a Button so TalkBack announces it as an
258 // actionable control. Without an explicit role, a TextButton inside
259 // supportingText can be missed by screen-reader users who don't scan
260 // the error slot visually.
261 TextButton(
262 onClick = { vm.update { it.copy(baseUrl = suggestion) } },
263 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
264 modifier = Modifier.semantics {
265 role = Role.Button
266 contentDescription = state.baseUrl
267 },
268 ) {
269 Text(stringResource(labelRes))
270 }
271 } else {
272 Text(stringResource(R.string.invalid_url))
273 }
274 } else if (state.baseUrl.isBlank()) {
275 // Surface the QR-paste shortcut so a user with a shared server config knows
276 // they can paste it here instead of scanning an image.
277 Text(stringResource(R.string.qr_paste_hint))
278 }
279 },
280 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
281 keyboardActions = KeyboardActions(onDone = { keyboard?.hide() }),
282 modifier = Modifier
283 .fillMaxWidth()
284 .focusRequester(baseUrlFocus)
285 .testTag("server_url"),
286 )
287 // Auth fields appear only once a connect attempt learns the server requires them
288 // (or an authed profile is loaded) — the common no-auth server never sees them.
289 AnimatedVisibility(visible = state.authFieldsVisible) {
290 AuthFields(
291 state = state,
292 passwordVisible = passwordVisible,
293 onTogglePassword = onTogglePassword,
294 onUpdate = vm::update,
295 onTestCredentials = vm::testCredentials,
296 onImeDone = { if (canSave && !state.saving) vm.connect(onDone) },
297 )
298 }
299 // Pre-save reachability probe: lets the user confirm the URL is reachable before
300 // committing. Extracted to [TestConnectionControl] to keep this function's complexity
301 // under the detekt threshold.
302 TestConnectionControl(
303 canSave = canSave,
304 testing = state.testingConnection,
305 saving = state.saving,
306 testingCredentials = state.testingCredentials,
307 result = state.connectionResult,
308 onTest = vm::testConnection,
309 )
310 // Single primary action: probe-then-save-and-connect. Replaces the former
311 // Check-connectivity / Save / Save-&-connect trio.
312 Button(
313 onClick = {
314 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
315 vm.connect(onDone)
316 },
317 enabled = canSave && !state.saving,
318 modifier = Modifier.fillMaxWidth().testTag("server_connect"),
319 ) {
320 if (state.saving) {
321 CircularProgressIndicator(
322 modifier = Modifier.size(20.dp),
323 color = MaterialTheme.colorScheme.onPrimary,
324 strokeWidth = 2.dp,
325 )
326 Text(stringResource(R.string.connecting), modifier = Modifier.padding(start = 8.dp))
327 } else {
328 Text(stringResource(R.string.connect))
329 }
330 }
331 // Keep the error directly under the primary action so it's in view where the user is
332 // looking when a Connect/Save fails — not buried further down the form.
333 state.error?.let { message ->
334 Text(
335 message,
336 color = MaterialTheme.colorScheme.error,
337 style = MaterialTheme.typography.bodySmall,
338 modifier = Modifier.fillMaxWidth(),
339 )
340 }
341 // Secondary, low-emphasis: persist without connecting (offline setup).
342 TextButton(
343 onClick = {
344 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
345 vm.save(onDone)
346 },
347 enabled = canSave && !state.saving,
348 modifier = Modifier.fillMaxWidth().testTag("server_save"),
349 ) {
350 Text(stringResource(R.string.save_without_connecting))
351 }
352 // De-emphasized optional label at the bottom: displayLabel already falls back to the
353 // URL, so most servers don't need one.
354 OutlinedTextField(
355 value = state.label,
356 onValueChange = { v -> vm.update { it.copy(label = v) } },
357 label = { Text(stringResource(R.string.label_optional)) },
358 singleLine = true,
359 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
360 modifier = Modifier.fillMaxWidth().testTag("server_label"),
361 )
362 AdvancedSection(state = state, onUpdate = vm::update)
363 }
366@Composable
367private fun AuthFields(
368 state: ServerEditState,
369 passwordVisible: Boolean,
370 onTogglePassword: () -> Unit,
371 onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
372 onTestCredentials: () -> Unit,
373 onImeDone: () -> Unit,
374) {
375 val showPasswordLabel = stringResource(R.string.show_password)
376 val hidePasswordLabel = stringResource(R.string.hide_password)
377 // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on
378 // every keystroke's recomposition (see ServerEditForm).
379 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
380 val usernameFocus = remember { FocusRequester() }
381 val passwordFocus = remember { FocusRequester() }
382 // AuthFields only enters composition once the server is known to require credentials
383 // (AnimatedVisibility in ServerEditForm), so a Unit-keyed effect fires once on reveal
384 // and focuses the username — otherwise the user has to tap into it after the probe flips
385 // the auth fields open.
386 LaunchedEffect(Unit) {
387 runCatching { usernameFocus.requestFocus() }
388 }
389 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
390 Text(
391 stringResource(R.string.auth_required),
392 style = MaterialTheme.typography.bodySmall,
393 color = MaterialTheme.colorScheme.error,
394 )
395 OutlinedTextField(
396 value = state.username,
397 onValueChange = { v -> onUpdate { it.copy(username = v) } },
398 label = { Text(stringResource(R.string.username_optional)) },
399 singleLine = true,
400 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
401 keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
402 modifier = Modifier
403 .fillMaxWidth()
404 .focusRequester(usernameFocus)
405 .then(autofillModifier(AutofillHint.Username) { v -> onUpdate { it.copy(username = v) } })
406 .testTag("server_username"),
407 )
408 OutlinedTextField(
409 value = state.password,
410 onValueChange = { v -> onUpdate { it.copy(password = v) } },
411 label = { Text(stringResource(R.string.password_optional)) },
412 singleLine = true,
413 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
414 trailingIcon = {
415 IconButton(onClick = onTogglePassword) {
416 Icon(
417 if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
418 contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
419 )
420 }
421 },
422 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
423 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
424 modifier = Modifier
425 .fillMaxWidth()
426 .focusRequester(passwordFocus)
427 .then(autofillModifier(AutofillHint.Password) { v -> onUpdate { it.copy(password = v) } })
428 .testTag("server_password"),
429 )
430 OutlinedButton(
431 onClick = onTestCredentials,
432 // Require at least one credential field: testCredentials() no-ops when both are
433 // blank, so without this the button is tappable but silently does nothing.
434 enabled = canSave && !state.testingCredentials && !state.saving &&
435 (state.username.isNotBlank() || state.password.isNotBlank()),
436 modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
437 ) {
438 if (state.testingCredentials) {
439 CircularProgressIndicator(
440 modifier = Modifier.size(20.dp),
441 strokeWidth = 2.dp,
442 )
443 Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
444 } else {
445 Text(stringResource(R.string.test_credentials))
446 }
447 }
448 state.credentialsResult?.let { ok ->
449 Text(
450 stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
451 style = MaterialTheme.typography.bodySmall,
452 color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
453 )
454 }
455 }
458/** The "Test connection" button plus its success/failure feedback line. Extracted from
459 * [ServerEditForm] to keep that function's cyclomatic complexity under the detekt threshold. */
460@Composable
461private fun TestConnectionControl(
462 canSave: Boolean,
463 testing: Boolean,
464 saving: Boolean,
465 testingCredentials: Boolean,
466 result: ConnectionProbeResult?,
467 onTest: () -> Unit,
468) {
469 OutlinedButton(
470 onClick = onTest,
471 enabled = canSave && !testing && !saving && !testingCredentials,
472 modifier = Modifier.fillMaxWidth().testTag("server_test_connection"),
473 ) {
474 if (testing) {
475 CircularProgressIndicator(
476 modifier = Modifier.size(20.dp),
477 strokeWidth = 2.dp,
478 )
479 Text(stringResource(R.string.testing_connection), modifier = Modifier.padding(start = 8.dp))
480 } else {
481 Text(stringResource(R.string.test_connection))
482 }
483 }
484 ConnectionProbeFeedback(result)
487/** Renders the success/failed feedback line for a "Test connection" probe. Extracted from
488 * [ServerEditForm] to keep that function's cyclomatic complexity under the detekt threshold. */
489@Composable
490private fun ConnectionProbeFeedback(result: ConnectionProbeResult?) {
491 if (result == null) return
492 val (msg, color) = when (result) {
493 is ConnectionProbeResult.Success ->
494 stringResource(R.string.test_connection_success, result.latencyMs) to MaterialTheme.colorScheme.primary
495 is ConnectionProbeResult.Failed ->
496 stringResource(R.string.test_connection_failed, result.message) to MaterialTheme.colorScheme.error
497 }
498 Text(
499 msg,
500 style = MaterialTheme.typography.bodySmall,
501 color = color,
502 )
505/**
506 * Optional TLS hardening, tucked behind a collapsed "Security" expander so the common
507 * localhost/HTTP setup never sees it. Auto-expands when a loaded profile already uses one of
508 * these settings (so an invalid pin can't silently disable Connect from inside a closed
509 * section). Contains: force HTTPS (upgrade cleartext) and pin the server certificate — both
510 * off by default.
511 */
512@Composable
513private fun AdvancedSection(
514 state: ServerEditState,
515 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
516) {
517 var expanded by rememberSaveable { mutableStateOf(state.requireHttps || state.certPin.isNotBlank()) }
518 val stateLabel = stringResource(if (expanded) R.string.state_expanded else R.string.state_collapsed)
519 Column(modifier = Modifier.fillMaxWidth()) {
520 TextButton(
521 onClick = { expanded = !expanded },
522 modifier = Modifier.semantics { stateDescription = stateLabel },
523 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
524 ) {
525 Icon(
526 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
527 contentDescription = null,
528 modifier = Modifier.size(18.dp),
529 )
530 Text(stringResource(R.string.server_security), modifier = Modifier.padding(start = 8.dp))
531 }
532 AnimatedVisibility(visible = expanded) {
533 SecurityFields(state = state, onUpdate = onUpdate)
534 }
535 }
538/** The actual security controls, rendered inside [AdvancedSection]'s expander. */
539@Composable
540private fun SecurityFields(
541 state: ServerEditState,
542 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
543) {
544 Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
545 Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
546 Column(modifier = Modifier.weight(1f)) {
547 Text(stringResource(R.string.require_https), style = MaterialTheme.typography.bodyLarge)
548 Text(
549 stringResource(R.string.require_https_desc),
550 style = MaterialTheme.typography.bodySmall,
551 color = MaterialTheme.colorScheme.onSurfaceVariant,
552 )
553 }
554 Switch(
555 checked = state.requireHttps,
556 onCheckedChange = { v -> onUpdate { it.copy(requireHttps = v) } },
557 )
558 }
559 OutlinedTextField(
560 value = state.certPin,
561 onValueChange = { v -> onUpdate { it.copy(certPin = v) } },
562 label = { Text(stringResource(R.string.cert_pin)) },
563 placeholder = { Text(stringResource(R.string.cert_pin_hint)) },
564 singleLine = true,
565 isError = !state.certPinValid,
566 supportingText = {
567 Text(
568 stringResource(if (state.certPinValid) R.string.cert_pin_desc else R.string.cert_pin_invalid),
569 style = MaterialTheme.typography.bodySmall,
570 color = if (state.certPinValid) MaterialTheme.colorScheme.onSurfaceVariant
571 else MaterialTheme.colorScheme.error,
572 )
573 },
574 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
575 modifier = Modifier.fillMaxWidth().testTag("server_cert_pin"),
576 )
577 }