@@ -16,9 +16,11 @@ import androidx.compose.foundation.lazy.items
16import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.filled.Add
17import androidx.compose.material.icons.filled.Add
18import androidx.compose.material.icons.filled.CheckCircle
18import androidx.compose.material.icons.filled.CheckCircle
19+import androidx.compose.material.icons.filled.Error
19import androidx.compose.material.icons.filled.Delete
20import androidx.compose.material.icons.filled.Delete
20import androidx.compose.material.icons.filled.Dns
21import androidx.compose.material.icons.filled.Dns
21import androidx.compose.material.icons.filled.MoreVert
22import androidx.compose.material.icons.filled.MoreVert
23+import androidx.compose.material.icons.filled.QrCodeScanner
22import androidx.compose.material3.AlertDialog
24import androidx.compose.material3.AlertDialog
23import androidx.compose.material3.Button
25import androidx.compose.material3.Button
24import androidx.compose.material3.Card
26import androidx.compose.material3.Card
@@ -57,18 +59,22 @@ import androidx.compose.ui.platform.LocalHapticFeedback
57import androidx.compose.ui.platform.testTag
59import androidx.compose.ui.platform.testTag
58import androidx.compose.ui.semantics.Role
60import androidx.compose.ui.semantics.Role
59import androidx.compose.ui.semantics.contentDescription
61import androidx.compose.ui.semantics.contentDescription
62+import androidx.compose.ui.platform.LocalContext
60import androidx.compose.ui.semantics.semantics
63import androidx.compose.ui.semantics.semantics
61import androidx.compose.ui.text.style.TextOverflow
64import androidx.compose.ui.text.style.TextOverflow
62import androidx.compose.ui.unit.dp
65import androidx.compose.ui.unit.dp
63import androidx.compose.ui.res.stringResource
66import androidx.compose.ui.res.stringResource
67+import androidx.activity.compose.rememberLauncherForActivityResult
64import androidx.lifecycle.compose.collectAsStateWithLifecycle
68import androidx.lifecycle.compose.collectAsStateWithLifecycle
65import androidx.lifecycle.viewmodel.compose.viewModel
69import androidx.lifecycle.viewmodel.compose.viewModel
66import kotlinx.coroutines.coroutineScope
70import kotlinx.coroutines.coroutineScope
67import kotlinx.coroutines.flow.collectLatest
71import kotlinx.coroutines.flow.collectLatest
68import kotlinx.coroutines.delay
72import kotlinx.coroutines.delay
69import kotlinx.coroutines.launch
73import kotlinx.coroutines.launch
74+import kotlinx.coroutines.withContext
70import soy.iko.opencode.data.model.ServerProfile
75import soy.iko.opencode.data.model.ServerProfile
71import soy.iko.opencode.data.network.NetworkConfig
76import soy.iko.opencode.data.network.NetworkConfig
77+import soy.iko.opencode.util.runCatchingCancellable
72import soy.iko.opencode.di.AppContainer
78import soy.iko.opencode.di.AppContainer
73import soy.iko.opencode.R
79import soy.iko.opencode.R
74import soy.iko.opencode.ui.components.rememberRelativeTime
80import soy.iko.opencode.ui.components.rememberRelativeTime
@@ -88,6 +94,11 @@ fun ServerListScreen(
88 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
94 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
89 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
95 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
90 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
96 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)
91 val haptics = LocalHapticFeedback.current
102 val haptics = LocalHapticFeedback.current
92 val snackbar = remember { SnackbarHostState() }
103 val snackbar = remember { SnackbarHostState() }
93 val retryLabel = stringResource(R.string.retry)
104 val retryLabel = stringResource(R.string.retry)
@@ -95,6 +106,19 @@ fun ServerListScreen(
95 val serverRemovedLabel = stringResource(R.string.server_removed)
106 val serverRemovedLabel = stringResource(R.string.server_removed)
96 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
107 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
97 val connectedId = activeConnection?.profile?.id
108 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(
119+ snackbar = snackbar,
99 LaunchedEffect(Unit) {
123 LaunchedEffect(Unit) {
100 vm.errorEvents.collect { event ->
124 vm.errorEvents.collect { event ->
@@ -160,7 +184,20 @@ fun ServerListScreen(
162 Scaffold(
186 Scaffold(
163− topBar = { TopAppBar(title = { Text(stringResource(R.string.servers_title)) }) },
189+ title = { Text(stringResource(R.string.servers_title)) },
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))
164 snackbarHost = { SnackbarHost(snackbar) },
201 snackbarHost = { SnackbarHost(snackbar) },
165 floatingActionButton = {
202 floatingActionButton = {
166 FloatingActionButton(onClick = onAddProfile) {
203 FloatingActionButton(onClick = onAddProfile) {
@@ -221,9 +258,11 @@ fun ServerListScreen(
221 profile = profile,
258 profile = profile,
222 isActive = true,
259 isActive = true,
223 isConnecting = false,
260 isConnecting = false,
261+ activeState = activeSseState,
224 onEdit = { onEditProfile(profile.id) },
262 onEdit = { onEditProfile(profile.id) },
225 onDuplicate = { onDuplicateProfile(profile.id) },
263 onDuplicate = { onDuplicateProfile(profile.id) },
226 onPendingDelete = { pendingDeleteId = profile.id },
264 onPendingDelete = { pendingDeleteId = profile.id },
265+ onShareQr = { pendingQrProfile = profile },
@@ -275,6 +314,7 @@ fun ServerListScreen(
275 onEdit = { onEditProfile(profile.id) },
314 onEdit = { onEditProfile(profile.id) },
276 onDuplicate = { onDuplicateProfile(profile.id) },
315 onDuplicate = { onDuplicateProfile(profile.id) },
277 onPendingDelete = { pendingDeleteId = profile.id },
316 onPendingDelete = { pendingDeleteId = profile.id },
317+ onShareQr = { pendingQrProfile = profile },
@@ -311,6 +351,12 @@ fun ServerListScreen(
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 })
316/** Shared content for a server profile card — used by both the active-row (plain Card)
362/** Shared content for a server profile card — used by both the active-row (plain Card)
@@ -325,6 +371,8 @@ private fun ServerCardContent(
325 onEdit: () -> Unit,
371 onEdit: () -> Unit,
326 onDuplicate: () -> Unit,
372 onDuplicate: () -> Unit,
327 onPendingDelete: () -> Unit,
373 onPendingDelete: () -> Unit,
374+ onShareQr: () -> Unit = {},
375+ activeState: soy.iko.opencode.data.network.EventStreamClient.ConnectionState? = null,
330 modifier = Modifier.fillMaxWidth().padding(16.dp),
378 modifier = Modifier.fillMaxWidth().padding(16.dp),
@@ -345,21 +393,55 @@ private fun ServerCardContent(
345 overflow = TextOverflow.Ellipsis,
393 overflow = TextOverflow.Ellipsis,
347 if (isActive) {
395 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
349− stringResource(R.string.connected),
408+ statusText,
350 style = MaterialTheme.typography.labelSmall,
409 style = MaterialTheme.typography.labelSmall,
351− color = MaterialTheme.colorScheme.primary,
410+ color = statusColor,
354 LastUsedText(profile.lastUsed)
413 LastUsedText(profile.lastUsed)
356 if (isActive) {
415 if (isActive) {
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 },
427+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Failed,
428+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.AuthFailed -> {
431+ contentDescription = stringResource(R.string.connection_failed_short),
432+ tint = MaterialTheme.colorScheme.error,
433+ modifier = Modifier.size(22.dp),
358− Icons.Filled.CheckCircle,
438+ Icons.Filled.CheckCircle,
359− contentDescription = stringResource(R.string.connected),
439+ contentDescription = stringResource(R.string.connected),
360− tint = MaterialTheme.colorScheme.primary,
440+ tint = MaterialTheme.colorScheme.primary,
361− modifier = Modifier.size(22.dp),
441+ modifier = Modifier.size(22.dp),
364 if (isConnecting) {
446 if (isConnecting) {
365 val connectingLabel = stringResource(R.string.connecting)
447 val connectingLabel = stringResource(R.string.connecting)
@@ -388,6 +470,10 @@ private fun ServerCardContent(
388 text = { Text(duplicateLabel) },
470 text = { Text(duplicateLabel) },
389 onClick = { showRowMenu = false; onDuplicate() },
471 onClick = { showRowMenu = false; onDuplicate() },
474+ text = { Text(stringResource(R.string.share_qr)) },
475+ onClick = { showRowMenu = false; onShareQr() },
391 DropdownMenuItem(
477 DropdownMenuItem(
392 text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
478 text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
393 onClick = { showRowMenu = false; onPendingDelete() },
479 onClick = { showRowMenu = false; onPendingDelete() },
@@ -449,3 +535,66 @@ private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
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. */
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(),
556+ if (uri == null) return@rememberLauncherForActivityResult
558+ val qr = soy.iko.opencode.ui.components.decodeServerQr(context, uri)
559+ if (qr != null) pendingImport = qr else snackbar.showSnackbar(qrImportFailedMsg)
562+ pendingImport?.let { qr ->
564+ onDismissRequest = { pendingImport = null },
565+ title = { Text(stringResource(R.string.qr_import_title)) },
568+ Text(qr.baseUrl, style = MaterialTheme.typography.bodyLarge)
569+ if (qr.label.isNotBlank()) {
572+ style = MaterialTheme.typography.bodySmall,
573+ color = MaterialTheme.colorScheme.onSurfaceVariant,
576+ if (qr.password != null) {
578+ stringResource(R.string.qr_includes_password),
579+ style = MaterialTheme.typography.labelSmall,
580+ color = MaterialTheme.colorScheme.error,
586+ TextButton(onClick = {
587+ pendingImport = null
590+ snackbar.showSnackbar(qrAddedMsg)
592+ }) { Text(stringResource(R.string.add_server)) }
595+ TextButton(onClick = { pendingImport = null }) { Text(stringResource(R.string.cancel)) }