@@ -23,7 +23,6 @@ import androidx.compose.material.icons.filled.Error
2323import androidx.compose.material.icons.filled.Delete
2424import androidx.compose.material.icons.filled.Dns
2525import androidx.compose.material.icons.filled.MoreVert
26−import androidx.compose.material.icons.filled.PhotoLibrary
2726import androidx.compose.material.icons.filled.Search
2827import androidx.compose.material3.AlertDialog
2928import androidx.compose.material3.Button
@@ -65,13 +64,11 @@ import androidx.compose.ui.platform.LocalHapticFeedback
6564import androidx.compose.ui.platform.testTag
6665import androidx.compose.ui.semantics.Role
6766import androidx.compose.ui.semantics.contentDescription
68−import androidx.compose.ui.platform.LocalContext
6967import androidx.compose.ui.semantics.semantics
7068import androidx.compose.ui.text.style.TextOverflow
7169import androidx.compose.ui.unit.dp
7270import androidx.compose.ui.res.stringResource
7371import androidx.activity.compose.BackHandler
74−import androidx.activity.compose.rememberLauncherForActivityResult
7572import androidx.compose.animation.Crossfade
7673import androidx.compose.animation.core.tween
7774import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -118,10 +115,10 @@ fun ServerListScreen(
118115 .collectAsStateWithLifecycle(initialValue = soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected)
119116 val haptics = LocalHapticFeedback.current
120117 val snackbar = remember { SnackbarHostState() }
121− // Separate host for transient error messages (connect/probe failures, QR import results)
122− // so a transient error can't preempt an undo snackbar's full window — the undo and error
123− // channels don't share a queue, so a connect error can't cancel an in-flight undo and
124− // strand a pending server delete.
118+ // Separate host for transient error messages (connect/probe failures) so a transient
119+ // error can't preempt an undo snackbar's full window — the undo and error channels
120+ // don't share a queue, so a connect error can't cancel an in-flight undo and strand a
121+ // pending server delete.
125122 val errorSnackbar = remember { SnackbarHostState() }
126123 val retryLabel = stringResource(R.string.retry)
127124 val undoLabel = stringResource(R.string.undo)
@@ -132,19 +129,6 @@ fun ServerListScreen(
132129 // threshold logic the file/session pickers use for their search fields.
133130 var serverQuery by rememberSaveable { mutableStateOf("") }
134131 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(
143− vm = vm,
144− scope = scope,
145− snackbar = errorSnackbar,
146− context = context,
147− )
148132
149133 LaunchedEffect(Unit) {
150134 vm.errorEvents.collect { event ->
@@ -259,13 +243,6 @@ fun ServerListScreen(
259243 )
260244 }
261245 }
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))
268− }
269246 },
270247 )
271248 },
@@ -345,7 +322,6 @@ fun ServerListScreen(
345322 }
346323 "empty" -> EmptyServers(
347324 onAdd = onAddProfile,
348− onQrImport = { qrImportLauncher.launch(arrayOf("image/*")) },
349325 modifier = Modifier.fillMaxSize(),
350326 )
351327 else -> {
@@ -416,7 +392,6 @@ fun ServerListScreen(
416392 onEdit = { onEditProfile(profile.id) },
417393 onDuplicate = { onDuplicateProfile(profile.id) },
418394 onPendingDelete = { pendingDeleteId = profile.id },
419− onShareQr = { pendingQrProfile = profile },
420395 onTestConnection = { vm.testConnection(profile) },
421396 isProbing = probingId == profile.id,
422397 )
@@ -470,7 +445,6 @@ fun ServerListScreen(
470445 onEdit = { onEditProfile(profile.id) },
471446 onDuplicate = { onDuplicateProfile(profile.id) },
472447 onPendingDelete = { pendingDeleteId = profile.id },
473− onShareQr = { pendingQrProfile = profile },
474448 onTestConnection = { vm.testConnection(profile) },
475449 isProbing = probingId == profile.id,
476450 )
@@ -510,12 +484,6 @@ fun ServerListScreen(
510484 },
511485 )
512486 }
513−
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 })
518− }
519487}
520488
521489/** Shared content for a server profile card — used by both the active-row (plain Card)
@@ -530,7 +498,6 @@ private fun ServerCardContent(
530498 onEdit: () -> Unit,
531499 onDuplicate: () -> Unit,
532500 onPendingDelete: () -> Unit,
533− onShareQr: () -> Unit = {},
534501 onTestConnection: () -> Unit = {},
535502 isProbing: Boolean = false,
536503 activeState: soy.iko.opencode.data.network.EventStreamClient.ConnectionState? = null,
@@ -665,10 +632,6 @@ private fun ServerCardContent(
665632 text = { Text(testConnLabel) },
666633 onClick = { showRowMenu = false; onTestConnection() },
667634 )
668− DropdownMenuItem(
669− text = { Text(stringResource(R.string.share_qr)) },
670− onClick = { showRowMenu = false; onShareQr() },
671− )
672635 DropdownMenuItem(
673636 text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
674637 onClick = { showRowMenu = false; onPendingDelete() },
@@ -703,7 +666,7 @@ private fun LastUsedText(lastUsed: Long) {
703666}
704667
705668@Composable
706−private fun EmptyServers(onAdd: () -> Unit, onQrImport: () -> Unit, modifier: Modifier = Modifier) {
669+private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
707670 Box(modifier = modifier, contentAlignment = Alignment.Center) {
708671 EmptyState(
709672 icon = Icons.Filled.Dns,
@@ -712,74 +675,8 @@ private fun EmptyServers(onAdd: () -> Unit, onQrImport: () -> Unit, modifier: Mo
712675 actionIcon = Icons.Filled.Add,
713676 actionLabel = stringResource(R.string.add_server),
714677 onAction = onAdd,
715− secondaryActionIcon = Icons.Filled.PhotoLibrary,
716− secondaryActionLabel = stringResource(R.string.import_from_image),
717− onSecondaryAction = onQrImport,
718− )
719− }
720−}
721−
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. */
726−@Composable
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(),
738− ) { uri ->
739− if (uri == null) return@rememberLauncherForActivityResult
740− scope.launch {
741− val qr = soy.iko.opencode.ui.components.decodeServerQr(context, uri)
742− if (qr != null) pendingImport = qr else snackbar.showSnackbar(qrImportFailedMsg)
743− }
744− }
745− pendingImport?.let { qr ->
746− AlertDialog(
747− onDismissRequest = { pendingImport = null },
748− title = { Text(stringResource(R.string.qr_import_title)) },
749− text = {
750− Column {
751− Text(qr.baseUrl, style = MaterialTheme.typography.bodyLarge)
752− if (qr.label.isNotBlank()) {
753− Text(
754− qr.label,
755− style = MaterialTheme.typography.bodySmall,
756− color = MaterialTheme.colorScheme.onSurfaceVariant,
757− )
758− }
759− if (qr.password != null) {
760− Text(
761− stringResource(R.string.qr_includes_password),
762− style = MaterialTheme.typography.labelSmall,
763− color = MaterialTheme.colorScheme.error,
764− )
765− }
766− }
767− },
768− confirmButton = {
769− TextButton(onClick = {
770− pendingImport = null
771− scope.launch {
772− vm.importFromQr(qr)
773− snackbar.showSnackbar(qrAddedMsg)
774− }
775− }) { Text(stringResource(R.string.add_server)) }
776− },
777− dismissButton = {
778− TextButton(onClick = { pendingImport = null }) { Text(stringResource(R.string.cancel)) }
779− },
780678 )
781679 }
782− return launcher
783680}
784681
785682/** Filter server profiles by label/base URL. Extracted from [ServerListScreen] to keep its