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