@@ -23,7 +23,6 @@ import androidx.compose.material.icons.filled.Error
23import androidx.compose.material.icons.filled.Delete
23import androidx.compose.material.icons.filled.Delete
24import androidx.compose.material.icons.filled.Dns
24import androidx.compose.material.icons.filled.Dns
25import androidx.compose.material.icons.filled.MoreVert
25import androidx.compose.material.icons.filled.MoreVert
26−import androidx.compose.material.icons.filled.PhotoLibrary
27import androidx.compose.material.icons.filled.Search
26import androidx.compose.material.icons.filled.Search
28import androidx.compose.material3.AlertDialog
27import androidx.compose.material3.AlertDialog
29import androidx.compose.material3.Button
28import androidx.compose.material3.Button
@@ -65,13 +64,11 @@ import androidx.compose.ui.platform.LocalHapticFeedback
65import androidx.compose.ui.platform.testTag
64import androidx.compose.ui.platform.testTag
66import androidx.compose.ui.semantics.Role
65import androidx.compose.ui.semantics.Role
67import androidx.compose.ui.semantics.contentDescription
66import androidx.compose.ui.semantics.contentDescription
68−import androidx.compose.ui.platform.LocalContext
69import androidx.compose.ui.semantics.semantics
67import androidx.compose.ui.semantics.semantics
70import androidx.compose.ui.text.style.TextOverflow
68import androidx.compose.ui.text.style.TextOverflow
71import androidx.compose.ui.unit.dp
69import androidx.compose.ui.unit.dp
72import androidx.compose.ui.res.stringResource
70import androidx.compose.ui.res.stringResource
73import androidx.activity.compose.BackHandler
71import androidx.activity.compose.BackHandler
74−import androidx.activity.compose.rememberLauncherForActivityResult
75import androidx.compose.animation.Crossfade
72import androidx.compose.animation.Crossfade
76import androidx.compose.animation.core.tween
73import androidx.compose.animation.core.tween
77import androidx.lifecycle.compose.collectAsStateWithLifecycle
74import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -118,10 +115,10 @@ fun ServerListScreen(
118 .collectAsStateWithLifecycle(initialValue = soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected)
115 .collectAsStateWithLifecycle(initialValue = soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected)
119 val haptics = LocalHapticFeedback.current
116 val haptics = LocalHapticFeedback.current
120 val snackbar = remember { SnackbarHostState() }
117 val snackbar = remember { SnackbarHostState() }
121− // Separate host for transient error messages (connect/probe failures, QR import results)
118+ // Separate host for transient error messages (connect/probe failures) so a transient
122− // so a transient error can't preempt an undo snackbar's full window — the undo and error
119+ // error can't preempt an undo snackbar's full window — the undo and error channels
123− // channels don't share a queue, so a connect error can't cancel an in-flight undo and
120+ // don't share a queue, so a connect error can't cancel an in-flight undo and strand a
124− // strand a pending server delete.
121+ // pending server delete.
125 val errorSnackbar = remember { SnackbarHostState() }
122 val errorSnackbar = remember { SnackbarHostState() }
126 val retryLabel = stringResource(R.string.retry)
123 val retryLabel = stringResource(R.string.retry)
127 val undoLabel = stringResource(R.string.undo)
124 val undoLabel = stringResource(R.string.undo)
@@ -132,19 +129,6 @@ fun ServerListScreen(
132 // threshold logic the file/session pickers use for their search fields.
129 // threshold logic the file/session pickers use for their search fields.
133 var serverQuery by rememberSaveable { mutableStateOf("") }
130 var serverQuery by rememberSaveable { mutableStateOf("") }
134 val connectedId = activeConnection?.profile?.id
131 val connectedId = activeConnection?.profile?.id
135− // Profile being shown as a scannable QR (Share as QR overflow action), and a decoded QR
136− // payload awaiting the user's confirm before it's saved (Import from image action).
137− var pendingQrProfile by remember { mutableStateOf<ServerProfile?>(null) }
138− val context = androidx.compose.ui.platform.LocalContext.current
139− val scope = androidx.compose.runtime.rememberCoroutineScope()
140− // QR import (image pick → decode → confirm → save) is encapsulated in its own composable so the
141− // branches don't inflate ServerListScreen's complexity. It renders the confirm dialog inline.
142− val qrImportLauncher = ServerQrImportHandler(
145− snackbar = errorSnackbar,
149 LaunchedEffect(Unit) {
133 LaunchedEffect(Unit) {
150 vm.errorEvents.collect { event ->
134 vm.errorEvents.collect { event ->
@@ -259,13 +243,6 @@ fun ServerListScreen(
262− // Import a server by decoding a QR code from a saved image (e.g. a screenshot
263− // of another device's Share-as-QR dialog). Camera scanning would need extra
264− // permissions; an image picker reuses the system gallery and covers the
265− // cross-device transfer use case.
266− IconButton(onClick = { qrImportLauncher.launch(arrayOf("image/*")) }) {
267− Icon(Icons.Filled.PhotoLibrary, contentDescription = stringResource(R.string.import_from_image))
@@ -345,7 +322,6 @@ fun ServerListScreen(
346 "empty" -> EmptyServers(
323 "empty" -> EmptyServers(
347 onAdd = onAddProfile,
324 onAdd = onAddProfile,
348− onQrImport = { qrImportLauncher.launch(arrayOf("image/*")) },
349 modifier = Modifier.fillMaxSize(),
325 modifier = Modifier.fillMaxSize(),
351 else -> {
327 else -> {
@@ -416,7 +392,6 @@ fun ServerListScreen(
416 onEdit = { onEditProfile(profile.id) },
392 onEdit = { onEditProfile(profile.id) },
417 onDuplicate = { onDuplicateProfile(profile.id) },
393 onDuplicate = { onDuplicateProfile(profile.id) },
418 onPendingDelete = { pendingDeleteId = profile.id },
394 onPendingDelete = { pendingDeleteId = profile.id },
419− onShareQr = { pendingQrProfile = profile },
420 onTestConnection = { vm.testConnection(profile) },
395 onTestConnection = { vm.testConnection(profile) },
421 isProbing = probingId == profile.id,
396 isProbing = probingId == profile.id,
@@ -470,7 +445,6 @@ fun ServerListScreen(
470 onEdit = { onEditProfile(profile.id) },
445 onEdit = { onEditProfile(profile.id) },
471 onDuplicate = { onDuplicateProfile(profile.id) },
446 onDuplicate = { onDuplicateProfile(profile.id) },
472 onPendingDelete = { pendingDeleteId = profile.id },
447 onPendingDelete = { pendingDeleteId = profile.id },
473− onShareQr = { pendingQrProfile = profile },
474 onTestConnection = { vm.testConnection(profile) },
448 onTestConnection = { vm.testConnection(profile) },
475 isProbing = probingId == profile.id,
449 isProbing = probingId == profile.id,
@@ -510,12 +484,6 @@ fun ServerListScreen(
514− // Share-as-QR dialog for the selected profile. (The import confirm dialog is rendered by
515− // ServerQrImportHandler above.)
516− pendingQrProfile?.let { profile ->
517− soy.iko.opencode.ui.components.QrShareDialog(profile = profile, onDismiss = { pendingQrProfile = null })
521/** Shared content for a server profile card — used by both the active-row (plain Card)
489/** Shared content for a server profile card — used by both the active-row (plain Card)
@@ -530,7 +498,6 @@ private fun ServerCardContent(
530 onEdit: () -> Unit,
498 onEdit: () -> Unit,
531 onDuplicate: () -> Unit,
499 onDuplicate: () -> Unit,
532 onPendingDelete: () -> Unit,
500 onPendingDelete: () -> Unit,
533− onShareQr: () -> Unit = {},
534 onTestConnection: () -> Unit = {},
501 onTestConnection: () -> Unit = {},
535 isProbing: Boolean = false,
502 isProbing: Boolean = false,
536 activeState: soy.iko.opencode.data.network.EventStreamClient.ConnectionState? = null,
503 activeState: soy.iko.opencode.data.network.EventStreamClient.ConnectionState? = null,
@@ -665,10 +632,6 @@ private fun ServerCardContent(
665 text = { Text(testConnLabel) },
632 text = { Text(testConnLabel) },
666 onClick = { showRowMenu = false; onTestConnection() },
633 onClick = { showRowMenu = false; onTestConnection() },
669− text = { Text(stringResource(R.string.share_qr)) },
670− onClick = { showRowMenu = false; onShareQr() },
672 DropdownMenuItem(
635 DropdownMenuItem(
673 text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
636 text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
674 onClick = { showRowMenu = false; onPendingDelete() },
637 onClick = { showRowMenu = false; onPendingDelete() },
@@ -703,7 +666,7 @@ private fun LastUsedText(lastUsed: Long) {
705@Composable
668@Composable
706−private fun EmptyServers(onAdd: () -> Unit, onQrImport: () -> Unit, modifier: Modifier = Modifier) {
669+private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
707 Box(modifier = modifier, contentAlignment = Alignment.Center) {
670 Box(modifier = modifier, contentAlignment = Alignment.Center) {
708 EmptyState(
671 EmptyState(
709 icon = Icons.Filled.Dns,
672 icon = Icons.Filled.Dns,
@@ -712,74 +675,8 @@ private fun EmptyServers(onAdd: () -> Unit, onQrImport: () -> Unit, modifier: Mo
712 actionIcon = Icons.Filled.Add,
675 actionIcon = Icons.Filled.Add,
713 actionLabel = stringResource(R.string.add_server),
676 actionLabel = stringResource(R.string.add_server),
714 onAction = onAdd,
677 onAction = onAdd,
715− secondaryActionIcon = Icons.Filled.PhotoLibrary,
716− secondaryActionLabel = stringResource(R.string.import_from_image),
717− onSecondaryAction = onQrImport,
722−/** Owns the QR-import flow: an image-picker launcher plus the confirm dialog for a decoded
723− * server payload. Extracted from [ServerListScreen] so its branches (null checks, decode
724− * result, confirm) don't push the parent composable over the cyclomatic-complexity threshold.
725− * Renders the confirm dialog inline. */
727−private fun ServerQrImportHandler(
728− vm: ServerListViewModel,
729− scope: kotlinx.coroutines.CoroutineScope,
730− snackbar: SnackbarHostState,
731− context: android.content.Context,
732−): androidx.activity.result.ActivityResultLauncher<Array<String>> {
733− var pendingImport by remember { mutableStateOf<soy.iko.opencode.ui.components.ServerProfileQr?>(null) }
734− val qrAddedMsg = stringResource(R.string.qr_added)
735− val qrImportFailedMsg = stringResource(R.string.qr_import_failed)
736− val launcher = rememberLauncherForActivityResult(
737− androidx.activity.result.contract.ActivityResultContracts.OpenDocument(),
739− if (uri == null) return@rememberLauncherForActivityResult
741− val qr = soy.iko.opencode.ui.components.decodeServerQr(context, uri)
742− if (qr != null) pendingImport = qr else snackbar.showSnackbar(qrImportFailedMsg)
745− pendingImport?.let { qr ->
747− onDismissRequest = { pendingImport = null },
748− title = { Text(stringResource(R.string.qr_import_title)) },
751− Text(qr.baseUrl, style = MaterialTheme.typography.bodyLarge)
752− if (qr.label.isNotBlank()) {
755− style = MaterialTheme.typography.bodySmall,
756− color = MaterialTheme.colorScheme.onSurfaceVariant,
759− if (qr.password != null) {
761− stringResource(R.string.qr_includes_password),
762− style = MaterialTheme.typography.labelSmall,
763− color = MaterialTheme.colorScheme.error,
769− TextButton(onClick = {
770− pendingImport = null
773− snackbar.showSnackbar(qrAddedMsg)
775− }) { Text(stringResource(R.string.add_server)) }
778− TextButton(onClick = { pendingImport = null }) { Text(stringResource(R.string.cancel)) }
785/** Filter server profiles by label/base URL. Extracted from [ServerListScreen] to keep its
682/** Filter server profiles by label/base URL. Extracted from [ServerListScreen] to keep its