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)
b42208diko
app/build.gradle.kts+1
@@ -136,6 +136,7 @@ dependencies {
136 implementation(libs.coil.compose)
136 implementation(libs.coil.compose)
137 implementation(libs.markdown.renderer)
137 implementation(libs.markdown.renderer)
138 implementation(libs.markdown.renderer.m3)
138 implementation(libs.markdown.renderer.m3)
139+ implementation(libs.zxing.core)
139
140
140 testImplementation(libs.junit)
141 testImplementation(libs.junit)
141 testImplementation(libs.kotlinx.coroutines.test)
142 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
22import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
22import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
23import androidx.fragment.app.FragmentActivity
23import androidx.fragment.app.FragmentActivity
24import androidx.lifecycle.compose.collectAsStateWithLifecycle
24import androidx.lifecycle.compose.collectAsStateWithLifecycle
25+import soy.iko.opencode.data.repo.SettingsStore
25import soy.iko.opencode.data.repo.ThemeMode
26import soy.iko.opencode.data.repo.ThemeMode
26import soy.iko.opencode.ui.AppLockGate
27import soy.iko.opencode.ui.AppLockGate
27import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
28import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
@@ -78,6 +79,8 @@ class MainActivity : FragmentActivity() {
78 .combine(container.settingsStore.dynamicColor, ::Pair)
79 .combine(container.settingsStore.dynamicColor, ::Pair)
79 }.collectAsStateWithLifecycle(initialValue = null)
80 }.collectAsStateWithLifecycle(initialValue = null)
80 val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = null)
81 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)
81 LaunchedEffect(theme, appLock) { if (theme != null && appLock != null) settingsLoaded = true }
84 LaunchedEffect(theme, appLock) { if (theme != null && appLock != null) settingsLoaded = true }
82 // Hide the app's content from screenshots / the recents thumbnail while app lock is
85 // Hide the app's content from screenshots / the recents thumbnail while app lock is
83 // enabled — and while its value is still unknown (null) at cold start, so a protected
86 // enabled — and while its value is still unknown (null) at cold start, so a protected
@@ -98,7 +101,7 @@ class MainActivity : FragmentActivity() {
98 ThemeMode.AMOLED -> true
101 ThemeMode.AMOLED -> true
99 }
102 }
100 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor, amoled = amoled) {
103 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor, amoled = amoled) {
101− AppLockGate(enabled = locked) {
104+ AppLockGate(enabled = locked, reLockDelaySeconds = appLockReLockSeconds) {
102 OpencodeAppUi(container = container)
105 OpencodeAppUi(container = container)
103 }
106 }
104 }
107 }
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+19
@@ -4,6 +4,7 @@ import android.content.Context
4import androidx.datastore.preferences.core.booleanPreferencesKey
4import androidx.datastore.preferences.core.booleanPreferencesKey
5import androidx.datastore.preferences.core.edit
5import androidx.datastore.preferences.core.edit
6import androidx.datastore.preferences.core.floatPreferencesKey
6import androidx.datastore.preferences.core.floatPreferencesKey
7+import androidx.datastore.preferences.core.intPreferencesKey
7import androidx.datastore.preferences.core.stringPreferencesKey
8import androidx.datastore.preferences.core.stringPreferencesKey
8import androidx.datastore.preferences.preferencesDataStore
9import androidx.datastore.preferences.preferencesDataStore
9import kotlinx.coroutines.flow.Flow
10import kotlinx.coroutines.flow.Flow
@@ -21,6 +22,7 @@ class SettingsStore(context: Context) {
21 private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
22 private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
22 private val sendOnEnterKey = booleanPreferencesKey("send_on_enter")
23 private val sendOnEnterKey = booleanPreferencesKey("send_on_enter")
23 private val appLockKey = booleanPreferencesKey("app_lock")
24 private val appLockKey = booleanPreferencesKey("app_lock")
25+ private val appLockReLockSecondsKey = intPreferencesKey("app_lock_relock_seconds")
24 private val chatTextScaleKey = floatPreferencesKey("chat_text_scale")
26 private val chatTextScaleKey = floatPreferencesKey("chat_text_scale")
25 private val codeWrapKey = booleanPreferencesKey("code_wrap")
27 private val codeWrapKey = booleanPreferencesKey("code_wrap")
26
28
@@ -30,6 +32,10 @@ class SettingsStore(context: Context) {
30 const val MIN_CHAT_TEXT_SCALE = 0.8f
32 const val MIN_CHAT_TEXT_SCALE = 0.8f
31 const val MAX_CHAT_TEXT_SCALE = 1.6f
33 const val MAX_CHAT_TEXT_SCALE = 1.6f
32 const val DEFAULT_CHAT_TEXT_SCALE = 1.0f
34 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
33 }
39 }
34
40
35 val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
41 val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
@@ -58,6 +64,13 @@ class SettingsStore(context: Context) {
58 prefs[appLockKey] ?: false
64 prefs[appLockKey] ?: false
59 }
65 }
60
66
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+
61 /** Multiplier applied to chat message (markdown) and code text so users can size the
74 /** Multiplier applied to chat message (markdown) and code text so users can size the
62 * conversation for readability independently of the system font scale. Clamped to
75 * conversation for readability independently of the system font scale. Clamped to
63 * [[MIN_CHAT_TEXT_SCALE], [MAX_CHAT_TEXT_SCALE]] on read so a corrupt/out-of-range
76 * [[MIN_CHAT_TEXT_SCALE], [MAX_CHAT_TEXT_SCALE]] on read so a corrupt/out-of-range
@@ -89,6 +102,12 @@ class SettingsStore(context: Context) {
89 appContext.settingsDataStore.edit { it[appLockKey] = enabled }
102 appContext.settingsDataStore.edit { it[appLockKey] = enabled }
90 }
103 }
91
104
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+
92 suspend fun setChatTextScale(scale: Float) {
111 suspend fun setChatTextScale(scale: Float) {
93 appContext.settingsDataStore.edit {
112 appContext.settingsDataStore.edit {
94 it[chatTextScaleKey] = scale.coerceIn(MIN_CHAT_TEXT_SCALE, MAX_CHAT_TEXT_SCALE)
113 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? {
89 * [canAuthenticateForAppLock] is true, so this fallback just prevents a hard lock-out.
89 * [canAuthenticateForAppLock] is true, so this fallback just prevents a hard lock-out.
90 */
90 */
91@Composable
91@Composable
92−fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
92+fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composable () -> Unit) {
93 if (!enabled) {
93 if (!enabled) {
94 content()
94 content()
95 return
95 return
@@ -116,6 +116,12 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
116 // app-lock bypass. Rotation is handled by the activity's configChanges (no recreation), so a
116 // app-lock bypass. Rotation is handled by the activity's configChanges (no recreation), so a
117 // plain remember still avoids re-prompting on rotation while re-locking on real recreation.
117 // plain remember still avoids re-prompting on rotation while re-locking on real recreation.
118 var unlocked by remember { mutableStateOf(false) }
118 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
119
125
120 val title = stringResource(R.string.app_lock_prompt_title)
126 val title = stringResource(R.string.app_lock_prompt_title)
121 val subtitle = stringResource(R.string.app_lock_prompt_subtitle)
127 val subtitle = stringResource(R.string.app_lock_prompt_subtitle)
@@ -175,13 +181,24 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
175 val observer = LifecycleEventObserver { _, event ->
181 val observer = LifecycleEventObserver { _, event ->
176 when (event) {
182 when (event) {
177 Lifecycle.Event.ON_STOP -> {
183 Lifecycle.Event.ON_STOP -> {
178− unlocked = false
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
179− // The system dismisses an in-flight prompt on background and posts its
187+ // is still cleared because the system dismisses an in-flight prompt on background.
180− // onAuthenticationError asynchronously; clear the flag now so ON_START re-prompts
188+ lastStopTimeMs[0] = System.currentTimeMillis()
181− // instead of no-opping on a stale authInFlight and stranding the manual button.
182 authInFlight = false
189 authInFlight = false
183 }
190 }
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+ }
185 else -> {}
202 else -> {}
186 }
203 }
187 }
204 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+42−6
@@ -116,6 +116,7 @@ import androidx.compose.ui.unit.dp
116import androidx.lifecycle.compose.collectAsStateWithLifecycle
116import androidx.lifecycle.compose.collectAsStateWithLifecycle
117import androidx.lifecycle.viewmodel.compose.viewModel
117import androidx.lifecycle.viewmodel.compose.viewModel
118import kotlinx.coroutines.Dispatchers
118import kotlinx.coroutines.Dispatchers
119+import kotlinx.coroutines.flow.distinctUntilChanged
119import kotlinx.coroutines.launch
120import kotlinx.coroutines.launch
120import kotlinx.coroutines.withContext
121import kotlinx.coroutines.withContext
121import soy.iko.opencode.data.model.Part
122import soy.iko.opencode.data.model.Part
@@ -721,7 +722,15 @@ fun ChatScreen(
721 val listState = rememberLazyListState()
722 val listState = rememberLazyListState()
722 val contentScope = rememberCoroutineScope()
723 val contentScope = rememberCoroutineScope()
723 val timeTick = rememberRelativeTimeTick()
724 val timeTick = rememberRelativeTimeTick()
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() }
724− // Group messages by day and interleave date separators so a long conversation
733+ } // Group messages by day and interleave date separators so a long conversation
725 // has visual "Today"/"Yesterday"/date breaks. Computed once per messages
734 // has visual "Today"/"Yesterday"/date breaks. Computed once per messages
726 // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives
735 // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives
727 // outside the PullToRefreshBox so the auto-scroll effects below can reference
736 // outside the PullToRefreshBox so the auto-scroll effects below can reference
@@ -883,11 +892,24 @@ fun ChatScreen(
883 val topPad = if (bannerVisible) 44.dp else 16.dp
892 val topPad = if (bannerVisible) 44.dp else 16.dp
884 if (loading && messages.isEmpty()) {
893 if (loading && messages.isEmpty()) {
885 val loadingLabel = stringResource(R.string.loading)
894 val loadingLabel = stringResource(R.string.loading)
886− CircularProgressIndicator(
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(
887− Modifier.align(Alignment.Center).padding(top = topPad),
899+ modifier = Modifier.align(Alignment.Center).padding(top = topPad),
888− strokeWidth = 2.dp,
900+ horizontalAlignment = Alignment.CenterHorizontally,
889− )
901+ ) {
902+ CircularProgressIndicator(
890− Box(modifier = Modifier.semantics { contentDescription = loadingLabel })
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+ }
891 } else if (loadError && messages.isEmpty()) {
913 } else if (loadError && messages.isEmpty()) {
892 // A failed load with nothing to show. Distinct from the empty
914 // A failed load with nothing to show. Distinct from the empty
893 // conversation state below — the conversation isn't empty, it
915 // conversation state below — the conversation isn't empty, it
@@ -1002,6 +1024,20 @@ fun ChatScreen(
1002 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
1024 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
1003 },
1025 },
1004 isEdited = message.info.time?.let { it.updated != null && it.updated != it.created } == true,
1026 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+ },
1005 )
1041 )
1006 }
1042 }
1007 }
1043 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+15
@@ -1101,6 +1101,21 @@ class ChatViewModel(
1101 }
1101 }
1102 }
1102 }
1103
1103
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+
1104 /**
1119 /**
1105 * Queue [text] to be sent automatically when the current run finishes, or clear any
1120 * Queue [text] to be sent automatically when the current run finishes, or clear any
1106 * queued follow-up when [text] is blank. Used when the user taps Send while a run
1121 * 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 @@
1package soy.iko.opencode.ui.chat
1package soy.iko.opencode.ui.chat
2
2
3import androidx.compose.foundation.background
3import androidx.compose.foundation.background
4+import androidx.compose.foundation.clickable
4import androidx.compose.foundation.gestures.detectTapGestures
5import androidx.compose.foundation.gestures.detectTapGestures
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Box
@@ -15,6 +16,7 @@ import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.automirrored.filled.HelpOutline
16import androidx.compose.material.icons.automirrored.filled.HelpOutline
16import androidx.compose.material.icons.automirrored.filled.VolumeUp
17import androidx.compose.material.icons.automirrored.filled.VolumeUp
17import androidx.compose.material.icons.filled.CallSplit
18import androidx.compose.material.icons.filled.CallSplit
19+import androidx.compose.material.icons.filled.Close
18import androidx.compose.material.icons.filled.ContentCopy
20import androidx.compose.material.icons.filled.ContentCopy
19import androidx.compose.material.icons.filled.Edit
21import androidx.compose.material.icons.filled.Edit
20import androidx.compose.material.icons.filled.Error
22import androidx.compose.material.icons.filled.Error
@@ -29,6 +31,7 @@ import androidx.compose.material3.CircularProgressIndicator
29import androidx.compose.material3.Icon
31import androidx.compose.material3.Icon
30import androidx.compose.material3.IconButton
32import androidx.compose.material3.IconButton
31import androidx.compose.material3.MaterialTheme
33import androidx.compose.material3.MaterialTheme
34+import androidx.compose.material3.ripple
32import androidx.compose.material3.Text
35import androidx.compose.material3.Text
33import androidx.compose.runtime.Composable
36import androidx.compose.runtime.Composable
34import androidx.compose.runtime.getValue
37import androidx.compose.runtime.getValue
@@ -44,7 +47,11 @@ import androidx.compose.ui.input.pointer.pointerInput
44import androidx.compose.ui.platform.LocalContext
47import androidx.compose.ui.platform.LocalContext
45import androidx.compose.ui.platform.LocalHapticFeedback
48import androidx.compose.ui.platform.LocalHapticFeedback
46import androidx.compose.ui.res.stringResource
49import 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
47import androidx.compose.ui.unit.dp
53import androidx.compose.ui.unit.dp
54+import androidx.compose.foundation.interaction.MutableInteractionSource
48import soy.iko.opencode.R
55import soy.iko.opencode.R
49import soy.iko.opencode.data.model.AssistantMessage
56import soy.iko.opencode.data.model.AssistantMessage
50import soy.iko.opencode.data.model.MessageWithParts
57import soy.iko.opencode.data.model.MessageWithParts
@@ -95,9 +102,11 @@ fun MessageBubble(
95 onBranch: ((String) -> Unit)? = null,
102 onBranch: ((String) -> Unit)? = null,
96 sendStatus: MessageSendStatus? = null,
103 sendStatus: MessageSendStatus? = null,
97 isEdited: Boolean = false,
104 isEdited: Boolean = false,
105+ onRetry: (() -> Unit)? = null,
106+ onDismiss: (() -> Unit)? = null,
98) {
107) {
99 when (message.info) {
108 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)
101 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
110 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
102 else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, onQuote, onBranch, isEdited)
111 else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, onQuote, onBranch, isEdited)
103 }
112 }
@@ -274,6 +283,8 @@ private fun UserBubble(
274 onBranch: ((String) -> Unit)? = null,
283 onBranch: ((String) -> Unit)? = null,
275 sendStatus: MessageSendStatus? = null,
284 sendStatus: MessageSendStatus? = null,
276 isEdited: Boolean = false,
285 isEdited: Boolean = false,
286+ onRetry: (() -> Unit)? = null,
287+ onDismiss: (() -> Unit)? = null,
277) {
288) {
278 val context = LocalContext.current
289 val context = LocalContext.current
279 val haptics = LocalHapticFeedback.current
290 val haptics = LocalHapticFeedback.current
@@ -353,17 +364,59 @@ private fun UserBubble(
353 )
364 )
354 }
365 }
355 MessageSendStatus.FAILED -> {
366 MessageSendStatus.FAILED -> {
356− Icon(
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(
357− Icons.Filled.Error,
392+ Icons.Filled.Error,
358− contentDescription = stringResource(R.string.message_send_failed),
393+ contentDescription = null,
359− modifier = Modifier.size(12.dp),
394+ modifier = Modifier.size(12.dp),
360− tint = MaterialTheme.colorScheme.error,
395+ tint = MaterialTheme.colorScheme.error,
361− )
396+ )
362− Text(
397+ Text(
363− stringResource(R.string.message_send_failed),
398+ stringResource(R.string.message_send_failed),
364− style = MaterialTheme.typography.labelSmall,
399+ style = MaterialTheme.typography.labelSmall,
365− color = MaterialTheme.colorScheme.error,
400+ color = MaterialTheme.colorScheme.error,
366− )
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+ }
367 }
420 }
368 null -> {}
421 null -> {}
369 }
422 }
app/src/main/java/soy/iko/opencode/ui/components/AppTopBar.kt+45−6
@@ -1,23 +1,33 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
3+import androidx.compose.foundation.layout.Box
4+import androidx.compose.foundation.layout.Column
3import androidx.compose.foundation.layout.RowScope
5import androidx.compose.foundation.layout.RowScope
6+import androidx.compose.foundation.layout.size
4import androidx.compose.material.icons.Icons
7import androidx.compose.material.icons.Icons
5import androidx.compose.material.icons.automirrored.filled.ArrowBack
8import androidx.compose.material.icons.automirrored.filled.ArrowBack
9+import androidx.compose.material3.CircularProgressIndicator
6import androidx.compose.material3.ExperimentalMaterial3Api
10import androidx.compose.material3.ExperimentalMaterial3Api
7import androidx.compose.material3.Icon
11import androidx.compose.material3.Icon
8import androidx.compose.material3.IconButton
12import androidx.compose.material3.IconButton
13+import androidx.compose.material3.MaterialTheme
9import androidx.compose.material3.Text
14import androidx.compose.material3.Text
10import androidx.compose.material3.TopAppBar
15import androidx.compose.material3.TopAppBar
16+import androidx.compose.material3.TopAppBarDefaults
11import androidx.compose.material3.TopAppBarScrollBehavior
17import androidx.compose.material3.TopAppBarScrollBehavior
12import androidx.compose.runtime.Composable
18import androidx.compose.runtime.Composable
19+import androidx.compose.ui.Alignment
20+import androidx.compose.ui.Modifier
13import androidx.compose.ui.res.stringResource
21import androidx.compose.ui.res.stringResource
22+import androidx.compose.ui.text.style.TextOverflow
23+import androidx.compose.ui.unit.dp
14import soy.iko.opencode.R
24import soy.iko.opencode.R
15
25
16/**
26/**
17− * The app's standard top bar: a title and an optional up/back button plus trailing actions.
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
18− * Extracted so the many plain back-title-actions screens share one implementation instead of
29+ * back-title-actions screens share one implementation instead of hand-rolling a [TopAppBar] each.
19− * hand-rolling a [TopAppBar] each. Screens with a bespoke title (dropdown server switcher,
30+ * Screens with a bespoke title (dropdown server switcher, clickable model picker) keep their own bar.
20− * clickable model picker) keep their own bar.
21 */
31 */
22@OptIn(ExperimentalMaterial3Api::class)
32@OptIn(ExperimentalMaterial3Api::class)
23@Composable
33@Composable
@@ -25,10 +35,27 @@ fun AppTopBar(
25 title: String,
35 title: String,
26 onBack: (() -> Unit)? = null,
36 onBack: (() -> Unit)? = null,
27 scrollBehavior: TopAppBarScrollBehavior? = null,
37 scrollBehavior: TopAppBarScrollBehavior? = null,
38+ subtitle: String? = null,
39+ loading: Boolean = false,
28 actions: @Composable RowScope.() -> Unit = {},
40 actions: @Composable RowScope.() -> Unit = {},
29) {
41) {
30 TopAppBar(
42 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+ },
32 navigationIcon = {
59 navigationIcon = {
33 if (onBack != null) {
60 if (onBack != null) {
34 IconButton(onClick = onBack) {
61 IconButton(onClick = onBack) {
@@ -39,7 +66,19 @@ fun AppTopBar(
39 }
66 }
40 }
67 }
41 },
68 },
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+ },
43 scrollBehavior = scrollBehavior,
81 scrollBehavior = scrollBehavior,
82+ colors = TopAppBarDefaults.topAppBarColors(),
44 )
83 )
45}
84}
app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.kt+60−10
@@ -1,5 +1,6 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
3+import androidx.compose.foundation.background
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.fillMaxWidth
@@ -7,6 +8,7 @@ import androidx.compose.foundation.layout.heightIn
7import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.items
10import androidx.compose.foundation.lazy.items
11+import androidx.compose.foundation.lazy.rememberLazyListState
10import androidx.compose.foundation.text.KeyboardActions
12import androidx.compose.foundation.text.KeyboardActions
11import androidx.compose.foundation.text.KeyboardOptions
13import androidx.compose.foundation.text.KeyboardOptions
12import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.Icons
@@ -27,6 +29,7 @@ import androidx.compose.runtime.setValue
27import androidx.compose.ui.Modifier
29import androidx.compose.ui.Modifier
28import androidx.compose.ui.focus.focusRequester
30import androidx.compose.ui.focus.focusRequester
29import androidx.compose.ui.input.key.Key
31import androidx.compose.ui.input.key.Key
32+import androidx.compose.ui.input.key.KeyEvent
30import androidx.compose.ui.input.key.KeyEventType
33import androidx.compose.ui.input.key.KeyEventType
31import androidx.compose.ui.input.key.key
34import androidx.compose.ui.input.key.key
32import androidx.compose.ui.input.key.onPreviewKeyEvent
35import androidx.compose.ui.input.key.onPreviewKeyEvent
@@ -55,11 +58,29 @@ fun CommandPalette(
55 val filtered = remember(query, actions) {
58 val filtered = remember(query, actions) {
56 if (query.isBlank()) actions else actions.filter { it.label.contains(query, ignoreCase = true) }
59 if (query.isBlank()) actions else actions.filter { it.label.contains(query, ignoreCase = true) }
57 }
60 }
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+ }
58 val focus = remember { androidx.compose.ui.focus.FocusRequester() }
74 val focus = remember { androidx.compose.ui.focus.FocusRequester() }
59 fun run(action: PaletteAction) {
75 fun run(action: PaletteAction) {
60 onDismiss()
76 onDismiss()
61 action.onSelect()
77 action.onSelect()
62 }
78 }
79+ fun moveSelection(delta: Int) {
80+ if (filtered.isNotEmpty()) {
81+ selectedIndex = (selectedIndex + delta).coerceIn(0, filtered.lastIndex)
82+ }
83+ }
63 Dialog(onDismissRequest = onDismiss) {
84 Dialog(onDismissRequest = onDismiss) {
64 Surface(
85 Surface(
65 shape = MaterialTheme.shapes.large,
86 shape = MaterialTheme.shapes.large,
@@ -81,18 +102,14 @@ fun CommandPalette(
81 }
102 }
82 },
103 },
83 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
104 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+ }),
85 modifier = Modifier
109 modifier = Modifier
86 .fillMaxWidth()
110 .fillMaxWidth()
87 .focusRequester(focus)
111 .focusRequester(focus)
88− .onPreviewKeyEvent { ev ->
112+ .onPreviewKeyEvent { ev -> handlePaletteKey(ev, filtered, selectedIndex, ::moveSelection, ::run) },
89− if (ev.type == KeyEventType.KeyDown && ev.key == Key.Enter) {
90− filtered.firstOrNull()?.let { run(it) }
91− true
92− } else {
93− false
94− }
95− },
96 )
113 )
97 LaunchedEffect(Unit) { runCatchingCancellable { focus.requestFocus() } }
114 LaunchedEffect(Unit) { runCatchingCancellable { focus.requestFocus() } }
98 if (filtered.isEmpty()) {
115 if (filtered.isEmpty()) {
@@ -103,13 +120,21 @@ fun CommandPalette(
103 modifier = Modifier.padding(16.dp),
120 modifier = Modifier.padding(16.dp),
104 )
121 )
105 } else {
122 } else {
106− LazyColumn(modifier = Modifier.heightIn(max = 360.dp)) {
123+ LazyColumn(state = listState, modifier = Modifier.heightIn(max = 360.dp)) {
107 items(filtered, key = { it.id }) { action ->
124 items(filtered, key = { it.id }) { action ->
125+ val index = filtered.indexOf(action)
126+ val isSelected = index == selectedIndex
108 Text(
127 Text(
109 action.label,
128 action.label,
110 style = MaterialTheme.typography.bodyLarge,
129 style = MaterialTheme.typography.bodyLarge,
130+ color = if (isSelected) MaterialTheme.colorScheme.primary
131+ else MaterialTheme.colorScheme.onSurface,
111 modifier = Modifier
132 modifier = Modifier
112 .fillMaxWidth()
133 .fillMaxWidth()
134+ .background(
135+ if (isSelected) MaterialTheme.colorScheme.secondaryContainer
136+ else androidx.compose.ui.graphics.Color.Transparent,
137+ )
113 .clickable(role = Role.Button) { run(action) }
138 .clickable(role = Role.Button) { run(action) }
114 .padding(horizontal = 12.dp, vertical = 14.dp),
139 .padding(horizontal = 12.dp, vertical = 14.dp),
115 )
140 )
@@ -120,3 +145,28 @@ fun CommandPalette(
120 }
145 }
121 }
146 }
122}
147}
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
15import androidx.compose.material3.Text
15import androidx.compose.material3.Text
16import androidx.compose.material3.TextButton
16import androidx.compose.material3.TextButton
17import androidx.compose.runtime.Composable
17import androidx.compose.runtime.Composable
18+import androidx.compose.runtime.SideEffect
18import androidx.compose.runtime.getValue
19import androidx.compose.runtime.getValue
19import androidx.compose.runtime.produceState
20import androidx.compose.runtime.produceState
20import androidx.compose.runtime.remember
21import androidx.compose.runtime.remember
@@ -74,7 +75,10 @@ fun ConnectionBanner(
74 // early-returning on null there would leave nothing to fade, so the banner would just
75 // early-returning on null there would leave nothing to fade, so the banner would just
75 // vanish instead of fading out.
76 // vanish instead of fading out.
76 val lastText = remember { mutableStateOf(text) }
77 val lastText = remember { mutableStateOf(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.
77− if (text != null) lastText.value = text
81+ SideEffect { if (text != null) lastText.value = text }
78 AnimatedVisibility(visible = text != null, enter = fadeIn(), exit = fadeOut()) {
82 AnimatedVisibility(visible = text != null, enter = fadeIn(), exit = fadeOut()) {
79 val shown = lastText.value ?: return@AnimatedVisibility
83 val shown = lastText.value ?: return@AnimatedVisibility
80 // Distinguish a hard failure (e.g. bad credentials) and an offline device from
84 // 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
160 // One horizontalScroll on the container instead of one per row: each modifier
160 // One horizontalScroll on the container instead of one per row: each modifier
161 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
161 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
162 // paying for 200 of them. The shared scroll state still synchronizes all rows.
162 // 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+)? @@""")
163 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
168 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
164 visibleLines.forEach { line ->
169 visibleLines.forEach { line ->
165 Row(modifier = Modifier.padding(horizontal = 10.dp)) {
170 Row(modifier = Modifier.padding(horizontal = 10.dp)) {
166 when (line) {
171 when (line) {
167− is DiffLine.Hunk -> Text(
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(
168− line.text,
179+ line.text,
169− style = MaterialTheme.typography.bodySmall,
180+ style = MaterialTheme.typography.bodySmall,
170− fontFamily = FontFamily.Monospace,
181+ fontFamily = FontFamily.Monospace,
171− color = MaterialTheme.colorScheme.tertiary,
182+ color = MaterialTheme.colorScheme.tertiary,
172− modifier = Modifier.padding(vertical = 2.dp),
183+ modifier = Modifier.padding(vertical = 2.dp),
173− )
184+ )
185+ }
174 is DiffLine.FileHeader -> Text(
186 is DiffLine.FileHeader -> Text(
175 line.text,
187 line.text,
176 style = MaterialTheme.typography.bodySmall,
188 style = MaterialTheme.typography.bodySmall,
@@ -185,9 +197,16 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
185 color = MaterialTheme.colorScheme.onSurfaceVariant,
197 color = MaterialTheme.colorScheme.onSurfaceVariant,
186 modifier = Modifier.padding(vertical = 1.dp),
198 modifier = Modifier.padding(vertical = 1.dp),
187 )
199 )
200+ is DiffLine.Add -> {
188− is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
201+ val n = newLine++; DiffRow(line.text, "+", addColor, addText, oldLine = null, newLine = n)
202+ }
203+ is DiffLine.Remove -> {
189− is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
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++
190− is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
208+ DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface, oldLine = o, newLine = n)
209+ }
191 }
210 }
192 }
211 }
193 }
212 }
@@ -209,10 +228,35 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
209}
228}
210
229
211@Composable
230@Composable
231+private fun DiffRow(
232+ text: String,
233+ prefix: String,
234+ bg: Color,
212−private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.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)
213 Row(
240 Row(
214 modifier = Modifier.background(bg),
241 modifier = Modifier.background(bg),
215 ) {
242 ) {
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+ )
216 Text(
260 Text(
217 prefix,
261 prefix,
218 style = MaterialTheme.typography.bodySmall,
262 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
18import androidx.compose.foundation.text.selection.SelectionContainer
18import androidx.compose.foundation.text.selection.SelectionContainer
19import androidx.compose.material.icons.Icons
19import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.filled.ContentCopy
20import androidx.compose.material.icons.filled.ContentCopy
21+import androidx.compose.material.icons.filled.Check
21import androidx.compose.material.icons.automirrored.filled.WrapText
22import androidx.compose.material.icons.automirrored.filled.WrapText
22import androidx.compose.material3.Icon
23import androidx.compose.material3.Icon
23import androidx.compose.material3.IconButton
24import androidx.compose.material3.IconButton
@@ -191,6 +192,16 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
191 // while this block is composed (rememberSaveable would over-persist across blocks).
192 // while this block is composed (rememberSaveable would over-persist across blocks).
192 val defaultWrap = LocalCodeWrap.current
193 val defaultWrap = LocalCodeWrap.current
193 var wrap by remember(defaultWrap) { mutableStateOf(defaultWrap) }
194 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+ }
194 Column(
205 Column(
195 modifier = Modifier
206 modifier = Modifier
196 .fillMaxWidth()
207 .fillMaxWidth()
@@ -220,13 +231,18 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
220 )
231 )
221 }
232 }
222 IconButton(
233 IconButton(
234+ onClick = {
223− onClick = { copyToClipboard(context, context.getString(R.string.clip_label_code), code) },
235+ copyToClipboard(context, context.getString(R.string.clip_label_code), code)
236+ copied = true
237+ },
224 ) {
238 ) {
225 Icon(
239 Icon(
226− Icons.Filled.ContentCopy,
240+ if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
241+ contentDescription = if (copied) context.getString(R.string.copied)
227− contentDescription = context.getString(R.string.copy),
242+ else context.getString(R.string.copy),
228 modifier = Modifier.size(18.dp),
243 modifier = Modifier.size(18.dp),
244+ tint = if (copied) MaterialTheme.colorScheme.primary
229− tint = MaterialTheme.colorScheme.onSurfaceVariant,
245+ else MaterialTheme.colorScheme.onSurfaceVariant,
230 )
246 )
231 }
247 }
232 }
248 }
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
19import androidx.compose.foundation.text.KeyboardActions
19import androidx.compose.foundation.text.KeyboardActions
20import androidx.compose.foundation.text.KeyboardOptions
20import androidx.compose.foundation.text.KeyboardOptions
21import androidx.compose.material.icons.Icons
21import androidx.compose.material.icons.Icons
22−import androidx.compose.material.icons.automirrored.filled.ArrowBack
23import androidx.compose.material.icons.filled.ChevronRight
22import androidx.compose.material.icons.filled.ChevronRight
24import androidx.compose.material.icons.filled.Close
23import androidx.compose.material.icons.filled.Close
25import androidx.compose.material.icons.filled.Description
24import androidx.compose.material.icons.filled.Description
26import androidx.compose.material.icons.filled.Folder
25import androidx.compose.material.icons.filled.Folder
27import androidx.compose.material.icons.filled.Home
26import 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
28import androidx.compose.material.icons.filled.Search
30import androidx.compose.material.icons.filled.Search
29import androidx.compose.material3.CircularProgressIndicator
31import androidx.compose.material3.CircularProgressIndicator
30import androidx.compose.material3.ExperimentalMaterial3Api
32import androidx.compose.material3.ExperimentalMaterial3Api
@@ -39,11 +41,13 @@ import androidx.compose.material3.Scaffold
39import androidx.compose.material3.SnackbarHost
41import androidx.compose.material3.SnackbarHost
40import androidx.compose.material3.SnackbarHostState
42import androidx.compose.material3.SnackbarHostState
41import androidx.compose.material3.Text
43import androidx.compose.material3.Text
42−import androidx.compose.material3.TopAppBar
43import androidx.compose.runtime.Composable
44import androidx.compose.runtime.Composable
44import androidx.compose.runtime.LaunchedEffect
45import androidx.compose.runtime.LaunchedEffect
45import androidx.compose.runtime.getValue
46import androidx.compose.runtime.getValue
47+import androidx.compose.runtime.mutableStateOf
46import androidx.compose.runtime.remember
48import androidx.compose.runtime.remember
49+import androidx.compose.runtime.saveable.rememberSaveable
50+import androidx.compose.runtime.setValue
47import androidx.compose.ui.Alignment
51import androidx.compose.ui.Alignment
48import androidx.compose.ui.Modifier
52import androidx.compose.ui.Modifier
49import androidx.compose.ui.focus.FocusRequester
53import androidx.compose.ui.focus.FocusRequester
@@ -73,7 +77,9 @@ import soy.iko.opencode.data.model.SymbolResult
73import soy.iko.opencode.data.model.symbolKindLabel
77import soy.iko.opencode.data.model.symbolKindLabel
74import soy.iko.opencode.di.AppContainer
78import soy.iko.opencode.di.AppContainer
75import soy.iko.opencode.R
79import soy.iko.opencode.R
80+import soy.iko.opencode.ui.components.AppTopBar
76import soy.iko.opencode.ui.components.ConnectionBannerFor
81import soy.iko.opencode.ui.components.ConnectionBannerFor
82+import soy.iko.opencode.ui.components.EmptyState
77import soy.iko.opencode.ui.vmFactory
83import soy.iko.opencode.ui.vmFactory
78import soy.iko.opencode.util.runCatchingCancellable
84import soy.iko.opencode.util.runCatchingCancellable
79
85
@@ -97,14 +103,7 @@ fun FileBrowserScreen(
97
103
98 Scaffold(
104 Scaffold(
99 topBar = {
105 topBar = {
100− TopAppBar(
101− title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
106+ AppTopBar(title = stringResource(R.string.files), onBack = onBack)
102− navigationIcon = {
103− IconButton(onClick = onBack) {
104− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
105− }
106− },
107− )
108 },
107 },
109 snackbarHost = { SnackbarHost(snackbar) },
108 snackbarHost = { SnackbarHost(snackbar) },
110 ) { padding ->
109 ) { padding ->
@@ -481,6 +480,16 @@ private fun DirectoryListing(
481 )
480 )
482 return
481 return
483 }
482 }
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+ }
484 LazyColumn(modifier = Modifier.fillMaxSize()) {
493 LazyColumn(modifier = Modifier.fillMaxSize()) {
485 if (state.path.isNotBlank()) {
494 if (state.path.isNotBlank()) {
486 item(key = "__up") {
495 item(key = "__up") {
@@ -488,7 +497,25 @@ private fun DirectoryListing(
488 HorizontalDivider()
497 HorizontalDivider()
489 }
498 }
490 }
499 }
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+ }
491− items(state.entries, key = { it.path + "_" + it.name }) { node ->
518+ items(sorted, key = { it.path + "_" + it.name }) { node ->
492 FileRow(
519 FileRow(
493 icon = node.isDirectory,
520 icon = node.isDirectory,
494 label = node.name,
521 label = node.name,
@@ -501,28 +528,11 @@ private fun DirectoryListing(
501 }
528 }
502}
529}
503
530
504−/** Shared empty-state for the file browser: icon + message, matching the icon+text
531+/** Shared empty-state for the file browser. Delegates to the canonical [EmptyState] so every
505− * pattern used by the session and server lists (the prior versions were bare Text
532+ * screen's "nothing here" reads and behaves identically (icon size, spacing, styling). */
506− * lines that read as status messages rather than intentional empty states). */
507@Composable
533@Composable
508private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
534private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
509− Column(
535+ EmptyState(icon = icon, title = message, modifier = modifier)
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− }
526}
536}
527
537
528@Composable
538@Composable
@@ -559,7 +569,7 @@ private fun FileRow(
559 verticalAlignment = Alignment.CenterVertically,
569 verticalAlignment = Alignment.CenterVertically,
560 ) {
570 ) {
561 Icon(
571 Icon(
562− if (icon) Icons.Filled.Folder else Icons.Filled.Description,
572+ if (icon) Icons.Filled.Folder else iconForFile(label),
563 contentDescription = null,
573 contentDescription = null,
564 modifier = Modifier.size(20.dp),
574 modifier = Modifier.size(20.dp),
565 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
575 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
@@ -613,3 +623,18 @@ private fun StatusBadge(status: FileStatusEntry) {
613 }
623 }
614 }
624 }
615}
625}
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
5import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.PaddingValues
7import androidx.compose.foundation.layout.PaddingValues
8+import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.padding
@@ -172,30 +173,52 @@ fun GlobalSearchScreen(
172
173
173@Composable
174@Composable
174private fun SearchResultCard(hit: SearchHit, query: String, onClick: () -> Unit) {
175private 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+ }
175 Card(modifier = Modifier.fillMaxWidth().clickable(role = Role.Button) { onClick() }) {
183 Card(modifier = Modifier.fillMaxWidth().clickable(role = Role.Button) { onClick() }) {
176 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
184 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
185+ Row(verticalAlignment = Alignment.CenterVertically) {
177− Text(
186+ Text(
178− hit.session.displayTitle,
187+ title,
179− style = MaterialTheme.typography.titleMedium,
188+ style = MaterialTheme.typography.titleMedium,
180− maxLines = 1,
189+ maxLines = 1,
181− overflow = TextOverflow.Ellipsis,
190+ overflow = TextOverflow.Ellipsis,
182− )
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+ }
183 RelativeTimeText(
205 RelativeTimeText(
184 hit.session.time?.updated ?: hit.session.time?.created,
206 hit.session.time?.updated ?: hit.session.time?.created,
185 modifier = Modifier.padding(top = 2.dp),
207 modifier = Modifier.padding(top = 2.dp),
186 )
208 )
187− val highlightColor = MaterialTheme.colorScheme.primary
209+ if (hit.snippet.isNotEmpty()) {
188− val snippet = remember(hit.snippet, query, highlightColor) {
210+ val snippet = remember(hit.snippet, query, highlightColor) {
189− highlightMatches(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+ )
190 }
221 }
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− )
199 }
222 }
200 }
223 }
201}
224}
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+51−4
@@ -24,9 +24,16 @@ import kotlinx.coroutines.sync.Semaphore
24import kotlinx.coroutines.sync.withPermit
24import kotlinx.coroutines.sync.withPermit
25import java.util.concurrent.ConcurrentHashMap
25import java.util.concurrent.ConcurrentHashMap
26
26
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. */
28@Immutable
30@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+)
30
37
31@Immutable
38@Immutable
32data class GlobalSearchState(
39data class GlobalSearchState(
@@ -122,8 +129,18 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
122 emptyList()
129 emptyList()
123 }
130 }
124 val snippet = matchSnippet(messages, query)
131 val snippet = matchSnippet(messages, query)
125− ?: session.displayTitle.takeIf { it.contains(query, ignoreCase = true) }
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)
126− if (snippet != null) hits[session.id] = SearchHit(session, snippet)
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+ }
127 }
144 }
128 }
145 }
129 }
146 }
@@ -163,4 +180,34 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
163 val suffix = if (end < text.length) "…" else ""
180 val suffix = if (end < text.length) "…" else ""
164 return prefix + text.substring(start, end).trim() + suffix
181 return prefix + text.substring(start, end).trim() + suffix
165 }
182 }
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+ }
166}
213}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+8−1
@@ -365,7 +365,14 @@ class ServerEditViewModel(
365 }
365 }
366 if (connectAfter) {
366 if (connectAfter) {
367 container.connect(saved)
367 container.connect(saved)
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.
368− container.activeConnection.value?.api?.ping()
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+ }
369 }
376 }
370 }
377 }
371 result.onSuccess { onDone() }
378 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
16import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.filled.Add
17import androidx.compose.material.icons.filled.Add
18import androidx.compose.material.icons.filled.CheckCircle
18import androidx.compose.material.icons.filled.CheckCircle
19+import androidx.compose.material.icons.filled.Error
19import androidx.compose.material.icons.filled.Delete
20import androidx.compose.material.icons.filled.Delete
20import androidx.compose.material.icons.filled.Dns
21import androidx.compose.material.icons.filled.Dns
21import androidx.compose.material.icons.filled.MoreVert
22import androidx.compose.material.icons.filled.MoreVert
23+import androidx.compose.material.icons.filled.QrCodeScanner
22import androidx.compose.material3.AlertDialog
24import androidx.compose.material3.AlertDialog
23import androidx.compose.material3.Button
25import androidx.compose.material3.Button
24import androidx.compose.material3.Card
26import androidx.compose.material3.Card
@@ -57,18 +59,22 @@ import androidx.compose.ui.platform.LocalHapticFeedback
57import androidx.compose.ui.platform.testTag
59import androidx.compose.ui.platform.testTag
58import androidx.compose.ui.semantics.Role
60import androidx.compose.ui.semantics.Role
59import androidx.compose.ui.semantics.contentDescription
61import androidx.compose.ui.semantics.contentDescription
62+import androidx.compose.ui.platform.LocalContext
60import androidx.compose.ui.semantics.semantics
63import androidx.compose.ui.semantics.semantics
61import androidx.compose.ui.text.style.TextOverflow
64import androidx.compose.ui.text.style.TextOverflow
62import androidx.compose.ui.unit.dp
65import androidx.compose.ui.unit.dp
63import androidx.compose.ui.res.stringResource
66import androidx.compose.ui.res.stringResource
67+import androidx.activity.compose.rememberLauncherForActivityResult
64import androidx.lifecycle.compose.collectAsStateWithLifecycle
68import androidx.lifecycle.compose.collectAsStateWithLifecycle
65import androidx.lifecycle.viewmodel.compose.viewModel
69import androidx.lifecycle.viewmodel.compose.viewModel
66import kotlinx.coroutines.coroutineScope
70import kotlinx.coroutines.coroutineScope
67import kotlinx.coroutines.flow.collectLatest
71import kotlinx.coroutines.flow.collectLatest
68import kotlinx.coroutines.delay
72import kotlinx.coroutines.delay
69import kotlinx.coroutines.launch
73import kotlinx.coroutines.launch
74+import kotlinx.coroutines.withContext
70import soy.iko.opencode.data.model.ServerProfile
75import soy.iko.opencode.data.model.ServerProfile
71import soy.iko.opencode.data.network.NetworkConfig
76import soy.iko.opencode.data.network.NetworkConfig
77+import soy.iko.opencode.util.runCatchingCancellable
72import soy.iko.opencode.di.AppContainer
78import soy.iko.opencode.di.AppContainer
73import soy.iko.opencode.R
79import soy.iko.opencode.R
74import soy.iko.opencode.ui.components.rememberRelativeTime
80import soy.iko.opencode.ui.components.rememberRelativeTime
@@ -88,6 +94,11 @@ fun ServerListScreen(
88 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
94 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
89 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
95 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
90 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
96 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
97+ // The active server's real SSE state, so its card reflects a dropped/failed stream instead of
98+ // always reading "Connected" (which is misleading while reconnecting or after an auth failure).
99+ val activeSseState by (activeConnection?.events?.state
100+ ?: kotlinx.coroutines.flow.flowOf(soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connected))
101+ .collectAsStateWithLifecycle(initialValue = soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connected)
91 val haptics = LocalHapticFeedback.current
102 val haptics = LocalHapticFeedback.current
92 val snackbar = remember { SnackbarHostState() }
103 val snackbar = remember { SnackbarHostState() }
93 val retryLabel = stringResource(R.string.retry)
104 val retryLabel = stringResource(R.string.retry)
@@ -95,6 +106,19 @@ fun ServerListScreen(
95 val serverRemovedLabel = stringResource(R.string.server_removed)
106 val serverRemovedLabel = stringResource(R.string.server_removed)
96 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
107 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
97 val connectedId = activeConnection?.profile?.id
108 val connectedId = activeConnection?.profile?.id
109+ // Profile being shown as a scannable QR (Share as QR overflow action), and a decoded QR
110+ // payload awaiting the user's confirm before it's saved (Import from image action).
111+ var pendingQrProfile by remember { mutableStateOf<ServerProfile?>(null) }
112+ val context = androidx.compose.ui.platform.LocalContext.current
113+ val scope = androidx.compose.runtime.rememberCoroutineScope()
114+ // QR import (image pick → decode → confirm → save) is encapsulated in its own composable so the
115+ // branches don't inflate ServerListScreen's complexity. It renders the confirm dialog inline.
116+ val qrImportLauncher = ServerQrImportHandler(
117+ vm = vm,
118+ scope = scope,
119+ snackbar = snackbar,
120+ context = context,
121+ )
98
122
99 LaunchedEffect(Unit) {
123 LaunchedEffect(Unit) {
100 vm.errorEvents.collect { event ->
124 vm.errorEvents.collect { event ->
@@ -160,7 +184,20 @@ fun ServerListScreen(
160 }
184 }
161
185
162 Scaffold(
186 Scaffold(
187+ topBar = {
188+ TopAppBar(
163− topBar = { TopAppBar(title = { Text(stringResource(R.string.servers_title)) }) },
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+ },
164 snackbarHost = { SnackbarHost(snackbar) },
201 snackbarHost = { SnackbarHost(snackbar) },
165 floatingActionButton = {
202 floatingActionButton = {
166 FloatingActionButton(onClick = onAddProfile) {
203 FloatingActionButton(onClick = onAddProfile) {
@@ -221,9 +258,11 @@ fun ServerListScreen(
221 profile = profile,
258 profile = profile,
222 isActive = true,
259 isActive = true,
223 isConnecting = false,
260 isConnecting = false,
261+ activeState = activeSseState,
224 onEdit = { onEditProfile(profile.id) },
262 onEdit = { onEditProfile(profile.id) },
225 onDuplicate = { onDuplicateProfile(profile.id) },
263 onDuplicate = { onDuplicateProfile(profile.id) },
226 onPendingDelete = { pendingDeleteId = profile.id },
264 onPendingDelete = { pendingDeleteId = profile.id },
265+ onShareQr = { pendingQrProfile = profile },
227 )
266 )
228 }
267 }
229 } else {
268 } else {
@@ -275,6 +314,7 @@ fun ServerListScreen(
275 onEdit = { onEditProfile(profile.id) },
314 onEdit = { onEditProfile(profile.id) },
276 onDuplicate = { onDuplicateProfile(profile.id) },
315 onDuplicate = { onDuplicateProfile(profile.id) },
277 onPendingDelete = { pendingDeleteId = profile.id },
316 onPendingDelete = { pendingDeleteId = profile.id },
317+ onShareQr = { pendingQrProfile = profile },
278 )
318 )
279 }
319 }
280 }
320 }
@@ -311,6 +351,12 @@ fun ServerListScreen(
311 },
351 },
312 )
352 )
313 }
353 }
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+ }
314}
360}
315
361
316/** Shared content for a server profile card — used by both the active-row (plain Card)
362/** Shared content for a server profile card — used by both the active-row (plain Card)
@@ -325,6 +371,8 @@ private fun ServerCardContent(
325 onEdit: () -> Unit,
371 onEdit: () -> Unit,
326 onDuplicate: () -> Unit,
372 onDuplicate: () -> Unit,
327 onPendingDelete: () -> Unit,
373 onPendingDelete: () -> Unit,
374+ onShareQr: () -> Unit = {},
375+ activeState: soy.iko.opencode.data.network.EventStreamClient.ConnectionState? = null,
328) {
376) {
329 Row(
377 Row(
330 modifier = Modifier.fillMaxWidth().padding(16.dp),
378 modifier = Modifier.fillMaxWidth().padding(16.dp),
@@ -345,21 +393,55 @@ private fun ServerCardContent(
345 overflow = TextOverflow.Ellipsis,
393 overflow = TextOverflow.Ellipsis,
346 )
394 )
347 if (isActive) {
395 if (isActive) {
396+ // Reflect the live SSE state instead of a static "Connected": a dropped or failed
397+ // stream reads as the real status so a glance at the card isn't misleading.
398+ val (statusText, statusColor) = when (activeState) {
399+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Failed,
400+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.AuthFailed ->
401+ stringResource(R.string.connection_failed_short) to MaterialTheme.colorScheme.error
402+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connecting,
403+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected ->
404+ stringResource(R.string.reconnecting) to MaterialTheme.colorScheme.onSurfaceVariant
405+ else -> stringResource(R.string.connected) to MaterialTheme.colorScheme.primary
406+ }
348 Text(
407 Text(
349− stringResource(R.string.connected),
408+ statusText,
350 style = MaterialTheme.typography.labelSmall,
409 style = MaterialTheme.typography.labelSmall,
351− color = MaterialTheme.colorScheme.primary,
410+ color = statusColor,
352 )
411 )
353 }
412 }
354 LastUsedText(profile.lastUsed)
413 LastUsedText(profile.lastUsed)
355 }
414 }
356 if (isActive) {
415 if (isActive) {
416+ // Swap the green check for a spinner while reconnecting, or an error glyph on a hard
417+ // failure, so the trailing icon matches the status text on the left.
418+ when (activeState) {
419+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connecting,
420+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected -> {
421+ val reconnectingLabel = stringResource(R.string.reconnecting)
422+ CircularProgressIndicator(
423+ modifier = Modifier.size(22.dp).semantics { contentDescription = reconnectingLabel },
424+ strokeWidth = 2.dp,
425+ )
426+ }
427+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Failed,
428+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.AuthFailed -> {
357− Icon(
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(
358− Icons.Filled.CheckCircle,
438+ Icons.Filled.CheckCircle,
359− contentDescription = stringResource(R.string.connected),
439+ contentDescription = stringResource(R.string.connected),
360− tint = MaterialTheme.colorScheme.primary,
440+ tint = MaterialTheme.colorScheme.primary,
361− modifier = Modifier.size(22.dp),
441+ modifier = Modifier.size(22.dp),
362− )
442+ )
443+ }
444+ }
363 }
445 }
364 if (isConnecting) {
446 if (isConnecting) {
365 val connectingLabel = stringResource(R.string.connecting)
447 val connectingLabel = stringResource(R.string.connecting)
@@ -388,6 +470,10 @@ private fun ServerCardContent(
388 text = { Text(duplicateLabel) },
470 text = { Text(duplicateLabel) },
389 onClick = { showRowMenu = false; onDuplicate() },
471 onClick = { showRowMenu = false; onDuplicate() },
390 )
472 )
473+ DropdownMenuItem(
474+ text = { Text(stringResource(R.string.share_qr)) },
475+ onClick = { showRowMenu = false; onShareQr() },
476+ )
391 DropdownMenuItem(
477 DropdownMenuItem(
392 text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
478 text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
393 onClick = { showRowMenu = false; onPendingDelete() },
479 onClick = { showRowMenu = false; onPendingDelete() },
@@ -449,3 +535,66 @@ private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
449 }
535 }
450 }
536 }
451}
537}
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() {
148 if (!container.cancelProfileDelete(profileId)) return
148 if (!container.cancelProfileDelete(profileId)) return
149 _hiddenIds.update { it - profileId }
149 _hiddenIds.update { it - profileId }
150 }
150 }
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+ }
151}
167}
app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+17−3
@@ -85,7 +85,7 @@ fun NewSessionDialog(
85 sessionDirectories: List<String>,
85 sessionDirectories: List<String>,
86 lastChosenDirectory: String?,
86 lastChosenDirectory: String?,
87 creating: Boolean,
87 creating: Boolean,
88− onCreate: (String?) -> Unit,
88+ onCreate: (directory: String?, title: String?) -> Unit,
89 onDismiss: () -> Unit,
89 onDismiss: () -> Unit,
90) {
90) {
91 // Directories to list as selectable rows: known projects + directories of existing
91 // Directories to list as selectable rows: known projects + directories of existing
@@ -106,6 +106,9 @@ fun NewSessionDialog(
106 )
106 )
107 }
107 }
108 var customText by rememberSaveable { mutableStateOf("") }
108 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("") }
109
112
110 // The initializer above may have run while options were still loading (serverDefault
113 // The initializer above may have run while options were still loading (serverDefault
111 // null), pinning choice to DirChoice.Known(preselect). Once the real serverDefault
114 // null), pinning choice to DirChoice.Known(preselect). Once the real serverDefault
@@ -124,6 +127,7 @@ fun NewSessionDialog(
124 is DirChoice.Known -> c.path
127 is DirChoice.Known -> c.path
125 is DirChoice.Custom -> customText.trim().takeIf { it.isNotBlank() }
128 is DirChoice.Custom -> customText.trim().takeIf { it.isNotBlank() }
126 }
129 }
130+ val resolvedTitle: String? = titleText.trim().takeIf { it.isNotBlank() }
127 // Custom requires a non-blank path; the other choices are always valid.
131 // Custom requires a non-blank path; the other choices are always valid.
128 val canCreate = !creating && (choice !is DirChoice.Custom || customText.isNotBlank())
132 val canCreate = !creating && (choice !is DirChoice.Custom || customText.isNotBlank())
129
133
@@ -137,6 +141,16 @@ fun NewSessionDialog(
137 .heightIn(max = 380.dp)
141 .heightIn(max = 380.dp)
138 .verticalScroll(rememberScrollState()),
142 .verticalScroll(rememberScrollState()),
139 ) {
143 ) {
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+ )
140 Text(
154 Text(
141 stringResource(R.string.directory_label),
155 stringResource(R.string.directory_label),
142 style = MaterialTheme.typography.labelLarge,
156 style = MaterialTheme.typography.labelLarge,
@@ -185,7 +199,7 @@ fun NewSessionDialog(
185 singleLine = true,
199 singleLine = true,
186 enabled = !creating,
200 enabled = !creating,
187 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
201 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
188− keyboardActions = KeyboardActions(onDone = { if (canCreate) onCreate(resolved) }),
202+ keyboardActions = KeyboardActions(onDone = { if (canCreate) onCreate(resolved, resolvedTitle) }),
189 )
203 )
190 }
204 }
191
205
@@ -206,7 +220,7 @@ fun NewSessionDialog(
206 }
220 }
207 },
221 },
208 confirmButton = {
222 confirmButton = {
209− TextButton(onClick = { onCreate(resolved) }, enabled = canCreate) {
223+ TextButton(onClick = { onCreate(resolved, resolvedTitle) }, enabled = canCreate) {
210 if (creating) {
224 if (creating) {
211 CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
225 CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
212 } else {
226 } else {
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+90−54
@@ -22,6 +22,7 @@ import androidx.compose.material.icons.Icons
22import androidx.compose.material.icons.automirrored.filled.Logout
22import androidx.compose.material.icons.automirrored.filled.Logout
23import androidx.compose.material.icons.automirrored.filled.Sort
23import androidx.compose.material.icons.automirrored.filled.Sort
24import androidx.compose.material.icons.filled.Add
24import androidx.compose.material.icons.filled.Add
25+import androidx.compose.material.icons.filled.ErrorOutline
25import androidx.compose.material.icons.filled.Archive
26import androidx.compose.material.icons.filled.Archive
26import androidx.compose.material.icons.filled.Unarchive
27import androidx.compose.material.icons.filled.Unarchive
27import androidx.compose.material.icons.filled.ArrowDownward
28import androidx.compose.material.icons.filled.ArrowDownward
@@ -104,6 +105,7 @@ import soy.iko.opencode.platform.AppShortcuts
104import soy.iko.opencode.platform.SessionsWidgetProvider
105import soy.iko.opencode.platform.SessionsWidgetProvider
105import soy.iko.opencode.R
106import soy.iko.opencode.R
106import soy.iko.opencode.ui.components.ConnectionBanner
107import soy.iko.opencode.ui.components.ConnectionBanner
108+import soy.iko.opencode.ui.components.EmptyState
107import soy.iko.opencode.ui.components.LocalRelativeTimeTick
109import soy.iko.opencode.ui.components.LocalRelativeTimeTick
108import soy.iko.opencode.ui.components.RelativeTimeText
110import soy.iko.opencode.ui.components.RelativeTimeText
109import soy.iko.opencode.ui.components.rememberRelativeTimeTick
111import soy.iko.opencode.ui.components.rememberRelativeTimeTick
@@ -242,18 +244,14 @@ fun SessionListScreen(
242 expanded = showServerMenu,
244 expanded = showServerMenu,
243 onExpand = { showServerMenu = true },
245 onExpand = { showServerMenu = true },
244 onDismiss = { showServerMenu = false },
246 onDismiss = { showServerMenu = false },
245− onSelect = { profile ->
247+ // The active-profile no-op and the active-run confirmation are handled
246− // Keep the switcher open so its per-row spinner (driven by
248+ // inside ServerSwitcherMenu, so onSelect only fires for a genuine switch.
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)
249+ onSelect = { profile -> vm.switchServer(profile) },
252− },
253 onAddServer = {
250 onAddServer = {
254 showServerMenu = false
251 showServerMenu = false
255 onAddServer()
252 onAddServer()
256 },
253 },
254+ anyRunActive = anyRunActive,
257 )
255 )
258 },
256 },
259 actions = {
257 actions = {
@@ -391,19 +389,9 @@ fun SessionListScreen(
391 }
389 }
392
390
393 if (showDisconnectConfirm) {
391 if (showDisconnectConfirm) {
394− AlertDialog(
392+ DisconnectActiveConfirmDialog(
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()
393+ onConfirm = onDisconnect,
402− }) { Text(stringResource(R.string.disconnect), color = MaterialTheme.colorScheme.error) }
403− },
404− dismissButton = {
405− TextButton(onClick = { showDisconnectConfirm = false }) { Text(stringResource(R.string.cancel)) }
394+ onDismiss = { showDisconnectConfirm = false },
406− },
407 )
395 )
408 }
396 }
409
397
@@ -419,8 +407,8 @@ fun SessionListScreen(
419 sessionDirectories = sessionDirectories,
407 sessionDirectories = sessionDirectories,
420 lastChosenDirectory = vm.lastChosenDirectory,
408 lastChosenDirectory = vm.lastChosenDirectory,
421 creating = creating,
409 creating = creating,
422− onCreate = { dir ->
410+ onCreate = { dir, title ->
423− vm.createSession(directory = dir) { id ->
411+ vm.createSession(directory = dir, title = title) { id ->
424 showNewSessionDialog = false
412 showNewSessionDialog = false
425 onOpenSession(id)
413 onOpenSession(id)
426 }
414 }
@@ -460,17 +448,30 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
460 modifier = Modifier.align(Alignment.Center).padding(24.dp),
448 modifier = Modifier.align(Alignment.Center).padding(24.dp),
461 horizontalAlignment = Alignment.CenterHorizontally,
449 horizontalAlignment = Alignment.CenterHorizontally,
462 ) {
450 ) {
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))
463 Text(
458 Text(
464 state.error ?: "",
459 state.error ?: "",
465 color = MaterialTheme.colorScheme.error,
460 color = MaterialTheme.colorScheme.error,
461+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
466 )
462 )
467 Spacer(Modifier.size(12.dp))
463 Spacer(Modifier.size(12.dp))
468 TextButton(onClick = onRefresh) {
464 TextButton(onClick = onRefresh) {
469 Text(stringResource(R.string.retry))
465 Text(stringResource(R.string.retry))
470 }
466 }
471 }
467 }
472− state.sessions.isEmpty() -> EmptySessions(
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),
473− onCreate = onCreateSession,
474+ onAction = onCreateSession,
474 modifier = Modifier.align(Alignment.Center),
475 modifier = Modifier.align(Alignment.Center),
475 )
476 )
476 else -> Column(modifier = Modifier.fillMaxSize()) {
477 else -> Column(modifier = Modifier.fillMaxSize()) {
@@ -1031,34 +1032,6 @@ private fun SortMenu(
1031 }
1032 }
1032}
1033}
1033
1034
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−
1062/** Title + dropdown for quick-switching between saved servers. Highlights the active
1035/** Title + dropdown for quick-switching between saved servers. Highlights the active
1063 * server with a check mark and primary color so the user can tell which one they're
1036 * server with a check mark and primary color so the user can tell which one they're
1064 * on at a glance. Includes an "Add server" item so adding a new server doesn't require
1037 * on at a glance. Includes an "Add server" item so adding a new server doesn't require
@@ -1074,7 +1047,12 @@ private fun ServerSwitcherMenu(
1074 onDismiss: () -> Unit,
1047 onDismiss: () -> Unit,
1075 onSelect: (ServerProfile) -> Unit,
1048 onSelect: (ServerProfile) -> Unit,
1076 onAddServer: () -> Unit,
1049 onAddServer: () -> Unit,
1050+ anyRunActive: Boolean = false,
1077) {
1051) {
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) }
1078 Column {
1056 Column {
1079 Box {
1057 Box {
1080 Row(
1058 Row(
@@ -1130,7 +1108,16 @@ private fun ServerSwitcherMenu(
1130 }
1108 }
1131 }
1109 }
1132 },
1110 },
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.
1133− onClick = { onSelect(profile) },
1118+ else -> onSelect(profile)
1119+ }
1120+ },
1134 )
1121 )
1135 }
1122 }
1136 // Divider + Add server item so the user can add a server without leaving
1123 // Divider + Add server item so the user can add a server without leaving
@@ -1147,4 +1134,53 @@ private fun ServerSwitcherMenu(
1147 }
1134 }
1148 }
1135 }
1149 }
1136 }
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+ )
1150}
1186}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+9−8
@@ -602,14 +602,15 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
602 * launch cwd — the pre-directory behavior. The chosen directory is remembered
602 * launch cwd — the pre-directory behavior. The chosen directory is remembered
603 * ([lastChosenDirectory]) so the next new-session picker can preselect it.
603 * ([lastChosenDirectory]) so the next new-session picker can preselect it.
604 */
604 */
605− fun createSession(directory: String? = null, onCreated: (String) -> Unit) {
605+ fun createSession(directory: String? = null, title: String? = null, onCreated: (String) -> Unit) {
606− val conn = container.activeConnection.value ?: return
606+ val conn = container.activeConnection.value ?: return
607− if (!_creating.compareAndSet(false, true)) return
607+ if (!_creating.compareAndSet(false, true)) return
608− val dir = directory?.takeIf { it.isNotBlank() }
608+ val dir = directory?.takeIf { it.isNotBlank() }
609+ val resolvedTitle = title?.trim()?.takeIf { it.isNotBlank() }
609− viewModelScope.launch {
610+ viewModelScope.launch {
610− try {
611+ try {
611− runCatchingCancellable { conn.repository.createSession(directory = dir) }
612+ runCatchingCancellable { conn.repository.createSession(title = resolvedTitle, directory = dir) }
612− .onSuccess { lastChosenDirectory = dir; onCreated(it.id); refresh() }
613+ .onSuccess { lastChosenDirectory = dir; onCreated(it.id); refresh() }
613 .onFailure {
614 .onFailure {
614 _state.update { it.copy(error = null) }
615 _state.update { it.copy(error = null) }
615 _transientErrors.tryEmit(container.friendlyError(it))
616 _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
59import kotlinx.coroutines.withContext
59import kotlinx.coroutines.withContext
60import soy.iko.opencode.util.runCatchingCancellable
60import soy.iko.opencode.util.runCatchingCancellable
61import soy.iko.opencode.ui.components.AppTopBar
61import soy.iko.opencode.ui.components.AppTopBar
62+import soy.iko.opencode.ui.components.EmptyState
62import soy.iko.opencode.ui.components.copyToClipboard
63import soy.iko.opencode.ui.components.copyToClipboard
63import soy.iko.opencode.ui.components.showToast
64import soy.iko.opencode.ui.components.showToast
64import androidx.compose.ui.Alignment
65import androidx.compose.ui.Alignment
@@ -200,7 +201,9 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
200 .semantics { heading() },
201 .semantics { heading() },
201 )
202 )
202 if (reports.isEmpty()) {
203 if (reports.isEmpty()) {
203− EmptyCrashReports(
204+ EmptyState(
205+ icon = Icons.Filled.BugReport,
206+ title = stringResource(R.string.no_crash_reports),
204 modifier = Modifier.fillMaxWidth().padding(24.dp),
207 modifier = Modifier.fillMaxWidth().padding(24.dp),
205 )
208 )
206 } else {
209 } else {
@@ -425,27 +428,3 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
425 )
428 )
426 }
429 }
427}
430}
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
24import androidx.compose.material.icons.Icons
24import androidx.compose.material.icons.Icons
25import androidx.compose.material.icons.automirrored.filled.ArrowBack
25import androidx.compose.material.icons.automirrored.filled.ArrowBack
26import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
26import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
27+import androidx.compose.material.icons.filled.ArrowDropDown
27import androidx.compose.material.icons.filled.BugReport
28import androidx.compose.material.icons.filled.BugReport
28import androidx.compose.material.icons.filled.Dns
29import androidx.compose.material.icons.filled.Dns
29import androidx.compose.material.icons.filled.Download
30import androidx.compose.material.icons.filled.Download
@@ -57,6 +58,7 @@ import androidx.compose.ui.Modifier
57import androidx.compose.ui.draw.clip
58import androidx.compose.ui.draw.clip
58import androidx.compose.ui.graphics.Color
59import androidx.compose.ui.graphics.Color
59import androidx.compose.ui.platform.LocalContext
60import androidx.compose.ui.platform.LocalContext
61+import androidx.compose.ui.res.pluralStringResource
60import androidx.compose.ui.res.stringResource
62import androidx.compose.ui.res.stringResource
61import androidx.compose.ui.semantics.Role
63import androidx.compose.ui.semantics.Role
62import androidx.compose.ui.semantics.heading
64import androidx.compose.ui.semantics.heading
@@ -64,6 +66,8 @@ import androidx.compose.ui.semantics.role
64import androidx.compose.ui.semantics.semantics
66import androidx.compose.ui.semantics.semantics
65import androidx.compose.ui.text.style.TextAlign
67import androidx.compose.ui.text.style.TextAlign
66import androidx.compose.ui.unit.dp
68import androidx.compose.ui.unit.dp
69+import androidx.lifecycle.compose.LocalLifecycleOwner
70+import androidx.lifecycle.compose.LifecycleResumeEffect
67import androidx.lifecycle.compose.collectAsStateWithLifecycle
71import androidx.lifecycle.compose.collectAsStateWithLifecycle
68import kotlinx.coroutines.ExperimentalCoroutinesApi
72import kotlinx.coroutines.ExperimentalCoroutinesApi
69import kotlinx.coroutines.flow.combine
73import kotlinx.coroutines.flow.combine
@@ -109,12 +113,24 @@ fun SettingsScreen(
109 ) { theme, dyn, enter, lock -> SettingsValues(theme, dyn, enter, lock) as SettingsValues? }
113 ) { theme, dyn, enter, lock -> SettingsValues(theme, dyn, enter, lock) as SettingsValues? }
110 }
114 }
111 val settings by settingsFlow.collectAsStateWithLifecycle(initialValue = null)
115 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+ }
112 // Text/display prefs are collected separately from [settingsFlow] (kotlinx combine
126 // Text/display prefs are collected separately from [settingsFlow] (kotlinx combine
113 // tops out at 5 typed flows) and render independently — they don't need the same
127 // tops out at 5 typed flows) and render independently — they don't need the same
114 // load-gate as the appearance block.
128 // load-gate as the appearance block.
115 val chatTextScale by container.settingsStore.chatTextScale
129 val chatTextScale by container.settingsStore.chatTextScale
116 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
130 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
117 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
131 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)
118 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
134 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
119 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
135 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
120 // SSE connection state so the Settings screen can show a dropped stream (the
136 // SSE connection state so the Settings screen can show a dropped stream (the
@@ -181,6 +197,13 @@ fun SettingsScreen(
181 // Stage the file; the actual destructive import waits on the confirm dialog.
197 // Stage the file; the actual destructive import waits on the confirm dialog.
182 pendingImportUri = uri
198 pendingImportUri = uri
183 }
199 }
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+ }
184
207
185 Scaffold(
208 Scaffold(
186 topBar = {
209 topBar = {
@@ -274,7 +297,7 @@ fun SettingsScreen(
274 // App lock: gate on device capability. Always allow turning it *off* (in case
297 // App lock: gate on device capability. Always allow turning it *off* (in case
275 // the enrolled biometric was later removed), but only allow turning it on when
298 // the enrolled biometric was later removed), but only allow turning it on when
276 // a biometric or device credential is actually available.
299 // a biometric or device credential is actually available.
277− val appLockAvailable = remember { canAuthenticateForAppLock(context) }
300+ val appLockAvailable = remember(biometricCheckTick) { canAuthenticateForAppLock(context) }
278 val appLockToggleable = appLockAvailable || s.appLock
301 val appLockToggleable = appLockAvailable || s.appLock
279 Spacer(Modifier.size(4.dp))
302 Spacer(Modifier.size(4.dp))
280 Row(
303 Row(
@@ -303,6 +326,14 @@ fun SettingsScreen(
303 enabled = appLockToggleable,
326 enabled = appLockToggleable,
304 )
327 )
305 }
328 }
329+ if (s.appLock) {
330+ AppLockReLockSelector(
331+ seconds = appLockReLockSeconds,
332+ onSelect = { sec ->
333+ scope.launch { runCatchingCancellable { container.settingsStore.setAppLockReLockSeconds(sec) } }
334+ },
335+ )
336+ }
306 } else {
337 } else {
307 Box(
338 Box(
308 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
339 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
@@ -474,7 +505,7 @@ fun SettingsScreen(
474 // the file picker. Without passwords, export straight away.
505 // the file picker. Without passwords, export straight away.
475 onClick = {
506 onClick = {
476 if (includePasswords) showExportPasswordWarning = true
507 if (includePasswords) showExportPasswordWarning = true
477− else runCatching { exportLauncher.launch("opencode-backup.json") }
508+ else runCatching { exportLauncher.launch(backupFilename()) }
478 },
509 },
479 )
510 )
480 NavRow(
511 NavRow(
@@ -564,7 +595,7 @@ fun SettingsScreen(
564 confirmButton = {
595 confirmButton = {
565 TextButton(onClick = {
596 TextButton(onClick = {
566 showExportPasswordWarning = false
597 showExportPasswordWarning = false
567− runCatching { exportLauncher.launch("opencode-backup.json") }
598+ runCatching { exportLauncher.launch(backupFilename()) }
568 }) { Text(stringResource(R.string.export_backup), color = MaterialTheme.colorScheme.error) }
599 }) { Text(stringResource(R.string.export_backup), color = MaterialTheme.colorScheme.error) }
569 },
600 },
570 dismissButton = {
601 dismissButton = {
@@ -749,3 +780,50 @@ private fun Badge(count: Int) {
749 )
780 )
750 }
781 }
751}
782}
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 @@
564 <string name="crash_last_run_dismiss">Descartar</string>
564 <string name="crash_last_run_dismiss">Descartar</string>
565 <string name="connecting_to">Conectando a %1$s…</string>
565 <string name="connecting_to">Conectando a %1$s…</string>
566 <string name="scroll_to_top">Ir arriba</string>
566 <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>
567</resources>
589</resources>
app/src/main/res/values/strings.xml+20
@@ -39,6 +39,7 @@
39 <string name="reconnecting">Reconnecting…</string>
39 <string name="reconnecting">Reconnecting…</string>
40 <string name="connection_failed">Connection failed — check credentials</string>
40 <string name="connection_failed">Connection failed — check credentials</string>
41 <string name="connection_failed_endpoint">Connection failed — check the server URL and version</string>
41 <string name="connection_failed_endpoint">Connection failed — check the server URL and version</string>
42+ <string name="connection_failed_short">Connection failed</string>
42 <string name="reconnect">Reconnect</string>
43 <string name="reconnect">Reconnect</string>
43 <string name="not_connected">Not connected.</string>
44 <string name="not_connected">Not connected.</string>
44 <string name="no_servers_to_reconnect">No saved server to reconnect to.</string>
45 <string name="no_servers_to_reconnect">No saved server to reconnect to.</string>
@@ -57,6 +58,14 @@
57 <string name="server_auth_short"> • auth</string>
58 <string name="server_auth_short"> • auth</string>
58 <string name="server_auth_basic"> • Basic auth</string>
59 <string name="server_auth_basic"> • Basic auth</string>
59 <string name="server_removed">Server removed</string>
60 <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>
60
69
61 <!-- Server edit -->
70 <!-- Server edit -->
62 <string name="label_optional">Label (optional)</string>
71 <string name="label_optional">Label (optional)</string>
@@ -287,6 +296,7 @@
287 <string name="sort">Sort</string>
296 <string name="sort">Sort</string>
288 <string name="sort_recent">Recent activity</string>
297 <string name="sort_recent">Recent activity</string>
289 <string name="sort_title">Title</string>
298 <string name="sort_title">Title</string>
299+ <string name="sort_name">Name</string>
290 <string name="last_used_never">Not used yet</string>
300 <string name="last_used_never">Not used yet</string>
291 <string name="last_used">Last used %1$s</string>
301 <string name="last_used">Last used %1$s</string>
292 <string name="duplicate_server">Duplicate</string>
302 <string name="duplicate_server">Duplicate</string>
@@ -384,6 +394,16 @@
384 <string name="app_lock">App lock</string>
394 <string name="app_lock">App lock</string>
385 <string name="app_lock_desc">Require biometric or device credential to open the app.</string>
395 <string name="app_lock_desc">Require biometric or device credential to open the app.</string>
386 <string name="app_lock_unavailable">No biometric or device credential is set up.</string>
396 <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>
387 <string name="app_lock_prompt_title">Unlock opencode</string>
407 <string name="app_lock_prompt_title">Unlock opencode</string>
388 <string name="app_lock_prompt_subtitle">Authenticate to access your servers.</string>
408 <string name="app_lock_prompt_subtitle">Authenticate to access your servers.</string>
389 <string name="app_locked">opencode is locked</string>
409 <string name="app_locked">opencode is locked</string>
gradle/libs.versions.toml+2
@@ -13,6 +13,7 @@ securityCrypto = "1.1.0"
13coil = "2.7.0"
13coil = "2.7.0"
14biometric = "1.1.0"
14biometric = "1.1.0"
15markdownRenderer = "0.27.0"
15markdownRenderer = "0.27.0"
16+zxingCore = "3.5.3"
16coreKtx = "1.13.1"
17coreKtx = "1.13.1"
17splashscreen = "1.0.1"
18splashscreen = "1.0.1"
18junit = "4.13.2"
19junit = "4.13.2"
@@ -56,6 +57,7 @@ coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coi
56androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
57androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
57markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
58markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
58markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer", version.ref = "markdownRenderer" }
59markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer", version.ref = "markdownRenderer" }
60+zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxingCore" }
59
61
60junit = { group = "junit", name = "junit", version.ref = "junit" }
62junit = { group = "junit", name = "junit", version.ref = "junit" }
61androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }
63androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }