git.iko.soy/opencode-android
on 9f0326a help sign in
567 lines 27 kB history raw
1package soy.iko.opencode.ui.server
3import androidx.activity.compose.BackHandler
4import androidx.compose.animation.AnimatedVisibility
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Box
8import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.fillMaxSize
11import androidx.compose.foundation.layout.fillMaxWidth
12import androidx.compose.foundation.layout.heightIn
13import androidx.compose.foundation.layout.imePadding
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.size
16import androidx.compose.foundation.layout.widthIn
17import androidx.compose.foundation.layout.wrapContentWidth
18import androidx.compose.foundation.rememberScrollState
19import androidx.compose.foundation.text.KeyboardActions
20import androidx.compose.foundation.text.KeyboardOptions
21import androidx.compose.foundation.verticalScroll
22import androidx.compose.material.icons.Icons
23import androidx.compose.material.icons.automirrored.filled.ArrowBack
24import androidx.compose.material.icons.filled.ExpandLess
25import androidx.compose.material.icons.filled.ExpandMore
26import androidx.compose.material.icons.filled.Visibility
27import androidx.compose.material.icons.filled.VisibilityOff
28import androidx.compose.material.icons.filled.Wifi
29import androidx.compose.material3.AlertDialog
30import androidx.compose.material3.Button
31import androidx.compose.material3.CircularProgressIndicator
32import androidx.compose.material3.ExperimentalMaterial3Api
33import androidx.compose.material3.Icon
34import androidx.compose.material3.IconButton
35import androidx.compose.material3.MaterialTheme
36import androidx.compose.material3.OutlinedButton
37import androidx.compose.material3.OutlinedTextField
38import androidx.compose.material3.Scaffold
39import androidx.compose.material3.Switch
40import androidx.compose.material3.Text
41import androidx.compose.material3.TextButton
42import androidx.compose.material3.TopAppBar
43import androidx.compose.runtime.Composable
44import androidx.compose.runtime.LaunchedEffect
45import androidx.compose.runtime.getValue
46import androidx.compose.runtime.mutableStateOf
47import androidx.compose.runtime.remember
48import androidx.compose.runtime.saveable.rememberSaveable
49import androidx.compose.runtime.setValue
50import androidx.compose.ui.Alignment
51import androidx.compose.ui.Modifier
52import androidx.compose.ui.focus.FocusRequester
53import androidx.compose.ui.focus.focusRequester
54import androidx.compose.ui.platform.LocalContext
55import androidx.compose.ui.platform.LocalSoftwareKeyboardController
56import androidx.compose.ui.platform.testTag
57import androidx.compose.ui.semantics.Role
58import androidx.compose.ui.semantics.contentDescription
59import androidx.compose.ui.semantics.role
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.res.stringResource
67import androidx.compose.ui.unit.dp
68import androidx.lifecycle.compose.collectAsStateWithLifecycle
69import androidx.lifecycle.viewmodel.compose.viewModel
70import soy.iko.opencode.data.network.NsdDiscovery
71import soy.iko.opencode.data.network.NetworkConfig
72import soy.iko.opencode.di.AppContainer
73import soy.iko.opencode.R
74import soy.iko.opencode.ui.components.autofillModifier
75import soy.iko.opencode.ui.components.AutofillHint
76import soy.iko.opencode.ui.vmFactory
78@OptIn(ExperimentalMaterial3Api::class)
79@Composable
80fun ServerEditScreen(
81 container: AppContainer,
82 profileId: String?,
83 onDone: () -> Unit,
84 sourceId: String? = null,
85) {
86 val vm: ServerEditViewModel =
87 viewModel(factory = vmFactory { ServerEditViewModel(container, profileId, sourceId) })
88 val state by vm.state.collectAsStateWithLifecycle()
89 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
90 var passwordVisible by rememberSaveable { 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 // Intercept the system back gesture when the form has unsaved changes so the user
104 // gets a chance to keep editing instead of losing their work silently. Stays enabled
105 // (as a no-op) while a save is in flight so the gesture can't propagate to the NavHost
106 // and cancel the save's viewModelScope coroutine mid-write — matching safeExit()'s guard.
107 BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
108 if (!state.saving) showDiscardConfirm = true
109 }
111 Scaffold(
112 topBar = {
113 TopAppBar(
114 title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
115 navigationIcon = {
116 IconButton(onClick = ::safeExit) {
117 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
118 }
119 },
120 )
121 },
122 ) { padding ->
123 if (!state.loaded) {
124 val loadingLabel = stringResource(R.string.loading)
125 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
126 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
127 }
128 } else {
129 ServerEditForm(
130 state = state,
131 passwordVisible = passwordVisible,
132 onTogglePassword = { passwordVisible = !passwordVisible },
133 padding = padding,
134 vm = vm,
135 onDone = onDone,
136 )
137 }
138 }
140 if (showDiscardConfirm) {
141 AlertDialog(
142 onDismissRequest = { showDiscardConfirm = false },
143 title = { Text(stringResource(R.string.discard_title)) },
144 text = { Text(stringResource(R.string.discard_text)) },
145 confirmButton = {
146 TextButton(onClick = {
147 showDiscardConfirm = false
148 onDone()
149 }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
150 },
151 dismissButton = {
152 TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
153 },
154 )
155 }
158@Composable
159private fun ServerEditForm(
160 state: ServerEditState,
161 passwordVisible: Boolean,
162 onTogglePassword: () -> Unit,
163 padding: androidx.compose.foundation.layout.PaddingValues,
164 vm: ServerEditViewModel,
165 onDone: () -> Unit,
166) {
167 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
168 // canSave parses the base URL with OkHttp's parser (which allocates on the common
169 // half-typed URL). Memoize on baseUrl so it runs once per edit instead of being
170 // re-parsed at each of the enabled= call sites on every keystroke's recomposition.
171 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
172 // isValidUrl parses with OkHttp too; memoize on baseUrl so it runs once per edit instead
173 // of re-parsing at each isError/supportingText call site on every keystroke's recomposition.
174 val urlValid = remember(state.baseUrl) { isValidUrl(state.baseUrl) }
175 // Focus the Base URL field (and raise the keyboard) on a fresh Add-server form so the
176 // user can start typing the required field immediately.
177 val baseUrlFocus = remember { FocusRequester() }
178 val keyboard = LocalSoftwareKeyboardController.current
179 LaunchedEffect(Unit) {
180 if (state.isNew && state.baseUrl.isBlank()) {
181 baseUrlFocus.requestFocus()
182 keyboard?.show()
183 }
184 }
185 Column(
186 modifier = Modifier
187 .padding(padding)
188 .imePadding()
189 .fillMaxWidth()
190 .wrapContentWidth(Alignment.CenterHorizontally)
191 .widthIn(max = 600.dp)
192 .padding(16.dp)
193 .verticalScroll(rememberScrollState()),
194 verticalArrangement = Arrangement.spacedBy(12.dp),
195 ) {
196 // Base URL is the only required field, so it leads the form and takes focus on a
197 // fresh add form (via baseUrlFocus above).
198 OutlinedTextField(
199 value = state.baseUrl,
200 onValueChange = { v ->
201 // A paste of a full QR/JSON config into the URL field populates the whole form
202 // (so a user can paste a shared server config instead of scanning an image).
203 // Falls through to a normal URL edit when it isn't a recognized payload.
204 if (!vm.applyQrPayload(v)) vm.update { it.copy(baseUrl = v) }
205 },
206 label = { Text(stringResource(R.string.base_url)) },
207 placeholder = { Text(stringResource(R.string.base_url_hint)) },
208 singleLine = true,
209 isError = state.baseUrl.isNotBlank() && !urlValid,
210 supportingText = {
211 if (state.baseUrl.isNotBlank() && !urlValid) {
212 // Offer a one-tap scheme fix for bare host:port input instead of a
213 // generic error, so the user doesn't have to know the URL needs a scheme.
214 // suggestUrlScheme() picks https:// for TLS-shaped hosts (port 443, public
215 // domains) so the quick-fix doesn't nudge users onto cleartext.
216 val suggestion = suggestUrlScheme(state.baseUrl)
217 if (suggestion != null) {
218 val labelRes = if (suggestion.startsWith("https://")) R.string.suggest_scheme_https else R.string.suggest_scheme
219 // Mark the supporting-text fix as a Button so TalkBack announces it as an
220 // actionable control. Without an explicit role, a TextButton inside
221 // supportingText can be missed by screen-reader users who don't scan
222 // the error slot visually.
223 TextButton(
224 onClick = { vm.update { it.copy(baseUrl = suggestion) } },
225 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
226 modifier = Modifier.semantics {
227 role = Role.Button
228 contentDescription = state.baseUrl
229 },
230 ) {
231 Text(stringResource(labelRes))
232 }
233 } else {
234 Text(stringResource(R.string.invalid_url))
235 }
236 } else if (state.baseUrl.isBlank()) {
237 // Surface the QR-paste shortcut so a user with a shared server config knows
238 // they can paste it here instead of scanning an image.
239 Text(stringResource(R.string.qr_paste_hint))
240 }
241 },
242 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
243 keyboardActions = KeyboardActions(onDone = { keyboard?.hide() }),
244 modifier = Modifier
245 .fillMaxWidth()
246 .focusRequester(baseUrlFocus)
247 .testTag("server_url"),
248 )
249 // Auto-expand LAN discovery only on a fresh form (no URL yet) so tapping a found
250 // server is the primary path; on edit the populated URL keeps it (and its multicast)
251 // collapsed.
252 DiscoverySection(
253 initiallyExpanded = state.baseUrl.isBlank(),
254 onPick = { url -> vm.update { it.copy(baseUrl = url) } },
255 )
256 // Auth fields appear only once a connect attempt learns the server requires them
257 // (or an authed profile is loaded) — the common no-auth server never sees them.
258 AnimatedVisibility(visible = state.authFieldsVisible) {
259 AuthFields(
260 state = state,
261 passwordVisible = passwordVisible,
262 onTogglePassword = onTogglePassword,
263 onUpdate = vm::update,
264 onTestCredentials = vm::testCredentials,
265 onImeDone = { if (canSave && !state.saving) vm.connect(onDone) },
266 )
267 }
268 // Single primary action: probe-then-save-and-connect. Replaces the former
269 // Check-connectivity / Save / Save-&-connect trio.
270 Button(
271 onClick = {
272 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
273 vm.connect(onDone)
274 },
275 enabled = canSave && !state.saving,
276 modifier = Modifier.fillMaxWidth().testTag("server_connect"),
277 ) {
278 if (state.saving) {
279 CircularProgressIndicator(
280 modifier = Modifier.size(20.dp),
281 color = MaterialTheme.colorScheme.onPrimary,
282 strokeWidth = 2.dp,
283 )
284 Text(stringResource(R.string.connecting), modifier = Modifier.padding(start = 8.dp))
285 } else {
286 Text(stringResource(R.string.connect))
287 }
288 }
289 // Keep the error directly under the primary action so it's in view where the user is
290 // looking when a Connect/Save fails — not buried further down the form.
291 state.error?.let { message ->
292 Text(
293 message,
294 color = MaterialTheme.colorScheme.error,
295 style = MaterialTheme.typography.bodySmall,
296 modifier = Modifier.fillMaxWidth(),
297 )
298 }
299 // Secondary, low-emphasis: persist without connecting (offline setup).
300 TextButton(
301 onClick = {
302 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
303 vm.save(onDone)
304 },
305 enabled = canSave && !state.saving,
306 modifier = Modifier.fillMaxWidth().testTag("server_save"),
307 ) {
308 Text(stringResource(R.string.save_without_connecting))
309 }
310 // De-emphasized optional label at the bottom: displayLabel already falls back to the
311 // URL, so most servers don't need one.
312 OutlinedTextField(
313 value = state.label,
314 onValueChange = { v -> vm.update { it.copy(label = v) } },
315 label = { Text(stringResource(R.string.label_optional)) },
316 singleLine = true,
317 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
318 modifier = Modifier.fillMaxWidth().testTag("server_label"),
319 )
320 AdvancedSection(state = state, onUpdate = vm::update)
321 }
324@Composable
325private fun AuthFields(
326 state: ServerEditState,
327 passwordVisible: Boolean,
328 onTogglePassword: () -> Unit,
329 onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
330 onTestCredentials: () -> Unit,
331 onImeDone: () -> Unit,
332) {
333 val showPasswordLabel = stringResource(R.string.show_password)
334 val hidePasswordLabel = stringResource(R.string.hide_password)
335 // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on
336 // every keystroke's recomposition (see ServerEditForm).
337 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
338 val usernameFocus = remember { FocusRequester() }
339 val passwordFocus = remember { FocusRequester() }
340 // AuthFields only enters composition once the server is known to require credentials
341 // (AnimatedVisibility in ServerEditForm), so a Unit-keyed effect fires once on reveal
342 // and focuses the username — otherwise the user has to tap into it after the probe flips
343 // the auth fields open.
344 LaunchedEffect(Unit) {
345 runCatching { usernameFocus.requestFocus() }
346 }
347 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
348 Text(
349 stringResource(R.string.auth_required),
350 style = MaterialTheme.typography.bodySmall,
351 color = MaterialTheme.colorScheme.error,
352 )
353 OutlinedTextField(
354 value = state.username,
355 onValueChange = { v -> onUpdate { it.copy(username = v) } },
356 label = { Text(stringResource(R.string.username_optional)) },
357 singleLine = true,
358 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
359 keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
360 modifier = Modifier
361 .fillMaxWidth()
362 .focusRequester(usernameFocus)
363 .then(autofillModifier(AutofillHint.Username) { v -> onUpdate { it.copy(username = v) } })
364 .testTag("server_username"),
365 )
366 OutlinedTextField(
367 value = state.password,
368 onValueChange = { v -> onUpdate { it.copy(password = v) } },
369 label = { Text(stringResource(R.string.password_optional)) },
370 singleLine = true,
371 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
372 trailingIcon = {
373 IconButton(onClick = onTogglePassword) {
374 Icon(
375 if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
376 contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
377 )
378 }
379 },
380 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
381 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
382 modifier = Modifier
383 .fillMaxWidth()
384 .focusRequester(passwordFocus)
385 .then(autofillModifier(AutofillHint.Password) { v -> onUpdate { it.copy(password = v) } })
386 .testTag("server_password"),
387 )
388 OutlinedButton(
389 onClick = onTestCredentials,
390 // Require at least one credential field: testCredentials() no-ops when both are
391 // blank, so without this the button is tappable but silently does nothing.
392 enabled = canSave && !state.testingCredentials && !state.saving &&
393 (state.username.isNotBlank() || state.password.isNotBlank()),
394 modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
395 ) {
396 if (state.testingCredentials) {
397 CircularProgressIndicator(
398 modifier = Modifier.size(20.dp),
399 strokeWidth = 2.dp,
400 )
401 Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
402 } else {
403 Text(stringResource(R.string.test_credentials))
404 }
405 }
406 state.credentialsResult?.let { ok ->
407 Text(
408 stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
409 style = MaterialTheme.typography.bodySmall,
410 color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
411 )
412 }
413 }
416/**
417 * Optional TLS hardening, tucked behind a collapsed "Security" expander so the common
418 * localhost/HTTP setup never sees it. Auto-expands when a loaded profile already uses one of
419 * these settings (so an invalid pin can't silently disable Connect from inside a closed
420 * section). Contains: force HTTPS (upgrade cleartext) and pin the server certificate — both
421 * off by default.
422 */
423@Composable
424private fun AdvancedSection(
425 state: ServerEditState,
426 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
427) {
428 var expanded by rememberSaveable { mutableStateOf(state.requireHttps || state.certPin.isNotBlank()) }
429 val stateLabel = stringResource(if (expanded) R.string.state_expanded else R.string.state_collapsed)
430 Column(modifier = Modifier.fillMaxWidth()) {
431 TextButton(
432 onClick = { expanded = !expanded },
433 modifier = Modifier.semantics { stateDescription = stateLabel },
434 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
435 ) {
436 Icon(
437 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
438 contentDescription = null,
439 modifier = Modifier.size(18.dp),
440 )
441 Text(stringResource(R.string.server_security), modifier = Modifier.padding(start = 8.dp))
442 }
443 AnimatedVisibility(visible = expanded) {
444 SecurityFields(state = state, onUpdate = onUpdate)
445 }
446 }
449/** The actual security controls, rendered inside [AdvancedSection]'s expander. */
450@Composable
451private fun SecurityFields(
452 state: ServerEditState,
453 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
454) {
455 Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
456 Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
457 Column(modifier = Modifier.weight(1f)) {
458 Text(stringResource(R.string.require_https), style = MaterialTheme.typography.bodyLarge)
459 Text(
460 stringResource(R.string.require_https_desc),
461 style = MaterialTheme.typography.bodySmall,
462 color = MaterialTheme.colorScheme.onSurfaceVariant,
463 )
464 }
465 Switch(
466 checked = state.requireHttps,
467 onCheckedChange = { v -> onUpdate { it.copy(requireHttps = v) } },
468 )
469 }
470 OutlinedTextField(
471 value = state.certPin,
472 onValueChange = { v -> onUpdate { it.copy(certPin = v) } },
473 label = { Text(stringResource(R.string.cert_pin)) },
474 placeholder = { Text(stringResource(R.string.cert_pin_hint)) },
475 singleLine = true,
476 isError = !state.certPinValid,
477 supportingText = {
478 Text(
479 stringResource(if (state.certPinValid) R.string.cert_pin_desc else R.string.cert_pin_invalid),
480 style = MaterialTheme.typography.bodySmall,
481 color = if (state.certPinValid) MaterialTheme.colorScheme.onSurfaceVariant
482 else MaterialTheme.colorScheme.error,
483 )
484 },
485 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
486 modifier = Modifier.fillMaxWidth().testTag("server_cert_pin"),
487 )
488 }
491/**
492 * Optional LAN discovery: browses for opencode servers advertised via mDNS (`--mdns`) and
493 * offers each as a one-tap fill for the base URL. Discovery (and its multicast traffic) only
494 * runs while the section is expanded — the collection stops when the section is collapsed or
495 * the screen leaves composition. [initiallyExpanded] starts it open on a fresh add form so the
496 * discovered list is the primary path; an edit form (populated URL) leaves it collapsed.
497 */
498@Composable
499private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolean = false) {
500 val context = LocalContext.current
501 var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) }
502 val stateLabel = stringResource(if (expanded) R.string.state_expanded else R.string.state_collapsed)
503 Column(modifier = Modifier.fillMaxWidth()) {
504 TextButton(
505 onClick = { expanded = !expanded },
506 modifier = Modifier.semantics { stateDescription = stateLabel },
507 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
508 ) {
509 Icon(Icons.Filled.Wifi, contentDescription = null, modifier = Modifier.size(18.dp))
510 Text(stringResource(R.string.discover_on_network), modifier = Modifier.padding(start = 8.dp))
511 }
512 if (expanded) {
513 val discovery = remember { NsdDiscovery(context) }
514 val servers by remember(discovery) { discovery.discover() }
515 .collectAsStateWithLifecycle(initialValue = emptyList())
516 // mDNS discovery is a continuous flow that never emits a terminal "no results"
517 // state — on a network with no opencode servers the spinner would spin forever.
518 // After the timeout, swap the spinner for a "no servers found" message. Discovery
519 // keeps running, so a late server can still replace the message when it arrives.
520 var searchTimedOut by remember { mutableStateOf(false) }
521 LaunchedEffect(Unit) {
522 searchTimedOut = false
523 kotlinx.coroutines.delay(NetworkConfig.nsdDiscoveryNoResultTimeoutMs)
524 searchTimedOut = true
525 }
526 if (servers.isNotEmpty()) {
527 servers.forEach { server ->
528 Column(
529 modifier = Modifier
530 .fillMaxWidth()
531 .heightIn(min = 48.dp)
532 .clickable(role = Role.Button) { onPick(server.baseUrl) }
533 .padding(vertical = 8.dp),
534 verticalArrangement = Arrangement.Center,
535 ) {
536 Text(server.name, style = MaterialTheme.typography.bodyMedium)
537 Text(
538 server.baseUrl,
539 style = MaterialTheme.typography.bodySmall,
540 color = MaterialTheme.colorScheme.onSurfaceVariant,
541 )
542 }
543 }
544 } else if (searchTimedOut) {
545 Text(
546 stringResource(R.string.no_servers_found),
547 style = MaterialTheme.typography.bodySmall,
548 color = MaterialTheme.colorScheme.onSurfaceVariant,
549 modifier = Modifier.padding(vertical = 8.dp),
550 )
551 } else {
552 Row(
553 verticalAlignment = Alignment.CenterVertically,
554 modifier = Modifier.padding(vertical = 8.dp),
555 ) {
556 CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
557 Text(
558 stringResource(R.string.searching_network),
559 style = MaterialTheme.typography.bodySmall,
560 color = MaterialTheme.colorScheme.onSurfaceVariant,
561 modifier = Modifier.padding(start = 8.dp),
562 )
563 }
564 }
565 }
566 }