@@ -16,9 +16,11 @@ import androidx.compose.foundation.lazy.items
1616import androidx.compose.material.icons.Icons
1717import androidx.compose.material.icons.filled.Add
1818import androidx.compose.material.icons.filled.CheckCircle
19+import androidx.compose.material.icons.filled.Error
1920import androidx.compose.material.icons.filled.Delete
2021import androidx.compose.material.icons.filled.Dns
2122import androidx.compose.material.icons.filled.MoreVert
23+import androidx.compose.material.icons.filled.QrCodeScanner
2224import androidx.compose.material3.AlertDialog
2325import androidx.compose.material3.Button
2426import androidx.compose.material3.Card
@@ -57,18 +59,22 @@ import androidx.compose.ui.platform.LocalHapticFeedback
5759import androidx.compose.ui.platform.testTag
5860import androidx.compose.ui.semantics.Role
5961import androidx.compose.ui.semantics.contentDescription
62+import androidx.compose.ui.platform.LocalContext
6063import androidx.compose.ui.semantics.semantics
6164import androidx.compose.ui.text.style.TextOverflow
6265import androidx.compose.ui.unit.dp
6366import androidx.compose.ui.res.stringResource
67+import androidx.activity.compose.rememberLauncherForActivityResult
6468import androidx.lifecycle.compose.collectAsStateWithLifecycle
6569import androidx.lifecycle.viewmodel.compose.viewModel
6670import kotlinx.coroutines.coroutineScope
6771import kotlinx.coroutines.flow.collectLatest
6872import kotlinx.coroutines.delay
6973import kotlinx.coroutines.launch
74+import kotlinx.coroutines.withContext
7075import soy.iko.opencode.data.model.ServerProfile
7176import soy.iko.opencode.data.network.NetworkConfig
77+import soy.iko.opencode.util.runCatchingCancellable
7278import soy.iko.opencode.di.AppContainer
7379import soy.iko.opencode.R
7480import soy.iko.opencode.ui.components.rememberRelativeTime
@@ -88,6 +94,11 @@ fun ServerListScreen(
8894 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
8995 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
9096 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
97+ // The active server's real SSE state, so its card reflects a dropped/failed stream instead of
98+ // always reading "Connected" (which is misleading while reconnecting or after an auth failure).
99+ val activeSseState by (activeConnection?.events?.state
100+ ?: kotlinx.coroutines.flow.flowOf(soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connected))
101+ .collectAsStateWithLifecycle(initialValue = soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connected)
91102 val haptics = LocalHapticFeedback.current
92103 val snackbar = remember { SnackbarHostState() }
93104 val retryLabel = stringResource(R.string.retry)
@@ -95,6 +106,19 @@ fun ServerListScreen(
95106 val serverRemovedLabel = stringResource(R.string.server_removed)
96107 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
97108 val connectedId = activeConnection?.profile?.id
109+ // Profile being shown as a scannable QR (Share as QR overflow action), and a decoded QR
110+ // payload awaiting the user's confirm before it's saved (Import from image action).
111+ var pendingQrProfile by remember { mutableStateOf<ServerProfile?>(null) }
112+ val context = androidx.compose.ui.platform.LocalContext.current
113+ val scope = androidx.compose.runtime.rememberCoroutineScope()
114+ // QR import (image pick → decode → confirm → save) is encapsulated in its own composable so the
115+ // branches don't inflate ServerListScreen's complexity. It renders the confirm dialog inline.
116+ val qrImportLauncher = ServerQrImportHandler(
117+ vm = vm,
118+ scope = scope,
119+ snackbar = snackbar,
120+ context = context,
121+ )
98122
99123 LaunchedEffect(Unit) {
100124 vm.errorEvents.collect { event ->
@@ -160,7 +184,20 @@ fun ServerListScreen(
160184 }
161185
162186 Scaffold(
163− topBar = { TopAppBar(title = { Text(stringResource(R.string.servers_title)) }) },
187+ topBar = {
188+ TopAppBar(
189+ title = { Text(stringResource(R.string.servers_title)) },
190+ actions = {
191+ // Import a server by decoding a QR code from a saved image (e.g. a screenshot
192+ // of another device's Share-as-QR dialog). Camera scanning would need extra
193+ // permissions; an image picker reuses the system gallery and covers the
194+ // cross-device transfer use case.
195+ IconButton(onClick = { qrImportLauncher.launch(arrayOf("image/*")) }) {
196+ Icon(Icons.Filled.QrCodeScanner, contentDescription = stringResource(R.string.import_from_image))
197+ }
198+ },
199+ )
200+ },
164201 snackbarHost = { SnackbarHost(snackbar) },
165202 floatingActionButton = {
166203 FloatingActionButton(onClick = onAddProfile) {
@@ -221,9 +258,11 @@ fun ServerListScreen(
221258 profile = profile,
222259 isActive = true,
223260 isConnecting = false,
261+ activeState = activeSseState,
224262 onEdit = { onEditProfile(profile.id) },
225263 onDuplicate = { onDuplicateProfile(profile.id) },
226264 onPendingDelete = { pendingDeleteId = profile.id },
265+ onShareQr = { pendingQrProfile = profile },
227266 )
228267 }
229268 } else {
@@ -275,6 +314,7 @@ fun ServerListScreen(
275314 onEdit = { onEditProfile(profile.id) },
276315 onDuplicate = { onDuplicateProfile(profile.id) },
277316 onPendingDelete = { pendingDeleteId = profile.id },
317+ onShareQr = { pendingQrProfile = profile },
278318 )
279319 }
280320 }
@@ -311,6 +351,12 @@ fun ServerListScreen(
311351 },
312352 )
313353 }
354+
355+ // Share-as-QR dialog for the selected profile. (The import confirm dialog is rendered by
356+ // ServerQrImportHandler above.)
357+ pendingQrProfile?.let { profile ->
358+ soy.iko.opencode.ui.components.QrShareDialog(profile = profile, onDismiss = { pendingQrProfile = null })
359+ }
314360}
315361
316362/** Shared content for a server profile card — used by both the active-row (plain Card)
@@ -325,6 +371,8 @@ private fun ServerCardContent(
325371 onEdit: () -> Unit,
326372 onDuplicate: () -> Unit,
327373 onPendingDelete: () -> Unit,
374+ onShareQr: () -> Unit = {},
375+ activeState: soy.iko.opencode.data.network.EventStreamClient.ConnectionState? = null,
328376) {
329377 Row(
330378 modifier = Modifier.fillMaxWidth().padding(16.dp),
@@ -345,21 +393,55 @@ private fun ServerCardContent(
345393 overflow = TextOverflow.Ellipsis,
346394 )
347395 if (isActive) {
396+ // Reflect the live SSE state instead of a static "Connected": a dropped or failed
397+ // stream reads as the real status so a glance at the card isn't misleading.
398+ val (statusText, statusColor) = when (activeState) {
399+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Failed,
400+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.AuthFailed ->
401+ stringResource(R.string.connection_failed_short) to MaterialTheme.colorScheme.error
402+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connecting,
403+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected ->
404+ stringResource(R.string.reconnecting) to MaterialTheme.colorScheme.onSurfaceVariant
405+ else -> stringResource(R.string.connected) to MaterialTheme.colorScheme.primary
406+ }
348407 Text(
349− stringResource(R.string.connected),
408+ statusText,
350409 style = MaterialTheme.typography.labelSmall,
351− color = MaterialTheme.colorScheme.primary,
410+ color = statusColor,
352411 )
353412 }
354413 LastUsedText(profile.lastUsed)
355414 }
356415 if (isActive) {
357− Icon(
358− Icons.Filled.CheckCircle,
359− contentDescription = stringResource(R.string.connected),
360− tint = MaterialTheme.colorScheme.primary,
361− modifier = Modifier.size(22.dp),
362− )
416+ // Swap the green check for a spinner while reconnecting, or an error glyph on a hard
417+ // failure, so the trailing icon matches the status text on the left.
418+ when (activeState) {
419+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connecting,
420+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected -> {
421+ val reconnectingLabel = stringResource(R.string.reconnecting)
422+ CircularProgressIndicator(
423+ modifier = Modifier.size(22.dp).semantics { contentDescription = reconnectingLabel },
424+ strokeWidth = 2.dp,
425+ )
426+ }
427+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Failed,
428+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.AuthFailed -> {
429+ Icon(
430+ Icons.Filled.Error,
431+ contentDescription = stringResource(R.string.connection_failed_short),
432+ tint = MaterialTheme.colorScheme.error,
433+ modifier = Modifier.size(22.dp),
434+ )
435+ }
436+ else -> {
437+ Icon(
438+ Icons.Filled.CheckCircle,
439+ contentDescription = stringResource(R.string.connected),
440+ tint = MaterialTheme.colorScheme.primary,
441+ modifier = Modifier.size(22.dp),
442+ )
443+ }
444+ }
363445 }
364446 if (isConnecting) {
365447 val connectingLabel = stringResource(R.string.connecting)
@@ -388,6 +470,10 @@ private fun ServerCardContent(
388470 text = { Text(duplicateLabel) },
389471 onClick = { showRowMenu = false; onDuplicate() },
390472 )
473+ DropdownMenuItem(
474+ text = { Text(stringResource(R.string.share_qr)) },
475+ onClick = { showRowMenu = false; onShareQr() },
476+ )
391477 DropdownMenuItem(
392478 text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
393479 onClick = { showRowMenu = false; onPendingDelete() },
@@ -449,3 +535,66 @@ private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
449535 }
450536 }
451537}
538+
539+/** Owns the QR-import flow: an image-picker launcher plus the confirm dialog for a decoded
540+ * server payload. Extracted from [ServerListScreen] so its branches (null checks, decode
541+ * result, confirm) don't push the parent composable over the cyclomatic-complexity threshold.
542+ * Renders the confirm dialog inline. */
543+@Composable
544+private fun ServerQrImportHandler(
545+ vm: ServerListViewModel,
546+ scope: kotlinx.coroutines.CoroutineScope,
547+ snackbar: SnackbarHostState,
548+ context: android.content.Context,
549+): androidx.activity.result.ActivityResultLauncher<Array<String>> {
550+ var pendingImport by remember { mutableStateOf<soy.iko.opencode.ui.components.ServerProfileQr?>(null) }
551+ val qrAddedMsg = stringResource(R.string.qr_added)
552+ val qrImportFailedMsg = stringResource(R.string.qr_import_failed)
553+ val launcher = rememberLauncherForActivityResult(
554+ androidx.activity.result.contract.ActivityResultContracts.OpenDocument(),
555+ ) { uri ->
556+ if (uri == null) return@rememberLauncherForActivityResult
557+ scope.launch {
558+ val qr = soy.iko.opencode.ui.components.decodeServerQr(context, uri)
559+ if (qr != null) pendingImport = qr else snackbar.showSnackbar(qrImportFailedMsg)
560+ }
561+ }
562+ pendingImport?.let { qr ->
563+ AlertDialog(
564+ onDismissRequest = { pendingImport = null },
565+ title = { Text(stringResource(R.string.qr_import_title)) },
566+ text = {
567+ Column {
568+ Text(qr.baseUrl, style = MaterialTheme.typography.bodyLarge)
569+ if (qr.label.isNotBlank()) {
570+ Text(
571+ qr.label,
572+ style = MaterialTheme.typography.bodySmall,
573+ color = MaterialTheme.colorScheme.onSurfaceVariant,
574+ )
575+ }
576+ if (qr.password != null) {
577+ Text(
578+ stringResource(R.string.qr_includes_password),
579+ style = MaterialTheme.typography.labelSmall,
580+ color = MaterialTheme.colorScheme.error,
581+ )
582+ }
583+ }
584+ },
585+ confirmButton = {
586+ TextButton(onClick = {
587+ pendingImport = null
588+ scope.launch {
589+ vm.importFromQr(qr)
590+ snackbar.showSnackbar(qrAddedMsg)
591+ }
592+ }) { Text(stringResource(R.string.add_server)) }
593+ },
594+ dismissButton = {
595+ TextButton(onClick = { pendingImport = null }) { Text(stringResource(R.string.cancel)) }
596+ },
597+ )
598+ }
599+ return launcher
600+}