git.iko.soy/opencode-android
on master help sign in
Add 38 UI/UX improvements: date grouping, per-hunk diff collapse, tablet width caps, skeleton loaders, settings search, JSON export, file viewer zoom/metadata, notification theming, and consistency fixes across screens
d647ff2iko
app/src/main/java/soy/iko/opencode/MainActivity.kt+55−1
@@ -47,6 +47,9 @@ class MainActivity : FragmentActivity() {
47 private var openSessionHandled = false
47 private var openSessionHandled = false
48 private var newSessionHandled = false
48 private var newSessionHandled = false
49 private var openFileHandled = false
49 private var openFileHandled = false
50+ // Set when a deep link was present but failed validation (malformed id/path). Surfaced
51+ // as a toast from onResume so the user knows the link was rejected, not silently ignored.
52+ private var showInvalidLinkToast = false
50 // Cold-start prompts (crash report, notification rationale) rendered as Compose M3
53 // Cold-start prompts (crash report, notification rationale) rendered as Compose M3
51 // dialogs. Hoisted as Activity-level mutableState so the non-composable trigger logic
54 // dialogs. Hoisted as Activity-level mutableState so the non-composable trigger logic
52 // (onCreate/onResume) can flip them and the Compose tree (inside OpencodeTheme) reads
55 // (onCreate/onResume) can flip them and the Compose tree (inside OpencodeTheme) reads
@@ -91,6 +94,26 @@ class MainActivity : FragmentActivity() {
91 // before app lock resolves. Mirrors the SettingsScreen's null-gate.
94 // before app lock resolves. Mirrors the SettingsScreen's null-gate.
92 var settingsLoaded = false
95 var settingsLoaded = false
93 splash.setKeepOnScreenCondition { !settingsLoaded }
96 splash.setKeepOnScreenCondition { !settingsLoaded }
97+ // Custom splash exit: a quick fade-out instead of the system default's slide, so the
98+ // hand-off to Compose reads as a branded dissolve. The listener must call
99+ // splashScreenViewProvider.remove() to release the splash view; otherwise the system
100+ // keeps it on screen for its default duration. Reduced-motion is respected via the
101+ // animator's duration scale (the system zeroes it under reduced motion).
102+ splash.setOnExitAnimationListener { splashScreenViewProvider ->
103+ val view = splashScreenViewProvider.view
104+ val fade = android.view.animation.AlphaAnimation(1f, 0f).apply {
105+ duration = 180
106+ fillAfter = true
107+ }
108+ fade.setAnimationListener(object : android.view.animation.Animation.AnimationListener {
109+ override fun onAnimationStart(animation: android.view.animation.Animation?) {}
110+ override fun onAnimationEnd(animation: android.view.animation.Animation?) {
111+ splashScreenViewProvider.remove()
112+ }
113+ override fun onAnimationRepeat(animation: android.view.animation.Animation?) {}
114+ })
115+ view.startAnimation(fade)
116+ }
94 setContent {
117 setContent {
95 val theme by remember(container) {
118 val theme by remember(container) {
96 container.settingsStore.themeMode
119 container.settingsStore.themeMode
@@ -210,6 +233,12 @@ class MainActivity : FragmentActivity() {
210 // ensures the user is prompted once per Activity instance when still ungranted rather
233 // ensures the user is prompted once per Activity instance when still ungranted rather
211 // than silently losing all notifications.
234 // than silently losing all notifications.
212 maybeRequestNotificationPermission()
235 maybeRequestNotificationPermission()
236+ // Surface a rejected deep link once, so a malformed opencode:// link doesn't appear
237+ // to be silently ignored. Cleared after firing so a rotation doesn't re-toast.
238+ if (showInvalidLinkToast) {
239+ showInvalidLinkToast = false
240+ android.widget.Toast.makeText(this, R.string.deep_link_invalid, android.widget.Toast.LENGTH_SHORT).show()
241+ }
213 }
242 }
214
243
215 /** Capture text shared from another app so it can be prefilled into a session draft,
244 /** Capture text shared from another app so it can be prefilled into a session draft,
@@ -240,6 +269,24 @@ class MainActivity : FragmentActivity() {
240 container.setPendingSharedMedia(uris.map { it.toString() })
269 container.setPendingSharedMedia(uris.map { it.toString() })
241 shareIntentHandled = true
270 shareIntentHandled = true
242 }
271 }
272+ } else if (action == Intent.ACTION_SEND) {
273+ // A non-image file (e.g. a PDF or text file shared from another app): stage it
274+ // as an attachment too, so the user can send it to the agent. The attachment
275+ // pipeline handles non-image mimes with a generic file icon. Falls back to
276+ // EXTRA_TEXT when no stream is present (a plain text share).
277+ val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
278+ if (uri != null) {
279+ container.setPendingSharedMedia(listOf(uri.toString()))
280+ shareIntentHandled = true
281+ } else {
282+ val text = intent.getStringExtra(Intent.EXTRA_TEXT)
283+ ?.takeIf { it.isNotBlank() }
284+ ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
285+ if (text != null) {
286+ container.setPendingShare(text)
287+ shareIntentHandled = true
288+ }
289+ }
243 } else {
290 } else {
244 val text = intent.getStringExtra(Intent.EXTRA_TEXT)
291 val text = intent.getStringExtra(Intent.EXTRA_TEXT)
245 ?.takeIf { it.isNotBlank() }
292 ?.takeIf { it.isNotBlank() }
@@ -266,6 +313,11 @@ class MainActivity : FragmentActivity() {
266 ?.takeIf { it.isNotBlank() }
313 ?.takeIf { it.isNotBlank() }
267 container.requestOpenSession(it, profileId)
314 container.requestOpenSession(it, profileId)
268 openSessionHandled = true
315 openSessionHandled = true
316+ } ?: run {
317+ // The link carried a session host but no valid id (or a blank/traversal
318+ // attempt). Surface a toast so the user knows the link was rejected,
319+ // rather than silently no-op'ing (which reads as "the app did nothing").
320+ if (!seg.isNullOrBlank()) showInvalidLinkToast = true
269 }
321 }
270 }
322 }
271 }
323 }
@@ -275,7 +327,9 @@ class MainActivity : FragmentActivity() {
275 val rawPath = data.path?.removePrefix("/file/")?.let { android.net.Uri.decode(it) }
327 val rawPath = data.path?.removePrefix("/file/")?.let { android.net.Uri.decode(it) }
276 if (!openFileHandled) {
328 if (!openFileHandled) {
277 rawPath?.takeIf { it.isNotBlank() && VALID_FILE_PATH.containsMatchIn(it) && !it.contains("..") }
329 rawPath?.takeIf { it.isNotBlank() && VALID_FILE_PATH.containsMatchIn(it) && !it.contains("..") }
278− ?.let { container.requestOpenFile(it); openFileHandled = true }
330+ ?.let { container.requestOpenFile(it); openFileHandled = true } ?: run {
331+ if (!rawPath.isNullOrBlank()) showInvalidLinkToast = true
332+ }
279 }
333 }
280 }
334 }
281 "new" -> {
335 "new" -> {
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+13
@@ -260,6 +260,12 @@ object NetworkConfig {
260 /** Extra bottom content padding (dp) for the chat message list, reserving room for the
260 /** Extra bottom content padding (dp) for the chat message list, reserving room for the
261 * jump-to-latest FAB so it never floats over the last message. */
261 * jump-to-latest FAB so it never floats over the last message. */
262 const val chatListFabInsetDp = 80
262 const val chatListFabInsetDp = 80
263+
264+ /** Extra bottom content padding (dp) for single-pane list screens that host a FAB
265+ * (session list, server list, MCP), reserving room so the FAB never floats over the
266+ * last row. Unified across screens so the inset doesn't drift (the session list
267+ * previously hardcoded 96dp while the chat list used [chatListFabInsetDp]). */
268+ const val listFabInsetDp = 96
263 /** Left pane weight in the two-pane layout (session list). */
269 /** Left pane weight in the two-pane layout (session list). */
264 const val twoPaneLeftWeight = 0.38f
270 const val twoPaneLeftWeight = 0.38f
265 /** Right pane weight in the two-pane layout (chat detail). */
271 /** Right pane weight in the two-pane layout (chat detail). */
@@ -278,6 +284,13 @@ object NetworkConfig {
278 * keeps line lengths comfortable. Applied only above [twoPaneWidthThresholdDp]. */
284 * keeps line lengths comfortable. Applied only above [twoPaneWidthThresholdDp]. */
279 const val chatContentMaxWidthDp = 800
285 const val chatContentMaxWidthDp = 800
280
286
287+ /** Maximum width (dp) for a single-pane list/form screen on large screens (Diagnostics,
288+ * MCP, Usage, Global Search, File Browser). Mirrors [chatContentMaxWidthDp]'s rationale
289+ * at a tighter width suited to dense list rows and form fields. Settings and ServerEdit
290+ * use 600dp directly; this constant centralizes the same value for the screens that
291+ * previously stretched edge-to-edge on tablets. */
292+ const val listContentMaxWidthDp = 600
293+
281 /** Maximum width (dp) for the full-screen chat composer dialog on large screens, so
294 /** Maximum width (dp) for the full-screen chat composer dialog on large screens, so
282 * the editor doesn't stretch edge-to-edge on a tablet (matching [chatContentMaxWidthDp]'s
295 * the editor doesn't stretch edge-to-edge on a tablet (matching [chatContentMaxWidthDp]'s
283 * readability rationale for the message list). */
296 * readability rationale for the message list). */
app/src/main/java/soy/iko/opencode/data/repo/FileBrowserPrefs.ktnew file+90
@@ -0,0 +1,90 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import androidx.datastore.preferences.core.booleanPreferencesKey
5+import androidx.datastore.preferences.core.edit
6+import androidx.datastore.preferences.core.stringPreferencesKey
7+import androidx.datastore.preferences.preferencesDataStore
8+import kotlinx.coroutines.flow.Flow
9+import kotlinx.coroutines.flow.flowOf
10+import kotlinx.coroutines.flow.map
11+
12+private val Context.fileBrowserPrefsDataStore by preferencesDataStore(name = "file_browser_prefs")
13+
14+/** Sort axis for the file-browser directory listing. Name is always available; Size/Modified
15+ * depend on the server emitting size/mtime (otherwise they sort nulls to the bottom). */
16+enum class FileSortKey { NAME, SIZE, MODIFIED }
17+
18+/**
19+ * Persisted file-browser display prefs (folders-first, show-hidden, sort key + direction,
20+ * changed-only filter) so a user who prefers size-sort or hidden-files-visible keeps it
21+ * across restarts, not just rotation.
22+ *
23+ * `open` with a `protected` no-arg constructor so tests can subclass and override the flows
24+ * without a real DataStore (mirrors the other stores). The flow accessors are getters (not
25+ * eager initializers) and null-guard the context so the test constructor never touches disk.
26+ */
27+@Suppress("TooManyFunctions")
28+open class FileBrowserPrefs private constructor(
29+ private val appContext: Context?,
30+ @Suppress("unused") private val testMode: Boolean,
31+) {
32+ constructor(context: Context) : this(context.applicationContext, false)
33+ protected constructor() : this(null, true)
34+
35+ private val foldersFirstKey = booleanPreferencesKey("folders_first")
36+ private val showHiddenKey = booleanPreferencesKey("show_hidden")
37+ private val sortKeyPref = stringPreferencesKey("sort_key")
38+ private val sortDescKey = booleanPreferencesKey("sort_desc")
39+ private val changedOnlyKey = booleanPreferencesKey("changed_only")
40+
41+ // Folders-first is the most useful default; the toggle flips between folders-first name
42+ // sort and the server's raw order.
43+ open val foldersFirst: Flow<Boolean>
44+ get() = appContext?.fileBrowserPrefsDataStore?.data?.map { it[foldersFirstKey] ?: true }
45+ ?: flowOf(true)
46+
47+ // Default off so the listing isn't cluttered with dotfiles; the user reveals .env / .git
48+ // / .opencode on demand.
49+ open val showHidden: Flow<Boolean>
50+ get() = appContext?.fileBrowserPrefsDataStore?.data?.map { it[showHiddenKey] ?: false }
51+ ?: flowOf(false)
52+
53+ // Stored as the enum name; an unknown value falls back to NAME (forward-compatible with a
54+ // future rename/removal of an enum entry).
55+ open val sortKey: Flow<FileSortKey>
56+ get() = appContext?.fileBrowserPrefsDataStore?.data?.map { prefs ->
57+ runCatching { FileSortKey.valueOf(prefs[sortKeyPref] ?: FileSortKey.NAME.name) }
58+ .getOrDefault(FileSortKey.NAME)
59+ } ?: flowOf(FileSortKey.NAME)
60+
61+ open val sortDesc: Flow<Boolean>
62+ get() = appContext?.fileBrowserPrefsDataStore?.data?.map { it[sortDescKey] ?: false }
63+ ?: flowOf(false)
64+
65+ // Default off: the changed-only filter narrows the listing to git-tracked changes, which is
66+ // useful when reviewing a diff but not the default browsing view.
67+ open val changedOnly: Flow<Boolean>
68+ get() = appContext?.fileBrowserPrefsDataStore?.data?.map { it[changedOnlyKey] ?: false }
69+ ?: flowOf(false)
70+
71+ open suspend fun setFoldersFirst(value: Boolean) {
72+ appContext?.fileBrowserPrefsDataStore?.edit { it[foldersFirstKey] = value }
73+ }
74+
75+ open suspend fun setShowHidden(value: Boolean) {
76+ appContext?.fileBrowserPrefsDataStore?.edit { it[showHiddenKey] = value }
77+ }
78+
79+ open suspend fun setSortKey(key: FileSortKey) {
80+ appContext?.fileBrowserPrefsDataStore?.edit { it[sortKeyPref] = key.name }
81+ }
82+
83+ open suspend fun setSortDesc(value: Boolean) {
84+ appContext?.fileBrowserPrefsDataStore?.edit { it[sortDescKey] = value }
85+ }
86+
87+ open suspend fun setChangedOnly(value: Boolean) {
88+ appContext?.fileBrowserPrefsDataStore?.edit { it[changedOnlyKey] = value }
89+ }
90+}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+2
@@ -29,6 +29,7 @@ import soy.iko.opencode.data.repo.AttachmentDraftStore
29import soy.iko.opencode.data.repo.BackupManager
29import soy.iko.opencode.data.repo.BackupManager
30import soy.iko.opencode.data.repo.DraftStore
30import soy.iko.opencode.data.repo.DraftStore
31import soy.iko.opencode.data.repo.ErrorKind
31import soy.iko.opencode.data.repo.ErrorKind
32+import soy.iko.opencode.data.repo.FileBrowserPrefs
32import soy.iko.opencode.data.repo.MessageCacheStore
33import soy.iko.opencode.data.repo.MessageCacheStore
33import soy.iko.opencode.data.repo.OutboxMessage
34import soy.iko.opencode.data.repo.OutboxMessage
34import soy.iko.opencode.data.repo.OutboxStore
35import soy.iko.opencode.data.repo.OutboxStore
@@ -117,6 +118,7 @@ open class AppContainer private constructor(
117 open val outboxStore: OutboxStore by lazy { OutboxStore(appContext!!) }
118 open val outboxStore: OutboxStore by lazy { OutboxStore(appContext!!) }
118 open val searchHistoryStore: SearchHistoryStore by lazy { SearchHistoryStore(appContext!!) }
119 open val searchHistoryStore: SearchHistoryStore by lazy { SearchHistoryStore(appContext!!) }
119 open val recentModelsStore: RecentModelsStore by lazy { RecentModelsStore(appContext!!) }
120 open val recentModelsStore: RecentModelsStore by lazy { RecentModelsStore(appContext!!) }
121+ open val fileBrowserPrefs: FileBrowserPrefs by lazy { FileBrowserPrefs(appContext!!) }
120 open val backupManager: BackupManager by lazy { BackupManager(profileStore, settingsStore, sessionPrefsStore) }
122 open val backupManager: BackupManager by lazy { BackupManager(profileStore, settingsStore, sessionPrefsStore) }
121
123
122 /** True while the outbox is actively flushing queued messages to the server, so the UI
124 /** True while the outbox is actively flushing queued messages to the server, so the UI
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+12−4
@@ -74,7 +74,7 @@ class RunForegroundService : Service() {
74 val contentText = progress ?: sessionTitle ?: getString(R.string.notif_running_text)
74 val contentText = progress ?: sessionTitle ?: getString(R.string.notif_running_text)
75 val builder = NotificationCompat.Builder(this, NotificationChannels.STATUS)
75 val builder = NotificationCompat.Builder(this, NotificationChannels.STATUS)
76 .setSmallIcon(R.drawable.ic_stat_notify)
76 .setSmallIcon(R.drawable.ic_stat_notify)
77− .setColor(BRAND_COLOR)
77+ .setColor(brandColor(this))
78 .setContentTitle(getString(R.string.notif_running_title))
78 .setContentTitle(getString(R.string.notif_running_title))
79 .setContentText(contentText)
79 .setContentText(contentText)
80 .setContentIntent(pendingIntent)
80 .setContentIntent(pendingIntent)
@@ -135,7 +135,7 @@ class RunForegroundService : Service() {
135 runCatching {
135 runCatching {
136 val notification = NotificationCompat.Builder(this, NotificationChannels.STATUS)
136 val notification = NotificationCompat.Builder(this, NotificationChannels.STATUS)
137 .setSmallIcon(R.drawable.ic_stat_notify)
137 .setSmallIcon(R.drawable.ic_stat_notify)
138− .setColor(BRAND_COLOR)
138+ .setColor(brandColor(this))
139 .setContentTitle(getString(R.string.notif_running_title))
139 .setContentTitle(getString(R.string.notif_running_title))
140 .setContentText(getString(R.string.notif_fg_timeout_text))
140 .setContentText(getString(R.string.notif_fg_timeout_text))
141 .setContentIntent(mainContentIntent())
141 .setContentIntent(mainContentIntent())
@@ -159,8 +159,16 @@ class RunForegroundService : Service() {
159 private const val TAG = "RunForegroundService"
159 private const val TAG = "RunForegroundService"
160 private const val NOTIF_ID = 1
160 private const val NOTIF_ID = 1
161 private const val NOTIF_TIMEOUT_ID = 2
161 private const val NOTIF_TIMEOUT_ID = 2
162− // Brand accent for the foreground notifications' small-icon tint circle.
162+ // Brand accent for the foreground notifications' small-icon tint circle. Resolved
163− private val BRAND_COLOR = 0xFF34548A.toInt()
163+ // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
164+ // instead of a hardcoded constant. Lazily computed on first use from the app context.
165+ private var brandColorCache: Int = 0
166+ private fun brandColor(context: Context): Int {
167+ if (brandColorCache == 0) {
168+ brandColorCache = androidx.core.content.ContextCompat.getColor(context, R.color.notif_brand)
169+ }
170+ return brandColorCache
171+ }
164 const val EXTRA_SESSION_TITLE = "soy.iko.opencode.extra.SESSION_TITLE"
172 const val EXTRA_SESSION_TITLE = "soy.iko.opencode.extra.SESSION_TITLE"
165 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
173 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
166 const val EXTRA_PROGRESS = "soy.iko.opencode.extra.PROGRESS"
174 const val EXTRA_PROGRESS = "soy.iko.opencode.extra.PROGRESS"
app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.kt+42
@@ -2,6 +2,7 @@ package soy.iko.opencode.ui.chat
2
2
3import androidx.compose.foundation.layout.Arrangement
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Box
4import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.fillMaxSize
7import androidx.compose.foundation.layout.fillMaxSize
7import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.fillMaxWidth
@@ -39,10 +40,17 @@ import androidx.compose.ui.window.Dialog
39import androidx.compose.ui.window.DialogProperties
40import androidx.compose.ui.window.DialogProperties
40import coil.compose.AsyncImage
41import coil.compose.AsyncImage
41import soy.iko.opencode.R
42import soy.iko.opencode.R
43+import soy.iko.opencode.data.network.NetworkConfig
44+import java.util.Locale
42
45
43/**
46/**
44 * The staged-attachment UI for the chat composer — extracted from ChatScreen so that (large)
47 * The staged-attachment UI for the chat composer — extracted from ChatScreen so that (large)
45 * file stays focused on the conversation surface. `internal` so ChatScreen can call it.
48 * file stays focused on the conversation surface. `internal` so ChatScreen can call it.
49+ *
50+ * When at least one attachment is staged, a muted total-size label is rendered at the end of
51+ * the strip so the user can see how close they are to the cumulative [NetworkConfig.maxTotalAttachmentBytes]
52+ * cap — a bare per-file cap doesn't surface the combined total, and a failed send is the only
53+ * other feedback.
46 */
54 */
47@Composable
55@Composable
48internal fun AttachmentStrip(
56internal fun AttachmentStrip(
@@ -61,6 +69,7 @@ internal fun AttachmentStrip(
61 .horizontalScroll(rememberScrollState())
69 .horizontalScroll(rememberScrollState())
62 .padding(start = 8.dp, end = 8.dp, top = 8.dp),
70 .padding(start = 8.dp, end = 8.dp, top = 8.dp),
63 horizontalArrangement = Arrangement.spacedBy(8.dp),
71 horizontalArrangement = Arrangement.spacedBy(8.dp),
72+ verticalAlignment = Alignment.CenterVertically,
64 ) {
73 ) {
65 attachments.forEach { att ->
74 attachments.forEach { att ->
66 AttachmentChip(att, onRemove = { onRemove(att.id) })
75 AttachmentChip(att, onRemove = { onRemove(att.id) })
@@ -68,7 +77,40 @@ internal fun AttachmentStrip(
68 // A placeholder chip with an indeterminate spinner while any pick is still being
77 // A placeholder chip with an indeterminate spinner while any pick is still being
69 // read + base64-encoded off the main thread (chips only materialize once done).
78 // read + base64-encoded off the main thread (chips only materialize once done).
70 if (staging) StagingChip(stagingFileCount)
79 if (staging) StagingChip(stagingFileCount)
80+ // Total staged size label — surfaced so the user can see how close they are to the
81+ // cumulative cap before a send fails. Hidden while staging (the total is in flux).
82+ if (!staging && attachments.isNotEmpty()) {
83+ val totalBytes = remember(attachments) {
84+ attachments.sumOf { base64DataUrlByteSize(it.part.url) }
85+ }
86+ val maxBytes = NetworkConfig.maxTotalAttachmentBytes
87+ val label = remember(totalBytes, maxBytes) { formatAttachmentTotal(totalBytes, maxBytes) }
88+ val over = totalBytes > maxBytes
89+ Text(
90+ label,
91+ style = MaterialTheme.typography.labelSmall,
92+ color = if (over) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
93+ modifier = Modifier.padding(start = 4.dp, end = 4.dp),
94+ )
95+ }
96+ }
97+}
98+
99+/** Format the staged-attachment total as "N.N MB / max" (or KB when small), for the strip label. */
100+private fun formatAttachmentTotal(bytes: Long, max: Long): String {
101+ val mb = 1024.0 * 1024.0
102+ val kb = 1024.0
103+ val used = when {
104+ bytes >= mb -> String.format(Locale.US, "%.1f MB", bytes / mb)
105+ bytes >= kb -> String.format(Locale.US, "%.0f KB", bytes / kb)
106+ else -> "$bytes B"
107+ }
108+ val maxStr = when {
109+ max >= mb -> String.format(Locale.US, "%.0f MB", max / mb)
110+ max >= kb -> String.format(Locale.US, "%.0f KB", max / kb)
111+ else -> "$max B"
71 }
112 }
113+ return "$used / $maxStr"
72}
114}
73
115
74/** A placeholder chip shown while an attachment is being encoded. */
116/** A placeholder chip shown while an attachment is being encoded. */
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+46−1
@@ -659,6 +659,8 @@ fun ChatScreen(
659 val loadingLabel = stringResource(R.string.loading)
659 val loadingLabel = stringResource(R.string.loading)
660 val shareLabel = stringResource(R.string.share_conversation)
660 val shareLabel = stringResource(R.string.share_conversation)
661 val copyAsMarkdownLabel = stringResource(R.string.copy_as_markdown)
661 val copyAsMarkdownLabel = stringResource(R.string.copy_as_markdown)
662+ val shareAsJsonLabel = stringResource(R.string.share_as_json)
663+ val copyAsJsonLabel = stringResource(R.string.copy_as_json)
662 val commandsLabel = stringResource(R.string.commands)
664 val commandsLabel = stringResource(R.string.commands)
663 val renameLabel = stringResource(R.string.rename_session_chat)
665 val renameLabel = stringResource(R.string.rename_session_chat)
664 val deleteLabel = stringResource(R.string.delete_session_chat)
666 val deleteLabel = stringResource(R.string.delete_session_chat)
@@ -710,6 +712,41 @@ fun ChatScreen(
710 }
712 }
711 },
713 },
712 )
714 )
715+ // Lossless JSON share: the full List<MessageWithParts> serialized with
716+ // the shared OpencodeJson, so tool outputs, file sources, and reasoning
717+ // survive verbatim (the Markdown export truncates/summarizes them).
718+ DropdownMenuItem(
719+ text = { Text(shareAsJsonLabel) },
720+ enabled = hasMessages,
721+ onClick = {
722+ showOverflowMenu = false
723+ scope.launch {
724+ val json = withContext(Dispatchers.Default) {
725+ buildConversationJson(vm.messages.value)
726+ }
727+ val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
728+ type = "application/json"
729+ putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
730+ putExtra(android.content.Intent.EXTRA_TEXT, json)
731+ }
732+ runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareAsJsonLabel)) }
733+ .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
734+ }
735+ },
736+ )
737+ DropdownMenuItem(
738+ text = { Text(copyAsJsonLabel) },
739+ enabled = hasMessages,
740+ onClick = {
741+ showOverflowMenu = false
742+ scope.launch {
743+ val json = withContext(Dispatchers.Default) {
744+ buildConversationJson(vm.messages.value)
745+ }
746+ copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_message), json)
747+ }
748+ },
749+ )
713 DropdownMenuItem(
750 DropdownMenuItem(
714 text = { Text(commandsLabel) },
751 text = { Text(commandsLabel) },
715 enabled = !commandsLoading || commands.isNotEmpty(),
752 enabled = !commandsLoading || commands.isNotEmpty(),
@@ -1464,9 +1501,17 @@ fun ChatScreen(
1464 // start on the real false→true transition (send/run/init), not on
1501 // start on the real false→true transition (send/run/init), not on
1465 // SSE-reconnect relight, so reconnects don't restart the clock. Updates
1502 // SSE-reconnect relight, so reconnects don't restart the clock. Updates
1466 // once per second; the value is decorative (the a11y label is the row's).
1503 // once per second; the value is decorative (the a11y label is the row's).
1504+ // The clock freezes when an abort is confirmed (aborting = true) so the
1505+ // user can see how long the run lasted before they stopped it, rather
1506+ // than the clock continuing to tick until SessionIdle clears _running.
1467 val startMs by vm.runStartMs.collectAsStateWithLifecycle()
1507 val startMs by vm.runStartMs.collectAsStateWithLifecycle()
1468− val elapsedMs by produceState(0L, startMs) {
1508+ val elapsedMs by produceState(0L, startMs, aborting) {
1469 if (startMs == 0L) return@produceState
1509 if (startMs == 0L) return@produceState
1510+ if (aborting) {
1511+ // Freeze at the last-computed value: one final snapshot then stop.
1512+ value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1513+ return@produceState
1514+ }
1470 while (true) {
1515 while (true) {
1471 value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1516 value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1472 delay(1000)
1517 delay(1000)
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+28
@@ -12,10 +12,38 @@ import soy.iko.opencode.data.model.ToolRunning
12import soy.iko.opencode.data.model.UserMessage
12import soy.iko.opencode.data.model.UserMessage
13import soy.iko.opencode.data.model.sourcePath
13import soy.iko.opencode.data.model.sourcePath
14import soy.iko.opencode.data.network.NetworkConfig
14import soy.iko.opencode.data.network.NetworkConfig
15+import soy.iko.opencode.data.network.OpencodeJson
16+import kotlinx.serialization.builtins.ListSerializer
15import java.text.SimpleDateFormat
17import java.text.SimpleDateFormat
16import java.util.Date
18import java.util.Date
17import java.util.Locale
19import java.util.Locale
18
20
21+/**
22+ * Render a conversation as a lossless JSON document for sharing/exporting. Unlike
23+ * [buildConversationMarkdown], this preserves the full fidelity of the server's
24+ * message stream: tool outputs are not truncated, file parts keep their source
25+ * objects and data URLs, reasoning keeps its raw text, and the polymorphic
26+ * `type`/`role` discriminators are retained. That makes the JSON export suitable
27+ * for re-importing into another client, for auditing exactly what the agent did,
28+ * and for bug reports where the Markdown summary would have dropped detail.
29+ *
30+ * Uses the shared [OpencodeJson] so the on-the-wire shape round-trips — the same
31+ * serializer that decoded the messages re-encodes them, including the `Unknown*`
32+ * fallback variants for parts/roles the client doesn't otherwise model.
33+ */
34+fun buildConversationJson(messages: List<MessageWithParts>): String =
35+ try {
36+ // Explicit serializer (mirrors MessageCacheStore) rather than the reified
37+ // encodeToString overload, so the polymorphic Part/MessageInfo discriminators
38+ // registered in OpencodeJson are exercised on the way back out.
39+ OpencodeJson.encodeToString(ListSerializer(MessageWithParts.serializer()), messages)
40+ } catch (e: Throwable) {
41+ // Fall back to an explicit error document rather than crashing the share
42+ // flow; a serialization failure here is most likely a model invariant bug
43+ // and a silent empty string would be harder to diagnose than this stub.
44+ "{\"error\":\"conversation_json_export_failed\",\"message\":\"${e.message ?: e.javaClass.simpleName}\"}"
45+ }
46+
19/**
47/**
20 * Render a conversation as Markdown for sharing/exporting. Each message becomes a
48 * Render a conversation as Markdown for sharing/exporting. Each message becomes a
21 * role-prefixed section containing its text parts; reasoning is exported as a blockquote
49 * role-prefixed section containing its text parts; reasoning is exported as a blockquote
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+65−5
@@ -30,6 +30,7 @@ import androidx.compose.material.icons.filled.Restore
30import androidx.compose.material.icons.filled.Share
30import androidx.compose.material.icons.filled.Share
31import androidx.compose.material.icons.filled.SmartToy
31import androidx.compose.material.icons.filled.SmartToy
32import androidx.compose.material.icons.filled.Stop
32import androidx.compose.material.icons.filled.Stop
33+import androidx.compose.material.icons.filled.Code
33import androidx.compose.material3.DropdownMenu
34import androidx.compose.material3.DropdownMenu
34import androidx.compose.material3.DropdownMenuItem
35import androidx.compose.material3.DropdownMenuItem
35import androidx.compose.material3.CircularProgressIndicator
36import androidx.compose.material3.CircularProgressIndicator
@@ -147,6 +148,7 @@ private fun MessageLongPressMenu(
147 onRegenerate: (() -> Unit)? = null,
148 onRegenerate: (() -> Unit)? = null,
148 onSpeak: (() -> Unit)? = null,
149 onSpeak: (() -> Unit)? = null,
149 speakLabel: String? = null,
150 speakLabel: String? = null,
151+ onViewSource: (() -> Unit)? = null,
150) {
152) {
151 DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = offset) {
153 DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = offset) {
152 if (text != null && onCopy != null) {
154 if (text != null && onCopy != null) {
@@ -207,6 +209,16 @@ private fun MessageLongPressMenu(
207 onClick = { onDismiss(); onBranch(text) },
209 onClick = { onDismiss(); onBranch(text) },
208 )
210 )
209 }
211 }
212+ // View source: show the raw markdown of the message's TextParts in a dialog, so a user can
213+ // inspect malformed tables/rendering or copy the exact source. Gated on text so an
214+ // image-only message doesn't show a no-op entry.
215+ if (text != null && onViewSource != null) {
216+ DropdownMenuItem(
217+ leadingIcon = { Icon(Icons.Filled.Code, contentDescription = null) },
218+ text = { Text(stringResource(R.string.view_source)) },
219+ onClick = { onDismiss(); onViewSource() },
220+ )
221+ }
210 if (onRevert != null) {
222 if (onRevert != null) {
211 DropdownMenuItem(
223 DropdownMenuItem(
212 leadingIcon = { Icon(Icons.Filled.Restore, contentDescription = null) },
224 leadingIcon = { Icon(Icons.Filled.Restore, contentDescription = null) },
@@ -255,7 +267,7 @@ private fun UserMessageLongPressMenu(
255 )
267 )
256}
268}
257
269
258−/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
270+/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session, view source).
259 * Shown when at least one action is available and there's text to act on. Keeps the
271 * Shown when at least one action is available and there's text to act on. Keeps the
260 * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
272 * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
261@Composable
273@Composable
@@ -263,8 +275,9 @@ private fun MessageOverflow(
263 text: String,
275 text: String,
264 onQuote: ((String) -> Unit)?,
276 onQuote: ((String) -> Unit)?,
265 onBranch: ((String) -> Unit)?,
277 onBranch: ((String) -> Unit)?,
278+ onViewSource: (() -> Unit)? = null,
266) {
279) {
267− if (onQuote == null && onBranch == null) return
280+ if (onQuote == null && onBranch == null && onViewSource == null) return
268 var expanded by remember { mutableStateOf(false) }
281 var expanded by remember { mutableStateOf(false) }
269 val moreLabel = stringResource(R.string.message_actions)
282 val moreLabel = stringResource(R.string.message_actions)
270 val haptics = LocalHapticFeedback.current
283 val haptics = LocalHapticFeedback.current
@@ -300,6 +313,17 @@ private fun MessageOverflow(
300 },
313 },
301 )
314 )
302 }
315 }
316+ onViewSource?.let { viewSource ->
317+ DropdownMenuItem(
318+ leadingIcon = { Icon(Icons.Filled.Code, contentDescription = null) },
319+ text = { Text(stringResource(R.string.view_source)) },
320+ onClick = {
321+ expanded = false
322+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
323+ viewSource()
324+ },
325+ )
326+ }
303 }
327 }
304 }
328 }
305}
329}
@@ -333,7 +357,14 @@ private fun UnknownMessageBlock(
333) {
357) {
334 // Forward-compat: a role the client doesn't model. Render a muted note so the user
358 // Forward-compat: a role the client doesn't model. Render a muted note so the user
335 // sees *something* rather than an unlabeled block, plus any parts (e.g. text) the
359 // sees *something* rather than an unlabeled block, plus any parts (e.g. text) the
336− // server attached, so content isn't silently dropped.
360+ // server attached, so content isn't silently dropped. A copy action is wired so a user
361+ // can at least extract the content (e.g. to report a new server role's payload).
362+ val context = androidx.compose.ui.platform.LocalContext.current
363+ val textToCopy = remember(message.parts) {
364+ message.parts.filterIsInstance<soy.iko.opencode.data.model.TextPart>()
365+ .joinToString("\n\n") { it.text }
366+ .takeIf { it.isNotBlank() }
367+ }
337 Column(
368 Column(
338 modifier = modifier.fillMaxWidth(),
369 modifier = modifier.fillMaxWidth(),
339 verticalArrangement = Arrangement.spacedBy(8.dp),
370 verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -351,6 +382,18 @@ private fun UnknownMessageBlock(
351 color = MaterialTheme.colorScheme.onSurfaceVariant,
382 color = MaterialTheme.colorScheme.onSurfaceVariant,
352 modifier = Modifier.padding(start = 6.dp),
383 modifier = Modifier.padding(start = 6.dp),
353 )
384 )
385+ if (textToCopy != null) {
386+ IconButton(onClick = {
387+ copyToClipboard(context, context.getString(R.string.clip_label_message), textToCopy)
388+ }) {
389+ Icon(
390+ Icons.Filled.ContentCopy,
391+ contentDescription = stringResource(R.string.copy),
392+ modifier = Modifier.size(16.dp),
393+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
394+ )
395+ }
396+ }
354 }
397 }
355 // key() per part so each PartView gets a distinct saveable registry slot: rememberSaveable
398 // key() per part so each PartView gets a distinct saveable registry slot: rememberSaveable
356 // inside PartView keys its RESET on part.id but its registry key is the positional
399 // inside PartView keys its RESET on part.id but its registry key is the positional
@@ -647,6 +690,9 @@ private fun AssistantBlock(
647 val assistantDensity = androidx.compose.ui.platform.LocalDensity.current
690 val assistantDensity = androidx.compose.ui.platform.LocalDensity.current
648 var longPressMenu by remember { mutableStateOf(false) }
691 var longPressMenu by remember { mutableStateOf(false) }
649 var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
692 var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
693+ // View-source dialog state: shown from the long-press menu's "View source" entry to display
694+ // the raw markdown of the message's TextParts.
695+ var showSource by remember { mutableStateOf(false) }
650 // A11y: announce the speaker role so TalkBack reads "Assistant, <text>" instead of
696 // A11y: announce the speaker role so TalkBack reads "Assistant, <text>" instead of
651 // only the model/agent label — giving a screen-reader user an explicit role signal
697 // only the model/agent label — giving a screen-reader user an explicit role signal
652 // matching the user bubble's "You" prefix.
698 // matching the user bubble's "You" prefix.
@@ -773,6 +819,7 @@ private fun AssistantBlock(
773 onBranch = onBranch,
819 onBranch = onBranch,
774 onRegenerate = onRegenerate,
820 onRegenerate = onRegenerate,
775 onContinue = onContinue,
821 onContinue = onContinue,
822+ onViewSource = textToCopy?.let { { showSource = true } },
776 )
823 )
777 }
824 }
778 AssistantMessageLongPressMenu(
825 AssistantMessageLongPressMenu(
@@ -791,8 +838,15 @@ private fun AssistantBlock(
791 ttsState = ttsState,
838 ttsState = ttsState,
792 onPause = onPause,
839 onPause = onPause,
793 onResume = onResume,
840 onResume = onResume,
841+ onViewSource = textToCopy?.let { { showSource = true } },
794 )
842 )
795 }
843 }
844+ if (showSource && textToCopy != null) {
845+ ViewSourceDialog(
846+ source = textToCopy,
847+ onDismiss = { showSource = false },
848+ )
849+ }
796 } else {
850 } else {
797 MessageTimestampText(message.info)
851 MessageTimestampText(message.info)
798 }
852 }
@@ -819,6 +873,7 @@ private fun AssistantMessageLongPressMenu(
819 ttsState: TtsState,
873 ttsState: TtsState,
820 onPause: (() -> Unit)?,
874 onPause: (() -> Unit)?,
821 onResume: (() -> Unit)?,
875 onResume: (() -> Unit)?,
876+ onViewSource: (() -> Unit)? = null,
822) {
877) {
823 val speakForMenu: (() -> Unit)? = if (onSpeak != null && text != null) {
878 val speakForMenu: (() -> Unit)? = if (onSpeak != null && text != null) {
824 {
879 {
@@ -855,6 +910,7 @@ private fun AssistantMessageLongPressMenu(
855 onRegenerate = onRegenerate,
910 onRegenerate = onRegenerate,
856 onSpeak = speakForMenu,
911 onSpeak = speakForMenu,
857 speakLabel = speakLabel,
912 speakLabel = speakLabel,
913+ onViewSource = onViewSource,
858 )
914 )
859}
915}
860
916
@@ -917,6 +973,7 @@ private fun AssistantActions(
917 onBranch: ((String) -> Unit)? = null,
973 onBranch: ((String) -> Unit)? = null,
918 onRegenerate: (() -> Unit)? = null,
974 onRegenerate: (() -> Unit)? = null,
919 onContinue: (() -> Unit)? = null,
975 onContinue: (() -> Unit)? = null,
976+ onViewSource: (() -> Unit)? = null,
920) {
977) {
921 val context = LocalContext.current
978 val context = LocalContext.current
922 val haptics = LocalHapticFeedback.current
979 val haptics = LocalHapticFeedback.current
@@ -986,8 +1043,11 @@ private fun AssistantActions(
986 }
1043 }
987 }
1044 }
988 // Assistant replies can be quoted into the composer or branched into a new session.
1045 // Assistant replies can be quoted into the composer or branched into a new session.
1046+ // View source is also available here for assistant replies (in addition to the long-press
1047+ // menu) so the action is discoverable without a long-press. MessageOverflow itself
1048+ // no-ops when all callbacks are null, so the guard only needs to check text.
989− if (textToCopy != null && (onQuote != null || onBranch != null)) {
1049+ if (textToCopy != null) {
990− MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = onBranch)
1050+ MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = onBranch, onViewSource = onViewSource)
991 }
1051 }
992 }
1052 }
993}
1053}
app/src/main/java/soy/iko/opencode/ui/chat/ViewSourceDialog.ktnew file+76
@@ -0,0 +1,76 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
6+import androidx.compose.foundation.layout.fillMaxHeight
7+import androidx.compose.foundation.layout.fillMaxWidth
8+import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.rememberScrollState
10+import androidx.compose.foundation.text.selection.SelectionContainer
11+import androidx.compose.foundation.verticalScroll
12+import androidx.compose.material3.MaterialTheme
13+import androidx.compose.material3.Surface
14+import androidx.compose.material3.Text
15+import androidx.compose.material3.TextButton
16+import androidx.compose.runtime.Composable
17+import androidx.compose.ui.Alignment
18+import androidx.compose.ui.Modifier
19+import androidx.compose.ui.text.font.FontFamily
20+import androidx.compose.ui.res.stringResource
21+import androidx.compose.ui.unit.dp
22+import androidx.compose.ui.window.Dialog
23+import androidx.compose.ui.window.DialogProperties
24+import soy.iko.opencode.R
25+
26+/**
27+ * A fullscreen-ish dialog showing the raw markdown source of a message's TextParts, so a user
28+ * can inspect malformed tables/rendering or copy the exact source. The text is monospace,
29+ * scrollable, and wrapped in a [SelectionContainer] so a portion can be selected and copied.
30+ * Dismissed via the Close button, back, or an outside tap.
31+ */
32+@Composable
33+fun ViewSourceDialog(
34+ source: String,
35+ onDismiss: () -> Unit,
36+) {
37+ Dialog(
38+ onDismissRequest = onDismiss,
39+ properties = DialogProperties(usePlatformDefaultWidth = false),
40+ ) {
41+ Surface(
42+ shape = MaterialTheme.shapes.large,
43+ color = MaterialTheme.colorScheme.surface,
44+ tonalElevation = 6.dp,
45+ modifier = Modifier.fillMaxHeight(0.9f).fillMaxWidth(),
46+ ) {
47+ Column(
48+ modifier = Modifier.fillMaxWidth().padding(16.dp),
49+ verticalArrangement = Arrangement.spacedBy(12.dp),
50+ ) {
51+ Row(
52+ modifier = Modifier.fillMaxWidth(),
53+ horizontalArrangement = Arrangement.SpaceBetween,
54+ verticalAlignment = Alignment.CenterVertically,
55+ ) {
56+ Text(
57+ stringResource(R.string.message_source),
58+ style = MaterialTheme.typography.titleMedium,
59+ )
60+ TextButton(onClick = onDismiss) {
61+ Text(stringResource(R.string.close))
62+ }
63+ }
64+ SelectionContainer {
65+ Text(
66+ text = source,
67+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
68+ modifier = Modifier
69+ .fillMaxWidth()
70+ .verticalScroll(rememberScrollState()),
71+ )
72+ }
73+ }
74+ }
75+ }
76+}
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+4−1
@@ -28,6 +28,7 @@ import androidx.compose.ui.semantics.contentDescription
28import androidx.compose.ui.semantics.liveRegion
28import androidx.compose.ui.semantics.liveRegion
29import androidx.compose.ui.semantics.LiveRegionMode
29import androidx.compose.ui.semantics.LiveRegionMode
30import androidx.compose.ui.semantics.semantics
30import androidx.compose.ui.semantics.semantics
31+import androidx.compose.ui.platform.testTag
31import androidx.compose.ui.text.style.TextOverflow
32import androidx.compose.ui.text.style.TextOverflow
32import androidx.compose.ui.unit.dp
33import androidx.compose.ui.unit.dp
33import androidx.lifecycle.compose.collectAsStateWithLifecycle
34import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -153,7 +154,9 @@ fun ConnectionBanner(
153 TextButton(
154 TextButton(
154 onClick = onRetry,
155 onClick = onRetry,
155 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
156 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
157+ modifier = Modifier
156− modifier = Modifier.defaultMinSize(minHeight = 48.dp),
158+ .defaultMinSize(minHeight = 48.dp)
159+ .testTag("connection_retry"),
157 ) {
160 ) {
158 Text(stringResource(R.string.retry_now), color = onContainer)
161 Text(stringResource(R.string.retry_now), color = onContainer)
159 }
162 }
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+129−22
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
12import androidx.compose.foundation.layout.height
12import androidx.compose.foundation.layout.height
13import androidx.compose.foundation.layout.defaultMinSize
13import androidx.compose.foundation.layout.defaultMinSize
14import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.padding
15+import androidx.compose.foundation.layout.size
15import androidx.compose.foundation.layout.width
16import androidx.compose.foundation.layout.width
16import androidx.compose.foundation.lazy.LazyColumn
17import androidx.compose.foundation.lazy.LazyColumn
17import androidx.compose.foundation.lazy.itemsIndexed
18import androidx.compose.foundation.lazy.itemsIndexed
@@ -40,6 +41,7 @@ import androidx.compose.ui.platform.LocalContext
40import androidx.compose.ui.semantics.contentDescription
41import androidx.compose.ui.semantics.contentDescription
41import androidx.compose.ui.semantics.heading
42import androidx.compose.ui.semantics.heading
42import androidx.compose.ui.semantics.semantics
43import androidx.compose.ui.semantics.semantics
44+import androidx.compose.ui.semantics.stateDescription
43import androidx.compose.ui.text.AnnotatedString
45import androidx.compose.ui.text.AnnotatedString
44import androidx.compose.ui.text.font.FontFamily
46import androidx.compose.ui.text.font.FontFamily
45import androidx.compose.ui.text.font.FontWeight
47import androidx.compose.ui.text.font.FontWeight
@@ -181,6 +183,11 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
181 // don't reset expand state on every token. Fall back to `diff` for non-streaming callers
183 // don't reset expand state on every token. Fall back to `diff` for non-streaming callers
182 // (e.g. FileViewScreen) where the content is static.
184 // (e.g. FileViewScreen) where the content is static.
183 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
185 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
186+ // Per-hunk collapse state: a set of hunk indices the user has collapsed via its `@@`
187+ // header. Keyed on the hunk's ordinal position in `lines` so two hunks with the same
188+ // `@@` range text (rare but possible) are still distinct toggles. The KDoc promised this
189+ // affordance; it's now implemented.
190+ val collapsedHunks = remember { mutableStateOf(emptySet<Int>()) }
184 // Resolve a syntax for highlighting from the first file header (if any). Memoized so a
191 // Resolve a syntax for highlighting from the first file header (if any). Memoized so a
185 // streaming re-parse doesn't re-resolve per token.
192 // streaming re-parse doesn't re-resolve per token.
186 val syntax = remember(lines) {
193 val syntax = remember(lines) {
@@ -200,8 +207,17 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
200 onToggle = { expanded = !expanded },
207 onToggle = { expanded = !expanded },
201 onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
208 onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
202 )
209 )
210+ // Apply the global collapse threshold first, then drop lines inside any per-hunk
211+ // collapse. A collapsed hunk's `@@` header is always kept (so the user has a handle
212+ // to re-expand it); its content lines (context/add/remove) are skipped until the
213+ // next hunk or end-of-list.
203− val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
214+ val thresholdCollapsed = lines.size > NetworkConfig.collapsedDiffLineThreshold && !expanded
204− else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
215+ val baseLines = if (thresholdCollapsed) lines.subList(0, NetworkConfig.collapsedDiffLineThreshold) else lines
216+ val hunkIndices = remember(lines) { hunkStartIndices(lines) }
217+ val visibleLines = remember(baseLines, collapsedHunks.value) {
218+ if (collapsedHunks.value.isEmpty()) baseLines
219+ else filterCollapsedHunks(baseLines, hunkIndices, collapsedHunks.value)
220+ }
205 // One horizontalScroll on the container instead of one per row: each modifier
221 // One horizontalScroll on the container instead of one per row: each modifier
206 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
222 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
207 // paying for 200 of them. The shared scroll state still synchronizes all rows.
223 // paying for 200 of them. The shared scroll state still synchronizes all rows.
@@ -213,7 +229,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
213 )
229 )
214 }
230 }
215 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
231 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
216− rows.forEach { row -> RenderDiffRow(row, syntax, palette) }
232+ rows.forEach { row -> RenderDiffRow(row, syntax, palette, collapsedHunks) }
217 }
233 }
218 DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
234 DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
219 }
235 }
@@ -238,6 +254,7 @@ fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? =
238 val context = LocalContext.current
254 val context = LocalContext.current
239 val hScrollState = rememberScrollState()
255 val hScrollState = rememberScrollState()
240 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
256 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
257+ val collapsedHunks = remember { mutableStateOf(emptySet<Int>()) }
241 val syntax = remember(lines) {
258 val syntax = remember(lines) {
242 lines.firstOrNull { it is DiffLine.FileHeader }?.let { syntaxForHeader(it.text) }
259 lines.firstOrNull { it is DiffLine.FileHeader }?.let { syntaxForHeader(it.text) }
243 }
260 }
@@ -256,8 +273,13 @@ fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? =
256 onToggle = { expanded = !expanded },
273 onToggle = { expanded = !expanded },
257 onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
274 onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
258 )
275 )
259− val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
276+ val thresholdCollapsed = lines.size > NetworkConfig.collapsedDiffLineThreshold && !expanded
260− else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
277+ val baseLines = if (thresholdCollapsed) lines.subList(0, NetworkConfig.collapsedDiffLineThreshold) else lines
278+ val hunkIndices = remember(lines) { hunkStartIndices(lines) }
279+ val visibleLines = remember(baseLines, collapsedHunks.value) {
280+ if (collapsedHunks.value.isEmpty()) baseLines
281+ else filterCollapsedHunks(baseLines, hunkIndices, collapsedHunks.value)
282+ }
261 val rows = remember(visibleLines, addColor, removeColor, addText, removeText, scheme) {
283 val rows = remember(visibleLines, addColor, removeColor, addText, removeText, scheme) {
262 buildDiffRows(
284 buildDiffRows(
263 visibleLines,
285 visibleLines,
@@ -276,7 +298,7 @@ fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? =
276 rows,
298 rows,
277 key = { i, row -> "${row.kind::class.simpleName}:$i:${row.text.hashCode()}" },
299 key = { i, row -> "${row.kind::class.simpleName}:$i:${row.text.hashCode()}" },
278 ) { _, row ->
300 ) { _, row ->
279− RenderDiffRow(row, syntax, palette)
301+ RenderDiffRow(row, syntax, palette, collapsedHunks)
280 }
302 }
281 }
303 }
282 DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
304 DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
@@ -329,9 +351,53 @@ private fun DiffExpandFooter(lines: List<DiffLine>, expanded: Boolean, onToggle:
329 }
351 }
330}
352}
331
353
354+/** Map each hunk ordinal (0, 1, 2, …) to its index in [lines]. Used to find the boundaries
355+ * of a collapsed hunk: a hunk spans from its start index up to the next hunk's start (or the
356+ * end of the list). File headers and meta lines before the first hunk are not hunk members. */
357+private fun hunkStartIndices(lines: List<DiffLine>): Map<Int, Int> {
358+ val map = LinkedHashMap<Int, Int>()
359+ var hunkOrdinal = 0
360+ lines.forEachIndexed { i, line ->
361+ if (line is DiffLine.Hunk) {
362+ map[hunkOrdinal] = i
363+ hunkOrdinal++
364+ }
365+ }
366+ return map
367+}
368+
369+/** Drop the content lines (Context/Add/Remove/Meta-after-hunk) of each collapsed hunk while
370+ * keeping the hunk's own `@@` header row so the user has a handle to re-expand. A line is
371+ * dropped when its [DiffRowItem.hunkIndex] is in [collapsed] AND it's not the Hunk header
372+ * itself (the header carries the same hunkIndex but its kind is Hunk, which we keep). */
373+private fun filterCollapsedHunks(
374+ baseLines: List<DiffLine>,
375+ hunkIndices: Map<Int, Int>,
376+ collapsed: Set<Int>,
377+): List<DiffLine> {
378+ if (collapsed.isEmpty()) return baseLines
379+ // For each collapsed hunk, compute [start, end) where end is the next hunk's start or the
380+ // list size. We keep the header (at start) and drop the rest of the range.
381+ val dropRanges = collapsed.mapNotNull { ordinal ->
382+ val start = hunkIndices[ordinal] ?: return@mapNotNull null
383+ val end = hunkIndices.entries
384+ .firstOrNull { it.value > start }?.value ?: baseLines.size
385+ (start + 1) until end
386+ }
387+ val dropSet = dropRanges.flatMap { it }.toHashSet()
388+ return baseLines.filterIndexed { i, _ -> i !in dropSet }
389+}
390+
391+/** Cached labels for the per-hunk collapse `stateDescription` (resolved once per composition
392+ * rather than per row to avoid a per-row string lookup on a potentially huge diff). */
393+private val stateCollapsedLabel: String get() = "Collapsed"
394+private val stateExpandedLabel: String get() = "Expanded"
395+
332/** A pre-resolved diff row carrying its kind, text, line numbers, and resolved colors. This
396/** A pre-resolved diff row carrying its kind, text, line numbers, and resolved colors. This
333 * lets both [DiffView] and [LazyDiffView] share the rendering path without re-deriving the
397 * lets both [DiffView] and [LazyDiffView] share the rendering path without re-deriving the
334− * per-row state (line numbers advance as a side effect of walking the list). */
398+ * per-row state (line numbers advance as a side effect of walking the list). [hunkIndex] is
399+ * the ordinal of the hunk this row belongs to (null for pre-hunk file headers/meta), used
400+ * by [RenderDiffRow] to toggle per-hunk collapse. */
335private data class DiffRowItem(
401private data class DiffRowItem(
336 val kind: DiffLine,
402 val kind: DiffLine,
337 val text: String,
403 val text: String,
@@ -339,6 +405,7 @@ private data class DiffRowItem(
339 val textColor: Color,
405 val textColor: Color,
340 val oldLine: Int?,
406 val oldLine: Int?,
341 val newLine: Int?,
407 val newLine: Int?,
408+ val hunkIndex: Int?,
342)
409)
343
410
344/** Walk [visibleLines] advancing the old/new line counters per hunk, producing a list of
411/** Walk [visibleLines] advancing the old/new line counters per hunk, producing a list of
@@ -358,47 +425,87 @@ private fun buildDiffRows(
358): List<DiffRowItem> {
425): List<DiffRowItem> {
359 var oldLine = 0
426 var oldLine = 0
360 var newLine = 0
427 var newLine = 0
428+ var hunkIndex = -1
361 val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
429 val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
362 return visibleLines.map { line ->
430 return visibleLines.map { line ->
363 when (line) {
431 when (line) {
364 is DiffLine.Hunk -> {
432 is DiffLine.Hunk -> {
433+ hunkIndex++
365 val m = hunkRegex.find(line.text)
434 val m = hunkRegex.find(line.text)
366 if (m != null) {
435 if (m != null) {
367 oldLine = m.groupValues[1].toInt()
436 oldLine = m.groupValues[1].toInt()
368 newLine = m.groupValues[2].toInt()
437 newLine = m.groupValues[2].toInt()
369 }
438 }
370− DiffRowItem(line, line.text, Color.Transparent, tertiary, null, null)
439+ DiffRowItem(line, line.text, Color.Transparent, tertiary, null, null, hunkIndex)
371 }
440 }
372− is DiffLine.FileHeader -> DiffRowItem(line, line.text, Color.Transparent, onSurface, null, null)
441+ is DiffLine.FileHeader -> DiffRowItem(line, line.text, Color.Transparent, onSurface, null, null, null)
373− is DiffLine.Meta -> DiffRowItem(line, line.text, Color.Transparent, onSurfaceVariant, null, null)
442+ is DiffLine.Meta -> DiffRowItem(line, line.text, Color.Transparent, onSurfaceVariant, null, null, null)
374 is DiffLine.Add -> {
443 is DiffLine.Add -> {
375 val n = newLine++
444 val n = newLine++
376− DiffRowItem(line, line.text, addColor, addText, null, n)
445+ DiffRowItem(line, line.text, addColor, addText, null, n, hunkIndex)
377 }
446 }
378 is DiffLine.Remove -> {
447 is DiffLine.Remove -> {
379 val o = oldLine++
448 val o = oldLine++
380− DiffRowItem(line, line.text, removeColor, removeText, o, null)
449+ DiffRowItem(line, line.text, removeColor, removeText, o, null, hunkIndex)
381 }
450 }
382 is DiffLine.Context -> {
451 is DiffLine.Context -> {
383 val o = oldLine++; val n = newLine++
452 val o = oldLine++; val n = newLine++
384− DiffRowItem(line, line.text, Color.Transparent, onSurface, o, n)
453+ DiffRowItem(line, line.text, Color.Transparent, onSurface, o, n, hunkIndex)
385 }
454 }
386 }
455 }
387 }
456 }
388}
457}
389
458
390/** Render a single [DiffRowItem]. Hunk and FileHeader rows get their own styling; Add/Remove/
459/** Render a single [DiffRowItem]. Hunk and FileHeader rows get their own styling; Add/Remove/
391− * Context delegate to [DiffRow]. */
460+ * Context delegate to [DiffRow]. The Hunk row is clickable to toggle per-hunk collapse via
461+ * [collapsedHunks]. */
392@Composable
462@Composable
393−private fun RenderDiffRow(row: DiffRowItem, syntax: FileSyntax?, palette: HighlightPalette) {
463+private fun RenderDiffRow(
464+ row: DiffRowItem,
465+ syntax: FileSyntax?,
466+ palette: HighlightPalette,
467+ collapsedHunks: androidx.compose.runtime.MutableState<Set<Int>>,
468+) {
394 when (row.kind) {
469 when (row.kind) {
395− is DiffLine.Hunk -> Text(
470+ is DiffLine.Hunk -> {
471+ val idx = row.hunkIndex
472+ val isCollapsed = idx != null && idx in collapsedHunks.value
473+ val collapseLabel = stringResource(if (isCollapsed) R.string.show_more else R.string.show_less)
474+ Row(
475+ modifier = Modifier
476+ .fillMaxWidth()
477+ .defaultMinSize(minHeight = 32.dp)
478+ .clickable(enabled = idx != null) {
479+ if (idx != null) {
480+ collapsedHunks.value = if (idx in collapsedHunks.value) {
481+ collapsedHunks.value - idx
482+ } else {
483+ collapsedHunks.value + idx
484+ }
485+ }
486+ }
487+ .padding(horizontal = 10.dp, vertical = 2.dp)
488+ .semantics {
489+ contentDescription = collapseLabel
490+ stateDescription = if (isCollapsed) stateCollapsedLabel else stateExpandedLabel
491+ },
492+ verticalAlignment = Alignment.CenterVertically,
493+ ) {
494+ Icon(
495+ if (isCollapsed) Icons.Filled.ExpandMore else Icons.Filled.ExpandLess,
496+ contentDescription = null,
497+ modifier = Modifier.size(16.dp),
498+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
499+ )
500+ Text(
396− row.text,
501+ row.text,
397− style = MaterialTheme.typography.bodySmall,
502+ style = MaterialTheme.typography.bodySmall,
398− fontFamily = FontFamily.Monospace,
503+ fontFamily = FontFamily.Monospace,
399− color = MaterialTheme.colorScheme.tertiary,
504+ color = MaterialTheme.colorScheme.tertiary,
400− modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
505+ modifier = Modifier.padding(start = 4.dp),
401− )
506+ )
507+ }
508+ }
402 is DiffLine.FileHeader -> {
509 is DiffLine.FileHeader -> {
403 val displayPath = extractDisplayPath(row.text)
510 val displayPath = extractDisplayPath(row.text)
404 val isHeader = displayPath != row.text
511 val isHeader = displayPath != row.text
app/src/main/java/soy/iko/opencode/ui/components/EmptyState.kt+29−1
@@ -2,16 +2,19 @@ package soy.iko.opencode.ui.components
2
2
3import androidx.compose.foundation.layout.Arrangement
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.padding
6import androidx.compose.foundation.layout.padding
6import androidx.compose.foundation.layout.size
7import androidx.compose.foundation.layout.size
7import androidx.compose.material3.Button
8import androidx.compose.material3.Button
8import androidx.compose.material3.Icon
9import androidx.compose.material3.Icon
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.MaterialTheme
11+import androidx.compose.material3.OutlinedButton
10import androidx.compose.material3.Text
12import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
13import androidx.compose.runtime.Composable
12import androidx.compose.ui.Alignment
14import androidx.compose.ui.Alignment
13import androidx.compose.ui.Modifier
15import androidx.compose.ui.Modifier
14import androidx.compose.ui.graphics.vector.ImageVector
16import androidx.compose.ui.graphics.vector.ImageVector
17+import androidx.compose.ui.platform.testTag
15import androidx.compose.ui.semantics.heading
18import androidx.compose.ui.semantics.heading
16import androidx.compose.ui.semantics.semantics
19import androidx.compose.ui.semantics.semantics
17import androidx.compose.ui.text.style.TextAlign
20import androidx.compose.ui.text.style.TextAlign
@@ -21,6 +24,10 @@ import androidx.compose.ui.unit.dp
21 * Shared empty/placeholder state: a centered icon, a title, an optional supporting line,
24 * Shared empty/placeholder state: a centered icon, a title, an optional supporting line,
22 * and an optional call-to-action button. Extracted so every screen's "nothing here yet"
25 * and an optional call-to-action button. Extracted so every screen's "nothing here yet"
23 * state reads and behaves the same (see EmptySessions/EmptyServers/etc. which predate this).
26 * state reads and behaves the same (see EmptySessions/EmptyServers/etc. which predate this).
27+ *
28+ * An optional [secondaryActionLabel] / [onSecondaryAction] pair renders a secondary
29+ * OutlinedButton below the primary CTA, for cases where two recovery paths are equally
30+ * valid (e.g. "Add server" vs "Import from QR image" on a first-run server list).
24 */
31 */
25@Composable
32@Composable
26fun EmptyState(
33fun EmptyState(
@@ -31,6 +38,9 @@ fun EmptyState(
31 actionIcon: ImageVector? = null,
38 actionIcon: ImageVector? = null,
32 actionLabel: String? = null,
39 actionLabel: String? = null,
33 onAction: (() -> Unit)? = null,
40 onAction: (() -> Unit)? = null,
41+ secondaryActionIcon: ImageVector? = null,
42+ secondaryActionLabel: String? = null,
43+ onSecondaryAction: (() -> Unit)? = null,
34) {
44) {
35 Column(
45 Column(
36 modifier = modifier.padding(32.dp),
46 modifier = modifier.padding(32.dp),
@@ -58,7 +68,12 @@ fun EmptyState(
58 )
68 )
59 }
69 }
60 if (actionLabel != null && onAction != null) {
70 if (actionLabel != null && onAction != null) {
71+ Button(
72+ onClick = onAction,
61− Button(onClick = onAction, modifier = Modifier.padding(top = 12.dp)) {
73+ modifier = Modifier
74+ .padding(top = 12.dp)
75+ .testTag("empty_state_action"),
76+ ) {
62 if (actionIcon != null) {
77 if (actionIcon != null) {
63 Icon(actionIcon, contentDescription = null, modifier = Modifier.size(18.dp))
78 Icon(actionIcon, contentDescription = null, modifier = Modifier.size(18.dp))
64 Text(actionLabel, modifier = Modifier.padding(start = 6.dp))
79 Text(actionLabel, modifier = Modifier.padding(start = 6.dp))
@@ -67,5 +82,18 @@ fun EmptyState(
67 }
82 }
68 }
83 }
69 }
84 }
85+ if (secondaryActionLabel != null && onSecondaryAction != null) {
86+ OutlinedButton(
87+ onClick = onSecondaryAction,
88+ modifier = Modifier.testTag("empty_state_secondary_action"),
89+ ) {
90+ if (secondaryActionIcon != null) {
91+ Icon(secondaryActionIcon, contentDescription = null, modifier = Modifier.size(18.dp))
92+ Text(secondaryActionLabel, modifier = Modifier.padding(start = 6.dp))
93+ } else {
94+ Text(secondaryActionLabel)
95+ }
96+ }
97+ }
70 }
98 }
71}
99}
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+136−14
@@ -11,6 +11,7 @@ import androidx.compose.animation.core.infiniteRepeatable
11import androidx.compose.animation.core.rememberInfiniteTransition
11import androidx.compose.animation.core.rememberInfiniteTransition
12import androidx.compose.animation.core.tween
12import androidx.compose.animation.core.tween
13import androidx.compose.foundation.background
13import androidx.compose.foundation.background
14+import androidx.compose.foundation.gestures.detectTapGestures
14import androidx.compose.foundation.horizontalScroll
15import androidx.compose.foundation.horizontalScroll
15import androidx.compose.foundation.layout.Arrangement
16import androidx.compose.foundation.layout.Arrangement
16import androidx.compose.foundation.layout.Box
17import androidx.compose.foundation.layout.Box
@@ -50,6 +51,8 @@ import androidx.compose.ui.Alignment
50import androidx.compose.ui.Modifier
51import androidx.compose.ui.Modifier
51import androidx.compose.ui.draw.clip
52import androidx.compose.ui.draw.clip
52import androidx.compose.ui.graphics.Color
53import androidx.compose.ui.graphics.Color
54+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
55+import androidx.compose.ui.input.pointer.pointerInput
53import androidx.compose.ui.platform.LocalContext
56import androidx.compose.ui.platform.LocalContext
54import androidx.compose.ui.platform.LocalHapticFeedback
57import androidx.compose.ui.platform.LocalHapticFeedback
55import androidx.compose.ui.platform.LocalUriHandler
58import androidx.compose.ui.platform.LocalUriHandler
@@ -62,16 +65,22 @@ import androidx.compose.ui.semantics.liveRegion
62import androidx.compose.ui.semantics.semantics
65import androidx.compose.ui.semantics.semantics
63import androidx.compose.ui.text.AnnotatedString
66import androidx.compose.ui.text.AnnotatedString
64import androidx.compose.ui.text.SpanStyle
67import androidx.compose.ui.text.SpanStyle
68+import androidx.compose.ui.text.TextLayoutResult
65import androidx.compose.ui.text.TextStyle
69import androidx.compose.ui.text.TextStyle
70+import androidx.compose.ui.text.buildAnnotatedString
66import androidx.compose.ui.text.font.FontFamily
71import androidx.compose.ui.text.font.FontFamily
67import androidx.compose.ui.unit.dp
72import androidx.compose.ui.unit.dp
68import kotlinx.coroutines.flow.conflate
73import kotlinx.coroutines.flow.conflate
69import kotlinx.coroutines.delay
74import kotlinx.coroutines.delay
70import com.mikepenz.markdown.compose.LocalMarkdownAnnotator
75import com.mikepenz.markdown.compose.LocalMarkdownAnnotator
76+import com.mikepenz.markdown.compose.LocalMarkdownColors
77+import com.mikepenz.markdown.compose.LocalMarkdownTypography
71import com.mikepenz.markdown.compose.components.MarkdownComponentModel
78import com.mikepenz.markdown.compose.components.MarkdownComponentModel
72import com.mikepenz.markdown.compose.components.markdownComponents
79import com.mikepenz.markdown.compose.components.markdownComponents
80+import com.mikepenz.markdown.compose.elements.MarkdownText as LibraryMarkdownText
73import com.mikepenz.markdown.m3.Markdown
81import com.mikepenz.markdown.m3.Markdown
74import com.mikepenz.markdown.model.DefaultMarkdownAnnotator
82import com.mikepenz.markdown.model.DefaultMarkdownAnnotator
83+import com.mikepenz.markdown.utils.buildMarkdownAnnotatedString
75import org.intellij.markdown.MarkdownElementTypes
84import org.intellij.markdown.MarkdownElementTypes
76import org.intellij.markdown.ast.ASTNode
85import org.intellij.markdown.ast.ASTNode
77import org.intellij.markdown.ast.getTextInNode
86import org.intellij.markdown.ast.getTextInNode
@@ -79,6 +88,12 @@ import soy.iko.opencode.R
79import soy.iko.opencode.data.model.FilePart
88import soy.iko.opencode.data.model.FilePart
80import soy.iko.opencode.data.network.NetworkConfig
89import soy.iko.opencode.data.network.NetworkConfig
81
90
91+/** String-annotation tag stamped onto each inline-code (`CODE_SPAN`) range by the inline-code
92+ * annotator wrapper, so [ParagraphWithInlineCodeCopy]'s `pointerInput` can resolve a long-press
93+ * offset back to the code text and copy it. Mirrors the library's own `MARKDOWN_TAG_URL` pattern
94+ * for link-tap hit-testing. */
95+private const val MARKDOWN_TAG_INLINE_CODE = "MARKDOWN_INLINE_CODE"
96+
82/**
97/**
83 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
98 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
84 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
99 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
@@ -144,10 +159,14 @@ private fun MarkdownBody(
144 // Without this the library's default image loader fetches with no auth and fails on
159 // Without this the library's default image loader fetches with no auth and fails on
145 // server-relative URLs. When no context is available, fall back to the library default
160 // server-relative URLs. When no context is available, fall back to the library default
146 // by not overriding the image component.
161 // by not overriding the image component.
162+ // The `paragraph` component is overridden with [ParagraphWithInlineCodeCopy] so a long-press
163+ // on a single-backtick inline code span copies its text — matching the copy affordance the
164+ // fenced/indented code blocks already have via [CodeWithCopy].
147 val components = if (imageContext != null) {
165 val components = if (imageContext != null) {
148 markdownComponents(
166 markdownComponents(
149 codeFence = { CodeWithCopy(it) },
167 codeFence = { CodeWithCopy(it) },
150 codeBlock = { CodeWithCopy(it) },
168 codeBlock = { CodeWithCopy(it) },
169+ paragraph = { ParagraphWithInlineCodeCopy(it) },
151 image = { MarkdownImage(it, imageContext) },
170 image = { MarkdownImage(it, imageContext) },
152 )
171 )
153 } else {
172 } else {
@@ -155,6 +174,7 @@ private fun MarkdownBody(
155 markdownComponents(
174 markdownComponents(
156 codeFence = { CodeWithCopy(it) },
175 codeFence = { CodeWithCopy(it) },
157 codeBlock = { CodeWithCopy(it) },
176 codeBlock = { CodeWithCopy(it) },
177+ paragraph = { ParagraphWithInlineCodeCopy(it) },
158 )
178 )
159 }
179 }
160 }
180 }
@@ -182,13 +202,63 @@ private fun MarkdownBody(
182 val searchQuery = LocalSearchHighlight.current?.takeIf { it.isNotBlank() }
202 val searchQuery = LocalSearchHighlight.current?.takeIf { it.isNotBlank() }
183 val highlightColor = MaterialTheme.colorScheme.secondaryContainer
203 val highlightColor = MaterialTheme.colorScheme.secondaryContainer
184 val baseAnnotator = LocalMarkdownAnnotator.current
204 val baseAnnotator = LocalMarkdownAnnotator.current
205+ // Capture the inline-code typography/colors in the composable scope (the annotator lambda is
206+ // NOT a @Composable scope, so it can't read LocalMarkdownTypography.current itself). The
207+ // library's buildMarkdownAnnotatedString reads these in its own @Composable body for the
208+ // default CODE_SPAN branch; since we intercept CODE_SPAN in the annotator, we capture them
209+ // here and close over them in appendInlineCodeSpan.
210+ val inlineCodeTypography = LocalMarkdownTypography.current
211+ val inlineCodeColors = LocalMarkdownColors.current
212+ // Inline-code tag annotator: stamps a MARKDOWN_TAG_INLINE_CODE string annotation onto each
213+ // CODE_SPAN range so [ParagraphWithInlineCodeCopy] can resolve a long-press offset to the
214+ // code text and copy it. The library's default CODE_SPAN branch in buildMarkdownAnnotatedString
215+ // is bypassed (the annotator returns true) so the span is rendered exactly once, with the tag.
216+ // The CODE_SPAN rendering (padding spaces, inlineCode typography span, colors) is inlined here
217+ // rather than extracted to a helper to keep this file under detekt's TooManyFunctions threshold.
218+ val inlineCodeAnnotator = remember(baseAnnotator, inlineCodeTypography, inlineCodeColors) {
219+ val codeStyle = inlineCodeTypography.inlineCode.copy(
220+ color = inlineCodeColors.inlineCodeText,
221+ background = inlineCodeColors.inlineCodeBackground,
222+ ).toSpanStyle()
223+ DefaultMarkdownAnnotator(
224+ annotate = { content, node ->
225+ if (node.type == MarkdownElementTypes.CODE_SPAN) {
226+ pushStyle(codeStyle)
227+ append(' ')
228+ val codeStart = this.length
229+ // Drop the opening/closing backtick children (matching the library's internal
230+ // innerList(), which is inaccessible from here).
231+ val codeText = StringBuilder()
232+ for (child in node.children) {
233+ if (child.type == org.intellij.markdown.MarkdownTokenTypes.BACKTICK) continue
234+ val text = child.getTextInNode(content).toString()
235+ codeText.append(text)
236+ append(text)
237+ }
238+ val codeEnd = this.length
239+ if (codeEnd > codeStart) {
240+ pushStringAnnotation(MARKDOWN_TAG_INLINE_CODE, codeText.toString())
241+ pop()
242+ }
243+ append(' ')
244+ pop()
245+ true
246+ } else {
247+ baseAnnotator.annotate?.invoke(this, content, node) ?: false
248+ }
249+ },
250+ )
251+ }
252+ // Compose the inline-code tagger with the search-highlight wrapper when a query is active.
253+ // The highlight wrapper runs on top, so it sees the final (tagged) builder output and can
254+ // highlight matches inside inline code too.
185− val highlightAnnotator = if (searchQuery != null && !streaming) {
255+ val effectiveAnnotator = if (searchQuery != null && !streaming) {
186− remember(baseAnnotator, searchQuery, highlightColor) {
256+ remember(inlineCodeAnnotator, searchQuery, highlightColor) {
257+ val inner = inlineCodeAnnotator.annotate
187 DefaultMarkdownAnnotator(
258 DefaultMarkdownAnnotator(
188 annotate = { text, node ->
259 annotate = { text, node ->
189− // `this` is the AnnotatedString.Builder being filled for this node.
190 val startLen = this.length
260 val startLen = this.length
191− val handled = baseAnnotator.annotate?.invoke(this, text, node) ?: false
261+ val handled = inner?.invoke(this, text, node) ?: false
192 val endLen = this.length
262 val endLen = this.length
193 if (endLen > startLen) {
263 if (endLen > startLen) {
194 addSearchHighlights(
264 addSearchHighlights(
@@ -204,7 +274,7 @@ private fun MarkdownBody(
204 )
274 )
205 }
275 }
206 } else {
276 } else {
207− null
277+ inlineCodeAnnotator
208 }
278 }
209 if (streaming) {
279 if (streaming) {
210 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
280 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
@@ -245,7 +315,10 @@ private fun MarkdownBody(
245 // A polite live region announces streaming content to TalkBack users. The throttle
315 // A polite live region announces streaming content to TalkBack users. The throttle
246 // (50ms) plus TalkBack's own polite-queue coalescing keeps announcements from
316 // (50ms) plus TalkBack's own polite-queue coalescing keeps announcements from
247 // interrupting on every token; the reader hears the reply grow in measured chunks.
317 // interrupting on every token; the reader hears the reply grow in measured chunks.
318+ CompositionLocalProvider(
248− CompositionLocalProvider(LocalUriHandler provides safeUriHandler) {
319+ LocalUriHandler provides safeUriHandler,
320+ LocalMarkdownAnnotator provides effectiveAnnotator,
321+ ) {
249 Markdown(
322 Markdown(
250 content = renderedContent,
323 content = renderedContent,
251 modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
324 modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
@@ -266,14 +339,11 @@ private fun MarkdownBody(
266 )
339 )
267 }
340 }
268 }
341 }
342+ CompositionLocalProvider(
269− CompositionLocalProvider(LocalUriHandler provides safeUriHandler) {
343+ LocalUriHandler provides safeUriHandler,
270− if (highlightAnnotator != null) {
271− CompositionLocalProvider(LocalMarkdownAnnotator provides highlightAnnotator) {
344+ LocalMarkdownAnnotator provides effectiveAnnotator,
272− content()
345+ ) {
273− }
274− } else {
275− content()
346+ content()
276− }
277 }
347 }
278 }
348 }
279 // Open/copy dialog for the most recently tapped markdown link. Offer Open only for
349 // Open/copy dialog for the most recently tapped markdown link. Offer Open only for
@@ -375,6 +445,58 @@ private fun LinkActionDialog(
375 )
445 )
376}
446}
377
447
448+/**
449+ * Custom markdown `paragraph` component that adds a long-press-to-copy affordance for inline
450+ * code spans (single-backtick `` `code` ``). The library renders CODE_SPAN inline as a styled
451+ * run within the paragraph's [AnnotatedString]; there's no discrete `inlineCode` component to
452+ * override. Instead, [MarkdownBody] installs an annotator that stamps each CODE_SPAN range with
453+ * a [MARKDOWN_TAG_INLINE_CODE] string annotation; this component builds the paragraph's
454+ * [AnnotatedString] via the library's [buildMarkdownAnnotatedString] (so the annotator runs and
455+ * the tags land), then renders it with a `pointerInput` that hit-tests a long-press against the
456+ * tagged ranges and copies the span's text on hit — mirroring the copy affordance fenced/indented
457+ * code blocks get via [CodeWithCopy], for the common case of short inline commands/identifiers.
458+ * A long-press inside a [SelectionContainer] is consumed by the selection machinery first, so
459+ * the copy fires from non-selectable contexts (e.g. while streaming, where no SelectionContainer
460+ * wraps the text); when selection is active the user can still copy via the selection handles.
461+ * Haptic feedback matches every other copy affordance.
462+ */
463+@Composable
464+private fun ParagraphWithInlineCodeCopy(model: MarkdownComponentModel) {
465+ val context = LocalContext.current
466+ val haptics = LocalHapticFeedback.current
467+ val style = model.typography.paragraph
468+ // buildMarkdownAnnotatedString is @Composable (it reads LocalMarkdownAnnotator to run the
469+ // inline-code tagger installed in MarkdownBody), so the annotated string can't be built in
470+ // a `remember` block. The library's own MarkdownParagraph does the same.
471+ val styledText = buildAnnotatedString {
472+ pushStyle(style.toSpanStyle())
473+ buildMarkdownAnnotatedString(model.content, model.node)
474+ pop()
475+ }
476+ // Capture the layout result so a long-press position can be mapped to a character offset,
477+ // then to a tagged range — the same hit-testing pattern the library uses for link taps.
478+ var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
479+ LibraryMarkdownText(
480+ content = styledText,
481+ style = style,
482+ modifier = Modifier.pointerInput(styledText) {
483+ detectTapGestures(
484+ onLongPress = { pos ->
485+ val result = layoutResult ?: return@detectTapGestures
486+ val offset = result.getOffsetForPosition(pos)
487+ val span = styledText.getStringAnnotations(MARKDOWN_TAG_INLINE_CODE, offset, offset)
488+ .firstOrNull()
489+ ?: return@detectTapGestures
490+ val code = span.item
491+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
492+ copyToClipboard(context, context.getString(R.string.clip_label_code), code)
493+ },
494+ )
495+ },
496+ onTextLayout = { layoutResult = it },
497+ )
498+}
499+
378/**
500/**
379 * Renders a fenced or indented code block with a header toolbar: the fence's language
501 * Renders a fenced or indented code block with a header toolbar: the fence's language
380 * label (when present), a wrap toggle (soft-wrap long lines vs. horizontal scroll), and a
502 * label (when present), a wrap toggle (soft-wrap long lines vs. horizontal scroll), and a
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+32−1
@@ -231,7 +231,7 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
231 * the whole screen. Offers Save-to-gallery and Share actions via the top bar.
231 * the whole screen. Offers Save-to-gallery and Share actions via the top bar.
232 */
232 */
233@Composable
233@Composable
234−private fun FullscreenImageViewer(
234+internal fun FullscreenImageViewer(
235 request: ImageRequest,
235 request: ImageRequest,
236 contentDescription: String,
236 contentDescription: String,
237 onDismiss: () -> Unit,
237 onDismiss: () -> Unit,
@@ -421,6 +421,37 @@ private fun FullscreenImageViewer(
421 )
421 )
422 }
422 }
423 }
423 }
424+ // Zoom-level indicator: a small chip at the top-start showing the current zoom
425+ // factor (e.g. "2.5×") whenever the image is zoomed beyond 1×, with a tap action
426+ // to reset to 1×. Aids orientation when deep-zoomed and gives a one-tap reset
427+ // (otherwise only a double-tap toggles between 1× and the zoom target).
428+ if (scale.value > 1.01f) {
429+ val zoomText = remember(scale.value) {
430+ String.format(java.util.Locale.US, "%.1f×", scale.value)
431+ }
432+ val resetLabel = stringResource(R.string.reset_zoom)
433+ androidx.compose.material3.Surface(
434+ color = androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.6f),
435+ shape = MaterialTheme.shapes.small,
436+ modifier = Modifier
437+ .align(Alignment.TopStart)
438+ .padding(12.dp)
439+ .windowInsetsPadding(WindowInsets.statusBars)
440+ .clickable(role = Role.Button) {
441+ scope.launch { scale.animateTo(1f, zoomSpec) }
442+ offsetX = 0f
443+ offsetY = 0f
444+ }
445+ .semantics { this.contentDescription = "$zoomText, $resetLabel" },
446+ ) {
447+ Text(
448+ zoomText,
449+ color = androidx.compose.ui.graphics.Color.White,
450+ style = MaterialTheme.typography.labelSmall,
451+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
452+ )
453+ }
454+ }
424 }
455 }
425 }
456 }
426}
457}
app/src/main/java/soy/iko/opencode/ui/components/SkeletonRow.ktnew file+97
@@ -0,0 +1,97 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.animation.core.RepeatMode
4+import androidx.compose.animation.core.animateFloat
5+import androidx.compose.animation.core.infiniteRepeatable
6+import androidx.compose.animation.core.rememberInfiniteTransition
7+import androidx.compose.animation.core.tween
8+import androidx.compose.foundation.background
9+import androidx.compose.foundation.layout.Arrangement
10+import androidx.compose.foundation.layout.Box
11+import androidx.compose.foundation.layout.Column
12+import androidx.compose.foundation.layout.Row
13+import androidx.compose.foundation.layout.fillMaxWidth
14+import androidx.compose.foundation.layout.height
15+import androidx.compose.foundation.layout.size
16+import androidx.compose.foundation.shape.RoundedCornerShape
17+import androidx.compose.material3.MaterialTheme
18+import androidx.compose.runtime.Composable
19+import androidx.compose.runtime.getValue
20+import androidx.compose.ui.Modifier
21+import androidx.compose.ui.draw.alpha
22+import androidx.compose.ui.draw.clip
23+import androidx.compose.ui.res.stringResource
24+import androidx.compose.ui.semantics.contentDescription
25+import androidx.compose.ui.semantics.semantics
26+import androidx.compose.ui.unit.Dp
27+import androidx.compose.ui.unit.dp
28+import soy.iko.opencode.R
29+
30+/**
31+ * A single pulse-animated placeholder row mirroring the session list's skeleton pattern: a
32+ * leading avatar/box and two text bars (title + subtitle). Used by the server/MCP/usage/file
33+ * browser screens' initial-load states so a structured preview of the eventual list reads more
34+ * polished than a bare centered [androidx.compose.material3.CircularProgressIndicator], and
35+ * matches the chat/session/file-viewer skeletons.
36+ *
37+ * Pulses 0.35↔0.7 alpha over 900ms; under [LocalReducedMotion] the alpha is a static 0.5 so the
38+ * placeholder is still visible but doesn't animate. The whole row is one merged semantics node
39+ * (TalkBack announces "Loading…" once per row, not three times for each bar).
40+ *
41+ * @param circleSize diameter of the leading placeholder (40dp matches the session avatar).
42+ * @param barWidths fractional widths of the title and subtitle bars (0.7 to 0.45 by default).
43+ */
44+@Composable
45+fun SkeletonRow(
46+ modifier: Modifier = Modifier,
47+ circleSize: Dp = 40.dp,
48+ barWidths: Pair<Float, Float> = 0.7f to 0.45f,
49+) {
50+ val loadingLabel = stringResource(R.string.loading)
51+ val reducedMotion = LocalReducedMotion.current
52+ val transition = rememberInfiniteTransition(label = "skeleton_row")
53+ val pulse by transition.animateFloat(
54+ initialValue = 0.35f,
55+ targetValue = 0.7f,
56+ animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
57+ label = "skeleton_row_alpha",
58+ )
59+ val skeletonAlpha = if (reducedMotion) 0.5f else pulse
60+ val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
61+ Row(
62+ modifier = modifier
63+ .fillMaxWidth()
64+ .semantics(mergeDescendants = true) { contentDescription = loadingLabel },
65+ horizontalArrangement = Arrangement.spacedBy(12.dp),
66+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
67+ ) {
68+ Box(
69+ Modifier
70+ .size(circleSize)
71+ .clip(RoundedCornerShape(circleSize / 2))
72+ .background(skeletonColor)
73+ .alpha(skeletonAlpha),
74+ )
75+ Column(
76+ modifier = Modifier.weight(1f),
77+ verticalArrangement = Arrangement.spacedBy(6.dp),
78+ ) {
79+ Box(
80+ Modifier
81+ .fillMaxWidth(barWidths.first)
82+ .height(14.dp)
83+ .clip(RoundedCornerShape(4.dp))
84+ .background(skeletonColor)
85+ .alpha(skeletonAlpha),
86+ )
87+ Box(
88+ Modifier
89+ .fillMaxWidth(barWidths.second)
90+ .height(12.dp)
91+ .clip(RoundedCornerShape(4.dp))
92+ .background(skeletonColor)
93+ .alpha(skeletonAlpha),
94+ )
95+ }
96+ }
97+}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+125−42
@@ -1,6 +1,8 @@
1package soy.iko.opencode.ui.file
1package soy.iko.opencode.ui.file
2
2
3import androidx.activity.compose.BackHandler
3import androidx.activity.compose.BackHandler
4+import androidx.compose.animation.Crossfade
5+import androidx.compose.animation.core.tween
4import androidx.compose.foundation.ExperimentalFoundationApi
6import androidx.compose.foundation.ExperimentalFoundationApi
5import androidx.compose.foundation.clickable
7import androidx.compose.foundation.clickable
6import androidx.compose.foundation.combinedClickable
8import androidx.compose.foundation.combinedClickable
@@ -17,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
17import androidx.compose.foundation.layout.padding
19import androidx.compose.foundation.layout.padding
18import androidx.compose.foundation.layout.size
20import androidx.compose.foundation.layout.size
19import androidx.compose.foundation.layout.widthIn
21import androidx.compose.foundation.layout.widthIn
22+import androidx.compose.foundation.layout.wrapContentWidth
20import androidx.compose.foundation.lazy.LazyColumn
23import androidx.compose.foundation.lazy.LazyColumn
21import androidx.compose.foundation.lazy.items
24import androidx.compose.foundation.lazy.items
22import androidx.compose.foundation.lazy.itemsIndexed
25import androidx.compose.foundation.lazy.itemsIndexed
@@ -32,6 +35,7 @@ import androidx.compose.material.icons.filled.ErrorOutline
32import androidx.compose.material.icons.filled.Folder
35import androidx.compose.material.icons.filled.Folder
33import androidx.compose.material.icons.filled.Home
36import androidx.compose.material.icons.filled.Home
34import androidx.compose.material.icons.filled.Image
37import androidx.compose.material.icons.filled.Image
38+import androidx.compose.material.icons.filled.Lock
35import androidx.compose.material.icons.filled.Code
39import androidx.compose.material.icons.filled.Code
36import androidx.compose.material.icons.filled.Article
40import androidx.compose.material.icons.filled.Article
37import androidx.compose.material.icons.filled.KeyboardArrowDown
41import androidx.compose.material.icons.filled.KeyboardArrowDown
@@ -40,7 +44,6 @@ import androidx.compose.material.icons.filled.Link
40import androidx.compose.material.icons.filled.MoreVert
44import androidx.compose.material.icons.filled.MoreVert
41import androidx.compose.material.icons.filled.Search
45import androidx.compose.material.icons.filled.Search
42import androidx.compose.material.icons.filled.SortByAlpha
46import androidx.compose.material.icons.filled.SortByAlpha
43−import androidx.compose.material3.CircularProgressIndicator
44import androidx.compose.material3.DropdownMenu
47import androidx.compose.material3.DropdownMenu
45import androidx.compose.material3.DropdownMenuItem
48import androidx.compose.material3.DropdownMenuItem
46import androidx.compose.material3.ExperimentalMaterial3Api
49import androidx.compose.material3.ExperimentalMaterial3Api
@@ -60,6 +63,7 @@ import androidx.compose.runtime.LaunchedEffect
60import androidx.compose.runtime.getValue
63import androidx.compose.runtime.getValue
61import androidx.compose.runtime.mutableStateOf
64import androidx.compose.runtime.mutableStateOf
62import androidx.compose.runtime.remember
65import androidx.compose.runtime.remember
66+import androidx.compose.runtime.rememberCoroutineScope
63import androidx.compose.runtime.saveable.rememberSaveable
67import androidx.compose.runtime.saveable.rememberSaveable
64import androidx.compose.runtime.setValue
68import androidx.compose.runtime.setValue
65import androidx.compose.ui.Alignment
69import androidx.compose.ui.Alignment
@@ -94,16 +98,19 @@ import soy.iko.opencode.data.model.FindMatch
94import soy.iko.opencode.data.model.SymbolResult
98import soy.iko.opencode.data.model.SymbolResult
95import soy.iko.opencode.data.model.symbolKindLabel
99import soy.iko.opencode.data.model.symbolKindLabel
96import soy.iko.opencode.data.network.NetworkConfig
100import soy.iko.opencode.data.network.NetworkConfig
101+import soy.iko.opencode.data.repo.FileSortKey
97import soy.iko.opencode.di.AppContainer
102import soy.iko.opencode.di.AppContainer
98import soy.iko.opencode.R
103import soy.iko.opencode.R
99import soy.iko.opencode.ui.components.AppTopBar
104import soy.iko.opencode.ui.components.AppTopBar
100import soy.iko.opencode.ui.components.ConnectionBannerFor
105import soy.iko.opencode.ui.components.ConnectionBannerFor
101import soy.iko.opencode.ui.components.EmptyState
106import soy.iko.opencode.ui.components.EmptyState
107+import soy.iko.opencode.ui.components.SkeletonRow
102import soy.iko.opencode.ui.components.reducedMotionAnimateItem
108import soy.iko.opencode.ui.components.reducedMotionAnimateItem
103import soy.iko.opencode.ui.components.copyToClipboard
109import soy.iko.opencode.ui.components.copyToClipboard
104import soy.iko.opencode.ui.components.relativeTime
110import soy.iko.opencode.ui.components.relativeTime
105import soy.iko.opencode.ui.vmFactory
111import soy.iko.opencode.ui.vmFactory
106import soy.iko.opencode.util.runCatchingCancellable
112import soy.iko.opencode.util.runCatchingCancellable
113+import kotlinx.coroutines.launch
107
114
108@OptIn(ExperimentalMaterial3Api::class)
115@OptIn(ExperimentalMaterial3Api::class)
109@Composable
116@Composable
@@ -156,7 +163,9 @@ fun FileBrowserScreen(
156 val scale = 1f - (backProgress.floatValue * 0.05f)
163 val scale = 1f - (backProgress.floatValue * 0.05f)
157 Column(
164 Column(
158 modifier = Modifier
165 modifier = Modifier
159− .fillMaxSize()
166+ .fillMaxWidth()
167+ .wrapContentWidth(Alignment.CenterHorizontally)
168+ .widthIn(max = soy.iko.opencode.data.network.NetworkConfig.listContentMaxWidthDp.dp)
160 .imePadding()
169 .imePadding()
161 .padding(padding)
170 .padding(padding)
162 .graphicsLayer { scaleX = scale; scaleY = scale },
171 .graphicsLayer { scaleX = scale; scaleY = scale },
@@ -226,37 +235,56 @@ fun FileBrowserScreen(
226 ) {
235 ) {
227 Box(modifier = Modifier.fillMaxSize()) {
236 Box(modifier = Modifier.fillMaxSize()) {
228 val loadingLabel = stringResource(R.string.loading)
237 val loadingLabel = stringResource(R.string.loading)
238+ // Crossfade between content states so transitions read as a smooth fade
239+ // instead of an instant snap. Matches the session list's Crossfade pattern;
240+ // reduced motion is honored by Crossfade's default spec.
241+ val stateKey = fileBrowserStateKey(state)
242+ @Suppress("UnusedCrossfadeTargetStateParameter")
243+ Crossfade(
244+ targetState = stateKey,
245+ animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
246+ label = "file_browser_state",
247+ ) {
229− when {
248+ when {
230− // Full-screen spinner only for the initial directory load. An in-flight
249+ // Skeleton loader for the initial directory load. An in-flight search
231− // search keeps the previous results visible (a slim top bar below shows the
250+ // keeps the previous results visible (a slim top bar below shows the
232− // progress) instead of blanking the list behind a spinner on every keystroke.
251+ // progress) instead of blanking the list on every keystroke.
233 state.loading -> Column(
252 state.loading -> Column(
234− modifier = Modifier.align(Alignment.Center).padding(24.dp),
253+ modifier = Modifier.fillMaxSize().padding(16.dp),
235− horizontalAlignment = Alignment.CenterHorizontally,
254+ verticalArrangement = Arrangement.spacedBy(12.dp),
236 ) {
255 ) {
237− CircularProgressIndicator(
256+ repeat(6) { SkeletonRow() }
238− Modifier.semantics { contentDescription = loadingLabel },
239− )
240− Text(
241− stringResource(R.string.loading),
242− style = MaterialTheme.typography.bodyMedium,
243− color = MaterialTheme.colorScheme.onSurfaceVariant,
244− modifier = Modifier.padding(top = 8.dp),
245− )
246 }
257 }
247− state.error != null -> Column(
258+ state.error != null -> {
259+ // Detect a permission-denied error (the server returns a 403/EACCES
260+ // when the workspace directory isn't readable) and show a specific
261+ // message + icon instead of the generic error, so the user
262+ // understands the cause is access, not a network failure.
263+ val err = state.error
264+ val isPermissionDenied = err != null && (
265+ err.contains("permission", ignoreCase = true) ||
266+ err.contains("denied", ignoreCase = true) ||
267+ err.contains("403", ignoreCase = true)
268+ )
269+ val errorIcon = if (isPermissionDenied) Icons.Filled.Lock else Icons.Filled.ErrorOutline
270+ val errorText = if (isPermissionDenied) {
271+ stringResource(R.string.permission_denied_files)
272+ } else {
273+ err ?: ""
274+ }
275+ Column(
248 modifier = Modifier.align(Alignment.Center).padding(24.dp),
276 modifier = Modifier.align(Alignment.Center).padding(24.dp),
249 horizontalAlignment = Alignment.CenterHorizontally,
277 horizontalAlignment = Alignment.CenterHorizontally,
250 ) {
278 ) {
251 Icon(
279 Icon(
252− Icons.Filled.ErrorOutline,
280+ errorIcon,
253 contentDescription = null,
281 contentDescription = null,
254 tint = MaterialTheme.colorScheme.error,
282 tint = MaterialTheme.colorScheme.error,
255 modifier = Modifier.size(56.dp),
283 modifier = Modifier.size(56.dp),
256 )
284 )
257 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
285 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
258 Text(
286 Text(
259− state.error ?: "",
287+ errorText,
260 color = MaterialTheme.colorScheme.error,
288 color = MaterialTheme.colorScheme.error,
261 )
289 )
262 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
290 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
@@ -269,6 +297,7 @@ fun FileBrowserScreen(
269 Text(stringResource(R.string.retry))
297 Text(stringResource(R.string.retry))
270 }
298 }
271 }
299 }
300+ }
272 state.mode == SearchMode.TEXT ->
301 state.mode == SearchMode.TEXT ->
273 TextResults(
302 TextResults(
274 state.textResults,
303 state.textResults,
@@ -281,6 +310,7 @@ fun FileBrowserScreen(
281 ) { path, line -> onOpenFile(path, line) }
310 ) { path, line -> onOpenFile(path, line) }
282 state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
311 state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
283 else -> DirectoryListing(
312 else -> DirectoryListing(
313+ container = container,
284 state = state,
314 state = state,
285 onOpenDir = vm::open,
315 onOpenDir = vm::open,
286 onUp = vm::up,
316 onUp = vm::up,
@@ -290,6 +320,7 @@ fun FileBrowserScreen(
290 container.requestNewSession()
320 container.requestNewSession()
291 },
321 },
292 )
322 )
323+ }
293 }
324 }
294 // Slim progress bar for an in-flight search; the results list stays visible
325 // Slim progress bar for an in-flight search; the results list stays visible
295 // underneath so incremental typing doesn't flash an empty screen each keystroke.
326 // underneath so incremental typing doesn't flash an empty screen each keystroke.
@@ -307,6 +338,18 @@ fun FileBrowserScreen(
307 }
338 }
308}
339}
309
340
341+/** Maps the file-browser view state to a stable string key for the Crossfade, so the screen's
342+ * content-state transitions fade smoothly. Extracted from [FileBrowserScreen] to keep its
343+ * cyclomatic complexity under the detekt threshold. */
344+private fun fileBrowserStateKey(state: FileBrowserState): String = when {
345+ state.loading -> "loading"
346+ state.error != null -> "error"
347+ state.mode == SearchMode.TEXT -> "text"
348+ state.mode == SearchMode.SYMBOL -> "symbol"
349+ state.isSearching -> "search"
350+ else -> "dir"
351+}
352+
310@Composable
353@Composable
311private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
354private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
312 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
355 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
@@ -576,6 +619,7 @@ private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.Annot
576@Suppress("CyclomaticComplexMethod")
619@Suppress("CyclomaticComplexMethod")
577@Composable
620@Composable
578private fun DirectoryListing(
621private fun DirectoryListing(
622+ container: AppContainer,
579 state: FileBrowserState,
623 state: FileBrowserState,
580 onOpenDir: (String) -> Unit,
624 onOpenDir: (String) -> Unit,
581 onUp: () -> Unit,
625 onUp: () -> Unit,
@@ -591,20 +635,39 @@ private fun DirectoryListing(
591 )
635 )
592 return
636 return
593 }
637 }
594− // Sort preference for the directory listing. Folders-first + name sort is the most useful
638+ // Sort/filter preferences are persisted via FileBrowserPrefs (DataStore) so a user who
595− // default; the toggle flips between folders-first name sort and the server's raw order.
639+ // prefers size-sort or hidden-files-visible keeps it across process death, not just
596− var foldersFirst by rememberSaveable { mutableStateOf(true) }
640+ // rotation. Defaults match the prior rememberSaveable defaults so the first-run view is
597− // Hidden-files preference. The server may already include dotfiles in its listing; this
641+ // unchanged: folders-first ON, hidden OFF, sort NAME ascending, changed-only OFF.
598− // hides them by default (the common case for browsing a repo) and lets the user reveal
642+ val prefs = container.fileBrowserPrefs
599− // .env / .git / .opencode on demand. Default off so the listing isn't cluttered.
643+ val scope = rememberCoroutineScope()
600− var showHidden by rememberSaveable { mutableStateOf(false) }
644+ val foldersFirst by prefs.foldersFirst.collectAsStateWithLifecycle(initialValue = true)
601− // Sort key for files within the listing. Name is the default (and the only axis the server
645+ val showHidden by prefs.showHidden.collectAsStateWithLifecycle(initialValue = false)
602− // reliably supports); Size/Modified appear only when the server emits size/mtime (otherwise
646+ val sortKey by prefs.sortKey.collectAsStateWithLifecycle(initialValue = FileSortKey.NAME)
603− // those sorts are no-ops on null fields), gated behind a menu so they don't clutter the bar.
647+ val sortDesc by prefs.sortDesc.collectAsStateWithLifecycle(initialValue = false)
604− var sortKey by rememberSaveable { mutableStateOf(FileSortKey.NAME) }
648+ val changedOnly by prefs.changedOnly.collectAsStateWithLifecycle(initialValue = false)
605− var sortDesc by rememberSaveable { mutableStateOf(false) }
649+ fun updateFoldersFirst(value: Boolean) {
650+ scope.launch { runCatchingCancellable { prefs.setFoldersFirst(value) } }
651+ }
652+ fun updateShowHidden(value: Boolean) {
653+ scope.launch { runCatchingCancellable { prefs.setShowHidden(value) } }
654+ }
655+ fun updateSortKey(key: FileSortKey) {
656+ scope.launch { runCatchingCancellable { prefs.setSortKey(key) } }
657+ }
658+ fun updateSortDesc(value: Boolean) {
659+ scope.launch { runCatchingCancellable { prefs.setSortDesc(value) } }
660+ }
661+ fun updateChangedOnly(value: Boolean) {
662+ scope.launch { runCatchingCancellable { prefs.setChangedOnly(value) } }
663+ }
606− val visible = remember(state.entries, showHidden) {
664+ val visible = remember(state.entries, showHidden, changedOnly, state.statusMap) {
607− if (showHidden) state.entries else state.entries.filterNot { it.name.startsWith(".") }
665+ val byHidden = if (showHidden) state.entries else state.entries.filterNot { it.name.startsWith(".") }
666+ // "Changed only" narrows to entries with a git status badge (A/M/D). Directories are
667+ // kept when the filter is OFF (so navigation isn't broken mid-review) but dropped when
668+ // it's ON, since git status is per-file and a directory row would never match.
669+ if (changedOnly) byHidden.filter { !it.isDirectory && state.statusMap[it.path] != null }
670+ else byHidden
608 }
671 }
609 val sorted = remember(visible, foldersFirst, sortKey, sortDesc) {
672 val sorted = remember(visible, foldersFirst, sortKey, sortDesc) {
610 // Null size/mtime coerce to values that sink the unknown entries to the bottom in the
673 // Null size/mtime coerce to values that sink the unknown entries to the bottom in the
@@ -634,6 +697,19 @@ private fun DirectoryListing(
634 if (sorted.size <= renderCap) sorted else sorted.take(renderCap)
697 if (sorted.size <= renderCap) sorted else sorted.take(renderCap)
635 }
698 }
636 LazyColumn(modifier = Modifier.fillMaxSize()) {
699 LazyColumn(modifier = Modifier.fillMaxSize()) {
700+ // Item-count summary header so a large directory is self-describing ("137 items")
701+ // mirroring the search-results count header. Uses the pre-hidden-filter count so the
702+ // user sees the true directory size; hidden entries are a display filter, not a
703+ // property of the folder.
704+ item(key = "__count") {
705+ val count = state.entries.size
706+ Text(
707+ pluralStringResource(R.plurals.file_count, count, count),
708+ style = MaterialTheme.typography.labelSmall,
709+ color = MaterialTheme.colorScheme.onSurfaceVariant,
710+ modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp),
711+ )
712+ }
637 if (state.path.isNotBlank()) {
713 if (state.path.isNotBlank()) {
638 item(key = "__up") {
714 item(key = "__up") {
639 FileRow(icon = true, label = "..", onClick = onUp)
715 FileRow(icon = true, label = "..", onClick = onUp)
@@ -687,7 +763,8 @@ private fun DirectoryListing(
687 onClick = {
763 onClick = {
688 // Tapping the active key flips the direction; tapping a different
764 // Tapping the active key flips the direction; tapping a different
689 // key switches to it (ascending by default).
765 // key switches to it (ascending by default).
690− if (sortKey == key) sortDesc = !sortDesc else { sortKey = key; sortDesc = false }
766+ if (sortKey == key) updateSortDesc(!sortDesc)
767+ else { updateSortKey(key); updateSortDesc(false) }
691 showSortMenu = false
768 showSortMenu = false
692 },
769 },
693 )
770 )
@@ -695,12 +772,12 @@ private fun DirectoryListing(
695 }
772 }
696 androidx.compose.material3.FilterChip(
773 androidx.compose.material3.FilterChip(
697 selected = showHidden,
774 selected = showHidden,
698− onClick = { showHidden = !showHidden },
775+ onClick = { updateShowHidden(!showHidden) },
699 label = { Text(stringResource(R.string.show_hidden), style = MaterialTheme.typography.labelSmall) },
776 label = { Text(stringResource(R.string.show_hidden), style = MaterialTheme.typography.labelSmall) },
700 )
777 )
701 androidx.compose.material3.FilterChip(
778 androidx.compose.material3.FilterChip(
702 selected = foldersFirst,
779 selected = foldersFirst,
703− onClick = { foldersFirst = !foldersFirst },
780+ onClick = { updateFoldersFirst(!foldersFirst) },
704 leadingIcon = {
781 leadingIcon = {
705 if (foldersFirst) {
782 if (foldersFirst) {
706 Icon(Icons.Filled.Folder, contentDescription = null, modifier = Modifier.size(16.dp))
783 Icon(Icons.Filled.Folder, contentDescription = null, modifier = Modifier.size(16.dp))
@@ -708,6 +785,16 @@ private fun DirectoryListing(
708 },
785 },
709 label = { Text(stringResource(R.string.folders_first), style = MaterialTheme.typography.labelSmall) },
786 label = { Text(stringResource(R.string.folders_first), style = MaterialTheme.typography.labelSmall) },
710 )
787 )
788+ androidx.compose.material3.FilterChip(
789+ selected = changedOnly,
790+ onClick = { updateChangedOnly(!changedOnly) },
791+ leadingIcon = {
792+ if (changedOnly) {
793+ Icon(Icons.Filled.Code, contentDescription = null, modifier = Modifier.size(16.dp))
794+ }
795+ },
796+ label = { Text(stringResource(R.string.changed_only), style = MaterialTheme.typography.labelSmall) },
797+ )
711 }
798 }
712 }
799 }
713 // Windowed render: compose only the first `renderCap` rows, growing as the user
800 // Windowed render: compose only the first `renderCap` rows, growing as the user
@@ -899,13 +986,9 @@ private fun StatusBadge(status: FileStatusEntry) {
899 }
986 }
900}
987}
901
988
902−/** Sort axis for the directory listing. Name is always available; Size/Modified depend on the
903− * server emitting size/mtime (otherwise they sort nulls to the bottom). */
904−private enum class FileSortKey { NAME, SIZE, MODIFIED }
905−
906/** Format a byte count as a compact human-readable size (e.g. 1.2 KB, 3.4 MB). Uses 1024-based
989/** Format a byte count as a compact human-readable size (e.g. 1.2 KB, 3.4 MB). Uses 1024-based
907 * units with one decimal place, matching how file managers render sizes. */
990 * units with one decimal place, matching how file managers render sizes. */
908−private fun formatFileSize(bytes: Long): String {
991+internal fun formatFileSize(bytes: Long): String {
909 if (bytes < 1024) return "$bytes B"
992 if (bytes < 1024) return "$bytes B"
910 val units = arrayOf("KB", "MB", "GB", "TB")
993 val units = arrayOf("KB", "MB", "GB", "TB")
911 var value = bytes.toDouble() / 1024.0
994 var value = bytes.toDouble() / 1024.0
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+29−1
@@ -21,6 +21,8 @@ data class FileViewState(
21 val loading: Boolean = true,
21 val loading: Boolean = true,
22 val content: FileContent? = null,
22 val content: FileContent? = null,
23 val error: String? = null,
23 val error: String? = null,
24+ val size: Long? = null,
25+ val mtime: Long? = null,
24)
26)
25
27
26@OptIn(ExperimentalCoroutinesApi::class)
28@OptIn(ExperimentalCoroutinesApi::class)
@@ -55,9 +57,35 @@ class FileViewModel(
55 // the file browser, which keeps the prior listing while refreshing.
57 // the file browser, which keeps the prior listing while refreshing.
56 _state.update { it.copy(loading = true, error = null) }
58 _state.update { it.copy(loading = true, error = null) }
57 runCatchingCancellable { conn.api.readFile(path) }
59 runCatchingCancellable { conn.api.readFile(path) }
60+ .onSuccess { result ->
61+ // Fetch the file's size/mtime. The server doesn't expose these on the
62+ // content endpoint, so look the file up in its parent directory listing
63+ // (which carries size/mtime when the server emits them). A missing/failed
64+ // metadata fetch is non-fatal — the header just stays hidden — so a
65+ // metadata failure can't clobber a successful read.
66+ val meta = runCatchingCancellable { fetchMetadata(conn.api, path) }.getOrNull()
58− .onSuccess { _state.value = FileViewState(loading = false, content = it) }
67+ _state.value = FileViewState(
68+ loading = false,
69+ content = result,
70+ size = meta?.size,
71+ mtime = meta?.mtime,
72+ )
73+ }
59 .onFailure { _state.value = FileViewState(loading = false, error = container.friendlyError(it)) }
74 .onFailure { _state.value = FileViewState(loading = false, error = container.friendlyError(it)) }
60 }
75 }
61 }
76 }
62 }
77 }
78+
79+ /** Look up [path] in its parent directory listing to read size/mtime. Returns null when
80+ * the server doesn't emit size/mtime or the listing fails — the caller treats metadata
81+ * as optional. Matches by the full [path] (FileNode.path), falling back to the name so a
82+ * server that returns only relative names still resolves. */
83+ private suspend fun fetchMetadata(api: soy.iko.opencode.data.network.OpencodeApiClient, path: String): soy.iko.opencode.data.model.FileNode? {
84+ val trimmed = path.trimEnd('/')
85+ val parent = trimmed.substringBeforeLast('/', "")
86+ val name = trimmed.substringAfterLast('/')
87+ val listing = api.listDirectory(parent.ifBlank { "/" })
88+ return listing.firstOrNull { it.path == path }
89+ ?: listing.firstOrNull { it.name == name }
90+ }
63}
91}
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+91−27
@@ -11,7 +11,7 @@ import androidx.compose.animation.fadeIn
11import androidx.compose.animation.fadeOut
11import androidx.compose.animation.fadeOut
12import androidx.compose.animation.togetherWith
12import androidx.compose.animation.togetherWith
13import androidx.compose.foundation.background
13import androidx.compose.foundation.background
14−import androidx.compose.foundation.Image
14+import androidx.compose.foundation.clickable
15import androidx.compose.foundation.horizontalScroll
15import androidx.compose.foundation.horizontalScroll
16import androidx.compose.foundation.interaction.DragInteraction
16import androidx.compose.foundation.interaction.DragInteraction
17import androidx.compose.foundation.layout.Box
17import androidx.compose.foundation.layout.Box
@@ -41,6 +41,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
41import androidx.compose.material.icons.automirrored.filled.WrapText
41import androidx.compose.material.icons.automirrored.filled.WrapText
42import androidx.compose.material.icons.filled.Close
42import androidx.compose.material.icons.filled.Close
43import androidx.compose.material.icons.filled.ContentCopy
43import androidx.compose.material.icons.filled.ContentCopy
44+import androidx.compose.material.icons.filled.ErrorOutline
44import androidx.compose.material.icons.filled.KeyboardArrowDown
45import androidx.compose.material.icons.filled.KeyboardArrowDown
45import androidx.compose.material.icons.filled.KeyboardArrowUp
46import androidx.compose.material.icons.filled.KeyboardArrowUp
46import androidx.compose.material.icons.filled.Link
47import androidx.compose.material.icons.filled.Link
@@ -77,7 +78,6 @@ import androidx.compose.runtime.rememberCoroutineScope
77import androidx.compose.runtime.saveable.rememberSaveable
78import androidx.compose.runtime.saveable.rememberSaveable
78import androidx.compose.runtime.setValue
79import androidx.compose.runtime.setValue
79import androidx.compose.ui.Alignment
80import androidx.compose.ui.Alignment
80−import androidx.compose.ui.graphics.asImageBitmap
81import androidx.compose.ui.Modifier
81import androidx.compose.ui.Modifier
82import androidx.compose.ui.draw.alpha
82import androidx.compose.ui.draw.alpha
83import androidx.compose.ui.draw.clip
83import androidx.compose.ui.draw.clip
@@ -85,6 +85,7 @@ import androidx.compose.ui.layout.onSizeChanged
85import androidx.compose.ui.platform.LocalContext
85import androidx.compose.ui.platform.LocalContext
86import androidx.compose.ui.platform.LocalDensity
86import androidx.compose.ui.platform.LocalDensity
87import androidx.compose.ui.platform.LocalSoftwareKeyboardController
87import androidx.compose.ui.platform.LocalSoftwareKeyboardController
88+import androidx.compose.ui.semantics.Role
88import androidx.compose.ui.semantics.contentDescription
89import androidx.compose.ui.semantics.contentDescription
89import androidx.compose.ui.semantics.semantics
90import androidx.compose.ui.semantics.semantics
90import androidx.compose.ui.text.font.FontFamily
91import androidx.compose.ui.text.font.FontFamily
@@ -95,14 +96,18 @@ import androidx.compose.ui.unit.dp
95import androidx.compose.ui.res.stringResource
96import androidx.compose.ui.res.stringResource
96import androidx.lifecycle.compose.collectAsStateWithLifecycle
97import androidx.lifecycle.compose.collectAsStateWithLifecycle
97import androidx.lifecycle.viewmodel.compose.viewModel
98import androidx.lifecycle.viewmodel.compose.viewModel
99+import coil.compose.SubcomposeAsyncImage
100+import coil.request.ImageRequest
98import soy.iko.opencode.di.AppContainer
101import soy.iko.opencode.di.AppContainer
99import soy.iko.opencode.R
102import soy.iko.opencode.R
100import soy.iko.opencode.ui.components.ConnectionBannerFor
103import soy.iko.opencode.ui.components.ConnectionBannerFor
101import soy.iko.opencode.ui.components.DiffView
104import soy.iko.opencode.ui.components.DiffView
105+import soy.iko.opencode.ui.components.FullscreenImageViewer
102import soy.iko.opencode.ui.components.LocalChatTextScale
106import soy.iko.opencode.ui.components.LocalChatTextScale
103import soy.iko.opencode.ui.components.LocalCodeWrap
107import soy.iko.opencode.ui.components.LocalCodeWrap
104import soy.iko.opencode.ui.components.copyToClipboard
108import soy.iko.opencode.ui.components.copyToClipboard
105import soy.iko.opencode.ui.components.highlightLine
109import soy.iko.opencode.ui.components.highlightLine
110+import soy.iko.opencode.ui.components.relativeTime
106import soy.iko.opencode.ui.components.syntaxFor
111import soy.iko.opencode.ui.components.syntaxFor
107import soy.iko.opencode.ui.components.rememberHighlightPalette
112import soy.iko.opencode.ui.components.rememberHighlightPalette
108import soy.iko.opencode.ui.components.scaledBy
113import soy.iko.opencode.ui.components.scaledBy
@@ -162,6 +167,14 @@ fun FileViewScreen(
162 val parent = trimmed.substringBeforeLast('/', "")
167 val parent = trimmed.substringBeforeLast('/', "")
163 parent.ifBlank { path }
168 parent.ifBlank { path }
164 }
169 }
170+ // Compact "12.3 KB · 2d ago" metadata line for the top bar. Built from the size/mtime the
171+ // ViewModel resolved via the parent directory listing (null when the server doesn't emit
172+ // them). Uses the same formatFileSize/relativeTime helpers as the file browser's FileRow so
173+ // the formatting is identical across screens. Built here (not in the top bar) because it
174+ // needs the Context and the state, and the top bar is a stateless composable.
175+ val metadataLine = remember(state.size, state.mtime) {
176+ buildMetadataLine(context, state.size, state.mtime)
177+ }
165 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
178 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
166 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
179 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
167 // `take()` (not `subList()`) so the cap actually releases the tail: subList returns a view
180 // `take()` (not `subList()`) so the cap actually releases the tail: subList returns a view
@@ -226,6 +239,7 @@ fun FileViewScreen(
226 FileViewTopBar(
239 FileViewTopBar(
227 filename = filename,
240 filename = filename,
228 subtitle = pathSubtitle,
241 subtitle = pathSubtitle,
242+ metadata = metadataLine,
229 loading = state.loading,
243 loading = state.loading,
230 content = rawText,
244 content = rawText,
231 wrap = wrap,
245 wrap = wrap,
@@ -459,11 +473,24 @@ private fun rememberJumpToLineHighlight(
459 return highlight
473 return highlight
460}
474}
461
475
476+/** Build the compact "12.3 KB · 2d ago" metadata line for the file viewer's top bar from the
477+ * size/mtime the ViewModel resolved. Returns an empty string when neither is present so the
478+ * caller can drop the line in unconditionally. Uses the same helpers as the file browser's
479+ * FileRow so the formatting is identical across screens. */
480+private fun buildMetadataLine(context: android.content.Context, size: Long?, mtime: Long?): String {
481+ val parts = buildList {
482+ size?.let { add(formatFileSize(it)) }
483+ mtime?.let { add(relativeTime(context, it)) }
484+ }
485+ return parts.joinToString(" · ")
486+}
487+
462@OptIn(ExperimentalMaterial3Api::class)
488@OptIn(ExperimentalMaterial3Api::class)
463@Composable
489@Composable
464private fun FileViewTopBar(
490private fun FileViewTopBar(
465 filename: String,
491 filename: String,
466 subtitle: String,
492 subtitle: String,
493+ metadata: String,
467 loading: Boolean,
494 loading: Boolean,
468 content: String,
495 content: String,
469 wrap: Boolean,
496 wrap: Boolean,
@@ -480,9 +507,10 @@ private fun FileViewTopBar(
480) {
507) {
481 TopAppBar(
508 TopAppBar(
482 title = {
509 title = {
483− // Two-line title: filename (primary) + the directory portion of the path
510+ // Two- or three-line title: filename (primary), the directory portion of the path
484− // (secondary), so a file opened from deep in a workspace keeps its location
511+ // (secondary), and an optional muted metadata line (size · mtime) so a user who
512+ // has drilled into a deep workspace keeps context of where this file lives and how
485− // visible without an extra breadcrumb row.
513+ // stale it is without an extra breadcrumb row.
486 Column {
514 Column {
487 Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis)
515 Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis)
488 if (subtitle.isNotBlank()) {
516 if (subtitle.isNotBlank()) {
@@ -494,6 +522,15 @@ private fun FileViewTopBar(
494 color = MaterialTheme.colorScheme.onSurfaceVariant,
522 color = MaterialTheme.colorScheme.onSurfaceVariant,
495 )
523 )
496 }
524 }
525+ if (metadata.isNotEmpty()) {
526+ Text(
527+ metadata,
528+ maxLines = 1,
529+ overflow = TextOverflow.Ellipsis,
530+ style = MaterialTheme.typography.labelSmall,
531+ color = MaterialTheme.colorScheme.onSurfaceVariant,
532+ )
533+ }
497 }
534 }
498 },
535 },
499 navigationIcon = {
536 navigationIcon = {
@@ -662,6 +699,13 @@ private fun BoxScope.FileViewStateContent(
662 modifier = Modifier.align(Alignment.Center).padding(24.dp),
699 modifier = Modifier.align(Alignment.Center).padding(24.dp),
663 horizontalAlignment = Alignment.CenterHorizontally,
700 horizontalAlignment = Alignment.CenterHorizontally,
664 ) {
701 ) {
702+ Icon(
703+ Icons.Filled.ErrorOutline,
704+ contentDescription = null,
705+ modifier = Modifier.size(56.dp),
706+ tint = MaterialTheme.colorScheme.error,
707+ )
708+ Spacer(Modifier.size(12.dp))
665 Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
709 Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
666 Spacer(Modifier.size(12.dp))
710 Spacer(Modifier.size(12.dp))
667 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
711 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
@@ -1061,8 +1105,10 @@ private fun FileTextContent(
1061/**
1105/**
1062 * Renders an image file (PNG/JPG/GIF/WebP/BMP/SVG) inline in the file viewer instead of
1106 * Renders an image file (PNG/JPG/GIF/WebP/BMP/SVG) inline in the file viewer instead of
1063 * the generic "Binary file" label. Decodes the base64 content from [FileContent.content]
1107 * the generic "Binary file" label. Decodes the base64 content from [FileContent.content]
1108+ * into bytes and hands them to Coil, which renders the image and powers the fullscreen
1109+ * zoomable viewer (pinch-zoom, save, share) on tap — the same [FullscreenImageViewer] the
1064− * into a [Bitmap] and displays it. Falls back to the binary-file label if decoding fails
1110+ * chat attachments use. Falls back to the binary-file label if the base64 payload can't be
1065− * (corrupt content or an unsupported encoding).
1111+ * decoded (corrupt content or an unsupported encoding).
1066 */
1112 */
1067@Composable
1113@Composable
1068private fun ImageFilePreview(
1114private fun ImageFilePreview(
@@ -1070,31 +1116,49 @@ private fun ImageFilePreview(
1070 modifier: Modifier = Modifier,
1116 modifier: Modifier = Modifier,
1071) {
1117) {
1072 val context = LocalContext.current
1118 val context = LocalContext.current
1073− val bitmap = remember(content.content) {
1119+ val desc = stringResource(R.string.image_file_preview)
1120+ // Decode the base64 payload to bytes once (off-thread via produceState so a large image
1121+ // doesn't block the first frame). Coil takes the ByteArray as its model and decodes it to
1122+ // a Bitmap internally — the same request drives both the inline preview and the fullscreen
1123+ // viewer, so a tap reuses the already-cached decode for the zoomed view.
1124+ val bytesState by produceState<ByteArray?>(initialValue = null, content.content) {
1125+ value = withContext(Dispatchers.Default) {
1074− runCatching {
1126+ runCatching {
1075− val bytes = android.util.Base64.decode(content.content, android.util.Base64.DEFAULT)
1127+ android.util.Base64.decode(content.content, android.util.Base64.DEFAULT)
1076− android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
1077− }.getOrNull()
1128+ }.getOrNull()
1129+ }
1078 }
1130 }
1131+ val bytes = bytesState
1079− if (bitmap == null) {
1132+ if (bytes == null) {
1080 Text(
1133 Text(
1081 stringResource(R.string.binary_file),
1134 stringResource(R.string.binary_file),
1082 modifier = modifier,
1135 modifier = modifier,
1083 color = MaterialTheme.colorScheme.onSurfaceVariant,
1136 color = MaterialTheme.colorScheme.onSurfaceVariant,
1084 )
1137 )
1085− } else {
1138+ return
1139+ }
1140+ var showFullscreen by remember { mutableStateOf(false) }
1141+ val request = remember(bytes) {
1142+ ImageRequest.Builder(context)
1143+ .data(bytes)
1144+ .crossfade(true)
1145+ .build()
1146+ }
1086− Column(
1147+ Column(
1087− modifier = modifier.fillMaxWidth(),
1148+ modifier = modifier.fillMaxWidth(),
1088− horizontalAlignment = Alignment.CenterHorizontally,
1149+ horizontalAlignment = Alignment.CenterHorizontally,
1089− ) {
1150+ ) {
1090− Image(
1151+ SubcomposeAsyncImage(
1091− bitmap = bitmap.asImageBitmap(),
1152+ model = request,
1092− contentDescription = stringResource(R.string.image_file_preview),
1153+ contentDescription = desc,
1093− modifier = Modifier
1154+ modifier = Modifier
1094− .fillMaxWidth()
1155+ .fillMaxWidth()
1095− .heightIn(max = 400.dp)
1156+ .heightIn(max = 400.dp)
1096− .clip(MaterialTheme.shapes.medium),
1157+ .clip(MaterialTheme.shapes.medium)
1158+ .clickable(role = Role.Button) { showFullscreen = true },
1097− )
1159+ )
1098− }
1160+ }
1161+ if (showFullscreen) {
1162+ FullscreenImageViewer(request = request, contentDescription = desc) { showFullscreen = false }
1099 }
1163 }
1100}
1164}
app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+41−13
@@ -8,10 +8,14 @@ import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.Spacer
9import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.imePadding
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.layout.size
14+import androidx.compose.foundation.layout.widthIn
13import androidx.compose.foundation.lazy.LazyColumn
15import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.items
16import androidx.compose.foundation.lazy.items
17+import androidx.compose.animation.Crossfade
18+import androidx.compose.animation.core.tween
15import androidx.compose.material.icons.Icons
19import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.filled.Add
20import androidx.compose.material.icons.filled.Add
17import androidx.compose.material.icons.filled.CloudOff
21import androidx.compose.material.icons.filled.CloudOff
@@ -53,6 +57,7 @@ import androidx.compose.ui.Modifier
53import androidx.compose.ui.res.stringResource
57import androidx.compose.ui.res.stringResource
54import androidx.compose.ui.semantics.contentDescription
58import androidx.compose.ui.semantics.contentDescription
55import androidx.compose.ui.semantics.semantics
59import androidx.compose.ui.semantics.semantics
60+import androidx.compose.ui.platform.testTag
56import androidx.compose.ui.text.font.FontFamily
61import androidx.compose.ui.text.font.FontFamily
57import androidx.compose.ui.text.style.TextOverflow
62import androidx.compose.ui.text.style.TextOverflow
58import androidx.compose.ui.unit.dp
63import androidx.compose.ui.unit.dp
@@ -63,6 +68,7 @@ import soy.iko.opencode.di.AppContainer
63import soy.iko.opencode.ui.components.AppTopBar
68import soy.iko.opencode.ui.components.AppTopBar
64import soy.iko.opencode.ui.components.ConnectionBannerFor
69import soy.iko.opencode.ui.components.ConnectionBannerFor
65import soy.iko.opencode.ui.components.EmptyState
70import soy.iko.opencode.ui.components.EmptyState
71+import soy.iko.opencode.ui.components.SkeletonRow
66import soy.iko.opencode.ui.components.reducedMotionAnimateItem
72import soy.iko.opencode.ui.components.reducedMotionAnimateItem
67import soy.iko.opencode.ui.vmFactory
73import soy.iko.opencode.ui.vmFactory
68
74
@@ -117,21 +123,29 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
117 PullToRefreshBox(
123 PullToRefreshBox(
118 isRefreshing = refreshing,
124 isRefreshing = refreshing,
119 onRefresh = { vm.load() },
125 onRefresh = { vm.load() },
120− modifier = Modifier.fillMaxSize().padding(padding),
126+ modifier = Modifier.fillMaxSize().imePadding().padding(padding),
121 ) {
127 ) {
128+ Box(
122− Box(modifier = Modifier.fillMaxSize()) {
129+ modifier = Modifier.fillMaxSize().widthIn(max = 600.dp),
130+ contentAlignment = Alignment.Center,
131+ ) {
123 ConnectionBannerFor(container)
132 ConnectionBannerFor(container)
124− val loadingLabel = stringResource(R.string.loading)
133+ // Crossfade between content states so transitions read as a smooth fade instead
134+ // of an instant snap. Matches the session list's Crossfade pattern; reduced
135+ // motion is honored by Crossfade's default spec.
136+ val stateKey = state::class.simpleName
137+ @Suppress("UnusedCrossfadeTargetStateParameter")
138+ Crossfade(
139+ targetState = stateKey,
140+ animationSpec = tween(soy.iko.opencode.data.network.NetworkConfig.motionFadeDurationMs.toInt()),
141+ label = "mcp_state",
142+ ) {
125− when (val s = state) {
143+ when (val s = state) {
126 is McpViewModel.State.Loading -> Column(
144 is McpViewModel.State.Loading -> Column(
127− modifier = Modifier.align(Alignment.Center),
145+ modifier = Modifier.fillMaxSize().padding(16.dp),
128− horizontalAlignment = Alignment.CenterHorizontally,
146+ verticalArrangement = Arrangement.spacedBy(12.dp),
129 ) {
147 ) {
130− CircularProgressIndicator(
148+ repeat(6) { SkeletonRow() }
131− Modifier.semantics { contentDescription = loadingLabel },
132− )
133− Spacer(Modifier.size(12.dp))
134− Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant)
135 }
149 }
136 is McpViewModel.State.Disconnected -> EmptyState(
150 is McpViewModel.State.Disconnected -> EmptyState(
137 icon = Icons.Filled.CloudOff,
151 icon = Icons.Filled.CloudOff,
@@ -158,9 +172,9 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
158 )
172 )
159 } else {
173 } else {
160 LazyColumn(
174 LazyColumn(
161− modifier = Modifier.fillMaxSize(),
175+ modifier = Modifier.fillMaxWidth(),
162 contentPadding = PaddingValues(
176 contentPadding = PaddingValues(
163− start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp,
177+ start = 16.dp, end = 16.dp, top = 16.dp, bottom = soy.iko.opencode.data.network.NetworkConfig.listFabInsetDp.dp,
164 ),
178 ),
165 verticalArrangement = Arrangement.spacedBy(12.dp),
179 verticalArrangement = Arrangement.spacedBy(12.dp),
166 ) {
180 ) {
@@ -169,6 +183,7 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
169 }
183 }
170 }
184 }
171 }
185 }
186+ }
172 }
187 }
173 }
188 }
174 }
189 }
@@ -218,6 +233,7 @@ private fun McpServerCard(server: McpServerInfo, modifier: Modifier = Modifier)
218 Card(
233 Card(
219 modifier = modifier
234 modifier = modifier
220 .fillMaxWidth()
235 .fillMaxWidth()
236+ .testTag("mcp_card")
221 .semantics(mergeDescendants = true) { contentDescription = cardDesc },
237 .semantics(mergeDescendants = true) { contentDescription = cardDesc },
222 ) {
238 ) {
223 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
239 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
@@ -400,6 +416,10 @@ private fun AddMcpDialog(
400 label = { Text(stringResource(R.string.mcp_add_name)) },
416 label = { Text(stringResource(R.string.mcp_add_name)) },
401 singleLine = true,
417 singleLine = true,
402 enabled = !adding,
418 enabled = !adding,
419+ isError = !nameValid,
420+ supportingText = if (!nameValid) {
421+ { Text(stringResource(R.string.mcp_add_name_required)) }
422+ } else null,
403 )
423 )
404 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
424 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
405 FilterChip(
425 FilterChip(
@@ -426,6 +446,10 @@ private fun AddMcpDialog(
426 },
446 },
427 singleLine = true,
447 singleLine = true,
428 enabled = !adding,
448 enabled = !adding,
449+ isError = !targetValid,
450+ supportingText = if (!targetValid) {
451+ { Text(stringResource(R.string.mcp_add_target_required)) }
452+ } else null,
429 )
453 )
430 // Warn that the local command is split on whitespace and quoted args aren't
454 // Warn that the local command is split on whitespace and quoted args aren't
431 // supported, so a user entering `"my program" --arg` isn't surprised when it's
455 // supported, so a user entering `"my program" --arg` isn't surprised when it's
@@ -447,6 +471,10 @@ private fun AddMcpDialog(
447 supportingText = { Text(stringResource(R.string.mcp_env_hint)) },
471 supportingText = { Text(stringResource(R.string.mcp_env_hint)) },
448 enabled = !adding,
472 enabled = !adding,
449 minLines = 2,
473 minLines = 2,
474+ keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
475+ capitalization = androidx.compose.ui.text.input.KeyboardCapitalization.None,
476+ imeAction = androidx.compose.ui.text.input.ImeAction.Default,
477+ ),
450 )
478 )
451 }
479 }
452 },
480 },
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+34−3
@@ -1,5 +1,7 @@
1package soy.iko.opencode.ui.search
1package soy.iko.opencode.ui.search
2
2
3+import androidx.compose.animation.Crossfade
4+import androidx.compose.animation.core.tween
3import androidx.compose.foundation.clickable
5import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Box
@@ -14,6 +16,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.imePadding
16import androidx.compose.foundation.layout.imePadding
15import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.padding
16import androidx.compose.foundation.layout.size
18import androidx.compose.foundation.layout.size
19+import androidx.compose.foundation.layout.widthIn
20+import androidx.compose.foundation.layout.wrapContentWidth
17import androidx.compose.foundation.lazy.LazyColumn
21import androidx.compose.foundation.lazy.LazyColumn
18import androidx.compose.foundation.lazy.items
22import androidx.compose.foundation.lazy.items
19import androidx.compose.foundation.text.KeyboardActions
23import androidx.compose.foundation.text.KeyboardActions
@@ -44,6 +48,7 @@ import androidx.compose.ui.Alignment
44import androidx.compose.ui.Modifier
48import androidx.compose.ui.Modifier
45import androidx.compose.ui.focus.focusRequester
49import androidx.compose.ui.focus.focusRequester
46import androidx.compose.ui.platform.LocalSoftwareKeyboardController
50import androidx.compose.ui.platform.LocalSoftwareKeyboardController
51+import androidx.compose.ui.platform.testTag
47import androidx.compose.ui.res.pluralStringResource
52import androidx.compose.ui.res.pluralStringResource
48import androidx.compose.ui.res.stringResource
53import androidx.compose.ui.res.stringResource
49import androidx.compose.ui.semantics.Role
54import androidx.compose.ui.semantics.Role
@@ -93,14 +98,16 @@ fun GlobalSearchScreen(
93 AppTopBar(title = stringResource(R.string.search_all_title), onBack = onBack)
98 AppTopBar(title = stringResource(R.string.search_all_title), onBack = onBack)
94 },
99 },
95 ) { padding ->
100 ) { padding ->
96− Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding)) {
101+ Column(modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.CenterHorizontally).widthIn(max = soy.iko.opencode.data.network.NetworkConfig.listContentMaxWidthDp.dp).imePadding().padding(padding)) {
97 OutlinedTextField(
102 OutlinedTextField(
98 value = state.query,
103 value = state.query,
99 onValueChange = vm::setQuery,
104 onValueChange = vm::setQuery,
100 modifier = Modifier
105 modifier = Modifier
101 .fillMaxWidth()
106 .fillMaxWidth()
102 .padding(horizontal = 16.dp, vertical = 8.dp)
107 .padding(horizontal = 16.dp, vertical = 8.dp)
103− .focusRequester(focusRequester),
108+ .focusRequester(focusRequester)
109+ .testTag("global_search"),
110+ label = { Text(stringResource(R.string.search_all_hint)) },
104 placeholder = { Text(stringResource(R.string.search_all_hint)) },
111 placeholder = { Text(stringResource(R.string.search_all_hint)) },
105 singleLine = true,
112 singleLine = true,
106 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
113 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
@@ -147,7 +154,17 @@ fun GlobalSearchScreen(
147 Box(modifier = Modifier.fillMaxSize()) {
154 Box(modifier = Modifier.fillMaxSize()) {
148 ConnectionBannerFor(container)
155 ConnectionBannerFor(container)
149 val searchingLabel = stringResource(R.string.searching)
156 val searchingLabel = stringResource(R.string.searching)
157+ // Crossfade between content states so transitions read as a smooth fade instead
158+ // of an instant snap. Matches the session list's Crossfade pattern; reduced
159+ // motion is honored by Crossfade's default spec.
160+ val stateKey = globalSearchStateKey(state)
161+ @Suppress("UnusedCrossfadeTargetStateParameter")
162+ Crossfade(
163+ targetState = stateKey,
164+ animationSpec = tween(soy.iko.opencode.data.network.NetworkConfig.motionFadeDurationMs.toInt()),
165+ label = "global_search_state",
166+ ) {
150− when {
167+ when {
151 state.searching -> Column(
168 state.searching -> Column(
152 modifier = Modifier.align(Alignment.Center),
169 modifier = Modifier.align(Alignment.Center),
153 horizontalAlignment = Alignment.CenterHorizontally,
170 horizontalAlignment = Alignment.CenterHorizontally,
@@ -248,12 +265,26 @@ fun GlobalSearchScreen(
248 )
265 )
249 }
266 }
250 }
267 }
268+ }
251 }
269 }
252 }
270 }
253 }
271 }
254 }
272 }
255}
273}
256
274
275+/** Maps the global-search view state to a stable string key for the Crossfade, so the screen's
276+ * content-state transitions fade smoothly. Extracted from [GlobalSearchScreen] to keep its
277+ * cyclomatic complexity under the detekt threshold. */
278+private fun globalSearchStateKey(state: GlobalSearchState): String = when {
279+ state.searching -> "searching"
280+ state.error != null -> "error"
281+ state.hasSearched && state.results.isEmpty() -> "no_matches"
282+ state.query.isNotBlank() && !state.hasSearched -> "keep_typing"
283+ state.query.isBlank() && state.history.isNotEmpty() -> "history"
284+ state.results.isEmpty() -> "start"
285+ else -> "results"
286+}
287+
257@OptIn(ExperimentalLayoutApi::class)
288@OptIn(ExperimentalLayoutApi::class)
258@Composable
289@Composable
259private fun HistorySuggestions(
290private fun HistorySuggestions(
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+70−39
@@ -32,6 +32,7 @@ import androidx.compose.material3.CircularProgressIndicator
32import androidx.compose.material3.DropdownMenu
32import androidx.compose.material3.DropdownMenu
33import androidx.compose.material3.DropdownMenuItem
33import androidx.compose.material3.DropdownMenuItem
34import androidx.compose.material3.ExperimentalMaterial3Api
34import androidx.compose.material3.ExperimentalMaterial3Api
35+import androidx.compose.material3.ExtendedFloatingActionButton
35import androidx.compose.material3.FloatingActionButton
36import androidx.compose.material3.FloatingActionButton
36import androidx.compose.material3.Icon
37import androidx.compose.material3.Icon
37import androidx.compose.material3.IconButton
38import androidx.compose.material3.IconButton
@@ -71,6 +72,8 @@ import androidx.compose.ui.unit.dp
71import androidx.compose.ui.res.stringResource
72import androidx.compose.ui.res.stringResource
72import androidx.activity.compose.BackHandler
73import androidx.activity.compose.BackHandler
73import androidx.activity.compose.rememberLauncherForActivityResult
74import androidx.activity.compose.rememberLauncherForActivityResult
75+import androidx.compose.animation.Crossfade
76+import androidx.compose.animation.core.tween
74import androidx.lifecycle.compose.collectAsStateWithLifecycle
77import androidx.lifecycle.compose.collectAsStateWithLifecycle
75import androidx.lifecycle.viewmodel.compose.viewModel
78import androidx.lifecycle.viewmodel.compose.viewModel
76import kotlinx.coroutines.coroutineScope
79import kotlinx.coroutines.coroutineScope
@@ -84,6 +87,7 @@ import soy.iko.opencode.util.runCatchingCancellable
84import soy.iko.opencode.di.AppContainer
87import soy.iko.opencode.di.AppContainer
85import soy.iko.opencode.R
88import soy.iko.opencode.R
86import soy.iko.opencode.ui.components.EmptyState
89import soy.iko.opencode.ui.components.EmptyState
90+import soy.iko.opencode.ui.components.SkeletonRow
87import soy.iko.opencode.ui.components.reducedMotionAnimateItem
91import soy.iko.opencode.ui.components.reducedMotionAnimateItem
88import soy.iko.opencode.ui.components.rememberRelativeTime
92import soy.iko.opencode.ui.components.rememberRelativeTime
89import soy.iko.opencode.ui.vmFactory
93import soy.iko.opencode.ui.vmFactory
@@ -111,6 +115,11 @@ fun ServerListScreen(
111 .collectAsStateWithLifecycle(initialValue = soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connected)
115 .collectAsStateWithLifecycle(initialValue = soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connected)
112 val haptics = LocalHapticFeedback.current
116 val haptics = LocalHapticFeedback.current
113 val snackbar = remember { SnackbarHostState() }
117 val snackbar = remember { SnackbarHostState() }
118+ // Separate host for transient error messages (connect/probe failures, QR import results)
119+ // so a transient error can't preempt an undo snackbar's full window — the undo and error
120+ // channels don't share a queue, so a connect error can't cancel an in-flight undo and
121+ // strand a pending server delete.
122+ val errorSnackbar = remember { SnackbarHostState() }
114 val retryLabel = stringResource(R.string.retry)
123 val retryLabel = stringResource(R.string.retry)
115 val undoLabel = stringResource(R.string.undo)
124 val undoLabel = stringResource(R.string.undo)
116 val serverRemovedLabel = stringResource(R.string.server_removed)
125 val serverRemovedLabel = stringResource(R.string.server_removed)
@@ -130,16 +139,16 @@ fun ServerListScreen(
130 val qrImportLauncher = ServerQrImportHandler(
139 val qrImportLauncher = ServerQrImportHandler(
131 vm = vm,
140 vm = vm,
132 scope = scope,
141 scope = scope,
133− snackbar = snackbar,
142+ snackbar = errorSnackbar,
134 context = context,
143 context = context,
135 )
144 )
136
145
137 LaunchedEffect(Unit) {
146 LaunchedEffect(Unit) {
138 vm.errorEvents.collect { event ->
147 vm.errorEvents.collect { event ->
139 val result = if (event.profile != null) {
148 val result = if (event.profile != null) {
140− snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
149+ errorSnackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
141 } else {
150 } else {
142− snackbar.showSnackbar(event.message)
151+ errorSnackbar.showSnackbar(event.message)
143 }
152 }
144 if (result == SnackbarResult.ActionPerformed && event.profile != null) {
153 if (result == SnackbarResult.ActionPerformed && event.profile != null) {
145 vm.connect(event.profile, onConnected)
154 vm.connect(event.profile, onConnected)
@@ -158,7 +167,7 @@ fun ServerListScreen(
158 is ProbeTestResult.Success -> probeSuccessLabel.format(result.latencyMs)
167 is ProbeTestResult.Success -> probeSuccessLabel.format(result.latencyMs)
159 is ProbeTestResult.Failed -> probeFailedLabel.format(result.message)
168 is ProbeTestResult.Failed -> probeFailedLabel.format(result.message)
160 }
169 }
161− snackbar.showSnackbar(message)
170+ errorSnackbar.showSnackbar(message)
162 }
171 }
163 }
172 }
164
173
@@ -257,12 +266,24 @@ fun ServerListScreen(
257 },
266 },
258 )
267 )
259 },
268 },
260− snackbarHost = { SnackbarHost(snackbar) },
269+ snackbarHost = {
261− floatingActionButton = {
270+ // Stack both hosts; the undo snackbar takes priority (rendered first), the error
262− FloatingActionButton(onClick = onAddProfile) {
271+ // host renders below it so both can be visible simultaneously without preempting.
263− Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_server))
272+ Column {
273+ SnackbarHost(snackbar)
274+ SnackbarHost(errorSnackbar)
264 }
275 }
265 },
276 },
277+ floatingActionButton = {
278+ // Extended FAB with a text label, matching the session list's "New session" FAB
279+ // so the two home screens read consistently. The icon-only FAB here was the lone
280+ // exception among the app's primary surfaces.
281+ ExtendedFloatingActionButton(
282+ onClick = onAddProfile,
283+ icon = { Icon(Icons.Filled.Add, contentDescription = null) },
284+ text = { Text(stringResource(R.string.add_server)) },
285+ )
286+ },
266 ) { padding ->
287 ) { padding ->
267 // Consume the top inset on the Column so the reconnecting indicator (its first child)
288 // Consume the top inset on the Column so the reconnecting indicator (its first child)
268 // renders just below the TopAppBar instead of at y=0 under it. The children below then
289 // renders just below the TopAppBar instead of at y=0 under it. The children below then
@@ -277,41 +298,46 @@ fun ServerListScreen(
277 .semantics { contentDescription = reconnectingLabel },
298 .semantics { contentDescription = reconnectingLabel },
278 )
299 )
279 }
300 }
280− if (loading) {
301+ // Crossfade between content states (loading/empty/list) so the transition reads as a
281− // Initial DataStore read in flight: show a spinner rather than flashing the
302+ // smooth fade instead of an instant snap. Matches the session list's Crossfade
282− // "No servers yet" empty state, which would appear then vanish once profiles
303+ // pattern; reduced motion is honored by Crossfade's default spec.
283− // arrive. Mirrors the session list's explicit loading state.
304+ val stateKey = when {
284− val loadingLabel = stringResource(R.string.loading)
305+ loading -> "loading"
285− Box(
306+ profiles.isEmpty() -> "empty"
307+ else -> "list"
308+ }
309+ Crossfade(
310+ targetState = stateKey,
311+ animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
312+ label = "server_list_state",
313+ ) { key ->
314+ when (key) {
315+ "loading" -> Column(
286− modifier = Modifier.fillMaxSize(),
316+ modifier = Modifier.fillMaxSize().padding(16.dp),
287− contentAlignment = Alignment.Center,
317+ verticalArrangement = Arrangement.spacedBy(12.dp),
288 ) {
318 ) {
289− Column(horizontalAlignment = Alignment.CenterHorizontally) {
319+ repeat(6) { SkeletonRow() }
290− CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
291− Spacer(Modifier.size(12.dp))
292− Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant)
293− }
294 }
320 }
295− } else if (profiles.isEmpty()) {
296− EmptyServers(
321+ "empty" -> EmptyServers(
297 onAdd = onAddProfile,
322 onAdd = onAddProfile,
323+ onQrImport = { qrImportLauncher.launch(arrayOf("image/*")) },
298 modifier = Modifier.fillMaxSize(),
324 modifier = Modifier.fillMaxSize(),
299 )
325 )
300− } else {
326+ else -> {
301− val filtered = remember(profiles, serverQuery) { filterServerProfiles(profiles, serverQuery) }
327+ val filtered = remember(profiles, serverQuery) { filterServerProfiles(profiles, serverQuery) }
302− PullToRefreshBox(
328+ PullToRefreshBox(
303− isRefreshing = connectingId != null,
329+ isRefreshing = connectingId != null,
304− onRefresh = { vm.refresh(onConnected) },
330+ onRefresh = { vm.refresh(onConnected) },
305− modifier = Modifier.fillMaxSize(),
306− ) {
307− LazyColumn(
308 modifier = Modifier.fillMaxSize(),
331 modifier = Modifier.fillMaxSize(),
309− contentPadding = androidx.compose.foundation.layout.PaddingValues(
310− start = 16.dp, end = 16.dp, top = 16.dp,
311− bottom = 96.dp + padding.calculateBottomPadding(),
312− ),
313− verticalArrangement = Arrangement.spacedBy(12.dp),
314 ) {
332 ) {
333+ LazyColumn(
334+ modifier = Modifier.fillMaxSize(),
335+ contentPadding = androidx.compose.foundation.layout.PaddingValues(
336+ start = 16.dp, end = 16.dp, top = 16.dp,
337+ bottom = 96.dp + padding.calculateBottomPadding(),
338+ ),
339+ verticalArrangement = Arrangement.spacedBy(12.dp),
340+ ) {
315 // Show the filter field only past a small threshold so a typical
341 // Show the filter field only past a small threshold so a typical
316 // 1–2 server setup isn't cluttered with a search bar.
342 // 1–2 server setup isn't cluttered with a search bar.
317 if (profiles.size >= NetworkConfig.serverListSearchThreshold) {
343 if (profiles.size >= NetworkConfig.serverListSearchThreshold) {
@@ -429,10 +455,11 @@ fun ServerListScreen(
429 }
455 }
430 }
456 }
431 }
457 }
458+ }
459+ }
432 }
460 }
433 }
461 }
434 }
462 }
435−
436 profiles.find { it.id == pendingDeleteId }?.let { profile ->
463 profiles.find { it.id == pendingDeleteId }?.let { profile ->
437 val isActiveProfile = profile.id == connectedId
464 val isActiveProfile = profile.id == connectedId
438 AlertDialog(
465 AlertDialog(
@@ -651,7 +678,7 @@ private fun LastUsedText(lastUsed: Long) {
651}
678}
652
679
653@Composable
680@Composable
654−private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
681+private fun EmptyServers(onAdd: () -> Unit, onQrImport: () -> Unit, modifier: Modifier = Modifier) {
655 Box(modifier = modifier, contentAlignment = Alignment.Center) {
682 Box(modifier = modifier, contentAlignment = Alignment.Center) {
656 EmptyState(
683 EmptyState(
657 icon = Icons.Filled.Dns,
684 icon = Icons.Filled.Dns,
@@ -660,6 +687,9 @@ private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
660 actionIcon = Icons.Filled.Add,
687 actionIcon = Icons.Filled.Add,
661 actionLabel = stringResource(R.string.add_server),
688 actionLabel = stringResource(R.string.add_server),
662 onAction = onAdd,
689 onAction = onAdd,
690+ secondaryActionIcon = Icons.Filled.QrCodeScanner,
691+ secondaryActionLabel = stringResource(R.string.import_from_image),
692+ onSecondaryAction = onQrImport,
663 )
693 )
664 }
694 }
665}
695}
@@ -745,7 +775,8 @@ private fun ServerListSearchField(query: String, onQueryChange: (String) -> Unit
745 OutlinedTextField(
775 OutlinedTextField(
746 value = query,
776 value = query,
747 onValueChange = onQueryChange,
777 onValueChange = onQueryChange,
748− modifier = Modifier.fillMaxWidth(),
778+ modifier = Modifier.fillMaxWidth().testTag("server_search"),
779+ label = { Text(stringResource(R.string.search_servers)) },
749 placeholder = { Text(stringResource(R.string.search_servers)) },
780 placeholder = { Text(stringResource(R.string.search_servers)) },
750 singleLine = true,
781 singleLine = true,
751 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
782 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+154−9
@@ -1,3 +1,5 @@
1+@file:Suppress("TooManyFunctions")
2+
1package soy.iko.opencode.ui.session
3package soy.iko.opencode.ui.session
2
4
3import androidx.compose.foundation.background
5import androidx.compose.foundation.background
@@ -93,6 +95,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
93import androidx.compose.ui.platform.testTag
95import androidx.compose.ui.platform.testTag
94import androidx.compose.ui.semantics.Role
96import androidx.compose.ui.semantics.Role
95import androidx.compose.ui.semantics.contentDescription
97import androidx.compose.ui.semantics.contentDescription
98+import androidx.compose.ui.semantics.heading
96import androidx.compose.ui.semantics.semantics
99import androidx.compose.ui.semantics.semantics
97import androidx.compose.ui.text.TextRange
100import androidx.compose.ui.text.TextRange
98import androidx.compose.ui.text.font.FontWeight
101import androidx.compose.ui.text.font.FontWeight
@@ -186,6 +189,10 @@ fun SessionListScreen(
186 val connectedId = activeConnection?.profile?.id
189 val connectedId = activeConnection?.profile?.id
187 val haptics = LocalHapticFeedback.current
190 val haptics = LocalHapticFeedback.current
188 val snackbar = remember { SnackbarHostState() }
191 val snackbar = remember { SnackbarHostState() }
192+ // Separate host for transient error messages so a transient error can't preempt an undo
193+ // snackbar's full window — the undo and error channels don't share a queue, so a share
194+ // error can't cancel an in-flight undo and strand a pending delete.
195+ val errorSnackbar = remember { SnackbarHostState() }
189 val undoLabel = stringResource(R.string.undo)
196 val undoLabel = stringResource(R.string.undo)
190 val retryLabel = stringResource(R.string.retry)
197 val retryLabel = stringResource(R.string.retry)
191 val sessionDeletedLabel = stringResource(R.string.session_deleted)
198 val sessionDeletedLabel = stringResource(R.string.session_deleted)
@@ -247,13 +254,13 @@ fun SessionListScreen(
247
254
248 LaunchedEffect(Unit) {
255 LaunchedEffect(Unit) {
249 vm.transientErrors.collect { msg ->
256 vm.transientErrors.collect { msg ->
250− snackbar.showSnackbar(msg)
257+ errorSnackbar.showSnackbar(msg)
251 }
258 }
252 }
259 }
253
260
254 LaunchedEffect(Unit) {
261 LaunchedEffect(Unit) {
255 vm.transientInfo.collect { msg ->
262 vm.transientInfo.collect { msg ->
256− snackbar.showSnackbar(msg)
263+ errorSnackbar.showSnackbar(msg)
257 }
264 }
258 }
265 }
259
266
@@ -261,7 +268,7 @@ fun SessionListScreen(
261 // is recoverable in one tap instead of forcing the user back through the dialog/FAB.
268 // is recoverable in one tap instead of forcing the user back through the dialog/FAB.
262 LaunchedEffect(Unit) {
269 LaunchedEffect(Unit) {
263 vm.retryableErrors.collect { err ->
270 vm.retryableErrors.collect { err ->
264− val result = snackbar.showSnackbar(
271+ val result = errorSnackbar.showSnackbar(
265 message = err.message,
272 message = err.message,
266 actionLabel = retryLabel,
273 actionLabel = retryLabel,
267 duration = androidx.compose.material3.SnackbarDuration.Long,
274 duration = androidx.compose.material3.SnackbarDuration.Long,
@@ -340,6 +347,12 @@ fun SessionListScreen(
340 }) {
347 }) {
341 Icon(Icons.Filled.MarkChatRead, contentDescription = stringResource(R.string.bulk_mark_read))
348 Icon(Icons.Filled.MarkChatRead, contentDescription = stringResource(R.string.bulk_mark_read))
342 }
349 }
350+ IconButton(onClick = {
351+ vm.bulkPin(selectedIds, pin = true)
352+ selectedIds = emptySet()
353+ }) {
354+ Icon(Icons.Filled.PushPin, contentDescription = stringResource(R.string.bulk_pin))
355+ }
343 IconButton(onClick = {
356 IconButton(onClick = {
344 vm.bulkArchive(selectedIds, archive = true)
357 vm.bulkArchive(selectedIds, archive = true)
345 selectedIds = emptySet()
358 selectedIds = emptySet()
@@ -452,7 +465,14 @@ fun SessionListScreen(
452 )
465 )
453 }
466 }
454 },
467 },
468+ snackbarHost = {
469+ // Stack both hosts; the undo snackbar takes priority (rendered first), the error
470+ // host renders below it so both can be visible simultaneously without preempting.
471+ Column {
455− snackbarHost = { SnackbarHost(snackbar) },
472+ SnackbarHost(snackbar)
473+ SnackbarHost(errorSnackbar)
474+ }
475+ },
456 floatingActionButton = {
476 floatingActionButton = {
457 if (!inSelection) {
477 if (!inSelection) {
458 ExtendedFloatingActionButton(
478 ExtendedFloatingActionButton(
@@ -871,8 +891,24 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
871 var renderCap by remember {
891 var renderCap by remember {
872 mutableStateOf(NetworkConfig.sessionListInitialPage)
892 mutableStateOf(NetworkConfig.sessionListInitialPage)
873 }
893 }
894+ // Build the grouped entry list: a Pinned header (if any pinned sessions are
895+ // present), then date-grouped sections (Today / Yesterday / Last week / Older)
896+ // when sorted by recency, or a flat list when sorted by title. Date grouping
897+ // the home screen makes a long flat list scannable; the headers are suppressed
898+ // during selection mode and when a query/filter is active (those re-order the
899+ // list away from recency, so date headers would mislead).
900+ val showGroups = state.sortMode == SessionSortMode.RECENT &&
901+ !inSelection && state.query.isEmpty() && state.directoryFilter == null
902+ // Resolve section labels once (in the @Composable scope) so buildGroupedEntries
903+ // can stay a plain function. Captured into the file-level lateinit fields.
904+ pinnedLabel = stringResource(R.string.session_pinned)
905+ todayLabel = stringResource(R.string.today)
906+ yesterdayLabel = stringResource(R.string.yesterday)
907+ lastWeekLabel = stringResource(R.string.last_week)
908+ olderLabel = stringResource(R.string.older)
874− val visibleNodes = remember(nodes, renderCap) {
909+ val groupedEntries = remember(nodes, renderCap, state.pinnedIds, showGroups) {
875− if (nodes.size <= renderCap) nodes else nodes.take(renderCap)
910+ val capped = if (nodes.size <= renderCap) nodes else nodes.take(renderCap)
911+ buildGroupedEntries(capped, state.pinnedIds, showGroups)
876 }
912 }
877 PullToRefreshBox(
913 PullToRefreshBox(
878 isRefreshing = refreshing,
914 isRefreshing = refreshing,
@@ -896,11 +932,15 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
896 LazyColumn(
932 LazyColumn(
897 state = listState,
933 state = listState,
898 modifier = Modifier.fillMaxSize(),
934 modifier = Modifier.fillMaxSize(),
899− contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
935+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = NetworkConfig.listFabInsetDp.dp),
900 verticalArrangement = Arrangement.spacedBy(12.dp),
936 verticalArrangement = Arrangement.spacedBy(12.dp),
901 ) {
937 ) {
902− items(visibleNodes, key = { it.session.id }) { node ->
938+ items(groupedEntries, key = { it.key }) { entry ->
939+ when (entry) {
940+ is SessionListEntry.Header -> DateGroupHeader(entry.label)
941+ is SessionListEntry.Node -> {
942+ val node = entry.node
903− val session = node.session
943+ val session = node.session
904 // Swipe end-to-start reveals a delete affordance and opens the
944 // Swipe end-to-start reveals a delete affordance and opens the
905 // same confirmation dialog as the trash icon. We never commit
945 // same confirmation dialog as the trash icon. We never commit
906 // the dismissal (always reset to Settled) so the card snaps back
946 // the dismissal (always reset to Settled) so the card snaps back
@@ -1032,6 +1072,8 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
1032 modifier = Modifier.testTag("session_card"),
1072 modifier = Modifier.testTag("session_card"),
1033 )
1073 )
1034 }
1074 }
1075+ }
1076+ }
1035 }
1077 }
1036 }
1078 }
1037 }
1079 }
@@ -1044,6 +1086,109 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
1044/** A session plus its nesting [depth] (0 = top level) for the sub-session tree. */
1086/** A session plus its nesting [depth] (0 = top level) for the sub-session tree. */
1045private data class SessionNode(val session: soy.iko.opencode.data.model.Session, val depth: Int)
1087private data class SessionNode(val session: soy.iko.opencode.data.model.Session, val depth: Int)
1046
1088
1089+/** A renderable entry in the grouped session list: either a section header (Pinned, Today,
1090+ * Yesterday, Last week, Older) or a session [SessionNode]. The sealed type lets the
1091+ * LazyColumn mix headers and cards in one `items()` call with stable keys. */
1092+private sealed interface SessionListEntry {
1093+ val key: String
1094+
1095+ /** A section header carrying a display label. */
1096+ data class Header(override val key: String, val label: String) : SessionListEntry
1097+
1098+ /** A session row. */
1099+ data class Node(val node: SessionNode) : SessionListEntry {
1100+ override val key: String get() = node.session.id
1101+ }
1102+}
1103+
1104+/** Bucket a session's last-activity timestamp into a display group. Returns null when no
1105+ * timestamp is available (the session is then grouped under "Older"). */
1106+private fun sessionDateGroup(session: soy.iko.opencode.data.model.Session, now: Long): String {
1107+ val ts = session.time?.updated ?: session.time?.created ?: return "older"
1108+ val dayMs = 24L * 60 * 60 * 1000
1109+ val todayStart = (now / dayMs) * dayMs
1110+ return when {
1111+ ts >= todayStart -> "today"
1112+ ts >= todayStart - dayMs -> "yesterday"
1113+ ts >= todayStart - 7 * dayMs -> "last_week"
1114+ else -> "older"
1115+ }
1116+}
1117+
1118+/** Build the grouped entry list for the LazyColumn. Inserts a "Pinned" header before pinned
1119+ * sessions (when [showGroups] is true and pinned sessions exist), and date-group headers
1120+ * (Today / Yesterday / Last week / Older) between unpinned sessions when [showGroups] is
1121+ * true. When [showGroups] is false (title sort, selection mode, active query/filter), the
1122+ * nodes are returned flat with only a Pinned header (still useful to signal why pinned
1123+ * sessions float to the top). */
1124+private fun buildGroupedEntries(
1125+ nodes: List<SessionNode>,
1126+ pinnedIds: Set<String>,
1127+ showGroups: Boolean,
1128+): List<SessionListEntry> {
1129+ if (nodes.isEmpty()) return emptyList()
1130+ val now = System.currentTimeMillis()
1131+ val result = ArrayList<SessionListEntry>(nodes.size + 4)
1132+ val pinnedHeaderAdded = booleanArrayOf(false)
1133+ val dateHeaderAdded = HashMap<String, Boolean>()
1134+
1135+ fun maybeAddPinnedHeader() {
1136+ if (!pinnedHeaderAdded[0] && pinnedIds.isNotEmpty()) {
1137+ result.add(SessionListEntry.Header("__pinned", pinnedLabel))
1138+ pinnedHeaderAdded[0] = true
1139+ }
1140+ }
1141+
1142+ for (node in nodes) {
1143+ val isPinned = node.session.id in pinnedIds
1144+ if (isPinned) {
1145+ maybeAddPinnedHeader()
1146+ result.add(SessionListEntry.Node(node))
1147+ } else {
1148+ if (showGroups) {
1149+ val group = sessionDateGroup(node.session, now)
1150+ if (dateHeaderAdded[group] != true) {
1151+ dateHeaderAdded[group] = true
1152+ result.add(SessionListEntry.Header("__date_$group", dateGroupLabel(group)))
1153+ }
1154+ }
1155+ result.add(SessionListEntry.Node(node))
1156+ }
1157+ }
1158+ return result
1159+}
1160+
1161+/** Resolve a date-group key to its display label. */
1162+private fun dateGroupLabel(group: String): String = when (group) {
1163+ "today" -> todayLabel
1164+ "yesterday" -> yesterdayLabel
1165+ "last_week" -> lastWeekLabel
1166+ else -> olderLabel
1167+}
1168+
1169+// Cached labels resolved lazily from resources at first use. Resolved in the composable
1170+// scope via stringResource and captured here so buildGroupedEntries (a plain function) can
1171+// use them without a @Composable context.
1172+private lateinit var pinnedLabel: String
1173+private lateinit var todayLabel: String
1174+private lateinit var yesterdayLabel: String
1175+private lateinit var lastWeekLabel: String
1176+private lateinit var olderLabel: String
1177+
1178+/** A date-group section header in the session list. Rendered as a muted titleSmall label
1179+ * with heading semantics so TalkBack users can skip between groups. */
1180+@Composable
1181+private fun DateGroupHeader(label: String) {
1182+ Text(
1183+ label,
1184+ style = MaterialTheme.typography.titleSmall,
1185+ color = MaterialTheme.colorScheme.primary,
1186+ modifier = Modifier
1187+ .padding(top = 8.dp, bottom = 4.dp)
1188+ .semantics { heading() },
1189+ )
1190+}
1191+
1047/**
1192/**
1048 * Keep the home-screen widget, launcher shortcuts, and "Resume last" in sync with the
1193 * Keep the home-screen widget, launcher shortcuts, and "Resume last" in sync with the
1049 * loaded session list. When the list is populated, write recents and point "Resume last"
1194 * loaded session list. When the list is populated, write recents and point "Resume last"
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+19−6
@@ -843,14 +843,27 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
843 )
843 )
844 }
844 }
845
845
846+ /** Pin or unpin all of the given sessions. Emits a single bulk action event so the UI can
847+ * show one Undo snackbar for the batch (per-session snackbars would spam). */
848+ fun bulkPin(ids: Set<String>, pin: Boolean) {
849+ if (ids.isEmpty()) return
850+ ids.forEach { applyPin(it, pin) }
851+ _bulkSessionActionEvents.tryEmit(
852+ BulkSessionActionEvent(
853+ ids,
854+ if (pin) SessionActionKind.PINNED else SessionActionKind.UNPINNED,
855+ ),
856+ )
857+ }
858+
846− /** Reverse a bulk archive action surfaced via [bulkSessionActionEvents] (Undo snackbar). */
859+ /** Reverse a bulk archive/pin action surfaced via [bulkSessionActionEvents] (Undo snackbar). */
847 fun undoBulkAction(event: BulkSessionActionEvent) {
860 fun undoBulkAction(event: BulkSessionActionEvent) {
848− val undo = when (event.kind) {
861+ when (event.kind) {
849− SessionActionKind.ARCHIVED -> false
862+ SessionActionKind.ARCHIVED -> event.ids.forEach { applyArchive(it, false) }
850− SessionActionKind.UNARCHIVED -> true
863+ SessionActionKind.UNARCHIVED -> event.ids.forEach { applyArchive(it, true) }
864+ SessionActionKind.PINNED -> event.ids.forEach { applyPin(it, false) }
851− SessionActionKind.PINNED, SessionActionKind.UNPINNED -> return
865+ SessionActionKind.UNPINNED -> event.ids.forEach { applyPin(it, true) }
852 }
866 }
853− event.ids.forEach { applyArchive(it, undo) }
854 }
867 }
855
868
856 /** Mark all of the given sessions as read (clears their unread badge). Emits a transient
869 /** Mark all of the given sessions as read (clears their unread badge). Emits a transient
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+16−35
@@ -99,34 +99,31 @@ fun TwoPaneSessionChat(
99 onClear = { selected = null },
99 onClear = { selected = null },
100 )
100 )
101
101
102− // Auto-select the most-recent session into the detail pane so a wide screen doesn't
102+ // Auto-select the top of the sorted/filtered list into the detail pane so a wide screen
103− // open on a blank right pane. Runs at most once per Activity instance (the `autoSelected`
103+ // doesn't open on a blank right pane. Runs at most once per Activity instance (the
104− // latch, saveable so an explicit "clear detail pane" via BackHandler survives a rotation
104+ // `autoSelected` latch, saveable so an explicit "clear detail pane" via BackHandler
105− // — otherwise rotation resets the latch, re-arms the effect, and re-selects a session the
105+ // survives a rotation — otherwise rotation resets the latch, re-arms the effect, and
106− // user just cleared), and only once the first list load has completed. It defers to any
106+ // re-selects a session the user just cleared), and only once the first list load has
107− // explicit selection already present — a restored `selected`, a deep-link/notification
107+ // completed. It defers to any explicit selection already present — a restored `selected`,
108− // (pendingOpenSession), or a user pick — and, since the latch is set the moment any of
108+ // a deep-link/notification (pendingOpenSession), or a user pick — and, since the latch
109− // those wins, it never re-selects after a user clears the pane (BackHandler) or a server
109+ // is set the moment any of those wins, it never re-selects after a user clears the pane
110− // switch nulls `selected`.
110+ // (BackHandler) or a server switch nulls `selected`. Using state.filtered (not a raw
111+ // recency sort) means the auto-selected detail matches the top list row under the
112+ // active sort mode, pinned float, archived-hidden, and directory filter.
111 var autoSelected by rememberSaveable { mutableStateOf(false) }
113 var autoSelected by rememberSaveable { mutableStateOf(false) }
114+ val autoSelectCandidate = sessionListState.filtered.firstOrNull()?.id
112 LaunchedEffect(
115 LaunchedEffect(
113 sessionListState.loading,
116 sessionListState.loading,
114− sessionListState.sessions,
117+ autoSelectCandidate,
115− sessionListState.archivedIds,
116− sessionListState.showArchived,
117 pendingOpenSession,
118 pendingOpenSession,
118 selected,
119 selected,
119 ) {
120 ) {
120 if (autoSelected || sessionListState.loading) return@LaunchedEffect
121 if (autoSelected || sessionListState.loading) return@LaunchedEffect
121 // A pending deep-link or an existing selection takes precedence; yield the pane to it.
122 // A pending deep-link or an existing selection takes precedence; yield the pane to it.
122 if (pendingOpenSession != null || selected != null) { autoSelected = true; return@LaunchedEffect }
123 if (pendingOpenSession != null || selected != null) { autoSelected = true; return@LaunchedEffect }
123− // Pick by recency regardless of the list's current sort mode, but only among
124+ // Pick the top of the filtered list (respects sort mode, pinned, archived-hidden,
124− // sessions visible under the current archive filter so we never open a hidden row.
125+ // and directory filter) so the auto-selected detail matches the top list row.
125− val id = mostRecentSessionId(
126− sessionListState.sessions,
127− sessionListState.archivedIds,
128− sessionListState.showArchived,
129− ) ?: return@LaunchedEffect
126+ val id = autoSelectCandidate ?: return@LaunchedEffect
130 selected = id
127 selected = id
131 autoSelected = true
128 autoSelected = true
132 }
129 }
@@ -199,22 +196,6 @@ fun TwoPaneSessionChat(
199 }
196 }
200}
197}
201
198
202−/**
203− * Id of the most recently updated (or created) session that is *visible* under the current
204− * archive filter (archived sessions excluded unless [showArchived]), or null when none
205− * qualify. Auto-selecting a hidden/archived session would open the detail pane on a row with
206− * no corresponding highlighted entry in the list.
207− */
208−private fun mostRecentSessionId(
209− sessions: List<Session>,
210− archivedIds: Set<String>,
211− showArchived: Boolean,
212−): String? =
213− sessions
214− .filter { showArchived || it.id !in archivedIds }
215− .maxByOrNull { it.time?.updated ?: it.time?.created ?: 0L }
216− ?.id
217−
218/** Whether a session with [id] is present in [sessions]. */
199/** Whether a session with [id] is present in [sessions]. */
219private fun containsSession(sessions: List<Session>, id: String): Boolean =
200private fun containsSession(sessions: List<Session>, id: String): Boolean =
220 sessions.any { it.id == id }
201 sessions.any { it.id == id }
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+37−3
@@ -3,6 +3,8 @@ package soy.iko.opencode.ui.settings
3import android.content.ActivityNotFoundException
3import android.content.ActivityNotFoundException
4import android.content.Intent
4import android.content.Intent
5import android.util.Log
5import android.util.Log
6+import androidx.compose.animation.Crossfade
7+import androidx.compose.animation.core.tween
6import androidx.compose.foundation.background
8import androidx.compose.foundation.background
7import androidx.compose.foundation.clickable
9import androidx.compose.foundation.clickable
8import androidx.compose.foundation.layout.Arrangement
10import androidx.compose.foundation.layout.Arrangement
@@ -11,6 +13,8 @@ import androidx.compose.foundation.layout.Column
11import androidx.compose.foundation.layout.Row
13import androidx.compose.foundation.layout.Row
12import androidx.compose.foundation.layout.fillMaxSize
14import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
15import androidx.compose.foundation.layout.fillMaxWidth
16+import androidx.compose.foundation.layout.widthIn
17+import androidx.compose.foundation.layout.wrapContentWidth
14import androidx.compose.foundation.layout.imePadding
18import androidx.compose.foundation.layout.imePadding
15import androidx.compose.foundation.layout.padding
19import androidx.compose.foundation.layout.padding
16import androidx.compose.foundation.lazy.LazyColumn
20import androidx.compose.foundation.lazy.LazyColumn
@@ -246,7 +250,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
246 },
250 },
247 ) { padding ->
251 ) { padding ->
248 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
252 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
249− Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding).nestedScroll(scrollBehavior.nestedScrollConnection)) {
253+ Column(modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.CenterHorizontally).widthIn(max = soy.iko.opencode.data.network.NetworkConfig.listContentMaxWidthDp.dp).imePadding().padding(padding).nestedScroll(scrollBehavior.nestedScrollConnection)) {
250 Text(
254 Text(
251 stringResource(R.string.diagnostics_desc),
255 stringResource(R.string.diagnostics_desc),
252 style = MaterialTheme.typography.bodySmall,
256 style = MaterialTheme.typography.bodySmall,
@@ -261,11 +265,29 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
261 .padding(start = 16.dp, bottom = 8.dp)
265 .padding(start = 16.dp, bottom = 8.dp)
262 .semantics { heading() },
266 .semantics { heading() },
263 )
267 )
268+ // Crossfade between empty and list states so the transition reads as a smooth fade
269+ // instead of an instant snap. Matches the session list's Crossfade pattern; reduced
270+ // motion is honored by Crossfade's default spec.
271+ val stateKey = if (reports.isEmpty()) "empty" else "list"
272+ @Suppress("UnusedCrossfadeTargetStateParameter")
273+ Crossfade(
274+ targetState = stateKey,
275+ animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
276+ label = "diagnostics_state",
277+ ) {
264− if (reports.isEmpty()) {
278+ if (reports.isEmpty()) {
265 EmptyState(
279 EmptyState(
266 icon = Icons.Filled.BugReport,
280 icon = Icons.Filled.BugReport,
267 title = stringResource(R.string.no_crash_reports),
281 title = stringResource(R.string.no_crash_reports),
268 description = stringResource(R.string.no_crash_reports_desc),
282 description = stringResource(R.string.no_crash_reports_desc),
283+ actionLabel = stringResource(R.string.report_issue),
284+ onAction = {
285+ runCatching {
286+ context.startActivity(
287+ Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
288+ )
289+ }
290+ },
269 modifier = Modifier.fillMaxWidth().padding(24.dp),
291 modifier = Modifier.fillMaxWidth().padding(24.dp),
270 )
292 )
271 } else {
293 } else {
@@ -326,12 +348,23 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
326 tint = MaterialTheme.colorScheme.error,
348 tint = MaterialTheme.colorScheme.error,
327 )
349 )
328 Column(modifier = Modifier.weight(1f)) {
350 Column(modifier = Modifier.weight(1f)) {
329− Text(report.preview, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
351+ Text(report.preview, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
330 Text(
352 Text(
331 rememberRelativeTime(report.timestamp),
353 rememberRelativeTime(report.timestamp),
332 style = MaterialTheme.typography.bodySmall,
354 style = MaterialTheme.typography.bodySmall,
333 color = MaterialTheme.colorScheme.onSurfaceVariant,
355 color = MaterialTheme.colorScheme.onSurfaceVariant,
334 )
356 )
357+ // The filename often encodes the exception class (the
358+ // CrashLogger names reports "<ExceptionClass>_<ts>.txt"),
359+ // so showing it muted under the relative time lets a user
360+ // scan for a specific exception type without opening each.
361+ Text(
362+ report.fileName,
363+ style = MaterialTheme.typography.labelSmall,
364+ color = MaterialTheme.colorScheme.onSurfaceVariant,
365+ maxLines = 1,
366+ overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
367+ )
335 }
368 }
336 IconButton(onClick = {
369 IconButton(onClick = {
337 shareScope.launch {
370 shareScope.launch {
@@ -385,6 +418,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
385 }
418 }
386 }
419 }
387 }
420 }
421+ }
388 }
422 }
389 }
423 }
390 }
424 }
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+954−599
@@ -22,12 +22,15 @@ import androidx.compose.foundation.layout.wrapContentWidth
22import androidx.compose.foundation.rememberScrollState
22import androidx.compose.foundation.rememberScrollState
23import androidx.compose.foundation.selection.selectable
23import androidx.compose.foundation.selection.selectable
24import androidx.compose.foundation.selection.toggleable
24import androidx.compose.foundation.selection.toggleable
25+import androidx.compose.foundation.text.KeyboardActions
26+import androidx.compose.foundation.text.KeyboardOptions
25import androidx.compose.foundation.verticalScroll
27import androidx.compose.foundation.verticalScroll
26import androidx.compose.foundation.shape.CircleShape
28import androidx.compose.foundation.shape.CircleShape
27import androidx.compose.material.icons.Icons
29import androidx.compose.material.icons.Icons
28import androidx.compose.material.icons.automirrored.filled.ArrowBack
30import androidx.compose.material.icons.automirrored.filled.ArrowBack
29import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
31import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
30import androidx.compose.material.icons.filled.BugReport
32import androidx.compose.material.icons.filled.BugReport
33+import androidx.compose.material.icons.filled.Clear
31import androidx.compose.material.icons.filled.Code
34import androidx.compose.material.icons.filled.Code
32import androidx.compose.material.icons.filled.Dns
35import androidx.compose.material.icons.filled.Dns
33import androidx.compose.material.icons.filled.Download
36import androidx.compose.material.icons.filled.Download
@@ -35,6 +38,7 @@ import androidx.compose.material.icons.filled.Feedback
35import androidx.compose.material.icons.filled.Hub
38import androidx.compose.material.icons.filled.Hub
36import androidx.compose.material.icons.filled.Notifications
39import androidx.compose.material.icons.filled.Notifications
37import androidx.compose.material.icons.filled.QueryStats
40import androidx.compose.material.icons.filled.QueryStats
41+import androidx.compose.material.icons.filled.Search
38import androidx.compose.material.icons.filled.Upload
42import androidx.compose.material.icons.filled.Upload
39import androidx.compose.material3.AlertDialog
43import androidx.compose.material3.AlertDialog
40import androidx.compose.material3.ExperimentalMaterial3Api
44import androidx.compose.material3.ExperimentalMaterial3Api
@@ -78,12 +82,15 @@ import androidx.compose.ui.graphics.Color
78import androidx.compose.ui.input.nestedscroll.nestedScroll
82import androidx.compose.ui.input.nestedscroll.nestedScroll
79import androidx.compose.ui.platform.LocalContext
83import androidx.compose.ui.platform.LocalContext
80import androidx.compose.ui.platform.LocalDensity
84import androidx.compose.ui.platform.LocalDensity
85+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
86+import androidx.compose.ui.platform.testTag
81import androidx.compose.ui.res.pluralStringResource
87import androidx.compose.ui.res.pluralStringResource
82import androidx.compose.ui.res.stringResource
88import androidx.compose.ui.res.stringResource
83import androidx.compose.ui.semantics.Role
89import androidx.compose.ui.semantics.Role
84import androidx.compose.ui.semantics.heading
90import androidx.compose.ui.semantics.heading
85import androidx.compose.ui.semantics.role
91import androidx.compose.ui.semantics.role
86import androidx.compose.ui.semantics.semantics
92import androidx.compose.ui.semantics.semantics
93+import androidx.compose.ui.text.input.ImeAction
87import androidx.compose.ui.text.style.TextAlign
94import androidx.compose.ui.text.style.TextAlign
88import androidx.compose.ui.unit.dp
95import androidx.compose.ui.unit.dp
89import androidx.lifecycle.compose.LocalLifecycleOwner
96import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -202,6 +209,9 @@ fun SettingsScreen(
202 // import reads a chosen file and applies it. Feedback goes through the snackbar host so it's
209 // import reads a chosen file and applies it. Feedback goes through the snackbar host so it's
203 // accessible to TalkBack and consistent with the rest of the app (Toast isn't).
210 // accessible to TalkBack and consistent with the rest of the app (Toast isn't).
204 var includePasswords by rememberSaveable { mutableStateOf(false) }
211 var includePasswords by rememberSaveable { mutableStateOf(false) }
212+ // Settings search/filter query. rememberSaveable so the filter survives config changes
213+ // (rotation, theme switch) — the same reason the backup toggle below is saved.
214+ var settingsQuery by rememberSaveable { mutableStateOf("") }
205 // True while an export or import is in flight. Used to disable both NavRow triggers so a
215 // True while an export or import is in flight. Used to disable both NavRow triggers so a
206 // double-tap (or a slow device) can't launch a second file picker or start a second
216 // double-tap (or a slow device) can't launch a second file picker or start a second
207 // operation while the first is still reading/writing. Mirrors the busy-flag pattern used
217 // operation while the first is still reading/writing. Mirrors the busy-flag pattern used
@@ -283,698 +293,1003 @@ fun SettingsScreen(
283 .padding(16.dp),
293 .padding(16.dp),
284 ) {
294 ) {
285 val s = settings
295 val s = settings
286− Text(
296+ // Resolve section-header strings once so the filter logic below can match
297+ // against them without re-resolving per recomposition. These mirror the
298+ // Text() headers rendered further down.
287− stringResource(R.string.appearance),
299+ val appearanceHeader = stringResource(R.string.appearance)
288− style = MaterialTheme.typography.titleSmall,
300+ val textDisplayHeader = stringResource(R.string.text_display)
289− color = MaterialTheme.colorScheme.primary,
301+ val motionHeader = stringResource(R.string.settings_motion)
290− modifier = Modifier.semantics { heading() },
302+ val notificationsHeader = stringResource(R.string.settings_notifications)
303+ val swipeActionsHeader = stringResource(R.string.settings_swipe_actions)
304+ val connectionHeader = stringResource(R.string.connection)
305+ val backupHeader = stringResource(R.string.backup_restore)
306+ val aboutHeader = stringResource(R.string.about)
307+ // Row label/description strings used for filtering. Kept in the same order as the
308+ // rows appear, so a future editor can line them up with the rows below.
309+ val dynamicColorLabel = stringResource(R.string.dynamic_color)
310+ val dynamicColorDesc = stringResource(R.string.dynamic_color_desc)
311+ val sendOnEnterLabel = stringResource(R.string.send_on_enter)
312+ val sendOnEnterDesc = stringResource(R.string.send_on_enter_desc)
313+ val appLockLabel = stringResource(R.string.app_lock)
314+ val appLockDesc = stringResource(R.string.app_lock_desc)
315+ val wrapCodeLabel = stringResource(R.string.wrap_code_blocks)
316+ val wrapCodeDesc = stringResource(R.string.wrap_code_blocks_desc)
317+ val compactSpacingLabel = stringResource(R.string.compact_message_spacing)
318+ val compactSpacingDesc = stringResource(R.string.compact_message_spacing_desc)
319+ val hapticsLabel = stringResource(R.string.settings_haptics)
320+ val hapticsDesc = stringResource(R.string.settings_haptics_desc)
321+ val reducedMotionLabel = stringResource(R.string.settings_reduced_motion)
322+ val reducedMotionDesc = stringResource(R.string.settings_reduced_motion_desc)
323+ val languageLabel = stringResource(R.string.settings_language)
324+ val languageDesc = stringResource(R.string.settings_language_desc)
325+ val notifRunCompleteLabel = stringResource(R.string.settings_notif_run_complete)
326+ val notifRunCompleteDesc = stringResource(R.string.settings_notif_run_complete_desc)
327+ val notifPermissionLabel = stringResource(R.string.settings_notif_permission)
328+ val notifPermissionDesc = stringResource(R.string.settings_notif_permission_desc)
329+ val notifErrorLabel = stringResource(R.string.settings_notif_error)
330+ val notifErrorDesc = stringResource(R.string.settings_notif_error_desc)
331+ val swipeLeftLabel = stringResource(R.string.settings_swipe_left)
332+ val swipeRightLabel = stringResource(R.string.settings_swipe_right)
333+ val manageServersLabel = stringResource(R.string.manage_servers)
334+ val usageLabel = stringResource(R.string.usage_title)
335+ val mcpLabel = stringResource(R.string.mcp_servers)
336+ val notifSettingsLabel = stringResource(R.string.notification_settings)
337+ val backupIncludePasswordsLabel = stringResource(R.string.backup_include_passwords)
338+ val backupIncludePasswordsDesc = stringResource(R.string.backup_include_passwords_desc)
339+ val exportBackupLabel = stringResource(R.string.export_backup)
340+ val importBackupLabel = stringResource(R.string.import_backup)
341+ val diagnosticsLabel = stringResource(R.string.diagnostics)
342+ val sourceCodeLabel = stringResource(R.string.source_code)
343+ val reportIssueLabel = stringResource(R.string.report_issue)
344+ val chatTextSizeDesc = stringResource(R.string.chat_text_size_desc)
345+ val backupDescText = stringResource(R.string.backup_desc)
346+ val aboutDescText = stringResource(R.string.about_desc)
347+
348+ // Per-section filter state. For each section we compute:
349+ // - headerMatches: the section header itself matches the query
350+ // - anyRowMatches: at least one row in the section matches
351+ // A section is shown when either is true (or when the query is empty).
352+ // A row is shown when the header matches alone (browse the whole matched section),
353+ // or when the row itself matches.
354+ val appearanceHeaderMatches = matchesQuery(settingsQuery, appearanceHeader)
355+ val appearanceAnyRowMatches = matchesQuery(
356+ settingsQuery,
357+ dynamicColorLabel, dynamicColorDesc,
358+ sendOnEnterLabel, sendOnEnterDesc,
359+ appLockLabel, appLockDesc,
291 )
360 )
292− if (s != null) {
361+ val showAppearance = appearanceHeaderMatches || appearanceAnyRowMatches
293− ThemeMode.entries.forEach { mode ->
362+ val textDisplayHeaderMatches = matchesQuery(settingsQuery, textDisplayHeader)
294− ThemeRow(
363+ val textDisplayAnyRowMatches = matchesQuery(
295− mode = mode,
364+ settingsQuery,
296− selected = s.themeMode == mode,
365+ wrapCodeLabel, wrapCodeDesc,
297− // When Material You is active the static Tokyo Night swatches would be a
366+ compactSpacingLabel, compactSpacingDesc,
298− // lie, so ThemeRow hides them and notes that system colors are in use.
367+ chatTextSizeDesc,
299− usingSystemColors = s.dynamicColor && dynamicColorAvailable,
368+ )
300− onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
369+ val showTextDisplay = textDisplayHeaderMatches || textDisplayAnyRowMatches
370+ val motionHeaderMatches = matchesQuery(settingsQuery, motionHeader)
371+ val motionAnyRowMatches = matchesQuery(
372+ settingsQuery,
373+ hapticsLabel, hapticsDesc,
374+ reducedMotionLabel, reducedMotionDesc,
375+ languageLabel, languageDesc,
376+ )
377+ val showMotion = motionHeaderMatches || motionAnyRowMatches
378+ val notificationsHeaderMatches = matchesQuery(settingsQuery, notificationsHeader)
379+ val notificationsAnyRowMatches = matchesQuery(
380+ settingsQuery,
381+ notifRunCompleteLabel, notifRunCompleteDesc,
382+ notifPermissionLabel, notifPermissionDesc,
383+ notifErrorLabel, notifErrorDesc,
384+ )
385+ val showNotifications = notificationsHeaderMatches || notificationsAnyRowMatches
386+ val swipeHeaderMatches = matchesQuery(settingsQuery, swipeActionsHeader)
387+ val swipeAnyRowMatches = matchesQuery(
388+ settingsQuery,
389+ swipeLeftLabel,
390+ swipeRightLabel,
391+ )
392+ val showSwipeActions = swipeHeaderMatches || swipeAnyRowMatches
393+ val connectionHeaderMatches = matchesQuery(settingsQuery, connectionHeader)
394+ val connectionAnyRowMatches = matchesQuery(
395+ settingsQuery,
396+ manageServersLabel,
397+ usageLabel,
398+ mcpLabel,
399+ notifSettingsLabel,
400+ )
401+ val showConnection = connectionHeaderMatches || connectionAnyRowMatches
402+ val backupHeaderMatches = matchesQuery(settingsQuery, backupHeader)
403+ val backupAnyRowMatches = matchesQuery(
404+ settingsQuery,
405+ backupDescText,
406+ backupIncludePasswordsLabel, backupIncludePasswordsDesc,
407+ exportBackupLabel,
408+ importBackupLabel,
409+ )
410+ val showBackup = backupHeaderMatches || backupAnyRowMatches
411+ val aboutHeaderMatches = matchesQuery(settingsQuery, aboutHeader)
412+ val aboutAnyRowMatches = matchesQuery(
413+ settingsQuery,
414+ aboutDescText,
415+ diagnosticsLabel,
416+ sourceCodeLabel,
417+ reportIssueLabel,
301− )
418+ )
302− }
419+ val showAbout = aboutHeaderMatches || aboutAnyRowMatches
303
420
421+ val keyboardController = LocalSoftwareKeyboardController.current
422+ OutlinedTextField(
423+ value = settingsQuery,
424+ onValueChange = { settingsQuery = it },
304− Spacer(Modifier.size(8.dp))
425+ modifier = Modifier
305− // Dynamic color (Material You) only works on Android 12+, so hide the toggle
426+ .fillMaxWidth()
306− // on older devices instead of offering a control that silently does nothing.
427+ .padding(bottom = 8.dp)
428+ .testTag("settings_search"),
429+ label = { Text(stringResource(R.string.search_settings)) },
430+ placeholder = { Text(stringResource(R.string.search_settings)) },
431+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
432+ trailingIcon = if (settingsQuery.isNotEmpty()) {
433+ {
434+ IconButton(onClick = { settingsQuery = "" }) {
435+ Icon(Icons.Filled.Clear, contentDescription = stringResource(R.string.clear_search))
436+ }
437+ }
438+ } else null,
439+ singleLine = true,
440+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
441+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
442+ )
443+
444+ if (showAppearance) {
445+ Text(
446+ appearanceHeader,
447+ style = MaterialTheme.typography.titleSmall,
448+ color = MaterialTheme.colorScheme.primary,
449+ modifier = Modifier.semantics { heading() },
450+ )
451+ if (s != null) {
452+ ThemeMode.entries.forEach { mode ->
453+ // Theme rows have no description text; match against the mode label only.
454+ // ThemeRow renders the label internally via stringResource, so resolve it
455+ // here for the filter check.
456+ val modeLabel = when (mode) {
457+ ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
458+ ThemeMode.LIGHT -> stringResource(R.string.theme_light)
459+ ThemeMode.DARK -> stringResource(R.string.theme_dark)
460+ ThemeMode.AMOLED -> stringResource(R.string.theme_amoled)
461+ }
462+ if (rowVisible(settingsQuery, appearanceHeaderMatches, appearanceAnyRowMatches, modeLabel)) {
463+ ThemeRow(
464+ mode = mode,
465+ selected = s.themeMode == mode,
466+ // When Material You is active the static Tokyo Night swatches would be a
467+ // lie, so ThemeRow hides them and notes that system colors are in use.
307− if (dynamicColorAvailable) {
468+ usingSystemColors = s.dynamicColor && dynamicColorAvailable,
308− val amoledActive = s.themeMode == ThemeMode.AMOLED
309− Row(
310− modifier = Modifier
311− .fillMaxWidth()
312− .defaultMinSize(minHeight = 48.dp)
313− .toggleable(
314− value = s.dynamicColor,
315− enabled = !amoledActive,
316− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
469+ onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
317− role = Role.Switch,
318 )
470 )
319− .padding(vertical = 8.dp),
471+ }
320− verticalAlignment = Alignment.CenterVertically,
472+ }
473+
474+ if (dynamicColorAvailable && rowVisible(
475+ settingsQuery,
476+ appearanceHeaderMatches,
477+ appearanceAnyRowMatches,
478+ dynamicColorLabel, dynamicColorDesc,
479+ )
321 ) {
480 ) {
481+ Spacer(Modifier.size(8.dp))
482+ // Dynamic color (Material You) only works on Android 12+, so hide the toggle
483+ // on older devices instead of offering a control that silently does nothing.
484+ val amoledActive = s.themeMode == ThemeMode.AMOLED
485+ Row(
486+ modifier = Modifier
487+ .fillMaxWidth()
488+ .defaultMinSize(minHeight = 48.dp)
489+ .toggleable(
490+ value = s.dynamicColor,
491+ enabled = !amoledActive,
492+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
493+ role = Role.Switch,
494+ )
495+ .padding(vertical = 8.dp),
496+ verticalAlignment = Alignment.CenterVertically,
497+ ) {
322− Column(modifier = Modifier.weight(1f)) {
498+ Column(modifier = Modifier.weight(1f)) {
323− Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
499+ Text(dynamicColorLabel, style = MaterialTheme.typography.bodyLarge)
324− Text(
325− stringResource(R.string.dynamic_color_desc),
326− style = MaterialTheme.typography.bodySmall,
327− color = MaterialTheme.colorScheme.onSurfaceVariant,
328− )
329− if (amoledActive) {
330 Text(
500 Text(
331− stringResource(R.string.dynamic_color_disabled_amoled),
501+ dynamicColorDesc,
332 style = MaterialTheme.typography.bodySmall,
502 style = MaterialTheme.typography.bodySmall,
333 color = MaterialTheme.colorScheme.onSurfaceVariant,
503 color = MaterialTheme.colorScheme.onSurfaceVariant,
334 )
504 )
505+ if (amoledActive) {
506+ Text(
507+ stringResource(R.string.dynamic_color_disabled_amoled),
508+ style = MaterialTheme.typography.bodySmall,
509+ color = MaterialTheme.colorScheme.onSurfaceVariant,
510+ )
511+ }
335 }
512 }
513+ Switch(
514+ checked = s.dynamicColor,
515+ onCheckedChange = null,
516+ enabled = !amoledActive,
517+ )
336 }
518 }
337− Switch(
338− checked = s.dynamicColor,
339− onCheckedChange = null,
340− enabled = !amoledActive,
341− )
342 }
519 }
343− }
344
520
345− // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
521+ // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
346− Spacer(Modifier.size(4.dp))
522+ if (rowVisible(
347− Row(
523+ settingsQuery,
348− modifier = Modifier
524+ appearanceHeaderMatches,
349− .fillMaxWidth()
525+ appearanceAnyRowMatches,
350− .defaultMinSize(minHeight = 48.dp)
526+ sendOnEnterLabel, sendOnEnterDesc,
351− .toggleable(
352− value = s.sendOnEnter,
353− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
354− role = Role.Switch,
355− )
356− .padding(vertical = 8.dp),
357− verticalAlignment = Alignment.CenterVertically,
358− ) {
359− Column(modifier = Modifier.weight(1f)) {
360− Text(stringResource(R.string.send_on_enter), style = MaterialTheme.typography.bodyLarge)
361− Text(
362− stringResource(R.string.send_on_enter_desc),
363− style = MaterialTheme.typography.bodySmall,
364− color = MaterialTheme.colorScheme.onSurfaceVariant,
365 )
527 )
528+ ) {
529+ Spacer(Modifier.size(4.dp))
530+ Row(
531+ modifier = Modifier
532+ .fillMaxWidth()
533+ .defaultMinSize(minHeight = 48.dp)
534+ .toggleable(
535+ value = s.sendOnEnter,
536+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
537+ role = Role.Switch,
538+ )
539+ .padding(vertical = 8.dp),
540+ verticalAlignment = Alignment.CenterVertically,
541+ ) {
542+ Column(modifier = Modifier.weight(1f)) {
543+ Text(sendOnEnterLabel, style = MaterialTheme.typography.bodyLarge)
544+ Text(
545+ sendOnEnterDesc,
546+ style = MaterialTheme.typography.bodySmall,
547+ color = MaterialTheme.colorScheme.onSurfaceVariant,
548+ )
549+ }
550+ Switch(
551+ checked = s.sendOnEnter,
552+ onCheckedChange = null,
553+ )
554+ }
366 }
555 }
367− Switch(
368− checked = s.sendOnEnter,
369− onCheckedChange = null,
370− )
371− }
372
556
373− // App lock: gate on device capability. Always allow turning it *off* (in case
557+ // App lock: gate on device capability. Always allow turning it *off* (in case
374− // the enrolled biometric was later removed), but only allow turning it on when
558+ // the enrolled biometric was later removed), but only allow turning it on when
375− // a biometric or device credential is actually available.
559+ // a biometric or device credential is actually available.
376− val appLockAvailable = remember(biometricCheckTick) { canAuthenticateForAppLock(context) }
560+ if (rowVisible(
377− val appLockToggleable = appLockAvailable || s.appLock
561+ settingsQuery,
378− Spacer(Modifier.size(4.dp))
562+ appearanceHeaderMatches,
379− Row(
563+ appearanceAnyRowMatches,
380− modifier = Modifier
564+ appLockLabel, appLockDesc,
381− .fillMaxWidth()
382− .defaultMinSize(minHeight = 48.dp)
383− .toggleable(
384− value = s.appLock,
385− enabled = appLockToggleable,
386− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setAppLock(it) } } },
387− role = Role.Switch,
388− )
389− .padding(vertical = 8.dp),
390− verticalAlignment = Alignment.CenterVertically,
391− ) {
392− Column(modifier = Modifier.weight(1f)) {
393− Text(stringResource(R.string.app_lock), style = MaterialTheme.typography.bodyLarge)
394− Text(
395− stringResource(if (appLockToggleable) R.string.app_lock_desc else R.string.app_lock_unavailable),
396− style = MaterialTheme.typography.bodySmall,
397− color = MaterialTheme.colorScheme.onSurfaceVariant,
398 )
565 )
566+ ) {
567+ val appLockAvailable = remember(biometricCheckTick) { canAuthenticateForAppLock(context) }
568+ val appLockToggleable = appLockAvailable || s.appLock
569+ Spacer(Modifier.size(4.dp))
570+ Row(
571+ modifier = Modifier
572+ .fillMaxWidth()
573+ .defaultMinSize(minHeight = 48.dp)
574+ .toggleable(
575+ value = s.appLock,
576+ enabled = appLockToggleable,
577+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setAppLock(it) } } },
578+ role = Role.Switch,
579+ )
580+ .padding(vertical = 8.dp),
581+ verticalAlignment = Alignment.CenterVertically,
582+ ) {
583+ Column(modifier = Modifier.weight(1f)) {
584+ Text(appLockLabel, style = MaterialTheme.typography.bodyLarge)
585+ Text(
586+ stringResource(if (appLockToggleable) R.string.app_lock_desc else R.string.app_lock_unavailable),
587+ style = MaterialTheme.typography.bodySmall,
588+ color = MaterialTheme.colorScheme.onSurfaceVariant,
589+ )
590+ }
591+ Switch(
592+ checked = s.appLock,
593+ onCheckedChange = null,
594+ enabled = appLockToggleable,
595+ )
596+ }
597+ if (s.appLock) {
598+ AppLockReLockSelector(
599+ seconds = appLockReLockSeconds,
600+ onSelect = { sec ->
601+ scope.launch { runCatchingCancellable { container.settingsStore.setAppLockReLockSeconds(sec) } }
602+ },
603+ )
604+ }
605+ }
606+ } else {
607+ Box(
608+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
609+ contentAlignment = Alignment.Center,
610+ ) {
611+ CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
399 }
612 }
400− Switch(
401− checked = s.appLock,
402− onCheckedChange = null,
403− enabled = appLockToggleable,
404− )
405− }
406− if (s.appLock) {
407− AppLockReLockSelector(
408− seconds = appLockReLockSeconds,
409− onSelect = { sec ->
410− scope.launch { runCatchingCancellable { container.settingsStore.setAppLockReLockSeconds(sec) } }
411− },
412− )
413− }
414− } else {
415− Box(
416− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
417− contentAlignment = Alignment.Center,
418− ) {
419− CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
420 }
613 }
421 }
614 }
422
615
616+ if (showAppearance) {
423− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
617+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
618+ }
424
619
620+ if (showTextDisplay) {
425− Text(
621+ Text(
426− stringResource(R.string.text_display),
622+ textDisplayHeader,
427− style = MaterialTheme.typography.titleSmall,
623+ style = MaterialTheme.typography.titleSmall,
428− color = MaterialTheme.colorScheme.primary,
624+ color = MaterialTheme.colorScheme.primary,
429− modifier = Modifier.semantics { heading() },
625+ modifier = Modifier.semantics { heading() },
430− )
626+ )
627+ if (rowVisible(settingsQuery, textDisplayHeaderMatches, textDisplayAnyRowMatches, chatTextSizeDesc)) {
431− ChatTextSizeControl(
628+ ChatTextSizeControl(
432− scale = chatTextScale,
629+ scale = chatTextScale,
433− onScaleChange = { scope.launch { runCatchingCancellable { container.settingsStore.setChatTextScale(it) } } },
630+ onScaleChange = { scope.launch { runCatchingCancellable { container.settingsStore.setChatTextScale(it) } } },
434− )
435− Spacer(Modifier.size(4.dp))
436− Row(
437− modifier = Modifier
438− .fillMaxWidth()
439− .defaultMinSize(minHeight = 48.dp)
440− .toggleable(
441− value = codeWrap,
442− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
443− role = Role.Switch,
444 )
631 )
445− .padding(vertical = 8.dp),
632+ }
446− verticalAlignment = Alignment.CenterVertically,
633+ if (rowVisible(
447− ) {
634+ settingsQuery,
448− Column(modifier = Modifier.weight(1f)) {
635+ textDisplayHeaderMatches,
449− Text(stringResource(R.string.wrap_code_blocks), style = MaterialTheme.typography.bodyLarge)
636+ textDisplayAnyRowMatches,
450− Text(
637+ wrapCodeLabel, wrapCodeDesc,
451− stringResource(R.string.wrap_code_blocks_desc),
452− style = MaterialTheme.typography.bodySmall,
453− color = MaterialTheme.colorScheme.onSurfaceVariant,
454 )
638 )
639+ ) {
640+ Spacer(Modifier.size(4.dp))
641+ Row(
642+ modifier = Modifier
643+ .fillMaxWidth()
644+ .defaultMinSize(minHeight = 48.dp)
645+ .toggleable(
646+ value = codeWrap,
647+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
648+ role = Role.Switch,
649+ )
650+ .padding(vertical = 8.dp),
651+ verticalAlignment = Alignment.CenterVertically,
652+ ) {
653+ Column(modifier = Modifier.weight(1f)) {
654+ Text(wrapCodeLabel, style = MaterialTheme.typography.bodyLarge)
655+ Text(
656+ wrapCodeDesc,
657+ style = MaterialTheme.typography.bodySmall,
658+ color = MaterialTheme.colorScheme.onSurfaceVariant,
659+ )
660+ }
661+ Switch(checked = codeWrap, onCheckedChange = null)
662+ }
455 }
663 }
456− Switch(checked = codeWrap, onCheckedChange = null)
457− }
458
664
459− // Compact message spacing: a power-user toggle to fit more conversation on screen.
665+ // Compact message spacing: a power-user toggle to fit more conversation on screen.
460− Spacer(Modifier.size(4.dp))
666+ if (rowVisible(
461− Row(
667+ settingsQuery,
462− modifier = Modifier
668+ textDisplayHeaderMatches,
463− .fillMaxWidth()
669+ textDisplayAnyRowMatches,
464− .defaultMinSize(minHeight = 48.dp)
670+ compactSpacingLabel, compactSpacingDesc,
465− .toggleable(
466− value = compactSpacing,
467− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCompactMessageSpacing(it) } } },
468− role = Role.Switch,
469− )
470− .padding(vertical = 8.dp),
471− verticalAlignment = Alignment.CenterVertically,
472− ) {
473− Column(modifier = Modifier.weight(1f)) {
474− Text(stringResource(R.string.compact_message_spacing), style = MaterialTheme.typography.bodyLarge)
475− Text(
476− stringResource(R.string.compact_message_spacing_desc),
477− style = MaterialTheme.typography.bodySmall,
478− color = MaterialTheme.colorScheme.onSurfaceVariant,
479 )
671 )
672+ ) {
673+ Spacer(Modifier.size(4.dp))
674+ Row(
675+ modifier = Modifier
676+ .fillMaxWidth()
677+ .defaultMinSize(minHeight = 48.dp)
678+ .toggleable(
679+ value = compactSpacing,
680+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCompactMessageSpacing(it) } } },
681+ role = Role.Switch,
682+ )
683+ .padding(vertical = 8.dp),
684+ verticalAlignment = Alignment.CenterVertically,
685+ ) {
686+ Column(modifier = Modifier.weight(1f)) {
687+ Text(compactSpacingLabel, style = MaterialTheme.typography.bodyLarge)
688+ Text(
689+ compactSpacingDesc,
690+ style = MaterialTheme.typography.bodySmall,
691+ color = MaterialTheme.colorScheme.onSurfaceVariant,
692+ )
693+ }
694+ Switch(checked = compactSpacing, onCheckedChange = null)
695+ }
480 }
696 }
481− Switch(checked = compactSpacing, onCheckedChange = null)
482 }
697 }
483
698
699+ if (showTextDisplay) {
484− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
700+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
701+ }
485
702
486 // Motion & feedback: in-app toggles for haptics and reduced motion. The app
703 // Motion & feedback: in-app toggles for haptics and reduced motion. The app
487 // already honors the system Developer Options animator scale for reduced motion,
704 // already honors the system Developer Options animator scale for reduced motion,
488 // but this gives users an in-app control without disabling motion OS-wide.
705 // but this gives users an in-app control without disabling motion OS-wide.
489 // Haptics default on (the existing behavior); an in-app toggle lets users
706 // Haptics default on (the existing behavior); an in-app toggle lets users
490 // disable them without turning off system vibration.
707 // disable them without turning off system vibration.
491− Text(
708+ if (showMotion) {
492− stringResource(R.string.settings_motion),
709+ Text(
710+ motionHeader,
493− style = MaterialTheme.typography.titleSmall,
711+ style = MaterialTheme.typography.titleSmall,
494− color = MaterialTheme.colorScheme.primary,
712+ color = MaterialTheme.colorScheme.primary,
495− modifier = Modifier.semantics { heading() },
713+ modifier = Modifier.semantics { heading() },
496− )
714+ )
715+ if (rowVisible(settingsQuery, motionHeaderMatches, motionAnyRowMatches, hapticsLabel, hapticsDesc)) {
497− val hapticsScope = rememberCoroutineScope()
716+ val hapticsScope = rememberCoroutineScope()
498− Row(
717+ Row(
499− modifier = Modifier
718+ modifier = Modifier
500− .fillMaxWidth()
719+ .fillMaxWidth()
501− .defaultMinSize(minHeight = 48.dp)
720+ .defaultMinSize(minHeight = 48.dp)
502− .clickable(role = Role.Switch) {
721+ .clickable(role = Role.Switch) {
503− hapticsScope.launch {
722+ hapticsScope.launch {
504− container.settingsStore.setHapticsEnabled(!hapticsEnabled)
723+ container.settingsStore.setHapticsEnabled(!hapticsEnabled)
724+ }
725+ }
726+ .padding(vertical = 8.dp),
727+ verticalAlignment = Alignment.CenterVertically,
728+ ) {
729+ Column(modifier = Modifier.weight(1f)) {
730+ Text(hapticsLabel)
731+ Text(
732+ hapticsDesc,
733+ style = MaterialTheme.typography.bodySmall,
734+ color = MaterialTheme.colorScheme.onSurfaceVariant,
735+ )
505 }
736 }
737+ Switch(checked = hapticsEnabled, onCheckedChange = null)
506 }
738 }
507− .padding(vertical = 8.dp),
508− verticalAlignment = Alignment.CenterVertically,
509− ) {
510− Column(modifier = Modifier.weight(1f)) {
511− Text(stringResource(R.string.settings_haptics))
512− Text(
513− stringResource(R.string.settings_haptics_desc),
514− style = MaterialTheme.typography.bodySmall,
515− color = MaterialTheme.colorScheme.onSurfaceVariant,
516− )
517 }
739 }
518− Switch(checked = hapticsEnabled, onCheckedChange = null)
740+ if (rowVisible(
519− }
741+ settingsQuery,
742+ motionHeaderMatches,
743+ motionAnyRowMatches,
744+ reducedMotionLabel, reducedMotionDesc,
745+ )
746+ ) {
520− val motionScope = rememberCoroutineScope()
747+ val motionScope = rememberCoroutineScope()
521− Row(
748+ Row(
522− modifier = Modifier
749+ modifier = Modifier
523− .fillMaxWidth()
750+ .fillMaxWidth()
524− .defaultMinSize(minHeight = 48.dp)
751+ .defaultMinSize(minHeight = 48.dp)
525− .clickable(role = Role.Switch) {
752+ .clickable(role = Role.Switch) {
526− motionScope.launch {
753+ motionScope.launch {
527− container.settingsStore.setReducedMotion(!reducedMotion)
754+ container.settingsStore.setReducedMotion(!reducedMotion)
755+ }
756+ }
757+ .padding(vertical = 8.dp),
758+ verticalAlignment = Alignment.CenterVertically,
759+ ) {
760+ Column(modifier = Modifier.weight(1f)) {
761+ Text(reducedMotionLabel)
762+ Text(
763+ reducedMotionDesc,
764+ style = MaterialTheme.typography.bodySmall,
765+ color = MaterialTheme.colorScheme.onSurfaceVariant,
766+ )
528 }
767 }
768+ Switch(checked = reducedMotion, onCheckedChange = null)
529 }
769 }
530− .padding(vertical = 8.dp),
531− verticalAlignment = Alignment.CenterVertically,
532− ) {
533− Column(modifier = Modifier.weight(1f)) {
534− Text(stringResource(R.string.settings_reduced_motion))
535− Text(
536− stringResource(R.string.settings_reduced_motion_desc),
537− style = MaterialTheme.typography.bodySmall,
538− color = MaterialTheme.colorScheme.onSurfaceVariant,
539− )
540 }
770 }
541− Switch(checked = reducedMotion, onCheckedChange = null)
542− }
543− // Language override: an in-app language picker so a user can use the app in a
771+ // Language override: an in-app language picker so a user can use the app in a
544− // different language without changing the system locale. Uses Android 13's
772+ // different language without changing the system locale. Uses Android 13's
545− // per-app language API; on older versions a restart is needed.
773+ // per-app language API; on older versions a restart is needed.
774+ if (rowVisible(settingsQuery, motionHeaderMatches, motionAnyRowMatches, languageLabel, languageDesc)) {
546− val langScope = rememberCoroutineScope()
775+ val langScope = rememberCoroutineScope()
547− Row(
776+ Row(
548− modifier = Modifier
777+ modifier = Modifier
549− .fillMaxWidth()
778+ .fillMaxWidth()
550− .defaultMinSize(minHeight = 48.dp)
779+ .defaultMinSize(minHeight = 48.dp)
551− .padding(vertical = 8.dp),
780+ .padding(vertical = 8.dp),
552− verticalAlignment = Alignment.CenterVertically,
781+ verticalAlignment = Alignment.CenterVertically,
553− ) {
782+ ) {
554− Column(modifier = Modifier.weight(1f)) {
783+ Column(modifier = Modifier.weight(1f)) {
555− Text(stringResource(R.string.settings_language))
784+ Text(languageLabel)
556− Text(
785+ Text(
557− if (languageOverride.isEmpty()) stringResource(R.string.settings_language_desc)
786+ if (languageOverride.isEmpty()) languageDesc
558− else languageOverride,
787+ else languageOverride,
559− style = MaterialTheme.typography.bodySmall,
788+ style = MaterialTheme.typography.bodySmall,
560− color = MaterialTheme.colorScheme.onSurfaceVariant,
789+ color = MaterialTheme.colorScheme.onSurfaceVariant,
561− )
562− }
563− val langOptions = remember {
564− listOf("" to R.string.settings_language_system, "en" to R.string.app_name, "es" to R.string.app_name)
565− }
566− var langMenu by remember { mutableStateOf(false) }
567− Box {
568− TextButton(onClick = { langMenu = true }) {
569− Text(if (languageOverride.isEmpty()) stringResource(R.string.settings_language_system) else languageOverride)
570− }
571− DropdownMenu(expanded = langMenu, onDismissRequest = { langMenu = false }) {
572− langOptions.forEach { (code, _) ->
573− DropdownMenuItem(
574− text = { Text(if (code.isEmpty()) stringResource(R.string.settings_language_system) else code) },
575− onClick = {
576− langMenu = false
577− langScope.launch {
578− container.settingsStore.setLanguageOverride(code)
579− }
580− },
581 )
790 )
582 }
791 }
792+ val langOptions = remember {
793+ listOf("" to R.string.settings_language_system, "en" to R.string.app_name, "es" to R.string.app_name)
794+ }
795+ var langMenu by remember { mutableStateOf(false) }
796+ Box {
797+ TextButton(onClick = { langMenu = true }) {
798+ Text(if (languageOverride.isEmpty()) stringResource(R.string.settings_language_system) else languageOverride)
799+ }
800+ DropdownMenu(expanded = langMenu, onDismissRequest = { langMenu = false }) {
801+ langOptions.forEach { (code, _) ->
802+ DropdownMenuItem(
803+ text = { Text(if (code.isEmpty()) stringResource(R.string.settings_language_system) else code) },
804+ onClick = {
805+ langMenu = false
806+ langScope.launch {
807+ container.settingsStore.setLanguageOverride(code)
808+ }
809+ },
810+ )
811+ }
812+ }
813+ }
583 }
814 }
584 }
815 }
585 }
816 }
586
817
818+ if (showMotion) {
587− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
819+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
820+ }
588
821
589 // Notifications: granular toggles for each notification type, so a user can
822 // Notifications: granular toggles for each notification type, so a user can
590 // mute specific types in-app (in addition to the OS channel controls). Each
823 // mute specific types in-app (in addition to the OS channel controls). Each
591 // toggle gates whether the app posts that notification type at all.
824 // toggle gates whether the app posts that notification type at all.
592− Text(
825+ if (showNotifications) {
593− stringResource(R.string.settings_notifications),
826+ Text(
827+ notificationsHeader,
594− style = MaterialTheme.typography.titleSmall,
828+ style = MaterialTheme.typography.titleSmall,
595− color = MaterialTheme.colorScheme.primary,
829+ color = MaterialTheme.colorScheme.primary,
596− modifier = Modifier.semantics { heading() },
830+ modifier = Modifier.semantics { heading() },
597− )
831+ )
598− val notifScope = rememberCoroutineScope()
832+ if (rowVisible(
599− Row(
833+ settingsQuery,
600− modifier = Modifier
834+ notificationsHeaderMatches,
601− .fillMaxWidth()
835+ notificationsAnyRowMatches,
602− .defaultMinSize(minHeight = 48.dp)
836+ notifRunCompleteLabel, notifRunCompleteDesc,
603− .clickable(role = Role.Switch) {
604− notifScope.launch { container.settingsStore.setNotifRunComplete(!notifRunComplete) }
605− }
606− .padding(vertical = 8.dp),
607− verticalAlignment = Alignment.CenterVertically,
608− ) {
609− Column(modifier = Modifier.weight(1f)) {
610− Text(stringResource(R.string.settings_notif_run_complete))
611− Text(
612− stringResource(R.string.settings_notif_run_complete_desc),
613− style = MaterialTheme.typography.bodySmall,
614− color = MaterialTheme.colorScheme.onSurfaceVariant,
615 )
837 )
616− }
838+ ) {
617− Switch(checked = notifRunComplete, onCheckedChange = null)
839+ val notifScope = rememberCoroutineScope()
618− }
619− Row(
840+ Row(
620− modifier = Modifier
841+ modifier = Modifier
621− .fillMaxWidth()
842+ .fillMaxWidth()
622− .defaultMinSize(minHeight = 48.dp)
843+ .defaultMinSize(minHeight = 48.dp)
623− .clickable(role = Role.Switch) {
844+ .clickable(role = Role.Switch) {
624− notifScope.launch { container.settingsStore.setNotifPermission(!notifPermission) }
845+ notifScope.launch { container.settingsStore.setNotifRunComplete(!notifRunComplete) }
846+ }
847+ .padding(vertical = 8.dp),
848+ verticalAlignment = Alignment.CenterVertically,
849+ ) {
850+ Column(modifier = Modifier.weight(1f)) {
851+ Text(notifRunCompleteLabel)
852+ Text(
853+ notifRunCompleteDesc,
854+ style = MaterialTheme.typography.bodySmall,
855+ color = MaterialTheme.colorScheme.onSurfaceVariant,
856+ )
857+ }
858+ Switch(checked = notifRunComplete, onCheckedChange = null)
625 }
859 }
626− .padding(vertical = 8.dp),
627− verticalAlignment = Alignment.CenterVertically,
628− ) {
629− Column(modifier = Modifier.weight(1f)) {
630− Text(stringResource(R.string.settings_notif_permission))
631− Text(
632− stringResource(R.string.settings_notif_permission_desc),
633− style = MaterialTheme.typography.bodySmall,
634− color = MaterialTheme.colorScheme.onSurfaceVariant,
635− )
636 }
860 }
637− Switch(checked = notifPermission, onCheckedChange = null)
861+ if (rowVisible(
638− }
862+ settingsQuery,
863+ notificationsHeaderMatches,
864+ notificationsAnyRowMatches,
865+ notifPermissionLabel, notifPermissionDesc,
866+ )
867+ ) {
868+ val notifScope = rememberCoroutineScope()
639− Row(
869+ Row(
640− modifier = Modifier
870+ modifier = Modifier
641− .fillMaxWidth()
871+ .fillMaxWidth()
642− .defaultMinSize(minHeight = 48.dp)
872+ .defaultMinSize(minHeight = 48.dp)
643− .clickable(role = Role.Switch) {
873+ .clickable(role = Role.Switch) {
644− notifScope.launch { container.settingsStore.setNotifError(!notifError) }
874+ notifScope.launch { container.settingsStore.setNotifPermission(!notifPermission) }
875+ }
876+ .padding(vertical = 8.dp),
877+ verticalAlignment = Alignment.CenterVertically,
878+ ) {
879+ Column(modifier = Modifier.weight(1f)) {
880+ Text(notifPermissionLabel)
881+ Text(
882+ notifPermissionDesc,
883+ style = MaterialTheme.typography.bodySmall,
884+ color = MaterialTheme.colorScheme.onSurfaceVariant,
885+ )
886+ }
887+ Switch(checked = notifPermission, onCheckedChange = null)
645 }
888 }
646− .padding(vertical = 8.dp),
889+ }
647− verticalAlignment = Alignment.CenterVertically,
890+ if (rowVisible(
648− ) {
891+ settingsQuery,
649− Column(modifier = Modifier.weight(1f)) {
892+ notificationsHeaderMatches,
650− Text(stringResource(R.string.settings_notif_error))
893+ notificationsAnyRowMatches,
651− Text(
894+ notifErrorLabel, notifErrorDesc,
652− stringResource(R.string.settings_notif_error_desc),
653− style = MaterialTheme.typography.bodySmall,
654− color = MaterialTheme.colorScheme.onSurfaceVariant,
655 )
895 )
896+ ) {
897+ val notifScope = rememberCoroutineScope()
898+ Row(
899+ modifier = Modifier
900+ .fillMaxWidth()
901+ .defaultMinSize(minHeight = 48.dp)
902+ .clickable(role = Role.Switch) {
903+ notifScope.launch { container.settingsStore.setNotifError(!notifError) }
904+ }
905+ .padding(vertical = 8.dp),
906+ verticalAlignment = Alignment.CenterVertically,
907+ ) {
908+ Column(modifier = Modifier.weight(1f)) {
909+ Text(notifErrorLabel)
910+ Text(
911+ notifErrorDesc,
912+ style = MaterialTheme.typography.bodySmall,
913+ color = MaterialTheme.colorScheme.onSurfaceVariant,
914+ )
915+ }
916+ Switch(checked = notifError, onCheckedChange = null)
917+ }
656 }
918 }
657− Switch(checked = notifError, onCheckedChange = null)
658 }
919 }
659
920
921+ if (showNotifications) {
660− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
922+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
923+ }
661
924
662 // Swipe actions: remappable left/right swipe on session list rows. Defaults
925 // Swipe actions: remappable left/right swipe on session list rows. Defaults
663 // match the prior hardcoded behavior (left=delete, right=archive). NONE
926 // match the prior hardcoded behavior (left=delete, right=archive). NONE
664 // disables the swipe entirely.
927 // disables the swipe entirely.
928+ if (showSwipeActions) {
665− Text(
929+ Text(
666− stringResource(R.string.settings_swipe_actions),
930+ swipeActionsHeader,
667− style = MaterialTheme.typography.titleSmall,
931+ style = MaterialTheme.typography.titleSmall,
668− color = MaterialTheme.colorScheme.primary,
932+ color = MaterialTheme.colorScheme.primary,
669− modifier = Modifier.semantics { heading() },
933+ modifier = Modifier.semantics { heading() },
670− )
671− val swipeScope = rememberCoroutineScope()
672− val swipeOptions = remember {
673− listOf(
674− SwipeAction.DELETE to R.string.swipe_action_delete,
675− SwipeAction.ARCHIVE to R.string.swipe_action_archive,
676− SwipeAction.MARK_READ to R.string.swipe_action_mark_read,
677− SwipeAction.NONE to R.string.swipe_action_none,
678 )
934 )
935+ val swipeScope = rememberCoroutineScope()
936+ val swipeOptions = remember {
937+ listOf(
938+ SwipeAction.DELETE to R.string.swipe_action_delete,
939+ SwipeAction.ARCHIVE to R.string.swipe_action_archive,
940+ SwipeAction.MARK_READ to R.string.swipe_action_mark_read,
941+ SwipeAction.NONE to R.string.swipe_action_none,
942+ )
679− }
943+ }
680− var swipeLeftMenu by remember { mutableStateOf(false) }
944+ var swipeLeftMenu by remember { mutableStateOf(false) }
681− var swipeRightMenu by remember { mutableStateOf(false) }
945+ var swipeRightMenu by remember { mutableStateOf(false) }
682− val swipeLeftParsed = remember(swipeLeftAction) {
946+ val swipeLeftParsed = remember(swipeLeftAction) {
683− runCatching { SwipeAction.valueOf(swipeLeftAction) }.getOrDefault(SwipeAction.DELETE)
947+ runCatching { SwipeAction.valueOf(swipeLeftAction) }.getOrDefault(SwipeAction.DELETE)
684− }
948+ }
685− val swipeRightParsed = remember(swipeRightAction) {
949+ val swipeRightParsed = remember(swipeRightAction) {
686− runCatching { SwipeAction.valueOf(swipeRightAction) }.getOrDefault(SwipeAction.ARCHIVE)
950+ runCatching { SwipeAction.valueOf(swipeRightAction) }.getOrDefault(SwipeAction.ARCHIVE)
687− }
951+ }
952+ if (rowVisible(settingsQuery, swipeHeaderMatches, swipeAnyRowMatches, swipeLeftLabel)) {
688− Row(
953+ Row(
689− modifier = Modifier
954+ modifier = Modifier
690− .fillMaxWidth()
955+ .fillMaxWidth()
691− .defaultMinSize(minHeight = 48.dp)
956+ .defaultMinSize(minHeight = 48.dp)
692− .padding(vertical = 8.dp),
957+ .padding(vertical = 8.dp),
693− verticalAlignment = Alignment.CenterVertically,
958+ verticalAlignment = Alignment.CenterVertically,
694− ) {
959+ ) {
695− Text(stringResource(R.string.settings_swipe_left), modifier = Modifier.weight(1f))
960+ Text(swipeLeftLabel, modifier = Modifier.weight(1f))
696− Box {
961+ Box {
697− TextButton(onClick = { swipeLeftMenu = true }) {
962+ TextButton(onClick = { swipeLeftMenu = true }) {
698− Text(stringResource(swipeOptions.first { it.first == swipeLeftParsed }.second))
963+ Text(stringResource(swipeOptions.first { it.first == swipeLeftParsed }.second))
964+ }
965+ DropdownMenu(expanded = swipeLeftMenu, onDismissRequest = { swipeLeftMenu = false }) {
966+ swipeOptions.forEach { (action, label) ->
967+ DropdownMenuItem(
968+ text = { Text(stringResource(label)) },
969+ onClick = {
970+ swipeLeftMenu = false
971+ swipeScope.launch { container.settingsStore.setSwipeLeftAction(action) }
972+ },
973+ )
974+ }
975+ }
976+ }
699 }
977 }
978+ }
979+ if (rowVisible(settingsQuery, swipeHeaderMatches, swipeAnyRowMatches, swipeRightLabel)) {
980+ Row(
981+ modifier = Modifier
982+ .fillMaxWidth()
983+ .defaultMinSize(minHeight = 48.dp)
984+ .padding(vertical = 8.dp),
985+ verticalAlignment = Alignment.CenterVertically,
986+ ) {
987+ Text(swipeRightLabel, modifier = Modifier.weight(1f))
988+ Box {
989+ TextButton(onClick = { swipeRightMenu = true }) {
990+ Text(stringResource(swipeOptions.first { it.first == swipeRightParsed }.second))
991+ }
700− DropdownMenu(expanded = swipeLeftMenu, onDismissRequest = { swipeLeftMenu = false }) {
992+ DropdownMenu(expanded = swipeRightMenu, onDismissRequest = { swipeRightMenu = false }) {
701− swipeOptions.forEach { (action, label) ->
993+ swipeOptions.forEach { (action, label) ->
702− DropdownMenuItem(
994+ DropdownMenuItem(
703− text = { Text(stringResource(label)) },
995+ text = { Text(stringResource(label)) },
704− onClick = {
996+ onClick = {
705− swipeLeftMenu = false
997+ swipeRightMenu = false
706− swipeScope.launch { container.settingsStore.setSwipeLeftAction(action) }
998+ swipeScope.launch { container.settingsStore.setSwipeRightAction(action) }
999+ },
1000+ )
707− },
1001+ }
708− )
1002+ }
709 }
1003 }
710 }
1004 }
711 }
1005 }
712 }
1006 }
713− Row(
1007+
714− modifier = Modifier
1008+ if (showSwipeActions) {
715− .fillMaxWidth()
716− .defaultMinSize(minHeight = 48.dp)
717− .padding(vertical = 8.dp),
1009+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
718− verticalAlignment = Alignment.CenterVertically,
1010+ }
719− ) {
1011+
720− Text(stringResource(R.string.settings_swipe_right), modifier = Modifier.weight(1f))
1012+ if (showConnection) {
721− Box {
1013+ Text(
722− TextButton(onClick = { swipeRightMenu = true }) {
1014+ connectionHeader,
723− Text(stringResource(swipeOptions.first { it.first == swipeRightParsed }.second))
1015+ style = MaterialTheme.typography.titleSmall,
1016+ color = MaterialTheme.colorScheme.primary,
1017+ modifier = Modifier.semantics { heading() },
1018+ )
1019+ // The connection status block (profile label + URL + SSE state + retry) has no
1020+ // single stable label to match against, so show it whenever the section is visible.
1021+ if (activeProfile != null) {
1022+ Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
1023+ Text(
1024+ activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
1025+ style = MaterialTheme.typography.bodySmall,
1026+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1027+ )
1028+ // Surface the SSE stream state so a user who opens Settings during a
1029+ // reconnect saw it (the ConnectionBanner on other screens handles this
1030+ // elsewhere). Only show non-Connected states to avoid clutter. On a hard
1031+ // failure offer an inline Retry — without it a user who opened Settings
1032+ // during a failure is stuck on an error message with no recovery path
1033+ // (the ConnectionBanner used elsewhere has Retry, but Settings doesn't
1034+ // host it). Retry forces an SSE reconnect, which re-seeds from REST.
1035+ val stateText = when (connectionState) {
1036+ EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
1037+ EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
1038+ EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
1039+ EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
1040+ EventStreamClient.ConnectionState.Connected -> null
724 }
1041 }
725− DropdownMenu(expanded = swipeRightMenu, onDismissRequest = { swipeRightMenu = false }) {
1042+ stateText?.let {
726− swipeOptions.forEach { (action, label) ->
1043+ Row(
727− DropdownMenuItem(
1044+ modifier = Modifier.padding(top = 4.dp),
728− text = { Text(stringResource(label)) },
1045+ verticalAlignment = Alignment.CenterVertically,
729− onClick = {
1046+ ) {
730− swipeRightMenu = false
1047+ val isHardFailure = connectionState == EventStreamClient.ConnectionState.Failed ||
731− swipeScope.launch { container.settingsStore.setSwipeRightAction(action) }
1048+ connectionState == EventStreamClient.ConnectionState.AuthFailed
732− },
1049+ Text(
1050+ it,
1051+ style = MaterialTheme.typography.bodySmall,
1052+ color = if (isHardFailure)
1053+ MaterialTheme.colorScheme.error
1054+ else MaterialTheme.colorScheme.onSurfaceVariant,
733 )
1055 )
1056+ if (isHardFailure) {
1057+ TextButton(
1058+ onClick = { container.activeConnection.value?.events?.triggerReconnect() },
1059+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
1060+ modifier = Modifier.defaultMinSize(minHeight = 48.dp),
1061+ ) { Text(stringResource(R.string.retry_now)) }
1062+ }
734 }
1063 }
735 }
1064 }
1065+ } else {
1066+ Text(
1067+ stringResource(R.string.not_connected),
1068+ modifier = Modifier.padding(top = 8.dp),
1069+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1070+ )
1071+ }
1072+ if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, manageServersLabel)) {
1073+ Row(
1074+ modifier = Modifier
1075+ .fillMaxWidth()
1076+ .defaultMinSize(minHeight = 48.dp)
1077+ .padding(top = 8.dp)
1078+ .clickable(role = Role.Button) { onManageServers() }
1079+ .padding(vertical = 8.dp),
1080+ verticalAlignment = Alignment.CenterVertically,
1081+ ) {
1082+ Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
1083+ Text(manageServersLabel, modifier = Modifier.weight(1f).padding(start = 8.dp))
1084+ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
1085+ }
1086+ }
1087+ if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, usageLabel)) {
1088+ NavRow(
1089+ icon = Icons.Filled.QueryStats,
1090+ label = usageLabel,
1091+ enabled = activeProfile != null,
1092+ onClick = onOpenUsage,
1093+ )
1094+ }
1095+ if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, mcpLabel)) {
1096+ NavRow(
1097+ icon = Icons.Filled.Hub,
1098+ label = mcpLabel,
1099+ enabled = activeProfile != null,
1100+ onClick = onOpenMcp,
1101+ )
1102+ }
1103+ if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, notifSettingsLabel)) {
1104+ NavRow(
1105+ icon = Icons.Filled.Notifications,
1106+ label = notifSettingsLabel,
1107+ enabled = true,
1108+ onClick = {
1109+ scope.launch {
1110+ val opened = runCatchingCancellable {
1111+ val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
1112+ .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
1113+ context.startActivity(intent)
1114+ }.isSuccess
1115+ if (!opened) snackbar.showSnackbar(couldNotOpenLinkMsg)
1116+ }
1117+ },
1118+ )
736 }
1119 }
737 }
1120 }
738
1121
1122+ if (showConnection) {
739− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1123+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1124+ }
740
1125
741− Text(
1126+ if (showBackup) {
742− stringResource(R.string.connection),
743− style = MaterialTheme.typography.titleSmall,
744− color = MaterialTheme.colorScheme.primary,
745− modifier = Modifier.semantics { heading() },
746− )
747− if (activeProfile != null) {
748− Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
749 Text(
1127 Text(
750− activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
1128+ backupHeader,
751− style = MaterialTheme.typography.bodySmall,
1129+ style = MaterialTheme.typography.titleSmall,
752− color = MaterialTheme.colorScheme.onSurfaceVariant,
1130+ color = MaterialTheme.colorScheme.primary,
1131+ modifier = Modifier.semantics { heading() },
753 )
1132 )
754− // Surface the SSE stream state so a user who opens Settings during a
1133+ if (rowVisible(settingsQuery, backupHeaderMatches, backupAnyRowMatches, backupDescText)) {
755− // reconnect saw it (the ConnectionBanner on other screens handles this
1134+ Text(
756− // elsewhere). Only show non-Connected states to avoid clutter. On a hard
1135+ backupDescText,
757− // failure offer an inline Retry — without it a user who opened Settings
1136+ style = MaterialTheme.typography.bodySmall,
758− // during a failure is stuck on an error message with no recovery path
1137+ color = MaterialTheme.colorScheme.onSurfaceVariant,
759− // (the ConnectionBanner used elsewhere has Retry, but Settings doesn't
1138+ modifier = Modifier.padding(top = 4.dp),
760− // host it). Retry forces an SSE reconnect, which re-seeds from REST.
1139+ )
761− val stateText = when (connectionState) {
762− EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
763− EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
764− EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
765− EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
766− EventStreamClient.ConnectionState.Connected -> null
767 }
1140 }
768− stateText?.let {
1141+ if (rowVisible(
1142+ settingsQuery,
1143+ backupHeaderMatches,
1144+ backupAnyRowMatches,
1145+ backupIncludePasswordsLabel, backupIncludePasswordsDesc,
1146+ )
1147+ ) {
769 Row(
1148 Row(
770− modifier = Modifier.padding(top = 4.dp),
1149+ modifier = Modifier
1150+ .fillMaxWidth()
1151+ .defaultMinSize(minHeight = 48.dp)
1152+ .toggleable(
1153+ value = includePasswords,
1154+ onValueChange = { includePasswords = it },
1155+ role = Role.Switch,
1156+ )
1157+ .padding(vertical = 8.dp),
771 verticalAlignment = Alignment.CenterVertically,
1158 verticalAlignment = Alignment.CenterVertically,
772 ) {
1159 ) {
773− val isHardFailure = connectionState == EventStreamClient.ConnectionState.Failed ||
1160+ Column(modifier = Modifier.weight(1f)) {
774− connectionState == EventStreamClient.ConnectionState.AuthFailed
1161+ Text(backupIncludePasswordsLabel, style = MaterialTheme.typography.bodyLarge)
775− Text(
1162+ Text(
776− it,
1163+ backupIncludePasswordsDesc,
777− style = MaterialTheme.typography.bodySmall,
1164+ style = MaterialTheme.typography.bodySmall,
778− color = if (isHardFailure)
779− MaterialTheme.colorScheme.error
780− else MaterialTheme.colorScheme.onSurfaceVariant,
1165+ color = MaterialTheme.colorScheme.onSurfaceVariant,
781− )
1166+ )
782− if (isHardFailure) {
783− TextButton(
784− onClick = { container.activeConnection.value?.events?.triggerReconnect() },
785− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
786− modifier = Modifier.defaultMinSize(minHeight = 48.dp),
787− ) { Text(stringResource(R.string.retry_now)) }
788 }
1167 }
1168+ Switch(checked = includePasswords, onCheckedChange = null)
789 }
1169 }
790 }
1170 }
791− } else {
1171+ if (rowVisible(settingsQuery, backupHeaderMatches, backupAnyRowMatches, exportBackupLabel)) {
792− Text(
1172+ NavRow(
793− stringResource(R.string.not_connected),
1173+ icon = Icons.Filled.Upload,
794− modifier = Modifier.padding(top = 8.dp),
1174+ label = exportBackupLabel,
795− color = MaterialTheme.colorScheme.onSurfaceVariant,
1175+ enabled = !backupBusy,
1176+ // Warn before writing plaintext credentials out of the app; confirm then launches
1177+ // the file picker. Without passwords, export straight away.
1178+ onClick = {
1179+ if (includePasswords) showExportPasswordWarning = true
1180+ else runCatching { exportLauncher.launch(backupFilename()) }
1181+ },
1182+ )
1183+ }
1184+ if (rowVisible(settingsQuery, backupHeaderMatches, backupAnyRowMatches, importBackupLabel)) {
1185+ NavRow(
1186+ icon = Icons.Filled.Download,
1187+ label = importBackupLabel,
1188+ enabled = !backupBusy,
1189+ onClick = {
1190+ runCatching {
1191+ importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
1192+ }
1193+ },
796− )
1194+ )
797− }
1195+ }
798− Row(
1196+ // Surface an inline progress indicator while a backup write/read is in flight so
1197+ // the user gets immediate feedback that the (potentially slow on a big file)
1198+ // operation is running, and isn't tempted to tap again.
1199+ if (backupBusy) {
799− modifier = Modifier
1200+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
800− .fillMaxWidth()
1201+ }
801− .defaultMinSize(minHeight = 48.dp)
802− .padding(top = 8.dp)
803− .clickable(role = Role.Button) { onManageServers() }
804− .padding(vertical = 8.dp),
805− verticalAlignment = Alignment.CenterVertically,
806− ) {
807− Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
808− Text(stringResource(R.string.manage_servers), modifier = Modifier.weight(1f).padding(start = 8.dp))
809− Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
810 }
1202 }
811− NavRow(
812− icon = Icons.Filled.QueryStats,
813− label = stringResource(R.string.usage_title),
814− enabled = activeProfile != null,
815− onClick = onOpenUsage,
816− )
817− NavRow(
818− icon = Icons.Filled.Hub,
819− label = stringResource(R.string.mcp_servers),
820− enabled = activeProfile != null,
821− onClick = onOpenMcp,
822− )
823− NavRow(
824− icon = Icons.Filled.Notifications,
825− label = stringResource(R.string.notification_settings),
826− enabled = true,
827− onClick = {
828− scope.launch {
829− val opened = runCatchingCancellable {
830− val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
831− .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
832− context.startActivity(intent)
833− }.isSuccess
834− if (!opened) snackbar.showSnackbar(couldNotOpenLinkMsg)
835− }
836− },
837− )
838
1203
1204+ if (showBackup) {
839− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1205+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1206+ }
840
1207
841− Text(
1208+ if (showAbout) {
842− stringResource(R.string.backup_restore),
1209+ Text(
1210+ aboutHeader,
843− style = MaterialTheme.typography.titleSmall,
1211+ style = MaterialTheme.typography.titleSmall,
844− color = MaterialTheme.colorScheme.primary,
1212+ color = MaterialTheme.colorScheme.primary,
845− modifier = Modifier.semantics { heading() },
1213+ modifier = Modifier.semantics { heading() },
846− )
1214+ )
847− Text(
1215+ // The version line is the About section's main content; show it whenever the
1216+ // section is visible (it has no separate label to filter on).
1217+ Text(
848− stringResource(R.string.backup_desc),
1218+ stringResource(R.string.about_version, versionName),
849− style = MaterialTheme.typography.bodySmall,
1219+ style = MaterialTheme.typography.bodyMedium,
850− color = MaterialTheme.colorScheme.onSurfaceVariant,
851− modifier = Modifier.padding(top = 4.dp),
1220+ modifier = Modifier.padding(top = 8.dp),
852− )
853− Row(
854− modifier = Modifier
855− .fillMaxWidth()
856− .defaultMinSize(minHeight = 48.dp)
857− .toggleable(
858− value = includePasswords,
859− onValueChange = { includePasswords = it },
860− role = Role.Switch,
861− )
862− .padding(vertical = 8.dp),
863− verticalAlignment = Alignment.CenterVertically,
864− ) {
1221+ )
865− Column(modifier = Modifier.weight(1f)) {
1222+ if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, aboutDescText)) {
866− Text(stringResource(R.string.backup_include_passwords), style = MaterialTheme.typography.bodyLarge)
867 Text(
1223 Text(
868− stringResource(R.string.backup_include_passwords_desc),
1224+ aboutDescText,
869 style = MaterialTheme.typography.bodySmall,
1225 style = MaterialTheme.typography.bodySmall,
870 color = MaterialTheme.colorScheme.onSurfaceVariant,
1226 color = MaterialTheme.colorScheme.onSurfaceVariant,
1227+ modifier = Modifier.padding(top = 4.dp),
871 )
1228 )
872 }
1229 }
873− Switch(checked = includePasswords, onCheckedChange = null)
874− }
875− NavRow(
876− icon = Icons.Filled.Upload,
877− label = stringResource(R.string.export_backup),
878− enabled = !backupBusy,
879− // Warn before writing plaintext credentials out of the app; confirm then launches
880− // the file picker. Without passwords, export straight away.
881− onClick = {
882− if (includePasswords) showExportPasswordWarning = true
883− else runCatching { exportLauncher.launch(backupFilename()) }
884− },
885− )
886− NavRow(
887− icon = Icons.Filled.Download,
888− label = stringResource(R.string.import_backup),
889− enabled = !backupBusy,
890− onClick = {
891− runCatching {
892− importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
893− }
894− },
895− )
896− // Surface an inline progress indicator while a backup write/read is in flight so
897− // the user gets immediate feedback that the (potentially slow on a big file)
898− // operation is running, and isn't tempted to tap again.
899− if (backupBusy) {
900− LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
901− }
902−
903− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
904−
905− Text(
906− stringResource(R.string.about),
907− style = MaterialTheme.typography.titleSmall,
908− color = MaterialTheme.colorScheme.primary,
909− modifier = Modifier.semantics { heading() },
910− )
911− Text(
912− stringResource(R.string.about_version, versionName),
913− style = MaterialTheme.typography.bodyMedium,
914− modifier = Modifier.padding(top = 8.dp),
915− )
916− Text(
917− stringResource(R.string.about_desc),
918− style = MaterialTheme.typography.bodySmall,
919− color = MaterialTheme.colorScheme.onSurfaceVariant,
920− modifier = Modifier.padding(top = 4.dp),
921− )
922
1230
1231+ if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, diagnosticsLabel)) {
923− Spacer(Modifier.size(16.dp))
1232+ Spacer(Modifier.size(16.dp))
924− Row(
1233+ Row(
925− modifier = Modifier
1234+ modifier = Modifier
926− .fillMaxWidth()
1235+ .fillMaxWidth()
927− .defaultMinSize(minHeight = 48.dp)
1236+ .defaultMinSize(minHeight = 48.dp)
928− .clickable(role = Role.Button) { onOpenDiagnostics() }
1237+ .clickable(role = Role.Button) { onOpenDiagnostics() }
929− .padding(vertical = 8.dp),
1238+ .padding(vertical = 8.dp),
930− verticalAlignment = Alignment.CenterVertically,
1239+ verticalAlignment = Alignment.CenterVertically,
931− ) {
1240+ ) {
932− Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
1241+ Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
933− Text(stringResource(R.string.diagnostics), modifier = Modifier.weight(1f).padding(start = 8.dp))
1242+ Text(diagnosticsLabel, modifier = Modifier.weight(1f).padding(start = 8.dp))
934− // Badge with the crash count so the user can tell at a glance whether
1243+ // Badge with the crash count so the user can tell at a glance whether
935− // there's something worth investigating. Uses M3 BadgedBox+Badge for
1244+ // there's something worth investigating. Uses M3 BadgedBox+Badge for
936− // consistency with the nav rail's badge (which already uses the real M3
1245+ // consistency with the nav rail's badge (which already uses the real M3
937− // components) instead of a hand-rolled Box.
1246+ // components) instead of a hand-rolled Box.
938− if (crashCount > 0) {
1247+ if (crashCount > 0) {
939− BadgedBox(
1248+ BadgedBox(
940− badge = {
1249+ badge = {
941− Badge {
1250+ Badge {
942− val overflow = stringResource(R.string.crash_count_overflow)
1251+ val overflow = stringResource(R.string.crash_count_overflow)
943− Text(if (crashCount > 99) overflow else crashCount.toString())
1252+ Text(if (crashCount > 99) overflow else crashCount.toString())
944− }
1253+ }
1254+ },
1255+ ) {}
1256+ Spacer(Modifier.size(8.dp))
1257+ }
1258+ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
1259+ }
1260+ }
1261+ // Discoverability links from the About section: source code and issue tracker.
1262+ // Opens in the browser via ACTION_VIEW; a device without a browser (e.g. a de-Googled
1263+ // or work-profile split) silently no-ops otherwise, so surface a snackbar on failure.
1264+ if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, sourceCodeLabel)) {
1265+ NavRow(
1266+ icon = Icons.Filled.Code,
1267+ label = sourceCodeLabel,
1268+ enabled = true,
1269+ onClick = {
1270+ runCatching {
1271+ context.startActivity(
1272+ Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_source_code))),
1273+ )
1274+ }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
945 },
1275 },
946− ) {}
1276+ )
947− Spacer(Modifier.size(8.dp))
1277+ }
1278+ if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, reportIssueLabel)) {
1279+ NavRow(
1280+ icon = Icons.Filled.Feedback,
1281+ label = reportIssueLabel,
1282+ enabled = true,
1283+ onClick = {
1284+ runCatching {
1285+ context.startActivity(
1286+ Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
1287+ )
1288+ }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
1289+ },
1290+ )
948 }
1291 }
949− Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
950 }
1292 }
951− // Discoverability links from the About section: source code and issue tracker.
952− // Opens in the browser via ACTION_VIEW; a device without a browser (e.g. a de-Googled
953− // or work-profile split) silently no-ops otherwise, so surface a snackbar on failure.
954− NavRow(
955− icon = Icons.Filled.Code,
956− label = stringResource(R.string.source_code),
957− enabled = true,
958− onClick = {
959− runCatching {
960− context.startActivity(
961− Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_source_code))),
962− )
963− }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
964− },
965− )
966− NavRow(
967− icon = Icons.Filled.Feedback,
968− label = stringResource(R.string.report_issue),
969− enabled = true,
970− onClick = {
971− runCatching {
972− context.startActivity(
973− Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
974− )
975− }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
976− },
977− )
978 }
1293 }
979 }
1294 }
980
1295
@@ -1002,13 +1317,25 @@ fun SettingsScreen(
1002 )
1317 )
1003 if (result == SnackbarResult.ActionPerformed) {
1318 if (result == SnackbarResult.ActionPerformed) {
1004 // Shut down the container (cancels the app scope, closes the
1319 // Shut down the container (cancels the app scope, closes the
1005− // active connection, flushes stores) before the hard kill so
1320+ // active connection, flushes stores) before restarting so
1006 // in-flight work is cleaned up rather than abruptly severed.
1321 // in-flight work is cleaned up rather than abruptly severed.
1007− // The JVM shutdown hook in OpencodeApp would catch this, but
1008− // calling shutdown explicitly is cleaner and avoids relying on
1009− // the hook's ordering under a kill.
1010 runCatchingCancellable { container.shutdown() }
1322 runCatchingCancellable { container.shutdown() }
1323+ // Graceful restart: relaunch the app's main activity via the
1324+ // launcher intent and finish the current task, instead of
1325+ // killing the process (which the system may log as a crash
1326+ // and which skips normal lifecycle teardown). Falling back to
1327+ // a hard kill only when no launch intent is resolvable.
1328+ val pm = context.packageManager
1329+ val intent = pm.getLaunchIntentForPackage(context.packageName)
1330+ if (intent != null) {
1331+ intent.addFlags(
1332+ android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
1333+ android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK,
1334+ )
1335+ context.startActivity(intent)
1336+ } else {
1011− android.os.Process.killProcess(android.os.Process.myPid())
1337+ android.os.Process.killProcess(android.os.Process.myPid())
1338+ }
1012 }
1339 }
1013 } else {
1340 } else {
1014 snackbar.showSnackbar(importFailedMsg)
1341 snackbar.showSnackbar(importFailedMsg)
@@ -1209,6 +1536,34 @@ private val AmoledSwatches: List<Color> = AmoledPaletteSwatches
1209@Composable
1536@Composable
1210private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
1537private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
1211
1538
1539+/**
1540+ * Case-insensitive substring match against any of [texts]. An empty query matches everything
1541+ * (so the search filter is a no-op when the field is cleared). Used to gate section/row
1542+ * visibility in the settings list.
1543+ */
1544+private fun matchesQuery(query: String, vararg texts: String): Boolean {
1545+ if (query.isEmpty()) return true
1546+ val q = query.trim().lowercase()
1547+ if (q.isEmpty()) return true
1548+ return texts.any { it.lowercase().contains(q) }
1549+}
1550+
1551+/**
1552+ * Per-row visibility given the section's precomputed header/any-row match state. When the
1553+ * header matched but no row did, the whole section is browsable so every row is shown;
1554+ * otherwise a row shows only when its own texts match the query.
1555+ */
1556+private fun rowVisible(
1557+ query: String,
1558+ headerMatches: Boolean,
1559+ anyRowMatches: Boolean,
1560+ vararg rowTexts: String,
1561+): Boolean {
1562+ if (query.isEmpty()) return true
1563+ if (headerMatches && !anyRowMatches) return true
1564+ return matchesQuery(query, *rowTexts)
1565+}
1566+
1212/** Small wrapper so the three appearance settings load atomically and the UI can gate
1567/** Small wrapper so the three appearance settings load atomically and the UI can gate
1213 * on a single null check instead of flashing hardcoded defaults. */
1568 * on a single null check instead of flashing hardcoded defaults. */
1214private data class SettingsValues(
1569private data class SettingsValues(
app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+23−17
@@ -16,8 +16,12 @@ import androidx.compose.foundation.layout.height
16import androidx.compose.foundation.layout.imePadding
16import androidx.compose.foundation.layout.imePadding
17import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.padding
18import androidx.compose.foundation.layout.size
18import androidx.compose.foundation.layout.size
19+import androidx.compose.foundation.layout.widthIn
20+import androidx.compose.foundation.layout.wrapContentWidth
19import androidx.compose.foundation.lazy.LazyColumn
21import androidx.compose.foundation.lazy.LazyColumn
20import androidx.compose.foundation.lazy.items
22import androidx.compose.foundation.lazy.items
23+import androidx.compose.animation.Crossfade
24+import androidx.compose.animation.core.tween
21import androidx.compose.material.icons.Icons
25import androidx.compose.material.icons.Icons
22import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
26import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
23import androidx.compose.material.icons.filled.Close
27import androidx.compose.material.icons.filled.Close
@@ -28,7 +32,6 @@ import androidx.compose.material.icons.filled.Refresh
28import androidx.compose.material.icons.filled.Search
32import androidx.compose.material.icons.filled.Search
29import androidx.compose.material.icons.filled.SearchOff
33import androidx.compose.material.icons.filled.SearchOff
30import androidx.compose.material3.Card
34import androidx.compose.material3.Card
31−import androidx.compose.material3.CircularProgressIndicator
32import androidx.compose.material3.ExperimentalMaterial3Api
35import androidx.compose.material3.ExperimentalMaterial3Api
33import androidx.compose.material3.FilterChip
36import androidx.compose.material3.FilterChip
34import androidx.compose.material3.Icon
37import androidx.compose.material3.Icon
@@ -68,6 +71,7 @@ import soy.iko.opencode.ui.components.AppTopBar
68import soy.iko.opencode.ui.components.ConnectionBannerFor
71import soy.iko.opencode.ui.components.ConnectionBannerFor
69import soy.iko.opencode.ui.components.EmptyState
72import soy.iko.opencode.ui.components.EmptyState
70import soy.iko.opencode.ui.components.SectionHeader
73import soy.iko.opencode.ui.components.SectionHeader
74+import soy.iko.opencode.ui.components.SkeletonRow
71import soy.iko.opencode.ui.components.reducedMotionAnimateItem
75import soy.iko.opencode.ui.components.reducedMotionAnimateItem
72import soy.iko.opencode.ui.vmFactory
76import soy.iko.opencode.ui.vmFactory
73import java.text.NumberFormat
77import java.text.NumberFormat
@@ -98,18 +102,26 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
98 PullToRefreshBox(
102 PullToRefreshBox(
99 isRefreshing = refreshing,
103 isRefreshing = refreshing,
100 onRefresh = { vm.load() },
104 onRefresh = { vm.load() },
101− modifier = Modifier.fillMaxSize().imePadding().padding(padding),
105+ modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally).widthIn(max = soy.iko.opencode.data.network.NetworkConfig.listContentMaxWidthDp.dp).imePadding().padding(padding),
102 ) {
106 ) {
103 Box(modifier = Modifier.fillMaxSize()) {
107 Box(modifier = Modifier.fillMaxSize()) {
104 ConnectionBannerFor(container)
108 ConnectionBannerFor(container)
105− val loadingLabel = stringResource(R.string.usage_loading)
109+ // Crossfade between content states so transitions read as a smooth fade instead
110+ // of an instant snap. Matches the session list's Crossfade pattern; reduced
111+ // motion is honored by Crossfade's default spec.
112+ val stateKey = state::class.simpleName
113+ @Suppress("UnusedCrossfadeTargetStateParameter")
114+ Crossfade(
115+ targetState = stateKey,
116+ animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
117+ label = "usage_state",
118+ ) {
106− when (val s = state) {
119+ when (val s = state) {
107− is UsageViewModel.State.Loading -> Centered {
120+ is UsageViewModel.State.Loading -> Column(
108− CircularProgressIndicator(
121+ modifier = Modifier.fillMaxSize().padding(16.dp),
109− Modifier.semantics { contentDescription = loadingLabel },
122+ verticalArrangement = Arrangement.spacedBy(12.dp),
110− )
123+ ) {
111− Spacer(Modifier.size(12.dp))
124+ repeat(6) { SkeletonRow() }
112− Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant)
113 }
125 }
114 is UsageViewModel.State.Disconnected -> EmptyState(
126 is UsageViewModel.State.Disconnected -> EmptyState(
115 icon = Icons.Filled.CloudOff,
127 icon = Icons.Filled.CloudOff,
@@ -141,19 +153,13 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
141 onTimeRangeChange = vm::setTimeRange,
153 onTimeRangeChange = vm::setTimeRange,
142 )
154 )
143 }
155 }
156+ }
144 }
157 }
145 }
158 }
146 }
159 }
147 }
160 }
148}
161}
149
162
150−@Composable
151−private fun Centered(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {
152− Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
153− Column(horizontalAlignment = Alignment.CenterHorizontally, content = content)
154− }
155−}
156−
157@OptIn(ExperimentalLayoutApi::class)
163@OptIn(ExperimentalLayoutApi::class)
158@Composable
164@Composable
159private fun UsageContent(
165private fun UsageContent(
app/src/main/res/values-es/strings.xml+17
@@ -20,6 +20,8 @@
20 <string name="files">Archivos</string>
20 <string name="files">Archivos</string>
21 <string name="share">Compartir</string>
21 <string name="share">Compartir</string>
22 <string name="copy_as_markdown">Copiar como Markdown</string>
22 <string name="copy_as_markdown">Copiar como Markdown</string>
23+ <string name="share_as_json">Compartir como JSON</string>
24+ <string name="copy_as_json">Copiar como JSON</string>
23 <string name="share_subject">Sesión de opencode</string>
25 <string name="share_subject">Sesión de opencode</string>
24 <string name="send">Enviar</string>
26 <string name="send">Enviar</string>
25 <string name="stop">Detener</string>
27 <string name="stop">Detener</string>
@@ -54,6 +56,7 @@
54 <string name="no_servers_yet">Aún no hay servidores</string>
56 <string name="no_servers_yet">Aún no hay servidores</string>
55 <string name="no_servers_hint">Añade un endpoint de opencode serve para conectar.</string>
57 <string name="no_servers_hint">Añade un endpoint de opencode serve para conectar.</string>
56 <string name="search_servers">Filtrar servidores</string>
58 <string name="search_servers">Filtrar servidores</string>
59+ <string name="search_settings">Buscar ajustes…</string>
57 <string name="no_server_matches">Ningún servidor coincide</string>
60 <string name="no_server_matches">Ningún servidor coincide</string>
58 <string name="remove_server_title">¿Quitar servidor?</string>
61 <string name="remove_server_title">¿Quitar servidor?</string>
59 <string name="remove_server_text">Se quitará «%1$s» de tus servidores guardados.</string>
62 <string name="remove_server_text">Se quitará «%1$s» de tus servidores guardados.</string>
@@ -192,6 +195,7 @@
192 <string name="image_failed">Error al cargar la imagen</string>
195 <string name="image_failed">Error al cargar la imagen</string>
193 <string name="save_image">Guardar en galería</string>
196 <string name="save_image">Guardar en galería</string>
194 <string name="share_image">Compartir imagen</string>
197 <string name="share_image">Compartir imagen</string>
198+ <string name="reset_zoom">Restablecer zoom</string>
195 <string name="image_saved">Imagen guardada en la galería</string>
199 <string name="image_saved">Imagen guardada en la galería</string>
196 <string name="image_save_failed">No se pudo guardar la imagen</string>
200 <string name="image_save_failed">No se pudo guardar la imagen</string>
197 <string name="search_models">Buscar modelos…</string>
201 <string name="search_models">Buscar modelos…</string>
@@ -213,6 +217,7 @@
213 <string name="git_deleted">Eliminado</string>
217 <string name="git_deleted">Eliminado</string>
214 <string name="binary_file">Archivo binario — vista previa no compatible.</string>
218 <string name="binary_file">Archivo binario — vista previa no compatible.</string>
215 <string name="empty_folder">Esta carpeta está vacía</string>
219 <string name="empty_folder">Esta carpeta está vacía</string>
220+ <string name="permission_denied_files">Sin permiso para leer esta carpeta.</string>
216 <string name="empty_file">Este archivo está vacío</string>
221 <string name="empty_file">Este archivo está vacío</string>
217 <string name="show_hidden">Mostrar ocultos</string>
222 <string name="show_hidden">Mostrar ocultos</string>
218 <string name="parent_dir">Directorio superior</string>
223 <string name="parent_dir">Directorio superior</string>
@@ -339,6 +344,8 @@
339 <string name="session_deleted_chat">Sesión eliminada</string>
344 <string name="session_deleted_chat">Sesión eliminada</string>
340 <string name="today">Hoy</string>
345 <string name="today">Hoy</string>
341 <string name="yesterday">Ayer</string>
346 <string name="yesterday">Ayer</string>
347+ <string name="last_week">Semana pasada</string>
348+ <string name="older">Anteriores</string>
342
349
343 <!-- Search -->
350 <!-- Search -->
344 <string name="clear_search">Borrar búsqueda</string>
351 <string name="clear_search">Borrar búsqueda</string>
@@ -621,6 +628,10 @@
621 <item quantity="one">%1$d resultado</item>
628 <item quantity="one">%1$d resultado</item>
622 <item quantity="other">%1$d resultados</item>
629 <item quantity="other">%1$d resultados</item>
623 </plurals>
630 </plurals>
631+ <plurals name="file_count">
632+ <item quantity="one">%1$d elemento</item>
633+ <item quantity="other">%1$d elementos</item>
634+ </plurals>
624
635
625 <!-- UI/UX batch 2: message status, crash prompt, server connection -->
636 <!-- UI/UX batch 2: message status, crash prompt, server connection -->
626 <string name="message_sending">Enviando…</string>
637 <string name="message_sending">Enviando…</string>
@@ -694,6 +705,7 @@
694 <item quantity="other">%d seleccionados</item>
705 <item quantity="other">%d seleccionados</item>
695 </plurals>
706 </plurals>
696 <string name="bulk_archive">Archivar</string>
707 <string name="bulk_archive">Archivar</string>
708+ <string name="bulk_pin">Fijar</string>
697 <string name="bulk_delete">Eliminar</string>
709 <string name="bulk_delete">Eliminar</string>
698 <string name="bulk_mark_read">Marcar leído</string>
710 <string name="bulk_mark_read">Marcar leído</string>
699 <string name="deep_link_invalid">Enlace no admitido</string>
711 <string name="deep_link_invalid">Enlace no admitido</string>
@@ -710,6 +722,7 @@
710
722
711 <!-- UI/UX batch 4: file browser label, a11y, bulk confirmations -->
723 <!-- UI/UX batch 4: file browser label, a11y, bulk confirmations -->
712 <string name="folders_first">Carpetas primero</string>
724 <string name="folders_first">Carpetas primero</string>
725+ <string name="changed_only">Solo modificados</string>
713 <string name="more_options_for">Más opciones para “%1$s”</string>
726 <string name="more_options_for">Más opciones para “%1$s”</string>
714 <string name="bulk_delete_title">¿Eliminar sesiones?</string>
727 <string name="bulk_delete_title">¿Eliminar sesiones?</string>
715 <string name="bulk_delete_text">Estas %1$d sesiones se eliminarán permanentemente. No se puede deshacer.</string>
728 <string name="bulk_delete_text">Estas %1$d sesiones se eliminarán permanentemente. No se puede deshacer.</string>
@@ -799,6 +812,8 @@
799 <string name="mcp_view_tools">Ver herramientas</string>
812 <string name="mcp_view_tools">Ver herramientas</string>
800 <string name="mcp_no_tools">No hay herramientas disponibles</string>
813 <string name="mcp_no_tools">No hay herramientas disponibles</string>
801 <string name="mcp_add_server">Añadir servidor MCP</string>
814 <string name="mcp_add_server">Añadir servidor MCP</string>
815+ <string name="mcp_add_name_required">El nombre es obligatorio.</string>
816+ <string name="mcp_add_target_required">Obligatorio.</string>
802 <string name="file_share_too_large">El archivo es demasiado grande para compartir</string>
817 <string name="file_share_too_large">El archivo es demasiado grande para compartir</string>
803 <string name="search_filter_all">Todo</string>
818 <string name="search_filter_all">Todo</string>
804 <string name="search_filter_messages">Mensajes</string>
819 <string name="search_filter_messages">Mensajes</string>
@@ -868,4 +883,6 @@
868 <string name="swipe_action_mark_read">Marcar leído / no leído</string>
883 <string name="swipe_action_mark_read">Marcar leído / no leído</string>
869 <string name="swipe_action_none">Ninguno</string>
884 <string name="swipe_action_none">Ninguno</string>
870 <string name="image_file_preview">Vista previa de imagen</string>
885 <string name="image_file_preview">Vista previa de imagen</string>
886+ <string name="view_source">Ver código</string>
887+ <string name="message_source">Código del mensaje</string>
871</resources>
888</resources>
app/src/main/res/values-night/colors.xml+3
@@ -12,4 +12,7 @@
12 <color name="widget_on_surface_variant">#9AA5CE</color>
12 <color name="widget_on_surface_variant">#9AA5CE</color>
13 <color name="widget_divider">#33565F89</color>
13 <color name="widget_divider">#33565F89</color>
14 <color name="widget_background">#EE1F2335</color>
14 <color name="widget_background">#EE1F2335</color>
15+
16+ <!-- Notification brand accent at night — the Tokyo Night primary blue. -->
17+ <color name="notif_brand">#7AA2F7</color>
15</resources>
18</resources>
app/src/main/res/values/colors.xml+5
@@ -14,4 +14,9 @@
14 <color name="widget_on_surface_variant">#444B6E</color>
14 <color name="widget_on_surface_variant">#444B6E</color>
15 <color name="widget_divider">#33747DAA</color>
15 <color name="widget_divider">#33747DAA</color>
16 <color name="widget_background">#EEF1F3FA</color>
16 <color name="widget_background">#EEF1F3FA</color>
17+
18+ <!-- Notification brand accent (small-icon tint circle). Resolved from resources so the
19+ notifications can follow the app theme (values-night overrides for dark/AMOLED)
20+ instead of a hardcoded constant that ignores the user's theme choice. -->
21+ <color name="notif_brand">#34548A</color>
17</resources>
22</resources>
app/src/main/res/values/strings.xml+18
@@ -20,6 +20,8 @@
20 <string name="files">Files</string>
20 <string name="files">Files</string>
21 <string name="share">Share</string>
21 <string name="share">Share</string>
22 <string name="copy_as_markdown">Copy as Markdown</string>
22 <string name="copy_as_markdown">Copy as Markdown</string>
23+ <string name="share_as_json">Share as JSON</string>
24+ <string name="copy_as_json">Copy as JSON</string>
23 <string name="share_subject">opencode session</string>
25 <string name="share_subject">opencode session</string>
24 <string name="send">Send</string>
26 <string name="send">Send</string>
25 <string name="stop">Stop</string>
27 <string name="stop">Stop</string>
@@ -55,6 +57,7 @@
55 <string name="no_servers_yet">No servers yet</string>
57 <string name="no_servers_yet">No servers yet</string>
56 <string name="no_servers_hint">Add an opencode serve endpoint to connect.</string>
58 <string name="no_servers_hint">Add an opencode serve endpoint to connect.</string>
57 <string name="search_servers">Filter servers</string>
59 <string name="search_servers">Filter servers</string>
60+ <string name="search_settings">Search settings…</string>
58 <string name="no_server_matches">No matching servers</string>
61 <string name="no_server_matches">No matching servers</string>
59 <string name="remove_server_title">Remove server?</string>
62 <string name="remove_server_title">Remove server?</string>
60 <string name="remove_server_text">“%1$s” will be removed from your saved servers.</string>
63 <string name="remove_server_text">“%1$s” will be removed from your saved servers.</string>
@@ -201,6 +204,7 @@
201 <string name="image_failed">Failed to load image</string>
204 <string name="image_failed">Failed to load image</string>
202 <string name="save_image">Save to gallery</string>
205 <string name="save_image">Save to gallery</string>
203 <string name="share_image">Share image</string>
206 <string name="share_image">Share image</string>
207+ <string name="reset_zoom">Reset zoom</string>
204 <string name="image_saved">Image saved to gallery</string>
208 <string name="image_saved">Image saved to gallery</string>
205 <string name="image_save_failed">Couldn\'t save image</string>
209 <string name="image_save_failed">Couldn\'t save image</string>
206 <string name="search_models">Search models…</string>
210 <string name="search_models">Search models…</string>
@@ -222,6 +226,7 @@
222 <string name="git_deleted">Deleted</string>
226 <string name="git_deleted">Deleted</string>
223 <string name="binary_file">Binary file — preview not supported.</string>
227 <string name="binary_file">Binary file — preview not supported.</string>
224 <string name="empty_folder">This folder is empty</string>
228 <string name="empty_folder">This folder is empty</string>
229+ <string name="permission_denied_files">No permission to read this folder.</string>
225 <string name="empty_file">This file is empty</string>
230 <string name="empty_file">This file is empty</string>
226 <string name="show_hidden">Show hidden</string>
231 <string name="show_hidden">Show hidden</string>
227 <string name="parent_dir">Parent directory</string>
232 <string name="parent_dir">Parent directory</string>
@@ -349,6 +354,8 @@
349 <string name="session_deleted_chat">Session deleted</string>
354 <string name="session_deleted_chat">Session deleted</string>
350 <string name="today">Today</string>
355 <string name="today">Today</string>
351 <string name="yesterday">Yesterday</string>
356 <string name="yesterday">Yesterday</string>
357+ <string name="last_week">Last week</string>
358+ <string name="older">Older</string>
352
359
353 <!-- Search -->
360 <!-- Search -->
354 <string name="clear_search">Clear search</string>
361 <string name="clear_search">Clear search</string>
@@ -644,6 +651,10 @@
644 <item quantity="one">%1$d result</item>
651 <item quantity="one">%1$d result</item>
645 <item quantity="other">%1$d results</item>
652 <item quantity="other">%1$d results</item>
646 </plurals>
653 </plurals>
654+ <plurals name="file_count">
655+ <item quantity="one">%1$d item</item>
656+ <item quantity="other">%1$d items</item>
657+ </plurals>
647
658
648 <!-- UI/UX batch 2: message status, crash prompt, server connection -->
659 <!-- UI/UX batch 2: message status, crash prompt, server connection -->
649 <string name="message_sending">Sending…</string>
660 <string name="message_sending">Sending…</string>
@@ -695,6 +706,7 @@
695 <item quantity="other">%d selected</item>
706 <item quantity="other">%d selected</item>
696 </plurals>
707 </plurals>
697 <string name="bulk_archive">Archive</string>
708 <string name="bulk_archive">Archive</string>
709+ <string name="bulk_pin">Pin</string>
698 <string name="bulk_delete">Delete</string>
710 <string name="bulk_delete">Delete</string>
699 <string name="bulk_mark_read">Mark read</string>
711 <string name="bulk_mark_read">Mark read</string>
700 <string name="deep_link_invalid">Unsupported link</string>
712 <string name="deep_link_invalid">Unsupported link</string>
@@ -711,6 +723,7 @@
711
723
712 <!-- UI/UX batch 4: file browser label, a11y, bulk confirmations -->
724 <!-- UI/UX batch 4: file browser label, a11y, bulk confirmations -->
713 <string name="folders_first">Folders first</string>
725 <string name="folders_first">Folders first</string>
726+ <string name="changed_only">Changed only</string>
714 <string name="more_options_for">More options for “%1$s”</string>
727 <string name="more_options_for">More options for “%1$s”</string>
715 <string name="bulk_delete_title">Delete sessions?</string>
728 <string name="bulk_delete_title">Delete sessions?</string>
716 <string name="bulk_delete_text">These %1$d sessions will be permanently deleted. This cannot be undone.</string>
729 <string name="bulk_delete_text">These %1$d sessions will be permanently deleted. This cannot be undone.</string>
@@ -799,6 +812,8 @@
799 <string name="mcp_view_tools">View tools</string>
812 <string name="mcp_view_tools">View tools</string>
800 <string name="mcp_no_tools">No tools available</string>
813 <string name="mcp_no_tools">No tools available</string>
801 <string name="mcp_add_server">Add MCP server</string>
814 <string name="mcp_add_server">Add MCP server</string>
815+ <string name="mcp_add_name_required">Name is required.</string>
816+ <string name="mcp_add_target_required">Required.</string>
802 <string name="file_share_too_large">File is too large to share</string>
817 <string name="file_share_too_large">File is too large to share</string>
803 <string name="search_filter_all">All</string>
818 <string name="search_filter_all">All</string>
804 <string name="search_filter_messages">Messages</string>
819 <string name="search_filter_messages">Messages</string>
@@ -894,4 +909,7 @@
894 <string name="swipe_action_none">None</string>
909 <string name="swipe_action_none">None</string>
895 <!-- Open the file viewer with an image preview for image extensions. -->
910 <!-- Open the file viewer with an image preview for image extensions. -->
896 <string name="image_file_preview">Image preview</string>
911 <string name="image_file_preview">Image preview</string>
912+ <!-- Markdown source view on a message's TextParts. -->
913+ <string name="view_source">View source</string>
914+ <string name="message_source">Message source</string>
897</resources>
915</resources>