git.iko.soy/opencode-android
on master help sign in
Implement broad UI/UX improvements: failed-message dismiss/retry, scroll-to-dismiss-keyboard, app-lock re-lock timing and availability refresh, QR server profile sharing, file-browser sort and file-type icons, diff line-number gutters, search match counts and title highlighting, new-session title field, server-switch active-run confirmation, live active-card connection state, ping-on-connect check, and shared-component migrations (EmptyState/AppTopBar, ConnectionBanner SideEffect fix, CommandPalette keyboard navigation)
commitb42208d695565282e487d5463a745672584dccad authoriko <mail@iko.soy> dateFri 3 Jul 2026, 14:06 (3 months ago) parent73868e9
27 files changed+1103−213
app/build.gradle.kts+1 app/src/main/java/soy/iko/opencode/MainActivity.kt+4−1 app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+19 app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+23−6 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+42−6 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+15 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+65−12 app/src/main/java/soy/iko/opencode/ui/components/AppTopBar.kt+45−6 app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.kt+60−10 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+5−1 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+55−11 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+20−4 app/src/main/java/soy/iko/opencode/ui/components/QrCode.kt+174 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+57−32 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+40−17 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+51−4 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+8−1 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+158−9 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+16 app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+17−3 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+90−54 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+9−8 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+4−25 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+81−3 app/src/main/res/values-es/strings.xml+22 app/src/main/res/values/strings.xml+20 gradle/libs.versions.toml+2
app/build.gradle.kts+1
@@ -136,6 +136,7 @@ dependencies {
136136 implementation(libs.coil.compose)
137137 implementation(libs.markdown.renderer)
138138 implementation(libs.markdown.renderer.m3)
139+ implementation(libs.zxing.core)
139140
140141 testImplementation(libs.junit)
141142 testImplementation(libs.kotlinx.coroutines.test)
app/src/main/java/soy/iko/opencode/MainActivity.kt+4−1
@@ -22,6 +22,7 @@ import androidx.core.content.ContextCompat
2222import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
2323import androidx.fragment.app.FragmentActivity
2424import androidx.lifecycle.compose.collectAsStateWithLifecycle
25+import soy.iko.opencode.data.repo.SettingsStore
2526import soy.iko.opencode.data.repo.ThemeMode
2627import soy.iko.opencode.ui.AppLockGate
2728import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
@@ -78,6 +79,8 @@ class MainActivity : FragmentActivity() {
7879 .combine(container.settingsStore.dynamicColor, ::Pair)
7980 }.collectAsStateWithLifecycle(initialValue = null)
8081 val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = null)
82+ val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds
83+ .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS)
8184 LaunchedEffect(theme, appLock) { if (theme != null && appLock != null) settingsLoaded = true }
8285 // Hide the app's content from screenshots / the recents thumbnail while app lock is
8386 // enabled — and while its value is still unknown (null) at cold start, so a protected
@@ -98,7 +101,7 @@ class MainActivity : FragmentActivity() {
98101 ThemeMode.AMOLED -> true
99102 }
100103 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor, amoled = amoled) {
101− AppLockGate(enabled = locked) {
104+ AppLockGate(enabled = locked, reLockDelaySeconds = appLockReLockSeconds) {
102105 OpencodeAppUi(container = container)
103106 }
104107 }
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+19
@@ -4,6 +4,7 @@ import android.content.Context
44import androidx.datastore.preferences.core.booleanPreferencesKey
55import androidx.datastore.preferences.core.edit
66import androidx.datastore.preferences.core.floatPreferencesKey
7+import androidx.datastore.preferences.core.intPreferencesKey
78import androidx.datastore.preferences.core.stringPreferencesKey
89import androidx.datastore.preferences.preferencesDataStore
910import kotlinx.coroutines.flow.Flow
@@ -21,6 +22,7 @@ class SettingsStore(context: Context) {
2122 private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
2223 private val sendOnEnterKey = booleanPreferencesKey("send_on_enter")
2324 private val appLockKey = booleanPreferencesKey("app_lock")
25+ private val appLockReLockSecondsKey = intPreferencesKey("app_lock_relock_seconds")
2426 private val chatTextScaleKey = floatPreferencesKey("chat_text_scale")
2527 private val codeWrapKey = booleanPreferencesKey("code_wrap")
2628
@@ -30,6 +32,10 @@ class SettingsStore(context: Context) {
3032 const val MIN_CHAT_TEXT_SCALE = 0.8f
3133 const val MAX_CHAT_TEXT_SCALE = 1.6f
3234 const val DEFAULT_CHAT_TEXT_SCALE = 1.0f
35+ /** 0 means re-lock the instant the app backgrounds. Other entries are the grace
36+ * period (seconds) during which a quick app-switch does NOT re-prompt for auth. */
37+ val APP_LOCK_RELOCK_OPTIONS_SECONDS: List<Int> = listOf(0, 30, 60, 300)
38+ const val DEFAULT_APP_LOCK_RELOCK_SECONDS = 0
3339 }
3440
3541 val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
@@ -58,6 +64,13 @@ class SettingsStore(context: Context) {
5864 prefs[appLockKey] ?: false
5965 }
6066
67+ /** Grace period (seconds) before re-locking after the app backgrounds. 0 = re-lock
68+ * immediately (the prior behavior). Lets a quick app-switch not re-prompt, which is the
69+ * standard reason users disable biometric locks in messaging/banking apps. */
70+ val appLockReLockSeconds: Flow<Int> = appContext.settingsDataStore.data.map { prefs ->
71+ prefs[appLockReLockSecondsKey] ?: DEFAULT_APP_LOCK_RELOCK_SECONDS
72+ }
73+
6174 /** Multiplier applied to chat message (markdown) and code text so users can size the
6275 * conversation for readability independently of the system font scale. Clamped to
6376 * [[MIN_CHAT_TEXT_SCALE], [MAX_CHAT_TEXT_SCALE]] on read so a corrupt/out-of-range
@@ -89,6 +102,12 @@ class SettingsStore(context: Context) {
89102 appContext.settingsDataStore.edit { it[appLockKey] = enabled }
90103 }
91104
105+ suspend fun setAppLockReLockSeconds(seconds: Int) {
106+ appContext.settingsDataStore.edit {
107+ it[appLockReLockSecondsKey] = APP_LOCK_RELOCK_OPTIONS_SECONDS.minBy { k -> Math.abs(k - seconds) }
108+ }
109+ }
110+
92111 suspend fun setChatTextScale(scale: Float) {
93112 appContext.settingsDataStore.edit {
94113 it[chatTextScaleKey] = scale.coerceIn(MIN_CHAT_TEXT_SCALE, MAX_CHAT_TEXT_SCALE)
app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+23−6
@@ -89,7 +89,7 @@ private fun Context.findFragmentActivity(): FragmentActivity? {
8989 * [canAuthenticateForAppLock] is true, so this fallback just prevents a hard lock-out.
9090 */
9191@Composable
92−fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
92+fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composable () -> Unit) {
9393 if (!enabled) {
9494 content()
9595 return
@@ -116,6 +116,12 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
116116 // app-lock bypass. Rotation is handled by the activity's configChanges (no recreation), so a
117117 // plain remember still avoids re-prompting on rotation while re-locking on real recreation.
118118 var unlocked by remember { mutableStateOf(false) }
119+ // Wall-clock time of the most recent ON_STOP. Used with [reLockDelaySeconds] so a quick
120+ // app-switch (e.g. glancing at another app for a few seconds) doesn't re-prompt — the most
121+ // common reason users disable biometric locks. A killed process resets unlocked anyway, so the
122+ // grace period only governs the in-memory re-lock decision across a surviving background stint.
123+ val lastStopTimeMs = remember { longArrayOf(0L) }
124+ val reLockDelayMs = reLockDelaySeconds * 1000L
119125
120126 val title = stringResource(R.string.app_lock_prompt_title)
121127 val subtitle = stringResource(R.string.app_lock_prompt_subtitle)
@@ -175,13 +181,24 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
175181 val observer = LifecycleEventObserver { _, event ->
176182 when (event) {
177183 Lifecycle.Event.ON_STOP -> {
178− unlocked = false
179− // The system dismisses an in-flight prompt on background and posts its
180− // onAuthenticationError asynchronously; clear the flag now so ON_START re-prompts
181− // instead of no-opping on a stale authInFlight and stranding the manual button.
184+ // Record the stop time but DON'T clear unlocked yet — the re-lock decision is
185+ // made on the next ON_START, so a brief background stint under the grace period
186+ // returns the user straight to their session without a re-prompt. authInFlight
187+ // is still cleared because the system dismisses an in-flight prompt on background.
188+ lastStopTimeMs[0] = System.currentTimeMillis()
182189 authInFlight = false
183190 }
184− Lifecycle.Event.ON_START -> if (!unlocked) authenticate()
191+ Lifecycle.Event.ON_START -> {
192+ // Only re-lock (and re-prompt) if more than the grace period elapsed since the
193+ // last ON_STOP, or the app is freshly starting (lastStopTimeMs == 0).
194+ val elapsed = System.currentTimeMillis() - lastStopTimeMs[0]
195+ if (unlocked && lastStopTimeMs[0] != 0L && elapsed < reLockDelayMs) {
196+ // Within the grace period — stay unlocked, no re-prompt.
197+ } else {
198+ unlocked = false
199+ if (!unlocked) authenticate()
200+ }
201+ }
185202 else -> {}
186203 }
187204 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+42−6
@@ -116,6 +116,7 @@ import androidx.compose.ui.unit.dp
116116import androidx.lifecycle.compose.collectAsStateWithLifecycle
117117import androidx.lifecycle.viewmodel.compose.viewModel
118118import kotlinx.coroutines.Dispatchers
119+import kotlinx.coroutines.flow.distinctUntilChanged
119120import kotlinx.coroutines.launch
120121import kotlinx.coroutines.withContext
121122import soy.iko.opencode.data.model.Part
@@ -721,7 +722,15 @@ fun ChatScreen(
721722 val listState = rememberLazyListState()
722723 val contentScope = rememberCoroutineScope()
723724 val timeTick = rememberRelativeTimeTick()
724− // Group messages by day and interleave date separators so a long conversation
725+ // Dismiss the soft keyboard when the user scrolls the message list, a standard Android
726+ // chat affordance for reading while the IME is open. Watching isScrollInProgress via
727+ // snapshotFlow avoids wiring a nested scroll connection and fires on any scroll gesture.
728+ val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
729+ androidx.compose.runtime.LaunchedEffect(listState) {
730+ androidx.compose.runtime.snapshotFlow { listState.isScrollInProgress }
731+ .distinctUntilChanged()
732+ .collect { scrolling -> if (scrolling) keyboardController?.hide() }
733+ } // Group messages by day and interleave date separators so a long conversation
725734 // has visual "Today"/"Yesterday"/date breaks. Computed once per messages
726735 // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives
727736 // outside the PullToRefreshBox so the auto-scroll effects below can reference
@@ -883,11 +892,24 @@ fun ChatScreen(
883892 val topPad = if (bannerVisible) 44.dp else 16.dp
884893 if (loading && messages.isEmpty()) {
885894 val loadingLabel = stringResource(R.string.loading)
886− CircularProgressIndicator(
887− Modifier.align(Alignment.Center).padding(top = topPad),
888− strokeWidth = 2.dp,
889− )
890− Box(modifier = Modifier.semantics { contentDescription = loadingLabel })
895+ // Attach the a11y label directly to the spinner (a detached zero-size Box
896+ // isn't reliably focused/announced by TalkBack) and show a visible label so
897+ // sighted users get "Loading…" rather than a bare spinner.
898+ Column(
899+ modifier = Modifier.align(Alignment.Center).padding(top = topPad),
900+ horizontalAlignment = Alignment.CenterHorizontally,
901+ ) {
902+ CircularProgressIndicator(
903+ Modifier.semantics { contentDescription = loadingLabel },
904+ strokeWidth = 2.dp,
905+ )
906+ Spacer(Modifier.size(8.dp))
907+ Text(
908+ loadingLabel,
909+ style = MaterialTheme.typography.labelMedium,
910+ color = MaterialTheme.colorScheme.onSurfaceVariant,
911+ )
912+ }
891913 } else if (loadError && messages.isEmpty()) {
892914 // A failed load with nothing to show. Distinct from the empty
893915 // conversation state below — the conversation isn't empty, it
@@ -1002,6 +1024,20 @@ fun ChatScreen(
10021024 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
10031025 },
10041026 isEdited = message.info.time?.let { it.updated != null && it.updated != it.created } == true,
1027+ onRetry = optimisticStatuses[message.info.id]?.let { failed ->
1028+ if (failed) {
1029+ { vm.retryOptimisticMessage(message.info.id) }
1030+ } else {
1031+ null
1032+ }
1033+ },
1034+ onDismiss = optimisticStatuses[message.info.id]?.let { failed ->
1035+ if (failed) {
1036+ { vm.dismissOptimistic(message.info.id) }
1037+ } else {
1038+ null
1039+ }
1040+ },
10051041 )
10061042 }
10071043 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+15
@@ -1101,6 +1101,21 @@ class ChatViewModel(
11011101 }
11021102 }
11031103
1104+ /** Dismiss a single failed optimistic message by its tempId so it no longer lingers in the
1105+ * list. Without this there's no recovery path for a failed message the user chose to abandon
1106+ * (its text won't ever match a real server message, so reconcileOptimistic never clears it). */
1107+ fun dismissOptimistic(tempId: String) {
1108+ _optimisticMessages.update { entries -> entries.filterNot { it.tempId == tempId } }
1109+ }
1110+
1111+ /** Re-send a specific failed optimistic message by its tempId. Loads its text into the
1112+ * composer and fires a send, mirroring the snackbar's Retry path. Returns false (and is a
1113+ * no-op) if the entry can't be found or a run is already active. */
1114+ fun retryOptimisticMessage(tempId: String): Boolean {
1115+ val entry = _optimisticMessages.value.firstOrNull { it.tempId == tempId && it.failed } ?: return false
1116+ return send(entry.text, includeAttachments = false)
1117+ }
1118+
11041119 /**
11051120 * Queue [text] to be sent automatically when the current run finishes, or clear any
11061121 * queued follow-up when [text] is blank. Used when the user taps Send while a run
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+65−12
@@ -1,6 +1,7 @@
11package soy.iko.opencode.ui.chat
22
33import androidx.compose.foundation.background
4+import androidx.compose.foundation.clickable
45import androidx.compose.foundation.gestures.detectTapGestures
56import androidx.compose.foundation.layout.Arrangement
67import androidx.compose.foundation.layout.Box
@@ -15,6 +16,7 @@ import androidx.compose.material.icons.Icons
1516import androidx.compose.material.icons.automirrored.filled.HelpOutline
1617import androidx.compose.material.icons.automirrored.filled.VolumeUp
1718import androidx.compose.material.icons.filled.CallSplit
19+import androidx.compose.material.icons.filled.Close
1820import androidx.compose.material.icons.filled.ContentCopy
1921import androidx.compose.material.icons.filled.Edit
2022import androidx.compose.material.icons.filled.Error
@@ -29,6 +31,7 @@ import androidx.compose.material3.CircularProgressIndicator
2931import androidx.compose.material3.Icon
3032import androidx.compose.material3.IconButton
3133import androidx.compose.material3.MaterialTheme
34+import androidx.compose.material3.ripple
3235import androidx.compose.material3.Text
3336import androidx.compose.runtime.Composable
3437import androidx.compose.runtime.getValue
@@ -44,7 +47,11 @@ import androidx.compose.ui.input.pointer.pointerInput
4447import androidx.compose.ui.platform.LocalContext
4548import androidx.compose.ui.platform.LocalHapticFeedback
4649import androidx.compose.ui.res.stringResource
50+import androidx.compose.ui.semantics.Role
51+import androidx.compose.ui.semantics.contentDescription
52+import androidx.compose.ui.semantics.semantics
4753import androidx.compose.ui.unit.dp
54+import androidx.compose.foundation.interaction.MutableInteractionSource
4855import soy.iko.opencode.R
4956import soy.iko.opencode.data.model.AssistantMessage
5057import soy.iko.opencode.data.model.MessageWithParts
@@ -95,9 +102,11 @@ fun MessageBubble(
95102 onBranch: ((String) -> Unit)? = null,
96103 sendStatus: MessageSendStatus? = null,
97104 isEdited: Boolean = false,
105+ onRetry: (() -> Unit)? = null,
106+ onDismiss: (() -> Unit)? = null,
98107) {
99108 when (message.info) {
100− is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited)
109+ is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited, onRetry, onDismiss)
101110 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
102111 else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, onQuote, onBranch, isEdited)
103112 }
@@ -274,6 +283,8 @@ private fun UserBubble(
274283 onBranch: ((String) -> Unit)? = null,
275284 sendStatus: MessageSendStatus? = null,
276285 isEdited: Boolean = false,
286+ onRetry: (() -> Unit)? = null,
287+ onDismiss: (() -> Unit)? = null,
277288) {
278289 val context = LocalContext.current
279290 val haptics = LocalHapticFeedback.current
@@ -353,17 +364,59 @@ private fun UserBubble(
353364 )
354365 }
355366 MessageSendStatus.FAILED -> {
356− Icon(
357− Icons.Filled.Error,
358− contentDescription = stringResource(R.string.message_send_failed),
359− modifier = Modifier.size(12.dp),
360− tint = MaterialTheme.colorScheme.error,
361− )
362− Text(
363− stringResource(R.string.message_send_failed),
364− style = MaterialTheme.typography.labelSmall,
365− color = MaterialTheme.colorScheme.error,
366− )
367+ // Tap-to-retry: the whole failed label is tappable to re-send (far more
368+ // discoverable than relying on the transient snackbar). A dismiss (×) button
369+ // removes the abandoned message entirely, since its text will never match a
370+ // real server message and would otherwise linger forever.
371+ val retryModifier = if (onRetry != null) {
372+ Modifier.clickable(
373+ interactionSource = remember { MutableInteractionSource() },
374+ indication = ripple(),
375+ role = Role.Button,
376+ onClick = {
377+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
378+ onRetry()
379+ },
380+ ).padding(vertical = 2.dp)
381+ } else {
382+ Modifier.padding(vertical = 2.dp)
383+ }
384+ Row(
385+ verticalAlignment = Alignment.CenterVertically,
386+ modifier = retryModifier.semantics(mergeDescendants = true) {
387+ contentDescription = context.getString(R.string.message_send_failed) +
388+ ". " + context.getString(R.string.tap_to_retry)
389+ },
390+ ) {
391+ Icon(
392+ Icons.Filled.Error,
393+ contentDescription = null,
394+ modifier = Modifier.size(12.dp),
395+ tint = MaterialTheme.colorScheme.error,
396+ )
397+ Text(
398+ stringResource(R.string.message_send_failed),
399+ style = MaterialTheme.typography.labelSmall,
400+ color = MaterialTheme.colorScheme.error,
401+ modifier = Modifier.padding(start = 4.dp),
402+ )
403+ }
404+ if (onDismiss != null) {
405+ IconButton(
406+ onClick = {
407+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
408+ onDismiss()
409+ },
410+ modifier = Modifier.size(20.dp),
411+ ) {
412+ Icon(
413+ Icons.Filled.Close,
414+ contentDescription = stringResource(R.string.outbox_discard),
415+ modifier = Modifier.size(14.dp),
416+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
417+ )
418+ }
419+ }
367420 }
368421 null -> {}
369422 }
app/src/main/java/soy/iko/opencode/ui/components/AppTopBar.kt+45−6
@@ -1,23 +1,33 @@
11package soy.iko.opencode.ui.components
22
3+import androidx.compose.foundation.layout.Box
4+import androidx.compose.foundation.layout.Column
35import androidx.compose.foundation.layout.RowScope
6+import androidx.compose.foundation.layout.size
47import androidx.compose.material.icons.Icons
58import androidx.compose.material.icons.automirrored.filled.ArrowBack
9+import androidx.compose.material3.CircularProgressIndicator
610import androidx.compose.material3.ExperimentalMaterial3Api
711import androidx.compose.material3.Icon
812import androidx.compose.material3.IconButton
13+import androidx.compose.material3.MaterialTheme
914import androidx.compose.material3.Text
1015import androidx.compose.material3.TopAppBar
16+import androidx.compose.material3.TopAppBarDefaults
1117import androidx.compose.material3.TopAppBarScrollBehavior
1218import androidx.compose.runtime.Composable
19+import androidx.compose.ui.Alignment
20+import androidx.compose.ui.Modifier
1321import androidx.compose.ui.res.stringResource
22+import androidx.compose.ui.text.style.TextOverflow
23+import androidx.compose.ui.unit.dp
1424import soy.iko.opencode.R
1525
1626/**
17− * The app's standard top bar: a title and an optional up/back button plus trailing actions.
18− * Extracted so the many plain back-title-actions screens share one implementation instead of
19− * hand-rolling a [TopAppBar] each. Screens with a bespoke title (dropdown server switcher,
20− * clickable model picker) keep their own bar.
27+ * The app's standard top bar: a title (with optional subtitle), an optional up/back button, an
28+ * optional inline loading indicator, and trailing actions. Extracted so the many plain
29+ * back-title-actions screens share one implementation instead of hand-rolling a [TopAppBar] each.
30+ * Screens with a bespoke title (dropdown server switcher, clickable model picker) keep their own bar.
2131 */
2232@OptIn(ExperimentalMaterial3Api::class)
2333@Composable
@@ -25,10 +35,27 @@ fun AppTopBar(
2535 title: String,
2636 onBack: (() -> Unit)? = null,
2737 scrollBehavior: TopAppBarScrollBehavior? = null,
38+ subtitle: String? = null,
39+ loading: Boolean = false,
2840 actions: @Composable RowScope.() -> Unit = {},
2941) {
3042 TopAppBar(
31− title = { Text(title) },
43+ title = {
44+ if (subtitle != null) {
45+ Column {
46+ Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis)
47+ Text(
48+ subtitle,
49+ style = MaterialTheme.typography.labelMedium,
50+ color = MaterialTheme.colorScheme.onSurfaceVariant,
51+ maxLines = 1,
52+ overflow = TextOverflow.Ellipsis,
53+ )
54+ }
55+ } else {
56+ Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis)
57+ }
58+ },
3259 navigationIcon = {
3360 if (onBack != null) {
3461 IconButton(onClick = onBack) {
@@ -39,7 +66,19 @@ fun AppTopBar(
3966 }
4067 }
4168 },
42− actions = actions,
69+ actions = {
70+ if (loading) {
71+ Box(modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center) {
72+ CircularProgressIndicator(
73+ Modifier.size(20.dp),
74+ strokeWidth = 2.dp,
75+ color = MaterialTheme.colorScheme.onSurfaceVariant,
76+ )
77+ }
78+ }
79+ actions()
80+ },
4381 scrollBehavior = scrollBehavior,
82+ colors = TopAppBarDefaults.topAppBarColors(),
4483 )
4584}
app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.kt+60−10
@@ -1,5 +1,6 @@
11package soy.iko.opencode.ui.components
22
3+import androidx.compose.foundation.background
34import androidx.compose.foundation.clickable
45import androidx.compose.foundation.layout.Column
56import androidx.compose.foundation.layout.fillMaxWidth
@@ -7,6 +8,7 @@ import androidx.compose.foundation.layout.heightIn
78import androidx.compose.foundation.layout.padding
89import androidx.compose.foundation.lazy.LazyColumn
910import androidx.compose.foundation.lazy.items
11+import androidx.compose.foundation.lazy.rememberLazyListState
1012import androidx.compose.foundation.text.KeyboardActions
1113import androidx.compose.foundation.text.KeyboardOptions
1214import androidx.compose.material.icons.Icons
@@ -27,6 +29,7 @@ import androidx.compose.runtime.setValue
2729import androidx.compose.ui.Modifier
2830import androidx.compose.ui.focus.focusRequester
2931import androidx.compose.ui.input.key.Key
32+import androidx.compose.ui.input.key.KeyEvent
3033import androidx.compose.ui.input.key.KeyEventType
3134import androidx.compose.ui.input.key.key
3235import androidx.compose.ui.input.key.onPreviewKeyEvent
@@ -55,11 +58,29 @@ fun CommandPalette(
5558 val filtered = remember(query, actions) {
5659 if (query.isBlank()) actions else actions.filter { it.label.contains(query, ignoreCase = true) }
5760 }
61+ // Keyboard selection index over `filtered`. Clamped when the list shrinks; Enter/Go run the
62+ // selected row (or the first match when nothing is highlighted) — the keyboard-first behavior
63+ // a command palette is expected to have.
64+ var selectedIndex by remember { mutableStateOf(0) }
65+ val listState = rememberLazyListState()
66+ LaunchedEffect(filtered.size, query) {
67+ // Reset selection whenever the result set changes so the highlight never points past the end.
68+ if (selectedIndex > filtered.lastIndex) selectedIndex = filtered.lastIndex.coerceAtLeast(0)
69+ }
70+ LaunchedEffect(selectedIndex) {
71+ // Keep the highlighted row visible as the user arrows through the list.
72+ if (filtered.isNotEmpty()) listState.animateScrollToItem(selectedIndex)
73+ }
5874 val focus = remember { androidx.compose.ui.focus.FocusRequester() }
5975 fun run(action: PaletteAction) {
6076 onDismiss()
6177 action.onSelect()
6278 }
79+ fun moveSelection(delta: Int) {
80+ if (filtered.isNotEmpty()) {
81+ selectedIndex = (selectedIndex + delta).coerceIn(0, filtered.lastIndex)
82+ }
83+ }
6384 Dialog(onDismissRequest = onDismiss) {
6485 Surface(
6586 shape = MaterialTheme.shapes.large,
@@ -81,18 +102,14 @@ fun CommandPalette(
81102 }
82103 },
83104 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
84− keyboardActions = KeyboardActions(onGo = { filtered.firstOrNull()?.let { run(it) } }),
105+ keyboardActions = KeyboardActions(onGo = {
106+ val target = filtered.getOrNull(selectedIndex) ?: filtered.firstOrNull()
107+ target?.let { run(it) }
108+ }),
85109 modifier = Modifier
86110 .fillMaxWidth()
87111 .focusRequester(focus)
88− .onPreviewKeyEvent { ev ->
89− if (ev.type == KeyEventType.KeyDown && ev.key == Key.Enter) {
90− filtered.firstOrNull()?.let { run(it) }
91− true
92− } else {
93− false
94− }
95− },
112+ .onPreviewKeyEvent { ev -> handlePaletteKey(ev, filtered, selectedIndex, ::moveSelection, ::run) },
96113 )
97114 LaunchedEffect(Unit) { runCatchingCancellable { focus.requestFocus() } }
98115 if (filtered.isEmpty()) {
@@ -103,13 +120,21 @@ fun CommandPalette(
103120 modifier = Modifier.padding(16.dp),
104121 )
105122 } else {
106− LazyColumn(modifier = Modifier.heightIn(max = 360.dp)) {
123+ LazyColumn(state = listState, modifier = Modifier.heightIn(max = 360.dp)) {
107124 items(filtered, key = { it.id }) { action ->
125+ val index = filtered.indexOf(action)
126+ val isSelected = index == selectedIndex
108127 Text(
109128 action.label,
110129 style = MaterialTheme.typography.bodyLarge,
130+ color = if (isSelected) MaterialTheme.colorScheme.primary
131+ else MaterialTheme.colorScheme.onSurface,
111132 modifier = Modifier
112133 .fillMaxWidth()
134+ .background(
135+ if (isSelected) MaterialTheme.colorScheme.secondaryContainer
136+ else androidx.compose.ui.graphics.Color.Transparent,
137+ )
113138 .clickable(role = Role.Button) { run(action) }
114139 .padding(horizontal = 12.dp, vertical = 14.dp),
115140 )
@@ -120,3 +145,28 @@ fun CommandPalette(
120145 }
121146 }
122147}
148+
149+/** Keyboard navigation for the palette: Enter runs the highlighted (or first) action; arrows/Page
150+ * keys move the highlight; Home/End jump to the ends. Returns true when the event is consumed.
151+ * Extracted from the composable so the key-handling branches don't inflate its complexity. */
152+private fun handlePaletteKey(
153+ ev: KeyEvent,
154+ filtered: List<PaletteAction>,
155+ selectedIndex: Int,
156+ moveSelection: (Int) -> Unit,
157+ run: (PaletteAction) -> Unit,
158+): Boolean {
159+ if (ev.type != KeyEventType.KeyDown) return false
160+ return when (ev.key) {
161+ Key.Enter -> {
162+ val target = filtered.getOrNull(selectedIndex) ?: filtered.firstOrNull()
163+ target?.let(run)
164+ true
165+ }
166+ Key.DirectionDown, Key.PageDown -> { moveSelection(1); true }
167+ Key.DirectionUp, Key.PageUp -> { moveSelection(-1); true }
168+ Key.MoveHome -> { moveSelection(Int.MIN_VALUE / 2); true }
169+ Key.MoveEnd -> { moveSelection(Int.MAX_VALUE / 2); true }
170+ else -> false
171+ }
172+}
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+5−1
@@ -15,6 +15,7 @@ import androidx.compose.material3.Surface
1515import androidx.compose.material3.Text
1616import androidx.compose.material3.TextButton
1717import androidx.compose.runtime.Composable
18+import androidx.compose.runtime.SideEffect
1819import androidx.compose.runtime.getValue
1920import androidx.compose.runtime.produceState
2021import androidx.compose.runtime.remember
@@ -74,7 +75,10 @@ fun ConnectionBanner(
7475 // early-returning on null there would leave nothing to fade, so the banner would just
7576 // vanish instead of fading out.
7677 val lastText = remember { mutableStateOf(text) }
77− if (text != null) lastText.value = text
78+ // Write the retained text in a SideEffect (not directly in the body): writing state during
79+ // composition is a documented anti-pattern that can trigger extra recompositions and isn't
80+ // guaranteed a defined ordering. SideEffect runs only after a successful composition.
81+ SideEffect { if (text != null) lastText.value = text }
7882 AnimatedVisibility(visible = text != null, enter = fadeIn(), exit = fadeOut()) {
7983 val shown = lastText.value ?: return@AnimatedVisibility
8084 // Distinguish a hard failure (e.g. bad credentials) and an offline device from
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+55−11
@@ -160,17 +160,29 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
160160 // One horizontalScroll on the container instead of one per row: each modifier
161161 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
162162 // paying for 200 of them. The shared scroll state still synchronizes all rows.
163+ // oldLine/newLine track the current line numbers derived from each @@ hunk header
164+ // so the gutter can show absolute line numbers (a big readability win for review).
165+ var oldLine = 0
166+ var newLine = 0
167+ val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
163168 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
164169 visibleLines.forEach { line ->
165170 Row(modifier = Modifier.padding(horizontal = 10.dp)) {
166171 when (line) {
167− is DiffLine.Hunk -> Text(
168− line.text,
169− style = MaterialTheme.typography.bodySmall,
170− fontFamily = FontFamily.Monospace,
171− color = MaterialTheme.colorScheme.tertiary,
172− modifier = Modifier.padding(vertical = 2.dp),
173− )
172+ is DiffLine.Hunk -> {
173+ val m = hunkRegex.find(line.text)
174+ if (m != null) {
175+ oldLine = m.groupValues[1].toInt()
176+ newLine = m.groupValues[2].toInt()
177+ }
178+ Text(
179+ line.text,
180+ style = MaterialTheme.typography.bodySmall,
181+ fontFamily = FontFamily.Monospace,
182+ color = MaterialTheme.colorScheme.tertiary,
183+ modifier = Modifier.padding(vertical = 2.dp),
184+ )
185+ }
174186 is DiffLine.FileHeader -> Text(
175187 line.text,
176188 style = MaterialTheme.typography.bodySmall,
@@ -185,9 +197,16 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
185197 color = MaterialTheme.colorScheme.onSurfaceVariant,
186198 modifier = Modifier.padding(vertical = 1.dp),
187199 )
188− is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
189− is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
190− is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
200+ is DiffLine.Add -> {
201+ val n = newLine++; DiffRow(line.text, "+", addColor, addText, oldLine = null, newLine = n)
202+ }
203+ is DiffLine.Remove -> {
204+ val o = oldLine++; DiffRow(line.text, "-", removeColor, removeText, oldLine = o, newLine = null)
205+ }
206+ is DiffLine.Context -> {
207+ val o = oldLine++; val n = newLine++
208+ DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface, oldLine = o, newLine = n)
209+ }
191210 }
192211 }
193212 }
@@ -209,10 +228,35 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
209228}
210229
211230@Composable
212−private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
231+private fun DiffRow(
232+ text: String,
233+ prefix: String,
234+ bg: Color,
235+ textColor: androidx.compose.ui.graphics.Color,
236+ oldLine: Int? = null,
237+ newLine: Int? = null,
238+) {
239+ val gutterColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
213240 Row(
214241 modifier = Modifier.background(bg),
215242 ) {
243+ // Old/new line-number gutters. Blank (not 0) for the side that doesn't have a number
244+ // (an added line has no old number; a removed line has no new number) so the columns
245+ // stay aligned.
246+ Text(
247+ oldLine?.toString() ?: "",
248+ style = MaterialTheme.typography.bodySmall,
249+ fontFamily = FontFamily.Monospace,
250+ color = gutterColor,
251+ modifier = Modifier.width(36.dp),
252+ )
253+ Text(
254+ newLine?.toString() ?: "",
255+ style = MaterialTheme.typography.bodySmall,
256+ fontFamily = FontFamily.Monospace,
257+ color = gutterColor,
258+ modifier = Modifier.width(36.dp),
259+ )
216260 Text(
217261 prefix,
218262 style = MaterialTheme.typography.bodySmall,
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+20−4
@@ -18,6 +18,7 @@ import androidx.compose.foundation.rememberScrollState
1818import androidx.compose.foundation.text.selection.SelectionContainer
1919import androidx.compose.material.icons.Icons
2020import androidx.compose.material.icons.filled.ContentCopy
21+import androidx.compose.material.icons.filled.Check
2122import androidx.compose.material.icons.automirrored.filled.WrapText
2223import androidx.compose.material3.Icon
2324import androidx.compose.material3.IconButton
@@ -191,6 +192,16 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
191192 // while this block is composed (rememberSaveable would over-persist across blocks).
192193 val defaultWrap = LocalCodeWrap.current
193194 var wrap by remember(defaultWrap) { mutableStateOf(defaultWrap) }
195+ // Briefly flip the copy button to a checkmark after a successful copy so the user gets
196+ // visible confirmation even on Android 13+, where the copy toast is suppressed in favor of
197+ // the platform's tiny (and easy to miss) confirmation chip.
198+ var copied by remember { mutableStateOf(false) }
199+ LaunchedEffect(copied) {
200+ if (copied) {
201+ delay(1200)
202+ copied = false
203+ }
204+ }
194205 Column(
195206 modifier = Modifier
196207 .fillMaxWidth()
@@ -220,13 +231,18 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
220231 )
221232 }
222233 IconButton(
223− onClick = { copyToClipboard(context, context.getString(R.string.clip_label_code), code) },
234+ onClick = {
235+ copyToClipboard(context, context.getString(R.string.clip_label_code), code)
236+ copied = true
237+ },
224238 ) {
225239 Icon(
226− Icons.Filled.ContentCopy,
227− contentDescription = context.getString(R.string.copy),
240+ if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
241+ contentDescription = if (copied) context.getString(R.string.copied)
242+ else context.getString(R.string.copy),
228243 modifier = Modifier.size(18.dp),
229− tint = MaterialTheme.colorScheme.onSurfaceVariant,
244+ tint = if (copied) MaterialTheme.colorScheme.primary
245+ else MaterialTheme.colorScheme.onSurfaceVariant,
230246 )
231247 }
232248 }
app/src/main/java/soy/iko/opencode/ui/components/QrCode.ktnew file+174
@@ -0,0 +1,174 @@
1+@file:Suppress("MatchingDeclarationName")
2+
3+package soy.iko.opencode.ui.components
4+
5+import android.graphics.Bitmap
6+import androidx.compose.foundation.Image
7+import androidx.compose.foundation.background
8+import androidx.compose.foundation.layout.Column
9+import androidx.compose.foundation.layout.Spacer
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
13+import androidx.compose.material3.AlertDialog
14+import androidx.compose.material3.MaterialTheme
15+import androidx.compose.material3.Text
16+import androidx.compose.material3.TextButton
17+import androidx.compose.runtime.Composable
18+import androidx.compose.runtime.remember
19+import androidx.compose.ui.Alignment
20+import androidx.compose.ui.Modifier
21+import androidx.compose.ui.graphics.Color
22+import androidx.compose.ui.graphics.asImageBitmap
23+import androidx.compose.ui.res.stringResource
24+import androidx.compose.ui.text.style.TextAlign
25+import androidx.compose.ui.unit.dp
26+import com.google.zxing.BarcodeFormat
27+import com.google.zxing.BinaryBitmap
28+import com.google.zxing.DecodeHintType
29+import com.google.zxing.MultiFormatReader
30+import com.google.zxing.MultiFormatWriter
31+import com.google.zxing.RGBLuminanceSource
32+import com.google.zxing.common.HybridBinarizer
33+import kotlinx.serialization.Serializable
34+import kotlinx.serialization.json.Json
35+import soy.iko.opencode.R
36+import soy.iko.opencode.data.model.ServerProfile
37+
38+/**
39+ * Wire format for sharing a [ServerProfile] as a QR code (and re-importing it). Only the
40+ * connection-relevant fields travel — [ServerProfile.id] and [ServerProfile.lastUsed] are
41+ * device-local and excluded. Marked with a [v] version tag so a future format change can be
42+ * detected and rejected instead of mis-parsed.
43+ */
44+@Serializable
45+data class ServerProfileQr(
46+ val v: Int = 1,
47+ val label: String = "",
48+ val baseUrl: String,
49+ val username: String? = null,
50+ val password: String? = null,
51+ val requireHttps: Boolean = false,
52+ val certPin: String? = null,
53+)
54+
55+/** Lenient JSON for QR encode/decode (independent of [soy.iko.opencode.data.network.OpencodeJson],
56+ * which is tuned for the server's polymorphic payloads). */
57+private val qrJson = Json { ignoreUnknownKeys = true; isLenient = true }
58+
59+/** Serialize a [ServerProfile] to the QR payload string. */
60+fun ServerProfile.toQrPayload(): String = qrJson.encodeToString(
61+ ServerProfileQr.serializer(),
62+ ServerProfileQr(
63+ label = label,
64+ baseUrl = baseUrl,
65+ username = username,
66+ password = password,
67+ requireHttps = requireHttps,
68+ certPin = certPin,
69+ ),
70+)
71+
72+/** Parse a scanned/decoded QR payload back into a [ServerProfileQr], or null if it isn't one of
73+ * our payloads (so an arbitrary QR doesn't blow up the import flow). */
74+fun parseQrPayload(payload: String): ServerProfileQr? = runCatching {
75+ qrJson.decodeFromString(ServerProfileQr.serializer(), payload)
76+}.getOrNull()
77+
78+/** Render a QR code carrying [content] at [sizePx] pixels, or null if encoding fails. Uses ZXing's
79+ * core (no camera/UI deps) so it stays a pure-Java encode. */
80+fun encodeQrBitmap(content: String, sizePx: Int): Bitmap? = runCatching {
81+ val matrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, sizePx, sizePx)
82+ val width = matrix.width
83+ val height = matrix.height
84+ val pixels = IntArray(width * height)
85+ for (y in 0 until height) {
86+ val offset = y * width
87+ for (x in 0 until width) {
88+ pixels[offset + x] = if (matrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
89+ }
90+ }
91+ Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply {
92+ setPixels(pixels, 0, width, 0, 0, width, height)
93+ }
94+}.getOrNull()
95+
96+/** Decode a QR code from a decoded [Bitmap] (e.g. an image the user picked to import a server),
97+ * returning its text content or null if no QR was found. */
98+fun decodeQrFromBitmap(bitmap: Bitmap): String? = runCatching {
99+ val width = bitmap.width
100+ val height = bitmap.height
101+ val pixels = IntArray(width * height)
102+ bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
103+ val source = RGBLuminanceSource(width, height, pixels)
104+ val binary = BinaryBitmap(HybridBinarizer(source))
105+ val reader = MultiFormatReader()
106+ reader.setHints(mapOf(DecodeHintType.TRY_HARDER to true))
107+ reader.decodeWithState(binary).text
108+}.getOrNull()
109+
110+/** Decode a server-profile QR from an image [uri] (the result of an image picker). Runs Bitmap
111+ * decode + ZXing scan on the caller's dispatcher. Returns null if the image couldn't be read or
112+ * its QR isn't one of our server payloads. */
113+suspend fun decodeServerQr(context: android.content.Context, uri: android.net.Uri): ServerProfileQr? =
114+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
115+ runCatching {
116+ val bitmap = android.provider.MediaStore.Images.Media.getBitmap(context.contentResolver, uri)
117+ ?: return@runCatching null
118+ decodeQrFromBitmap(bitmap)?.let { parseQrPayload(it) }
119+ }.getOrNull()
120+ }
121+
122+/** A Compose QR code image. The QR is generated off the composition; encoding a few hundred
123+ * modules is sub-millisecond so no background dispatching is warranted. Drawn on a white
124+ * background (QR scanners need the light module) with a small inner pad so the code isn't flush
125+ * with the dialog edges. */
126+@Composable
127+fun QrImage(content: String, sizePx: Int, modifier: Modifier = Modifier) {
128+ val bitmap = remember(content, sizePx) { encodeQrBitmap(content, sizePx) }
129+ if (bitmap != null) {
130+ Image(
131+ bitmap = bitmap.asImageBitmap(),
132+ contentDescription = null,
133+ modifier = modifier
134+ .size(sizePx.dp)
135+ .background(Color.White)
136+ .padding(8.dp),
137+ )
138+ }
139+}
140+
141+/** Dialog that shows a server profile as a scannable QR code, for transferring a profile (URL +
142+ * credentials + cert pin) to another device without re-typing. The payload includes the password
143+ * when present; a warning makes that explicit. */
144+@Composable
145+fun QrShareDialog(profile: ServerProfile, onDismiss: () -> Unit) {
146+ val payload = remember(profile.id, profile.baseUrl, profile.password) { profile.toQrPayload() }
147+ AlertDialog(
148+ onDismissRequest = onDismiss,
149+ confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.ok)) } },
150+ title = { Text(profile.displayLabel) },
151+ text = {
152+ Column(
153+ horizontalAlignment = Alignment.CenterHorizontally,
154+ modifier = Modifier.fillMaxWidth(),
155+ ) {
156+ QrImage(content = payload, sizePx = 256)
157+ Spacer(Modifier.size(8.dp))
158+ Text(
159+ profile.baseUrl,
160+ style = MaterialTheme.typography.bodySmall,
161+ color = MaterialTheme.colorScheme.onSurfaceVariant,
162+ textAlign = TextAlign.Center,
163+ )
164+ if (profile.password != null) {
165+ Text(
166+ stringResource(R.string.qr_includes_password_warning),
167+ style = MaterialTheme.typography.labelSmall,
168+ color = MaterialTheme.colorScheme.error,
169+ )
170+ }
171+ }
172+ },
173+ )
174+}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+57−32
@@ -19,12 +19,14 @@ import androidx.compose.foundation.rememberScrollState
1919import androidx.compose.foundation.text.KeyboardActions
2020import androidx.compose.foundation.text.KeyboardOptions
2121import androidx.compose.material.icons.Icons
22−import androidx.compose.material.icons.automirrored.filled.ArrowBack
2322import androidx.compose.material.icons.filled.ChevronRight
2423import androidx.compose.material.icons.filled.Close
2524import androidx.compose.material.icons.filled.Description
2625import androidx.compose.material.icons.filled.Folder
2726import androidx.compose.material.icons.filled.Home
27+import androidx.compose.material.icons.filled.Image
28+import androidx.compose.material.icons.filled.Code
29+import androidx.compose.material.icons.filled.Article
2830import androidx.compose.material.icons.filled.Search
2931import androidx.compose.material3.CircularProgressIndicator
3032import androidx.compose.material3.ExperimentalMaterial3Api
@@ -39,11 +41,13 @@ import androidx.compose.material3.Scaffold
3941import androidx.compose.material3.SnackbarHost
4042import androidx.compose.material3.SnackbarHostState
4143import androidx.compose.material3.Text
42−import androidx.compose.material3.TopAppBar
4344import androidx.compose.runtime.Composable
4445import androidx.compose.runtime.LaunchedEffect
4546import androidx.compose.runtime.getValue
47+import androidx.compose.runtime.mutableStateOf
4648import androidx.compose.runtime.remember
49+import androidx.compose.runtime.saveable.rememberSaveable
50+import androidx.compose.runtime.setValue
4751import androidx.compose.ui.Alignment
4852import androidx.compose.ui.Modifier
4953import androidx.compose.ui.focus.FocusRequester
@@ -73,7 +77,9 @@ import soy.iko.opencode.data.model.SymbolResult
7377import soy.iko.opencode.data.model.symbolKindLabel
7478import soy.iko.opencode.di.AppContainer
7579import soy.iko.opencode.R
80+import soy.iko.opencode.ui.components.AppTopBar
7681import soy.iko.opencode.ui.components.ConnectionBannerFor
82+import soy.iko.opencode.ui.components.EmptyState
7783import soy.iko.opencode.ui.vmFactory
7884import soy.iko.opencode.util.runCatchingCancellable
7985
@@ -97,14 +103,7 @@ fun FileBrowserScreen(
97103
98104 Scaffold(
99105 topBar = {
100− TopAppBar(
101− title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
102− navigationIcon = {
103− IconButton(onClick = onBack) {
104− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
105− }
106− },
107− )
106+ AppTopBar(title = stringResource(R.string.files), onBack = onBack)
108107 },
109108 snackbarHost = { SnackbarHost(snackbar) },
110109 ) { padding ->
@@ -481,6 +480,16 @@ private fun DirectoryListing(
481480 )
482481 return
483482 }
483+ // Sort preference for the directory listing. Folders-first + name sort is the most useful
484+ // default; the toggle flips between folders-first name sort and the server's raw order.
485+ var foldersFirst by rememberSaveable { mutableStateOf(true) }
486+ val sorted = remember(state.entries, foldersFirst) {
487+ if (!foldersFirst) state.entries
488+ else {
489+ val (dirs, files) = state.entries.partition { it.isDirectory }
490+ dirs.sortedBy { it.name.lowercase() } + files.sortedBy { it.name.lowercase() }
491+ }
492+ }
484493 LazyColumn(modifier = Modifier.fillMaxSize()) {
485494 if (state.path.isNotBlank()) {
486495 item(key = "__up") {
@@ -488,7 +497,25 @@ private fun DirectoryListing(
488497 HorizontalDivider()
489498 }
490499 }
491− items(state.entries, key = { it.path + "_" + it.name }) { node ->
500+ item(key = "__sort") {
501+ Row(
502+ modifier = Modifier.fillMaxWidth().padding(end = 8.dp, top = 2.dp, bottom = 2.dp),
503+ horizontalArrangement = Arrangement.End,
504+ verticalAlignment = Alignment.CenterVertically,
505+ ) {
506+ androidx.compose.material3.FilterChip(
507+ selected = foldersFirst,
508+ onClick = { foldersFirst = !foldersFirst },
509+ leadingIcon = {
510+ if (foldersFirst) {
511+ Icon(Icons.Filled.Folder, contentDescription = null, modifier = Modifier.size(16.dp))
512+ }
513+ },
514+ label = { Text(stringResource(R.string.sort_name), style = MaterialTheme.typography.labelSmall) },
515+ )
516+ }
517+ }
518+ items(sorted, key = { it.path + "_" + it.name }) { node ->
492519 FileRow(
493520 icon = node.isDirectory,
494521 label = node.name,
@@ -501,28 +528,11 @@ private fun DirectoryListing(
501528 }
502529}
503530
504−/** Shared empty-state for the file browser: icon + message, matching the icon+text
505− * pattern used by the session and server lists (the prior versions were bare Text
506− * lines that read as status messages rather than intentional empty states). */
531+/** Shared empty-state for the file browser. Delegates to the canonical [EmptyState] so every
532+ * screen's "nothing here" reads and behaves identically (icon size, spacing, styling). */
507533@Composable
508534private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
509− Column(
510− modifier = modifier,
511− horizontalAlignment = Alignment.CenterHorizontally,
512− ) {
513− Icon(
514− icon,
515− contentDescription = null,
516− modifier = Modifier.size(48.dp),
517− tint = MaterialTheme.colorScheme.onSurfaceVariant,
518− )
519− Spacer(Modifier.size(12.dp))
520− Text(
521− message,
522− style = MaterialTheme.typography.bodyMedium,
523− color = MaterialTheme.colorScheme.onSurfaceVariant,
524− )
525− }
535+ EmptyState(icon = icon, title = message, modifier = modifier)
526536}
527537
528538@Composable
@@ -559,7 +569,7 @@ private fun FileRow(
559569 verticalAlignment = Alignment.CenterVertically,
560570 ) {
561571 Icon(
562− if (icon) Icons.Filled.Folder else Icons.Filled.Description,
572+ if (icon) Icons.Filled.Folder else iconForFile(label),
563573 contentDescription = null,
564574 modifier = Modifier.size(20.dp),
565575 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
@@ -613,3 +623,18 @@ private fun StatusBadge(status: FileStatusEntry) {
613623 }
614624 }
615625}
626+
627+/** Pick an icon for a file by its extension so the directory listing is scannable instead of a
628+ * wall of identical `Description` glyphs: images, source code, and Markdown/article files each
629+ * get a distinct glyph; unknown extensions fall back to the generic document icon. */
630+private fun iconForFile(name: String): androidx.compose.ui.graphics.vector.ImageVector {
631+ val ext = name.substringAfterLast('.', missingDelimiterValue = "").lowercase()
632+ return when (ext) {
633+ "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "heic" -> Icons.Filled.Image
634+ "kt", "kts", "java", "js", "ts", "jsx", "tsx", "py", "rb", "go", "rs", "c", "cpp", "h",
635+ "hpp", "cs", "swift", "scala", "gradle", "groovy", "sh", "bash", "zsh", "fish", "ps1",
636+ "lua", "r", "dart", "php", "pl", "sql", "vue", "svelte" -> Icons.Filled.Code
637+ "md", "markdown", "txt", "rst", "adoc", "rtf", "doc", "docx", "pdf", "epub" -> Icons.Filled.Article
638+ else -> Icons.Filled.Description
639+ }
640+}
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+40−17
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
55import androidx.compose.foundation.layout.Box
66import androidx.compose.foundation.layout.Column
77import androidx.compose.foundation.layout.PaddingValues
8+import androidx.compose.foundation.layout.Row
89import androidx.compose.foundation.layout.fillMaxSize
910import androidx.compose.foundation.layout.fillMaxWidth
1011import androidx.compose.foundation.layout.padding
@@ -172,30 +173,52 @@ fun GlobalSearchScreen(
172173
173174@Composable
174175private fun SearchResultCard(hit: SearchHit, query: String, onClick: () -> Unit) {
176+ val highlightColor = MaterialTheme.colorScheme.primary
177+ // Highlight the title too when the match was in the title (otherwise the body snippet below
178+ // is highlighted but the title above stays plain — an inconsistency).
179+ val title = remember(hit.session.displayTitle, query, hit.matchedTitle, highlightColor) {
180+ if (hit.matchedTitle) highlightMatches(hit.session.displayTitle, query, highlightColor)
181+ else AnnotatedString(hit.session.displayTitle)
182+ }
175183 Card(modifier = Modifier.fillMaxWidth().clickable(role = Role.Button) { onClick() }) {
176184 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
177− Text(
178− hit.session.displayTitle,
179− style = MaterialTheme.typography.titleMedium,
180− maxLines = 1,
181− overflow = TextOverflow.Ellipsis,
182− )
185+ Row(verticalAlignment = Alignment.CenterVertically) {
186+ Text(
187+ title,
188+ style = MaterialTheme.typography.titleMedium,
189+ maxLines = 1,
190+ overflow = TextOverflow.Ellipsis,
191+ modifier = Modifier.weight(1f),
192+ )
193+ // Show how many times the query occurs so the user knows whether the session is
194+ // worth opening (multiple hits) instead of seeing only the single returned snippet.
195+ if (hit.matchCount > 1) {
196+ val countLabel = pluralStringResource(R.plurals.search_results_count, hit.matchCount, hit.matchCount)
197+ Text(
198+ countLabel,
199+ style = MaterialTheme.typography.labelSmall,
200+ color = MaterialTheme.colorScheme.primary,
201+ modifier = Modifier.padding(start = 8.dp),
202+ )
203+ }
204+ }
183205 RelativeTimeText(
184206 hit.session.time?.updated ?: hit.session.time?.created,
185207 modifier = Modifier.padding(top = 2.dp),
186208 )
187− val highlightColor = MaterialTheme.colorScheme.primary
188− val snippet = remember(hit.snippet, query, highlightColor) {
189− highlightMatches(hit.snippet, query, highlightColor)
209+ if (hit.snippet.isNotEmpty()) {
210+ val snippet = remember(hit.snippet, query, highlightColor) {
211+ highlightMatches(hit.snippet, query, highlightColor)
212+ }
213+ Text(
214+ snippet,
215+ style = MaterialTheme.typography.bodyMedium,
216+ color = MaterialTheme.colorScheme.onSurfaceVariant,
217+ maxLines = 3,
218+ overflow = TextOverflow.Ellipsis,
219+ modifier = Modifier.padding(top = 6.dp),
220+ )
190221 }
191− Text(
192− snippet,
193− style = MaterialTheme.typography.bodyMedium,
194− color = MaterialTheme.colorScheme.onSurfaceVariant,
195− maxLines = 3,
196− overflow = TextOverflow.Ellipsis,
197− modifier = Modifier.padding(top = 6.dp),
198− )
199222 }
200223 }
201224}
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+51−4
@@ -24,9 +24,16 @@ import kotlinx.coroutines.sync.Semaphore
2424import kotlinx.coroutines.sync.withPermit
2525import java.util.concurrent.ConcurrentHashMap
2626
27−/** One search result: the [session] that matched and a [snippet] of the matching text. */
27+/** One search result: the [session] that matched, a [snippet] of the matching text, the total
28+ * number of [matchCount] occurrences (across body + title), and whether the title itself matched
29+ * ([matchedTitle]) so the UI can highlight it like the snippet. */
2830@Immutable
29−data class SearchHit(val session: Session, val snippet: String)
31+data class SearchHit(
32+ val session: Session,
33+ val snippet: String,
34+ val matchCount: Int = 1,
35+ val matchedTitle: Boolean = false,
36+)
3037
3138@Immutable
3239data class GlobalSearchState(
@@ -122,8 +129,18 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
122129 emptyList()
123130 }
124131 val snippet = matchSnippet(messages, query)
125− ?: session.displayTitle.takeIf { it.contains(query, ignoreCase = true) }
126− if (snippet != null) hits[session.id] = SearchHit(session, snippet)
132+ val titleMatched = session.displayTitle.contains(query, ignoreCase = true)
133+ if (snippet != null || titleMatched) {
134+ val count = countMatches(messages, query) +
135+ countIn(session.displayTitle, query)
136+ hits[session.id] = SearchHit(
137+ session = session,
138+ snippet = snippet
139+ ?: session.displayTitle.takeIf { titleMatched }.orEmpty(),
140+ matchCount = count.coerceAtLeast(1),
141+ matchedTitle = titleMatched,
142+ )
143+ }
127144 }
128145 }
129146 }
@@ -163,4 +180,34 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
163180 val suffix = if (end < text.length) "…" else ""
164181 return prefix + text.substring(start, end).trim() + suffix
165182 }
183+
184+ /** Count all occurrences of [query] across the text parts of [messages] (case-insensitive,
185+ * non-overlapping) so a result card can show "N matches" instead of just one snippet. */
186+ private fun countMatches(
187+ messages: List<soy.iko.opencode.data.model.MessageWithParts>,
188+ query: String,
189+ ): Int {
190+ var count = 0
191+ for (message in messages) {
192+ for (part in message.parts) {
193+ if (part !is TextPart) continue
194+ count += countIn(part.text, query)
195+ }
196+ }
197+ return count
198+ }
199+
200+ /** Non-overlapping case-insensitive occurrence count of [query] within [text]. */
201+ private fun countIn(text: String, query: String): Int {
202+ if (query.isEmpty()) return 0
203+ var count = 0
204+ var idx = 0
205+ while (true) {
206+ val next = text.indexOf(query, idx, ignoreCase = true)
207+ if (next < 0) break
208+ count++
209+ idx = next + query.length
210+ }
211+ return count
212+ }
166213}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+8−1
@@ -365,7 +365,14 @@ class ServerEditViewModel(
365365 }
366366 if (connectAfter) {
367367 container.connect(saved)
368− container.activeConnection.value?.api?.ping()
368+ // Await the ping and treat a failure as a failed save: without this, a server
369+ // that accepts the connect but is half-up (wrong path, unresponsive /info) would
370+ // navigate to the session list as if everything succeeded, leaving the user
371+ // stranded on an empty screen with no error.
372+ val pingOk = runCatchingCancellable { container.activeConnection.value?.api?.ping() }.isSuccess
373+ if (!pingOk) {
374+ throw java.io.IOException("Server reachable but did not respond to ping")
375+ }
369376 }
370377 }
371378 result.onSuccess { onDone() }
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+158−9
@@ -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+}
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+16
@@ -148,4 +148,20 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
148148 if (!container.cancelProfileDelete(profileId)) return
149149 _hiddenIds.update { it - profileId }
150150 }
151+
152+ /** Save a new profile decoded from a scanned/imported QR payload, returning the created
153+ * profile so the caller can connect or route to the editor. Does not connect. */
154+ suspend fun importFromQr(qr: soy.iko.opencode.ui.components.ServerProfileQr): ServerProfile {
155+ val profile = ServerProfile(
156+ id = java.util.UUID.randomUUID().toString(),
157+ label = qr.label,
158+ baseUrl = qr.baseUrl,
159+ username = qr.username,
160+ password = qr.password,
161+ requireHttps = qr.requireHttps,
162+ certPin = qr.certPin,
163+ )
164+ container.profileStore.save(profile)
165+ return profile
166+ }
151167}
app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+17−3
@@ -85,7 +85,7 @@ fun NewSessionDialog(
8585 sessionDirectories: List<String>,
8686 lastChosenDirectory: String?,
8787 creating: Boolean,
88− onCreate: (String?) -> Unit,
88+ onCreate: (directory: String?, title: String?) -> Unit,
8989 onDismiss: () -> Unit,
9090) {
9191 // Directories to list as selectable rows: known projects + directories of existing
@@ -106,6 +106,9 @@ fun NewSessionDialog(
106106 )
107107 }
108108 var customText by rememberSaveable { mutableStateOf("") }
109+ // Optional title so a new session doesn't land in the list as "Untitled session" requiring a
110+ // rename. Trimmed to a sane max; an all-whitespace value is treated as no title.
111+ var titleText by rememberSaveable { mutableStateOf("") }
109112
110113 // The initializer above may have run while options were still loading (serverDefault
111114 // null), pinning choice to DirChoice.Known(preselect). Once the real serverDefault
@@ -124,6 +127,7 @@ fun NewSessionDialog(
124127 is DirChoice.Known -> c.path
125128 is DirChoice.Custom -> customText.trim().takeIf { it.isNotBlank() }
126129 }
130+ val resolvedTitle: String? = titleText.trim().takeIf { it.isNotBlank() }
127131 // Custom requires a non-blank path; the other choices are always valid.
128132 val canCreate = !creating && (choice !is DirChoice.Custom || customText.isNotBlank())
129133
@@ -137,6 +141,16 @@ fun NewSessionDialog(
137141 .heightIn(max = 380.dp)
138142 .verticalScroll(rememberScrollState()),
139143 ) {
144+ OutlinedTextField(
145+ value = titleText,
146+ onValueChange = { titleText = it.take(120) },
147+ modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
148+ label = { Text(stringResource(R.string.session_title_hint)) },
149+ placeholder = { Text(stringResource(R.string.new_session)) },
150+ singleLine = true,
151+ enabled = !creating,
152+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
153+ )
140154 Text(
141155 stringResource(R.string.directory_label),
142156 style = MaterialTheme.typography.labelLarge,
@@ -185,7 +199,7 @@ fun NewSessionDialog(
185199 singleLine = true,
186200 enabled = !creating,
187201 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
188− keyboardActions = KeyboardActions(onDone = { if (canCreate) onCreate(resolved) }),
202+ keyboardActions = KeyboardActions(onDone = { if (canCreate) onCreate(resolved, resolvedTitle) }),
189203 )
190204 }
191205
@@ -206,7 +220,7 @@ fun NewSessionDialog(
206220 }
207221 },
208222 confirmButton = {
209− TextButton(onClick = { onCreate(resolved) }, enabled = canCreate) {
223+ TextButton(onClick = { onCreate(resolved, resolvedTitle) }, enabled = canCreate) {
210224 if (creating) {
211225 CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
212226 } else {
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+90−54
@@ -22,6 +22,7 @@ import androidx.compose.material.icons.Icons
2222import androidx.compose.material.icons.automirrored.filled.Logout
2323import androidx.compose.material.icons.automirrored.filled.Sort
2424import androidx.compose.material.icons.filled.Add
25+import androidx.compose.material.icons.filled.ErrorOutline
2526import androidx.compose.material.icons.filled.Archive
2627import androidx.compose.material.icons.filled.Unarchive
2728import androidx.compose.material.icons.filled.ArrowDownward
@@ -104,6 +105,7 @@ import soy.iko.opencode.platform.AppShortcuts
104105import soy.iko.opencode.platform.SessionsWidgetProvider
105106import soy.iko.opencode.R
106107import soy.iko.opencode.ui.components.ConnectionBanner
108+import soy.iko.opencode.ui.components.EmptyState
107109import soy.iko.opencode.ui.components.LocalRelativeTimeTick
108110import soy.iko.opencode.ui.components.RelativeTimeText
109111import soy.iko.opencode.ui.components.rememberRelativeTimeTick
@@ -242,18 +244,14 @@ fun SessionListScreen(
242244 expanded = showServerMenu,
243245 onExpand = { showServerMenu = true },
244246 onDismiss = { showServerMenu = false },
245− onSelect = { profile ->
246− // Keep the switcher open so its per-row spinner (driven by
247− // switchingId) is visible during the switch; the LaunchedEffect
248− // above dismisses it once the switch resolves. Tapping the
249− // already-active server is a no-op, so just close the menu.
250− if (profile.id == connectedId) showServerMenu = false
251− else vm.switchServer(profile)
252− },
247+ // The active-profile no-op and the active-run confirmation are handled
248+ // inside ServerSwitcherMenu, so onSelect only fires for a genuine switch.
249+ onSelect = { profile -> vm.switchServer(profile) },
253250 onAddServer = {
254251 showServerMenu = false
255252 onAddServer()
256253 },
254+ anyRunActive = anyRunActive,
257255 )
258256 },
259257 actions = {
@@ -391,19 +389,9 @@ fun SessionListScreen(
391389 }
392390
393391 if (showDisconnectConfirm) {
394− AlertDialog(
395− onDismissRequest = { showDisconnectConfirm = false },
396− title = { Text(stringResource(R.string.disconnect_active_title)) },
397− text = { Text(stringResource(R.string.disconnect_active_text)) },
398− confirmButton = {
399− TextButton(onClick = {
400− showDisconnectConfirm = false
401− onDisconnect()
402− }) { Text(stringResource(R.string.disconnect), color = MaterialTheme.colorScheme.error) }
403− },
404− dismissButton = {
405− TextButton(onClick = { showDisconnectConfirm = false }) { Text(stringResource(R.string.cancel)) }
406− },
392+ DisconnectActiveConfirmDialog(
393+ onConfirm = onDisconnect,
394+ onDismiss = { showDisconnectConfirm = false },
407395 )
408396 }
409397
@@ -419,8 +407,8 @@ fun SessionListScreen(
419407 sessionDirectories = sessionDirectories,
420408 lastChosenDirectory = vm.lastChosenDirectory,
421409 creating = creating,
422− onCreate = { dir ->
423− vm.createSession(directory = dir) { id ->
410+ onCreate = { dir, title ->
411+ vm.createSession(directory = dir, title = title) { id ->
424412 showNewSessionDialog = false
425413 onOpenSession(id)
426414 }
@@ -460,17 +448,30 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
460448 modifier = Modifier.align(Alignment.Center).padding(24.dp),
461449 horizontalAlignment = Alignment.CenterHorizontally,
462450 ) {
451+ Icon(
452+ Icons.Filled.ErrorOutline,
453+ contentDescription = null,
454+ modifier = Modifier.size(56.dp),
455+ tint = MaterialTheme.colorScheme.error,
456+ )
457+ Spacer(Modifier.size(12.dp))
463458 Text(
464459 state.error ?: "",
465460 color = MaterialTheme.colorScheme.error,
461+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
466462 )
467463 Spacer(Modifier.size(12.dp))
468464 TextButton(onClick = onRefresh) {
469465 Text(stringResource(R.string.retry))
470466 }
471467 }
472− state.sessions.isEmpty() -> EmptySessions(
473− onCreate = onCreateSession,
468+ state.sessions.isEmpty() -> EmptyState(
469+ icon = Icons.Filled.ChatBubbleOutline,
470+ title = stringResource(R.string.no_sessions_yet),
471+ description = stringResource(R.string.no_sessions_hint),
472+ actionIcon = Icons.Filled.Add,
473+ actionLabel = stringResource(R.string.new_session),
474+ onAction = onCreateSession,
474475 modifier = Modifier.align(Alignment.Center),
475476 )
476477 else -> Column(modifier = Modifier.fillMaxSize()) {
@@ -1031,34 +1032,6 @@ private fun SortMenu(
10311032 }
10321033}
10331034
1034−@Composable
1035−private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
1036− Column(
1037− modifier = modifier.padding(32.dp),
1038− horizontalAlignment = Alignment.CenterHorizontally,
1039− ) {
1040− Icon(
1041− Icons.Filled.ChatBubbleOutline,
1042− contentDescription = null,
1043− modifier = Modifier.size(56.dp),
1044− tint = MaterialTheme.colorScheme.onSurfaceVariant,
1045− )
1046− Spacer(Modifier.size(16.dp))
1047− Text(stringResource(R.string.no_sessions_yet), style = MaterialTheme.typography.titleMedium)
1048− Spacer(Modifier.size(8.dp))
1049− Text(
1050− stringResource(R.string.no_sessions_hint),
1051− style = MaterialTheme.typography.bodyMedium,
1052− color = MaterialTheme.colorScheme.onSurfaceVariant,
1053− )
1054− Spacer(Modifier.size(20.dp))
1055− Button(onClick = onCreate) {
1056− Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
1057− Text(stringResource(R.string.new_session), modifier = Modifier.padding(start = 6.dp))
1058− }
1059− }
1060−}
1061−
10621035/** Title + dropdown for quick-switching between saved servers. Highlights the active
10631036 * server with a check mark and primary color so the user can tell which one they're
10641037 * on at a glance. Includes an "Add server" item so adding a new server doesn't require
@@ -1074,7 +1047,12 @@ private fun ServerSwitcherMenu(
10741047 onDismiss: () -> Unit,
10751048 onSelect: (ServerProfile) -> Unit,
10761049 onAddServer: () -> Unit,
1050+ anyRunActive: Boolean = false,
10771051) {
1052+ // Switching servers kills the SSE stream and any in-flight run (same hazard as disconnecting),
1053+ // so confirm first when a run is active. Kept inside the switcher so the call site stays a
1054+ // plain onSelect and SessionListScreen's complexity doesn't grow.
1055+ var pendingSwitch by rememberSaveable { mutableStateOf<ServerProfile?>(null) }
10781056 Column {
10791057 Box {
10801058 Row(
@@ -1130,7 +1108,16 @@ private fun ServerSwitcherMenu(
11301108 }
11311109 }
11321110 },
1133− onClick = { onSelect(profile) },
1111+ onClick = {
1112+ when {
1113+ isActiveProfile -> onDismiss()
1114+ anyRunActive -> pendingSwitch = profile
1115+ // Don't dismiss here: keep the switcher open so its per-row spinner
1116+ // (driven by switchingId) stays visible during the switch. The
1117+ // LaunchedEffect in SessionListScreen dismisses it once it resolves.
1118+ else -> onSelect(profile)
1119+ }
1120+ },
11341121 )
11351122 }
11361123 // Divider + Add server item so the user can add a server without leaving
@@ -1147,4 +1134,53 @@ private fun ServerSwitcherMenu(
11471134 }
11481135 }
11491136 }
1137+ SwitchServerConfirmDialog(
1138+ target = pendingSwitch,
1139+ onSwitch = { profile -> pendingSwitch = null; onSelect(profile) },
1140+ onDismiss = { pendingSwitch = null },
1141+ )
1142+}
1143+
1144+/** Confirmation shown before switching to another server while an agent run is active, since the
1145+ * switch kills the SSE stream and any in-flight run (same hazard as disconnecting). Renders
1146+ * nothing when [target] is null (the profile vanished before the dialog showed). */
1147+@Composable
1148+private fun SwitchServerConfirmDialog(
1149+ target: soy.iko.opencode.data.model.ServerProfile?,
1150+ onSwitch: (soy.iko.opencode.data.model.ServerProfile) -> Unit,
1151+ onDismiss: () -> Unit,
1152+) {
1153+ if (target == null) return
1154+ AlertDialog(
1155+ onDismissRequest = onDismiss,
1156+ title = { Text(stringResource(R.string.disconnect_active_title)) },
1157+ text = { Text(stringResource(R.string.disconnect_active_text)) },
1158+ confirmButton = {
1159+ TextButton(onClick = { onDismiss(); onSwitch(target) }) {
1160+ Text(stringResource(R.string.switch_server), color = MaterialTheme.colorScheme.error)
1161+ }
1162+ },
1163+ dismissButton = {
1164+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
1165+ },
1166+ )
1167+}
1168+
1169+/** Confirmation shown before disconnecting while an agent run is active, since disconnecting kills
1170+ * the SSE stream and any in-flight run. */
1171+@Composable
1172+private fun DisconnectActiveConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
1173+ AlertDialog(
1174+ onDismissRequest = onDismiss,
1175+ title = { Text(stringResource(R.string.disconnect_active_title)) },
1176+ text = { Text(stringResource(R.string.disconnect_active_text)) },
1177+ confirmButton = {
1178+ TextButton(onClick = { onDismiss(); onConfirm() }) {
1179+ Text(stringResource(R.string.disconnect), color = MaterialTheme.colorScheme.error)
1180+ }
1181+ },
1182+ dismissButton = {
1183+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
1184+ },
1185+ )
11501186}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+9−8
@@ -602,14 +602,15 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
602602 * launch cwd — the pre-directory behavior. The chosen directory is remembered
603603 * ([lastChosenDirectory]) so the next new-session picker can preselect it.
604604 */
605− fun createSession(directory: String? = null, onCreated: (String) -> Unit) {
606− val conn = container.activeConnection.value ?: return
607− if (!_creating.compareAndSet(false, true)) return
608− val dir = directory?.takeIf { it.isNotBlank() }
609− viewModelScope.launch {
610− try {
611− runCatchingCancellable { conn.repository.createSession(directory = dir) }
612− .onSuccess { lastChosenDirectory = dir; onCreated(it.id); refresh() }
605+ fun createSession(directory: String? = null, title: String? = null, onCreated: (String) -> Unit) {
606+ val conn = container.activeConnection.value ?: return
607+ if (!_creating.compareAndSet(false, true)) return
608+ val dir = directory?.takeIf { it.isNotBlank() }
609+ val resolvedTitle = title?.trim()?.takeIf { it.isNotBlank() }
610+ viewModelScope.launch {
611+ try {
612+ runCatchingCancellable { conn.repository.createSession(title = resolvedTitle, directory = dir) }
613+ .onSuccess { lastChosenDirectory = dir; onCreated(it.id); refresh() }
613614 .onFailure {
614615 _state.update { it.copy(error = null) }
615616 _transientErrors.tryEmit(container.friendlyError(it))
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+4−25
@@ -59,6 +59,7 @@ import kotlinx.coroutines.launch
5959import kotlinx.coroutines.withContext
6060import soy.iko.opencode.util.runCatchingCancellable
6161import soy.iko.opencode.ui.components.AppTopBar
62+import soy.iko.opencode.ui.components.EmptyState
6263import soy.iko.opencode.ui.components.copyToClipboard
6364import soy.iko.opencode.ui.components.showToast
6465import androidx.compose.ui.Alignment
@@ -200,7 +201,9 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
200201 .semantics { heading() },
201202 )
202203 if (reports.isEmpty()) {
203− EmptyCrashReports(
204+ EmptyState(
205+ icon = Icons.Filled.BugReport,
206+ title = stringResource(R.string.no_crash_reports),
204207 modifier = Modifier.fillMaxWidth().padding(24.dp),
205208 )
206209 } else {
@@ -425,27 +428,3 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
425428 )
426429 }
427430}
428−
429−/** Empty state for the crash reports list. Rendered with an icon + text to match the
430− * empty-state pattern used by the session and server lists (the prior version was a
431− * bare `Text` line that read as a status message rather than an intentional state). */
432−@Composable
433−private fun EmptyCrashReports(modifier: Modifier = Modifier) {
434− Column(
435− modifier = modifier,
436− horizontalAlignment = Alignment.CenterHorizontally,
437− ) {
438− Icon(
439− Icons.Filled.BugReport,
440− contentDescription = null,
441− modifier = Modifier.size(48.dp),
442− tint = MaterialTheme.colorScheme.onSurfaceVariant,
443− )
444− Spacer(Modifier.size(12.dp))
445− Text(
446− stringResource(R.string.no_crash_reports),
447− style = MaterialTheme.typography.bodyMedium,
448− color = MaterialTheme.colorScheme.onSurfaceVariant,
449− )
450− }
451−}
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+81−3
@@ -24,6 +24,7 @@ import androidx.compose.foundation.shape.CircleShape
2424import androidx.compose.material.icons.Icons
2525import androidx.compose.material.icons.automirrored.filled.ArrowBack
2626import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
27+import androidx.compose.material.icons.filled.ArrowDropDown
2728import androidx.compose.material.icons.filled.BugReport
2829import androidx.compose.material.icons.filled.Dns
2930import androidx.compose.material.icons.filled.Download
@@ -57,6 +58,7 @@ import androidx.compose.ui.Modifier
5758import androidx.compose.ui.draw.clip
5859import androidx.compose.ui.graphics.Color
5960import androidx.compose.ui.platform.LocalContext
61+import androidx.compose.ui.res.pluralStringResource
6062import androidx.compose.ui.res.stringResource
6163import androidx.compose.ui.semantics.Role
6264import androidx.compose.ui.semantics.heading
@@ -64,6 +66,8 @@ import androidx.compose.ui.semantics.role
6466import androidx.compose.ui.semantics.semantics
6567import androidx.compose.ui.text.style.TextAlign
6668import androidx.compose.ui.unit.dp
69+import androidx.lifecycle.compose.LocalLifecycleOwner
70+import androidx.lifecycle.compose.LifecycleResumeEffect
6771import androidx.lifecycle.compose.collectAsStateWithLifecycle
6872import kotlinx.coroutines.ExperimentalCoroutinesApi
6973import kotlinx.coroutines.flow.combine
@@ -109,12 +113,24 @@ fun SettingsScreen(
109113 ) { theme, dyn, enter, lock -> SettingsValues(theme, dyn, enter, lock) as SettingsValues? }
110114 }
111115 val settings by settingsFlow.collectAsStateWithLifecycle(initialValue = null)
116+ // Re-check biometric availability whenever the screen resumes. A user who enrolled a
117+ // fingerprint in system settings then returned here would otherwise still see "unavailable"
118+ // (remember with no key caches the first check forever). LifecycleResumeEffect bumps a tick
119+ // on each ON_RESUME; keying the availability check on it forces a recompute.
120+ val lifecycleOwner = LocalLifecycleOwner.current
121+ var biometricCheckTick by remember { mutableStateOf(0) }
122+ LifecycleResumeEffect(Unit, lifecycleOwner) {
123+ biometricCheckTick++
124+ onPauseOrDispose { }
125+ }
112126 // Text/display prefs are collected separately from [settingsFlow] (kotlinx combine
113127 // tops out at 5 typed flows) and render independently — they don't need the same
114128 // load-gate as the appearance block.
115129 val chatTextScale by container.settingsStore.chatTextScale
116130 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
117131 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
132+ val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds
133+ .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS)
118134 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
119135 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
120136 // SSE connection state so the Settings screen can show a dropped stream (the
@@ -181,6 +197,13 @@ fun SettingsScreen(
181197 // Stage the file; the actual destructive import waits on the confirm dialog.
182198 pendingImportUri = uri
183199 }
200+ // Timestamped export name so successive backups don't overwrite one another when the user
201+ // exports more than once (e.g. before and after a change).
202+ fun backupFilename(): String {
203+ val now = java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US)
204+ .format(java.util.Date())
205+ return "opencode-backup-$now.json"
206+ }
184207
185208 Scaffold(
186209 topBar = {
@@ -274,7 +297,7 @@ fun SettingsScreen(
274297 // App lock: gate on device capability. Always allow turning it *off* (in case
275298 // the enrolled biometric was later removed), but only allow turning it on when
276299 // a biometric or device credential is actually available.
277− val appLockAvailable = remember { canAuthenticateForAppLock(context) }
300+ val appLockAvailable = remember(biometricCheckTick) { canAuthenticateForAppLock(context) }
278301 val appLockToggleable = appLockAvailable || s.appLock
279302 Spacer(Modifier.size(4.dp))
280303 Row(
@@ -303,6 +326,14 @@ fun SettingsScreen(
303326 enabled = appLockToggleable,
304327 )
305328 }
329+ if (s.appLock) {
330+ AppLockReLockSelector(
331+ seconds = appLockReLockSeconds,
332+ onSelect = { sec ->
333+ scope.launch { runCatchingCancellable { container.settingsStore.setAppLockReLockSeconds(sec) } }
334+ },
335+ )
336+ }
306337 } else {
307338 Box(
308339 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
@@ -474,7 +505,7 @@ fun SettingsScreen(
474505 // the file picker. Without passwords, export straight away.
475506 onClick = {
476507 if (includePasswords) showExportPasswordWarning = true
477− else runCatching { exportLauncher.launch("opencode-backup.json") }
508+ else runCatching { exportLauncher.launch(backupFilename()) }
478509 },
479510 )
480511 NavRow(
@@ -564,7 +595,7 @@ fun SettingsScreen(
564595 confirmButton = {
565596 TextButton(onClick = {
566597 showExportPasswordWarning = false
567− runCatching { exportLauncher.launch("opencode-backup.json") }
598+ runCatching { exportLauncher.launch(backupFilename()) }
568599 }) { Text(stringResource(R.string.export_backup), color = MaterialTheme.colorScheme.error) }
569600 },
570601 dismissButton = {
@@ -749,3 +780,50 @@ private fun Badge(count: Int) {
749780 )
750781 }
751782}
783+
784+/** Dropdown selecting how long after backgrounding the app re-locks. Only shown when app lock
785+ * is on. The grace period avoids a re-prompt on every quick app-switch (the most common reason
786+ * users disable biometric locks), while still re-locking immediately by default. */
787+@Composable
788+private fun AppLockReLockSelector(seconds: Int, onSelect: (Int) -> Unit) {
789+ var expanded by remember { mutableStateOf(false) }
790+ val label = relockLabel(seconds)
791+ Row(
792+ modifier = Modifier
793+ .fillMaxWidth()
794+ .padding(start = 56.dp, top = 4.dp)
795+ .clickable(role = Role.Button) { expanded = true },
796+ verticalAlignment = Alignment.CenterVertically,
797+ ) {
798+ Text(
799+ stringResource(R.string.app_lock_relock),
800+ style = MaterialTheme.typography.bodyMedium,
801+ color = MaterialTheme.colorScheme.onSurfaceVariant,
802+ modifier = Modifier.weight(1f),
803+ )
804+ Text(label, style = MaterialTheme.typography.bodyMedium)
805+ Icon(
806+ Icons.Filled.ArrowDropDown,
807+ contentDescription = null,
808+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
809+ )
810+ }
811+ androidx.compose.material3.DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
812+ SettingsStore.APP_LOCK_RELOCK_OPTIONS_SECONDS.forEach { option ->
813+ androidx.compose.material3.DropdownMenuItem(
814+ text = { Text(relockLabel(option)) },
815+ onClick = { expanded = false; onSelect(option) },
816+ )
817+ }
818+ }
819+}
820+
821+@Composable
822+private fun relockLabel(seconds: Int): String = when {
823+ seconds <= 0 -> stringResource(R.string.app_lock_relock_immediately)
824+ seconds < 60 -> pluralStringResource(R.plurals.app_lock_relock_seconds, seconds, seconds)
825+ else -> {
826+ val mins = seconds / 60
827+ pluralStringResource(R.plurals.app_lock_relock_minutes, mins, mins)
828+ }
829+}
app/src/main/res/values-es/strings.xml+22
@@ -564,4 +564,26 @@
564564 <string name="crash_last_run_dismiss">Descartar</string>
565565 <string name="connecting_to">Conectando a %1$s…</string>
566566 <string name="scroll_to_top">Ir arriba</string>
567+
568+ <!-- UI/UX improvements: QR sharing, app-lock re-lock, sort, server state -->
569+ <string name="connection_failed_short">Conexión fallida</string>
570+ <string name="sort_name">Nombre</string>
571+ <string name="share_qr">Compartir como código QR</string>
572+ <string name="import_from_image">Importar desde imagen</string>
573+ <string name="qr_import_failed">No se encontró un código QR de servidor opencode en esa imagen.</string>
574+ <string name="qr_import_title">¿Añadir este servidor?</string>
575+ <string name="qr_added">Servidor añadido</string>
576+ <string name="ok">OK</string>
577+ <string name="qr_includes_password">Incluye la contraseña</string>
578+ <string name="qr_includes_password_warning">Incluye la contraseña — comparte con cuidado</string>
579+ <string name="app_lock_relock">Bloquear de nuevo tras</string>
580+ <string name="app_lock_relock_immediately">Inmediatamente</string>
581+ <plurals name="app_lock_relock_seconds">
582+ <item quantity="one">%1$d segundo</item>
583+ <item quantity="other">%1$d segundos</item>
584+ </plurals>
585+ <plurals name="app_lock_relock_minutes">
586+ <item quantity="one">%1$d minuto</item>
587+ <item quantity="other">%1$d minutos</item>
588+ </plurals>
567589</resources>
app/src/main/res/values/strings.xml+20
@@ -39,6 +39,7 @@
3939 <string name="reconnecting">Reconnecting…</string>
4040 <string name="connection_failed">Connection failed — check credentials</string>
4141 <string name="connection_failed_endpoint">Connection failed — check the server URL and version</string>
42+ <string name="connection_failed_short">Connection failed</string>
4243 <string name="reconnect">Reconnect</string>
4344 <string name="not_connected">Not connected.</string>
4445 <string name="no_servers_to_reconnect">No saved server to reconnect to.</string>
@@ -57,6 +58,14 @@
5758 <string name="server_auth_short"> • auth</string>
5859 <string name="server_auth_basic"> • Basic auth</string>
5960 <string name="server_removed">Server removed</string>
61+ <string name="share_qr">Share as QR code</string>
62+ <string name="import_from_image">Import from image</string>
63+ <string name="qr_import_failed">No opencode server QR found in that image.</string>
64+ <string name="qr_import_title">Add this server?</string>
65+ <string name="qr_added">Server added</string>
66+ <string name="ok">OK</string>
67+ <string name="qr_includes_password">Includes password</string>
68+ <string name="qr_includes_password_warning">Includes password — share carefully</string>
6069
6170 <!-- Server edit -->
6271 <string name="label_optional">Label (optional)</string>
@@ -287,6 +296,7 @@
287296 <string name="sort">Sort</string>
288297 <string name="sort_recent">Recent activity</string>
289298 <string name="sort_title">Title</string>
299+ <string name="sort_name">Name</string>
290300 <string name="last_used_never">Not used yet</string>
291301 <string name="last_used">Last used %1$s</string>
292302 <string name="duplicate_server">Duplicate</string>
@@ -384,6 +394,16 @@
384394 <string name="app_lock">App lock</string>
385395 <string name="app_lock_desc">Require biometric or device credential to open the app.</string>
386396 <string name="app_lock_unavailable">No biometric or device credential is set up.</string>
397+ <string name="app_lock_relock">Re-lock after</string>
398+ <string name="app_lock_relock_immediately">Immediately</string>
399+ <plurals name="app_lock_relock_seconds">
400+ <item quantity="one">%1$d second</item>
401+ <item quantity="other">%1$d seconds</item>
402+ </plurals>
403+ <plurals name="app_lock_relock_minutes">
404+ <item quantity="one">%1$d minute</item>
405+ <item quantity="other">%1$d minutes</item>
406+ </plurals>
387407 <string name="app_lock_prompt_title">Unlock opencode</string>
388408 <string name="app_lock_prompt_subtitle">Authenticate to access your servers.</string>
389409 <string name="app_locked">opencode is locked</string>
gradle/libs.versions.toml+2
@@ -13,6 +13,7 @@ securityCrypto = "1.1.0"
1313coil = "2.7.0"
1414biometric = "1.1.0"
1515markdownRenderer = "0.27.0"
16+zxingCore = "3.5.3"
1617coreKtx = "1.13.1"
1718splashscreen = "1.0.1"
1819junit = "4.13.2"
@@ -56,6 +57,7 @@ coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coi
5657androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
5758markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
5859markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer", version.ref = "markdownRenderer" }
60+zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxingCore" }
5961
6062junit = { group = "junit", name = "junit", version.ref = "junit" }
6163androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }