git.iko.soy/opencode-android
on master help sign in
Batch of UI/UX improvements: fix server-edit IME Next/Done and HTTPS scheme suggestion and localize the ping error, make file viewer code selectable with go-to-line and find case-sensitivity and a path subtitle, add show-hidden and breadcrumb ellipsizing plus a copy-path overflow to the file browser, add a chat loading skeleton and streaming elapsed-time and relabel the misleading Branch action, bold unread and add Mark-all-read and threshold-gate session search, fix the cold-start dark flash with a DayNight base theme and windowBackground and set nav-bar icon appearance and AMOLED-over-dynamic precedence and fill out the typography roles, give notifications a dedicated small icon and per-action icons and lock-screen privacy and setOnlyAlertOnce and group-summary cleanup, differentiate 4xx errors and surface dropped outbox replies and convert the first-launch dialogs to Compose M3, and add diagnostics delete-overflow and a full-screen selectable report viewer
commit7496a786cf189648b927222b2bf9050e0a222ca6 authoriko <mail@iko.soy> dateFri 3 Jul 2026, 17:54 (3 months ago) parent930859e
37 files changed+1039−269
app/src/main/java/soy/iko/opencode/MainActivity.kt+59−19 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+5 app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+1−1 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+39−3 app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+14−5 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+65−21 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+91−15 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+3−3 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+34−4 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+222−59 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+26−4 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+39−4 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+46−23 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+2−1 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+110−71 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+87−27 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+10−2 app/src/main/java/soy/iko/opencode/ui/theme/Typography.kt+38 app/src/main/res/drawable/ic_action_block.xml+10 app/src/main/res/drawable/ic_action_check.xml+9 app/src/main/res/drawable/ic_action_reply.xml+9 app/src/main/res/drawable/ic_action_stop.xml+9 app/src/main/res/drawable/ic_stat_notify.xml+9 app/src/main/res/values-es/strings.xml+11−3 app/src/main/res/values-es/strings_cross_ux.xml+7 app/src/main/res/values-es/strings_diag_ux.xml+4 app/src/main/res/values-es/strings_notif_ux.xml+5 app/src/main/res/values-es/strings_settings_ux.xml+12 app/src/main/res/values-night/colors.xml+2 app/src/main/res/values-night/themes.xml+8 app/src/main/res/values/colors.xml+3 app/src/main/res/values/strings.xml+11−3 app/src/main/res/values/strings_cross_ux.xml+7 app/src/main/res/values/strings_diag_ux.xml+4 app/src/main/res/values/strings_notif_ux.xml+8 app/src/main/res/values/strings_settings_ux.xml+12 app/src/main/res/values/themes.xml+8−1
app/src/main/java/soy/iko/opencode/MainActivity.kt+59−19
@@ -14,9 +14,15 @@ import androidx.activity.compose.setContent
1414import androidx.activity.enableEdgeToEdge
1515import androidx.activity.result.contract.ActivityResultContracts
1616import androidx.compose.foundation.isSystemInDarkTheme
17+import androidx.compose.material3.AlertDialog
18+import androidx.compose.material3.Text
19+import androidx.compose.material3.TextButton
1720import androidx.compose.runtime.LaunchedEffect
1821import androidx.compose.runtime.getValue
22+import androidx.compose.runtime.mutableStateOf
1923import androidx.compose.runtime.remember
24+import androidx.compose.runtime.setValue
25+import androidx.compose.ui.res.stringResource
2026import kotlinx.coroutines.flow.combine
2127import androidx.core.content.ContextCompat
2228import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@@ -40,6 +46,16 @@ class MainActivity : FragmentActivity() {
4046 private var shareIntentHandled = false
4147 private var openSessionHandled = false
4248 private var newSessionHandled = false
49+ // Cold-start prompts (crash report, notification rationale) rendered as Compose M3
50+ // dialogs. Hoisted as Activity-level mutableState so the non-composable trigger logic
51+ // (onCreate/onResume) can flip them and the Compose tree (inside OpencodeTheme) reads
52+ // them and renders an M3 AlertDialog that picks up the app palette. The triggers
53+ // themselves are idempotent per instance (crashes are acknowledged on first fire;
54+ // notificationPermissionRequested is restored from saved state), so a plain
55+ // mutableStateOf (not saveable) preserves the original dismissal semantics across
56+ // config-change recreation: the prompt simply doesn't re-fire after a rotate.
57+ private var showCrashPrompt by mutableStateOf(false)
58+ private var showNotifRationale by mutableStateOf(false)
4359 // Track whether the notification-permission prompt has been shown for this Activity
4460 // instance. onCreate only requests when savedInstanceState == null (a fresh process),
4561 // but an Activity recreated by a config change NOT listed in the manifest's configChanges
@@ -104,6 +120,47 @@ class MainActivity : FragmentActivity() {
104120 AppLockGate(enabled = locked, reLockDelaySeconds = appLockReLockSeconds) {
105121 OpencodeAppUi(container = container)
106122 }
123+ // First-launch prompts as Compose M3 dialogs (siblings of AppLockGate so they
124+ // still surface over the lock screen, matching the prior framework-dialog
125+ // behavior) and inside OpencodeTheme so they use the app palette.
126+ if (showCrashPrompt) {
127+ AlertDialog(
128+ onDismissRequest = { showCrashPrompt = false },
129+ title = { Text(stringResource(R.string.crash_last_run_title)) },
130+ text = { Text(stringResource(R.string.crash_last_run_text)) },
131+ confirmButton = {
132+ TextButton(onClick = {
133+ showCrashPrompt = false
134+ container.requestDiagnostics()
135+ }) { Text(stringResource(R.string.crash_last_run_view)) }
136+ },
137+ dismissButton = {
138+ TextButton(onClick = { showCrashPrompt = false }) {
139+ Text(stringResource(R.string.crash_last_run_dismiss))
140+ }
141+ },
142+ )
143+ }
144+ if (showNotifRationale) {
145+ AlertDialog(
146+ // Mirrors the original setCancelable(false): not dismissible by
147+ // back/outside tap, only by the buttons below.
148+ onDismissRequest = { },
149+ title = { Text(stringResource(R.string.notif_rationale_title)) },
150+ text = { Text(stringResource(R.string.notif_rationale_text)) },
151+ confirmButton = {
152+ TextButton(onClick = {
153+ showNotifRationale = false
154+ runCatching { requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) }
155+ }) { Text(stringResource(R.string.notif_rationale_allow)) }
156+ },
157+ dismissButton = {
158+ TextButton(onClick = { showNotifRationale = false }) {
159+ Text(stringResource(R.string.notif_rationale_skip))
160+ }
161+ },
162+ )
163+ }
107164 }
108165 }
109166 }
@@ -117,15 +174,7 @@ class MainActivity : FragmentActivity() {
117174 val crashLogger = soy.iko.opencode.data.repo.CrashLogger.get(this)
118175 if (!crashLogger.hasUnacknowledgedCrash()) return
119176 crashLogger.acknowledgeCrashes()
120− android.app.AlertDialog.Builder(this)
121− .setTitle(R.string.crash_last_run_title)
122− .setMessage(R.string.crash_last_run_text)
123− .setPositiveButton(R.string.crash_last_run_view) { _, _ ->
124− (application as OpencodeApp).container.requestDiagnostics()
125− }
126− .setNegativeButton(R.string.crash_last_run_dismiss, null)
127− .setCancelable(true)
128− .show()
177+ showCrashPrompt = true
129178 }
130179
131180 override fun onNewIntent(intent: Intent) {
@@ -272,15 +321,6 @@ class MainActivity : FragmentActivity() {
272321 }
273322
274323 private fun showNotificationRationaleDialog() {
275− val ctx = this
276− android.app.AlertDialog.Builder(ctx)
277− .setTitle(R.string.notif_rationale_title)
278− .setMessage(R.string.notif_rationale_text)
279− .setPositiveButton(R.string.notif_rationale_allow) { _, _ ->
280− runCatching { requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) }
281− }
282− .setNegativeButton(R.string.notif_rationale_skip, null)
283− .setCancelable(false)
284− .show()
324+ showNotifRationale = true
285325 }
286326}
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+5
@@ -233,6 +233,11 @@ object NetworkConfig {
233233 /** Minimum number of sessions in the usage report before the per-session list shows its
234234 * filter field. A short history is faster to scan than type into. */
235235 const val usageSessionSearchThreshold = 8
236+
237+ /** Minimum number of sessions before the session list shows its search/filter field.
238+ * Matches the sibling screens' convention: a short list is faster to scan by eye than
239+ * type into, so the field earns its vertical space only once the list grows. */
240+ const val sessionListSearchThreshold = 6
236241 /** Extra bottom content padding (dp) for the chat message list, reserving room for the
237242 * jump-to-latest FAB so it never floats over the last message. */
238243 const val chatListFabInsetDp = 80
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+1−1
@@ -34,7 +34,7 @@ class SettingsStore(context: Context) {
3434 const val DEFAULT_CHAT_TEXT_SCALE = 1.0f
3535 /** 0 means re-lock the instant the app backgrounds. Other entries are the grace
3636 * period (seconds) during which a quick app-switch does NOT re-prompt for auth. */
37− val APP_LOCK_RELOCK_OPTIONS_SECONDS: List<Int> = listOf(0, 30, 60, 300)
37+ val APP_LOCK_RELOCK_OPTIONS_SECONDS: List<Int> = listOf(0, 30, 60, 300, 1800, 3600)
3838 const val DEFAULT_APP_LOCK_RELOCK_SECONDS = 0
3939 }
4040
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+39−3
@@ -276,6 +276,11 @@ open class AppContainer private constructor(
276276 /** Mark a session as read (clear its unread badge) — the "Mark read" notification action. */
277277 open fun markSessionRead(id: String) = clearUnread(id)
278278
279+ /** Clear every session's unread badge at once — the session list's "Mark all read" action. */
280+ open fun clearAllUnread() {
281+ _unread.update { emptyMap() }
282+ }
283+
279284 /** Restore a session's unread badge after a failed server switch reconnects.
280285 * Preserves the prior [count] so a session badged with "5 unread" before the
281286 * switch attempt still shows 5 (not 1) after the restore. No-op for the session
@@ -314,8 +319,18 @@ open class AppContainer private constructor(
314319 // Show only the HTTP status (e.g. 401, 404) — never the request URL,
315320 // which a ClientRequestException carries in its message and which we
316321 // promised not to leak (it can include auth or internal paths).
317− ErrorKind.CLIENT -> responseStatusCode(t)?.let { string(R.string.error_client_status, it) }
318− ?: string(R.string.error_generic)
322+ ErrorKind.CLIENT -> {
323+ val code = responseStatusCode(t)
324+ when (code) {
325+ // Auth failures reuse the connection banner text: the request itself is
326+ // fine, the credentials aren't, so the fix is re-editing the profile —
327+ // not "request failed (401)" which implies an opaque program error.
328+ 401, 403 -> string(R.string.connection_failed)
329+ 404 -> string(R.string.error_not_found)
330+ else -> code?.let { string(R.string.error_client_status, it) }
331+ ?: string(R.string.error_generic)
332+ }
333+ }
319334 ErrorKind.UNKNOWN -> string(R.string.error_generic)
320335 }
321336
@@ -483,8 +498,29 @@ open class AppContainer private constructor(
483498 else -> false
484499 }
485500 if (permanent) {
486− Log.w("AppContainer", "Dropping undeliverable outbox message ${msg.id} for ${msg.sessionId} (HTTP $status)")
501+ Log.w(
502+ "AppContainer",
503+ "Dropping undeliverable outbox message ${msg.id} for ${msg.sessionId} (HTTP $status): ${string(R.string.outbox_dropped_text)}",
504+ )
487505 outboxStore.remove(msg.id)
506+ // Previously this silent drop only hit logcat, so the user got no signal
507+ // that their composed reply never landed. Surface it on the ERROR channel.
508+ // postError is the only public entry point that posts there; it renders a
509+ // fixed title/text and interpolates its title arg, so the localized
510+ // outbox_dropped_title is passed as that arg (a user-facing message rather
511+ // than an opaque session id). Tap routes back to the originating profile.
512+ val ctx = appContext
513+ if (ctx != null) {
514+ runCatchingCancellable {
515+ SessionNotifications.postError(
516+ ctx, msg.sessionId,
517+ string(R.string.outbox_dropped_title),
518+ conn.profile.id,
519+ )
520+ }.onFailure {
521+ Log.w("AppContainer", "outbox drop notification failed: ${safeExceptionSummary(it)}")
522+ }
523+ }
488524 } else {
489525 break
490526 }
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+14−5
@@ -58,18 +58,23 @@ class RunForegroundService : Service() {
5858 return START_NOT_STICKY
5959 }
6060
61− private fun buildNotification(sessionTitle: String?, sessionId: String?, progress: String? = null): Notification {
61+ private fun mainContentIntent(): PendingIntent {
6262 // Tapping the notification opens the app so the user can see the running session.
6363 val openIntent = Intent(this, MainActivity::class.java).apply {
6464 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
6565 }
66− val pendingIntent = PendingIntent.getActivity(
66+ return PendingIntent.getActivity(
6767 this, 0, openIntent,
6868 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
6969 )
70+ }
71+
72+ private fun buildNotification(sessionTitle: String?, sessionId: String?, progress: String? = null): Notification {
73+ val pendingIntent = mainContentIntent()
7074 val contentText = progress ?: sessionTitle ?: getString(R.string.notif_running_text)
7175 val builder = NotificationCompat.Builder(this, NotificationChannels.STATUS)
72− .setSmallIcon(R.drawable.ic_launcher_foreground)
76+ .setSmallIcon(R.drawable.ic_stat_notify)
77+ .setColor(BRAND_COLOR)
7378 .setContentTitle(getString(R.string.notif_running_title))
7479 .setContentText(contentText)
7580 .setContentIntent(pendingIntent)
@@ -94,7 +99,7 @@ class RunForegroundService : Service() {
9499 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
95100 )
96101 builder.addAction(
97− R.drawable.ic_launcher_foreground,
102+ R.drawable.ic_action_stop,
98103 getString(R.string.notif_action_stop),
99104 stopPending,
100105 )
@@ -126,9 +131,11 @@ class RunForegroundService : Service() {
126131 private fun postTimeoutNotification() {
127132 runCatching {
128133 val notification = NotificationCompat.Builder(this, NotificationChannels.STATUS)
129− .setSmallIcon(R.drawable.ic_launcher_foreground)
134+ .setSmallIcon(R.drawable.ic_stat_notify)
135+ .setColor(BRAND_COLOR)
130136 .setContentTitle(getString(R.string.notif_running_title))
131137 .setContentText(getString(R.string.notif_fg_timeout_text))
138+ .setContentIntent(mainContentIntent())
132139 .setAutoCancel(true)
133140 .setPriority(NotificationCompat.PRIORITY_LOW)
134141 .build()
@@ -149,6 +156,8 @@ class RunForegroundService : Service() {
149156 private const val TAG = "RunForegroundService"
150157 private const val NOTIF_ID = 1
151158 private const val NOTIF_TIMEOUT_ID = 2
159+ // Brand accent for the foreground notifications' small-icon tint circle.
160+ private val BRAND_COLOR = 0xFF34548A.toInt()
152161 const val EXTRA_SESSION_TITLE = "soy.iko.opencode.extra.SESSION_TITLE"
153162 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
154163 const val EXTRA_PROGRESS = "soy.iko.opencode.extra.PROGRESS"
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+65−21
@@ -1,6 +1,8 @@
11package soy.iko.opencode.notification
22
33import android.annotation.SuppressLint
4+import android.app.Notification
5+import android.app.NotificationManager
46import android.app.PendingIntent
57import android.content.Context
68import android.content.Intent
@@ -72,10 +74,6 @@ object SessionNotifications {
7274 context, android.Manifest.permission.POST_NOTIFICATIONS,
7375 ) == android.content.pm.PackageManager.PERMISSION_GRANTED
7476
75− /** Sanitize a session id before embedding it in the deep-link URI so characters like
76− * /, ?, # can't inject path segments or query parameters. */
77− private fun safeId(sessionId: String): String = notifIdRegex.replace(sessionId, "")
78−
7977 /** A PendingIntent that opens [sessionId] in the app when the notification body is tapped.
8078 * [profileId] (the originating server) is embedded so a tap after the user has switched
8179 * servers routes back to the server that ran the session, not whichever is active —
@@ -83,9 +81,12 @@ object SessionNotifications {
8381 private fun openSessionIntent(
8482 context: Context, sessionId: String, requestCode: Int, profileId: String?,
8583 ): PendingIntent {
84+ // Sanitize the session id before embedding it in the deep-link URI so characters like
85+ // /, ?, # can't inject path segments or query parameters.
86+ val safeId = notifIdRegex.replace(sessionId, "")
8687 val openIntent = Intent(context, MainActivity::class.java).apply {
8788 action = Intent.ACTION_VIEW
88− data = android.net.Uri.parse("opencode://session/${safeId(sessionId)}")
89+ data = android.net.Uri.parse("opencode://session/$safeId")
8990 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
9091 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
9192 }
@@ -120,7 +121,7 @@ object SessionNotifications {
120121 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
121122 )
122123 val replyAction = NotificationCompat.Action.Builder(
123− R.drawable.ic_launcher_foreground,
124+ R.drawable.ic_action_reply,
124125 context.getString(R.string.notif_action_reply),
125126 replyPending,
126127 ).addRemoteInput(remoteInput).setAllowGeneratedReplies(true).build()
@@ -136,19 +137,20 @@ object SessionNotifications {
136137 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
137138 )
138139 val markReadAction = NotificationCompat.Action.Builder(
139− R.drawable.ic_launcher_foreground,
140+ R.drawable.ic_action_check,
140141 context.getString(R.string.notif_action_mark_read),
141142 markReadPending,
142143 ).build()
143144
144145 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
145− .setSmallIcon(R.drawable.ic_launcher_foreground)
146+ .setSmallIcon(R.drawable.ic_stat_notify)
146147 .setContentTitle(context.getString(R.string.notif_completed_title))
147148 // No % escaping here: getString(id, arg) inserts the argument verbatim and
148149 // never re-scans it for format specifiers, so escaping the title would show
149150 // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
150151 .setContentText(context.getString(R.string.notif_completed_text, title))
151152 .setAutoCancel(true)
153+ .setOnlyAlertOnce(true)
152154 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
153155 .addAction(replyAction)
154156 .addAction(markReadAction)
@@ -171,10 +173,10 @@ object SessionNotifications {
171173 @SuppressLint("MissingPermission")
172174 private fun postCompletedSummary(context: Context) {
173175 val summary = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
174− .setSmallIcon(R.drawable.ic_launcher_foreground)
176+ .setSmallIcon(R.drawable.ic_stat_notify)
175177 .setContentTitle(context.getString(R.string.notif_completed_title))
176178 .setContentText(context.getString(R.string.app_name))
177− .setSmallIcon(R.drawable.ic_launcher_foreground)
179+ .setSmallIcon(R.drawable.ic_stat_notify)
178180 .setAutoCancel(true)
179181 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
180182 .setGroup(GROUP_COMPLETED)
@@ -201,23 +203,29 @@ object SessionNotifications {
201203 ?: context.getString(R.string.notif_permission_fallback)
202204
203205 val builder = NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
204− .setSmallIcon(R.drawable.ic_launcher_foreground)
206+ .setSmallIcon(R.drawable.ic_stat_notify)
205207 .setContentTitle(context.getString(R.string.notif_permission_title, sessionTitle))
206208 .setContentText(detail)
207209 .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
208210 .setAutoCancel(true)
209211 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
210− .setCategory(NotificationCompat.CATEGORY_CALL)
211212 .setPriority(NotificationCompat.PRIORITY_HIGH)
212213 .setGroup(GROUP_PERMISSION)
214+ .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
215+ .setPublicVersion(
216+ NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
217+ .setSmallIcon(R.drawable.ic_stat_notify)
218+ .setContentTitle(context.getString(R.string.notif_permission_public))
219+ .build(),
220+ )
213221
214222 // One action per response. Each PendingIntent needs a distinct request code or
215223 // FLAG_UPDATE_CURRENT would collapse them into one (the last extras win).
216224 listOf(
217− PermissionResponse.ONCE to R.string.notif_action_allow_once,
218− PermissionResponse.ALWAYS to R.string.notif_action_always,
219− PermissionResponse.REJECT to R.string.notif_action_reject,
220− ).forEach { (response, labelRes) ->
225+ Triple(PermissionResponse.ONCE, R.string.notif_action_allow_once, R.drawable.ic_action_check),
226+ Triple(PermissionResponse.ALWAYS, R.string.notif_action_always, R.drawable.ic_action_check),
227+ Triple(PermissionResponse.REJECT, R.string.notif_action_reject, R.drawable.ic_action_block),
228+ ).forEach { (response, labelRes, iconRes) ->
221229 val intent = Intent(context, NotificationActionReceiver::class.java).apply {
222230 action = NotificationActionReceiver.ACTION_PERMISSION
223231 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
@@ -229,7 +237,7 @@ object SessionNotifications {
229237 context, notifId + response.ordinal + 1, intent,
230238 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
231239 )
232− builder.addAction(R.drawable.ic_launcher_foreground, context.getString(labelRes), pending)
240+ builder.addAction(iconRes, context.getString(labelRes), pending)
233241 }
234242
235243 runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
@@ -246,15 +254,23 @@ object SessionNotifications {
246254 if (!canPost(context)) return
247255 val notifId = notifId(NS_ERROR, sessionId)
248256 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
249− .setSmallIcon(R.drawable.ic_launcher_foreground)
257+ .setSmallIcon(R.drawable.ic_stat_notify)
250258 .setContentTitle(context.getString(R.string.notif_error_title))
251259 .setContentText(context.getString(R.string.notif_error_text, title))
252260 .setAutoCancel(true)
261+ .setOnlyAlertOnce(true)
253262 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
254263 .setCategory(NotificationCompat.CATEGORY_ERROR)
255264 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
256265 // Group error notifications so a burst of failures collapses into a stackable group.
257266 .setGroup(GROUP_ERROR)
267+ .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
268+ .setPublicVersion(
269+ NotificationCompat.Builder(context, NotificationChannels.ERROR)
270+ .setSmallIcon(R.drawable.ic_stat_notify)
271+ .setContentTitle(context.getString(R.string.notif_error_public))
272+ .build(),
273+ )
258274 .build()
259275 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
260276 .onFailure { Log.w(TAG, "Failed to post error notification", it) }
@@ -273,7 +289,7 @@ object SessionNotifications {
273289 }
274290 val notifId = notifId(NS_COMPLETED, sessionId)
275291 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
276− .setSmallIcon(R.drawable.ic_launcher_foreground)
292+ .setSmallIcon(R.drawable.ic_stat_notify)
277293 .setContentTitle(context.getString(R.string.notif_reply_sent))
278294 .setAutoCancel(true)
279295 .setPriority(NotificationCompat.PRIORITY_LOW)
@@ -290,10 +306,11 @@ object SessionNotifications {
290306 @SuppressLint("MissingPermission")
291307 fun postReplyFailed(context: Context, sessionId: String, profileId: String? = null) {
292308 NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
309+ maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
293310 if (!canPost(context)) return
294311 val notifId = notifId(NS_ERROR, sessionId)
295312 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
296− .setSmallIcon(R.drawable.ic_launcher_foreground)
313+ .setSmallIcon(R.drawable.ic_stat_notify)
297314 .setContentTitle(context.getString(R.string.notif_reply_failed_title))
298315 .setContentText(context.getString(R.string.notif_reply_failed_text))
299316 .setAutoCancel(true)
@@ -312,11 +329,13 @@ object SessionNotifications {
312329 /** Cancel a session's completion notification (e.g. when the user opens it). */
313330 fun cancel(context: Context, sessionId: String) {
314331 NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
332+ maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
315333 }
316334
317335 /** Cancel a session's permission notification (on reply, or when the user opens it). */
318336 fun cancelPermission(context: Context, sessionId: String) {
319337 NotificationManagerCompat.from(context).cancel(notifId(NS_PERMISSION, sessionId))
338+ maybeCancelSummary(context, GROUP_PERMISSION, SUMMARY_PERMISSION_ID)
320339 }
321340
322341 /** Cancel a session's error notification (e.g. when the user opens it). Without this a
@@ -324,6 +343,31 @@ object SessionNotifications {
324343 * than tapping the notification itself (setAutoCancel only clears it on a direct tap). */
325344 fun cancelError(context: Context, sessionId: String) {
326345 NotificationManagerCompat.from(context).cancel(notifId(NS_ERROR, sessionId))
346+ maybeCancelSummary(context, GROUP_ERROR, SUMMARY_ERROR_ID)
347+ }
348+
349+ /** Build the lock-screen (public) version of a notification: only a generic title, no
350+ * detail line, so sensitive file paths / commands in the real content aren't revealed
351+ * while the device is locked. */
352+ /** When the last child of a group is dismissed, cancel its lingering summary so it
353+ * doesn't sit empty in the shade. NotificationManagerCompat lacks getActiveNotifications,
354+ * so reach for the platform NotificationManager. The children/summary are posted without
355+ * a tag, so matching by [android.app.Notification.getGroup] (plus excluding the summary
356+ * id) is the reliable way to tell if any child remains. */
357+ private fun maybeCancelSummary(context: Context, group: String, summaryId: Int) {
358+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
359+ ?: return
360+ val active = try {
361+ nm.activeNotifications
362+ } catch (e: Exception) {
363+ Log.w(TAG, "Failed to query active notifications for summary cleanup", e)
364+ return
365+ }
366+ val hasChild = active.any { it.id != summaryId && it.notification?.group == group }
367+ if (!hasChild) {
368+ runCatching { NotificationManagerCompat.from(context).cancel(summaryId) }
369+ .onFailure { Log.w(TAG, "Failed to cancel empty group summary", it) }
370+ }
327371 }
328372
329373 private const val GROUP_COMPLETED = "soy.iko.opencode.COMPLETED"
@@ -339,7 +383,7 @@ object SessionNotifications {
339383 private fun postGroupSummary(context: Context, channelId: String, group: String, summaryId: Int) {
340384 if (!canPost(context)) return
341385 val summary = NotificationCompat.Builder(context, channelId)
342− .setSmallIcon(R.drawable.ic_launcher_foreground)
386+ .setSmallIcon(R.drawable.ic_stat_notify)
343387 .setContentTitle(context.getString(R.string.app_name))
344388 .setAutoCancel(true)
345389 .setGroup(group)
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+91−15
@@ -11,6 +11,12 @@ import androidx.activity.compose.rememberLauncherForActivityResult
1111import androidx.activity.result.PickVisualMediaRequest
1212import androidx.activity.result.contract.ActivityResultContracts
1313import androidx.compose.animation.AnimatedVisibility
14+import androidx.compose.animation.core.RepeatMode
15+import androidx.compose.animation.core.animateFloat
16+import androidx.compose.animation.core.infiniteRepeatable
17+import androidx.compose.animation.core.rememberInfiniteTransition
18+import androidx.compose.animation.core.tween
19+import androidx.compose.foundation.background
1420import androidx.compose.foundation.clickable
1521import androidx.compose.foundation.layout.Arrangement
1622import androidx.compose.foundation.layout.Box
@@ -22,6 +28,7 @@ import androidx.compose.foundation.layout.Spacer
2228import androidx.compose.foundation.layout.WindowInsets
2329import androidx.compose.foundation.layout.fillMaxSize
2430import androidx.compose.foundation.layout.fillMaxWidth
31+import androidx.compose.foundation.layout.height
2532import androidx.compose.foundation.layout.imePadding
2633import androidx.compose.foundation.layout.navigationBars
2734import androidx.compose.foundation.layout.padding
@@ -33,6 +40,7 @@ import androidx.compose.foundation.layout.wrapContentWidth
3340import androidx.compose.foundation.lazy.LazyColumn
3441import androidx.compose.foundation.lazy.items
3542import androidx.compose.foundation.lazy.rememberLazyListState
43+import androidx.compose.foundation.shape.RoundedCornerShape
3644import androidx.compose.material.icons.Icons
3745import androidx.compose.material.icons.automirrored.filled.ArrowBack
3846import androidx.compose.material.icons.automirrored.filled.Send
@@ -89,7 +97,9 @@ import androidx.compose.runtime.LaunchedEffect
8997import androidx.compose.runtime.derivedStateOf
9098import androidx.compose.runtime.getValue
9199import androidx.compose.runtime.mutableIntStateOf
100+import androidx.compose.runtime.mutableLongStateOf
92101import androidx.compose.runtime.mutableStateOf
102+import androidx.compose.runtime.produceState
93103import androidx.compose.runtime.remember
94104import androidx.compose.runtime.rememberCoroutineScope
95105import androidx.compose.runtime.rememberUpdatedState
@@ -101,6 +111,8 @@ import androidx.compose.foundation.text.KeyboardOptions
101111import androidx.compose.ui.text.input.KeyboardCapitalization
102112import androidx.compose.ui.Alignment
103113import androidx.compose.ui.Modifier
114+import androidx.compose.ui.draw.alpha
115+import androidx.compose.ui.draw.clip
104116import androidx.compose.ui.focus.focusRequester
105117import androidx.compose.ui.input.key.Key
106118import androidx.compose.ui.input.key.KeyEventType
@@ -953,23 +965,55 @@ fun ChatScreen(
953965 // first message / state content. (topPad computed above.)
954966 if (loading && messages.isEmpty()) {
955967 val loadingLabel = stringResource(R.string.loading)
956− // Attach the a11y label directly to the spinner (a detached zero-size Box
957− // isn't reliably focused/announced by TalkBack) and show a visible label so
958− // sighted users get "Loading…" rather than a bare spinner.
968+ // Skeleton bubbles pre-structure the layout so the perceived load is faster
969+ // than a bare centered spinner and the first real messages pop into an
970+ // already-shaped list instead of a blank-then-pop. Alpha pulses (gated by
971+ // reduced-motion) to convey ongoing work; the column carries the a11y label.
972+ val reducedMotion = LocalReducedMotion.current
973+ val transition = rememberInfiniteTransition(label = "skeleton")
974+ val pulse by transition.animateFloat(
975+ initialValue = 0.35f,
976+ targetValue = 0.7f,
977+ animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
978+ label = "skeletonAlpha",
979+ )
980+ val skeletonAlpha = if (reducedMotion) 0.5f else pulse
981+ val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
982+ @Composable
983+ fun bar(widthFraction: Float) {
984+ Box(
985+ Modifier
986+ .fillMaxWidth(widthFraction)
987+ .height(14.dp)
988+ .clip(RoundedCornerShape(4.dp))
989+ .background(skeletonColor)
990+ .alpha(skeletonAlpha),
991+ )
992+ }
959993 Column(
960− modifier = Modifier.align(Alignment.Center).padding(top = topPad),
961− horizontalAlignment = Alignment.CenterHorizontally,
994+ modifier = Modifier
995+ .align(Alignment.TopCenter)
996+ .padding(top = topPad)
997+ .padding(16.dp)
998+ .fillMaxWidth()
999+ .semantics { contentDescription = loadingLabel },
1000+ verticalArrangement = Arrangement.spacedBy(14.dp),
9621001 ) {
963− CircularProgressIndicator(
964− Modifier.semantics { contentDescription = loadingLabel },
965− strokeWidth = 2.dp,
966− )
967− Spacer(Modifier.size(8.dp))
968− Text(
969− loadingLabel,
970− style = MaterialTheme.typography.labelMedium,
971− color = MaterialTheme.colorScheme.onSurfaceVariant,
972− )
1002+ repeat(4) { i ->
1003+ val left = i % 2 == 0
1004+ Row(
1005+ modifier = Modifier.fillMaxWidth(),
1006+ horizontalArrangement = if (left) Arrangement.Start else Arrangement.End,
1007+ ) {
1008+ Column(
1009+ modifier = Modifier.fillMaxWidth(if (left) 0.7f else 0.55f),
1010+ verticalArrangement = Arrangement.spacedBy(6.dp),
1011+ ) {
1012+ bar(1f)
1013+ bar(0.8f)
1014+ }
1015+ }
1016+ }
9731017 }
9741018 } else if (loadError && messages.isEmpty()) {
9751019 // A failed load with nothing to show. Distinct from the empty
@@ -1150,6 +1194,24 @@ fun ChatScreen(
11501194 if (running) {
11511195 item(key = "__typing") {
11521196 val workingText = stringResource(R.string.working)
1197+ // Elapsed-since-run chip: the typing row only composes while a run is
1198+ // active, so anchoring the timer to first composition approximates the
1199+ // run start and gives the user a sense of how long the agent has been
1200+ // working — distinguishing a healthy long run from a hung one. Updates
1201+ // once per second; the value is decorative (the a11y label is the row's).
1202+ var startMs by remember { mutableLongStateOf(0L) }
1203+ LaunchedEffect(Unit) { startMs = System.currentTimeMillis() }
1204+ val elapsedMs by produceState(0L, startMs) {
1205+ if (startMs == 0L) return@produceState
1206+ while (true) {
1207+ value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1208+ delay(1000)
1209+ }
1210+ }
1211+ val elapsedText = remember(elapsedMs) {
1212+ val total = elapsedMs / 1000
1213+ "%d:%02d".format(total / 60, total % 60)
1214+ }
11531215 Row(
11541216 verticalAlignment = Alignment.CenterVertically,
11551217 modifier = Modifier.semantics {
@@ -1158,6 +1220,20 @@ fun ChatScreen(
11581220 ) {
11591221 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
11601222 Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1223+ Spacer(Modifier.size(8.dp))
1224+ Icon(
1225+ Icons.Filled.Schedule,
1226+ contentDescription = null,
1227+ modifier = Modifier.size(14.dp),
1228+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1229+ )
1230+ Text(
1231+ elapsedText,
1232+ style = MaterialTheme.typography.labelMedium,
1233+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1234+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1235+ modifier = Modifier.padding(start = 4.dp),
1236+ )
11611237 }
11621238 }
11631239 }
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+3−3
@@ -14,8 +14,8 @@ import androidx.compose.foundation.layout.size
1414import androidx.compose.foundation.layout.widthIn
1515import androidx.compose.material.icons.Icons
1616import androidx.compose.material.icons.automirrored.filled.HelpOutline
17+import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
1718import androidx.compose.material.icons.automirrored.filled.VolumeUp
18−import androidx.compose.material.icons.filled.CallSplit
1919import androidx.compose.material.icons.filled.Close
2020import androidx.compose.material.icons.filled.ContentCopy
2121import androidx.compose.material.icons.filled.Edit
@@ -145,7 +145,7 @@ private fun MessageLongPressMenu(
145145 }
146146 if (text != null && onBranch != null) {
147147 DropdownMenuItem(
148− leadingIcon = { Icon(Icons.Filled.CallSplit, contentDescription = null) },
148+ leadingIcon = { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = null) },
149149 text = { Text(stringResource(R.string.branch_session)) },
150150 onClick = { onDismiss(); onBranch(text) },
151151 )
@@ -196,7 +196,7 @@ private fun MessageOverflow(
196196 }
197197 onBranch?.let { branch ->
198198 DropdownMenuItem(
199− leadingIcon = { Icon(Icons.Filled.CallSplit, contentDescription = null) },
199+ leadingIcon = { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = null) },
200200 text = { Text(stringResource(R.string.branch_session)) },
201201 onClick = {
202202 expanded = false
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+34−4
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxSize
1414import androidx.compose.foundation.layout.fillMaxWidth
1515import androidx.compose.foundation.layout.padding
1616import androidx.compose.foundation.layout.size
17+import androidx.compose.foundation.layout.widthIn
1718import androidx.compose.foundation.lazy.LazyColumn
1819import androidx.compose.foundation.lazy.items
1920import androidx.compose.foundation.lazy.itemsIndexed
@@ -30,6 +31,7 @@ import androidx.compose.material.icons.filled.Image
3031import androidx.compose.material.icons.filled.Code
3132import androidx.compose.material.icons.filled.Article
3233import androidx.compose.material.icons.filled.Link
34+import androidx.compose.material.icons.filled.MoreVert
3335import androidx.compose.material.icons.filled.Search
3436import androidx.compose.material3.CircularProgressIndicator
3537import androidx.compose.material3.DropdownMenu
@@ -266,11 +268,19 @@ private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Mo
266268 tint = MaterialTheme.colorScheme.onSurfaceVariant,
267269 )
268270 val target = acc
271+ // Cap each segment's width so a single long folder name (e.g. a lengthy monorepo
272+ // directory) doesn't push its siblings and the current folder off the screeen.
273+ // The deepest segment gets a larger cap since it's the one the user navigated to
274+ // and the auto-scroll keeps it in view.
275+ val segMaxWidth = if (index == segments.lastIndex) 220.dp else 120.dp
269276 Text(
270277 segment,
271278 style = MaterialTheme.typography.bodyMedium,
272279 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
280+ maxLines = 1,
281+ overflow = TextOverflow.Ellipsis,
273282 modifier = Modifier
283+ .widthIn(max = segMaxWidth)
274284 .clickable(role = Role.Button) { onNavigate(target) }
275285 .defaultMinSize(minHeight = 48.dp)
276286 .padding(horizontal = 6.dp, vertical = 14.dp),
@@ -500,10 +510,17 @@ private fun DirectoryListing(
500510 // Sort preference for the directory listing. Folders-first + name sort is the most useful
501511 // default; the toggle flips between folders-first name sort and the server's raw order.
502512 var foldersFirst by rememberSaveable { mutableStateOf(true) }
503− val sorted = remember(state.entries, foldersFirst) {
504− if (!foldersFirst) state.entries
513+ // Hidden-files preference. The server may already include dotfiles in its listing; this
514+ // hides them by default (the common case for browsing a repo) and lets the user reveal
515+ // .env / .git / .opencode on demand. Default off so the listing isn't cluttered.
516+ var showHidden by rememberSaveable { mutableStateOf(false) }
517+ val visible = remember(state.entries, showHidden) {
518+ if (showHidden) state.entries else state.entries.filterNot { it.name.startsWith(".") }
519+ }
520+ val sorted = remember(visible, foldersFirst) {
521+ if (!foldersFirst) visible
505522 else {
506− val (dirs, files) = state.entries.partition { it.isDirectory }
523+ val (dirs, files) = visible.partition { it.isDirectory }
507524 dirs.sortedBy { it.name.lowercase() } + files.sortedBy { it.name.lowercase() }
508525 }
509526 }
@@ -517,9 +534,14 @@ private fun DirectoryListing(
517534 item(key = "__sort") {
518535 Row(
519536 modifier = Modifier.fillMaxWidth().padding(end = 8.dp, top = 2.dp, bottom = 2.dp),
520− horizontalArrangement = Arrangement.End,
537+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
521538 verticalAlignment = Alignment.CenterVertically,
522539 ) {
540+ androidx.compose.material3.FilterChip(
541+ selected = showHidden,
542+ onClick = { showHidden = !showHidden },
543+ label = { Text(stringResource(R.string.show_hidden), style = MaterialTheme.typography.labelSmall) },
544+ )
523545 androidx.compose.material3.FilterChip(
524546 selected = foldersFirst,
525547 onClick = { foldersFirst = !foldersFirst },
@@ -623,6 +645,14 @@ private fun FileRow(
623645 }
624646 }
625647 if (status != null) StatusBadge(status)
648+ // Explicit overflow button so "Copy path" is reachable without a long-press (which
649+ // isn't available to TalkBack/keyboard users or anyone who can't hold the gesture).
650+ // Rendered only when the row supplies an onCopyPath (the parent ".." row does not).
651+ if (onCopyPath != null) {
652+ IconButton(onClick = { menu = true }) {
653+ Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
654+ }
655+ }
626656 DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
627657 DropdownMenuItem(
628658 leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+222−59
@@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
2727import androidx.compose.foundation.rememberScrollState
2828import androidx.compose.foundation.text.KeyboardActions
2929import androidx.compose.foundation.text.KeyboardOptions
30+import androidx.compose.foundation.text.selection.SelectionContainer
3031import androidx.compose.foundation.verticalScroll
3132import androidx.compose.material.icons.Icons
3233import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -40,6 +41,7 @@ import androidx.compose.material.icons.filled.MoreVert
4041import androidx.compose.material.icons.filled.Refresh
4142import androidx.compose.material.icons.filled.Search
4243import androidx.compose.material.icons.filled.Share
44+import androidx.compose.material3.AlertDialog
4345import androidx.compose.material3.CircularProgressIndicator
4446import androidx.compose.material3.DropdownMenu
4547import androidx.compose.material3.DropdownMenuItem
@@ -76,6 +78,7 @@ import androidx.compose.ui.semantics.contentDescription
7678import androidx.compose.ui.semantics.semantics
7779import androidx.compose.ui.text.font.FontFamily
7880import androidx.compose.ui.text.input.ImeAction
81+import androidx.compose.ui.text.input.KeyboardType
7982import androidx.compose.ui.text.style.TextOverflow
8083import androidx.compose.ui.unit.dp
8184import androidx.compose.ui.res.stringResource
@@ -141,6 +144,13 @@ fun FileViewScreen(
141144 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
142145 val listState = rememberLazyListState()
143146 val rawText = state.content?.content.orEmpty()
147+ // Directory portion of the path, shown under the filename in the top bar so a user who
148+ // has drilled into a deep workspace keeps context of where this file lives.
149+ val pathSubtitle = remember(path) {
150+ val trimmed = path.trimEnd('/')
151+ val parent = trimmed.substringBeforeLast('/', "")
152+ parent.ifBlank { path }
153+ }
144154 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
145155 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
146156 val allLines = remember(rawText) { rawText.split("\n") }
@@ -158,6 +168,9 @@ fun FileViewScreen(
158168 lineCount = lines.size,
159169 listState = listState,
160170 )
171+ // Find-in-file match-case toggle (default case-insensitive). Persisted so a rotation keeps
172+ // the choice, and applied to the off-thread match scan below.
173+ var caseSensitive by rememberSaveable(path) { mutableStateOf(false) }
161174 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
162175 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
163176 var debouncedFind by remember { mutableStateOf("") }
@@ -165,11 +178,13 @@ fun FileViewScreen(
165178 val q = findQuery.trim()
166179 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
167180 }
168− val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind) {
181+ val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind, caseSensitive) {
169182 val q = debouncedFind
170183 value = if (q.isEmpty()) emptyList()
171184 else withContext(Dispatchers.Default) {
172− lines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } }
185+ lines.mapIndexedNotNull { index, line ->
186+ index.takeIf { line.contains(q, ignoreCase = !caseSensitive) }
187+ }
173188 }
174189 }
175190 // Keep the current match index in range when the match set shrinks (e.g. the file
@@ -181,10 +196,19 @@ fun FileViewScreen(
181196 LaunchedEffect(matchPos, matchIndices) {
182197 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
183198 }
199+ // "Go to line" dialog state. Clears the jump highlight and scrolls the LazyColumn to the
200+ // requested line (clamped to the rendered range) when the user confirms.
201+ var showGoToLine by rememberSaveable(path) { mutableStateOf(false) }
202+ fun goToLine(n: Int) {
203+ val target = (n - 1).coerceIn(0, (lines.size - 1).coerceAtLeast(0))
204+ highlightLine.value = null
205+ scope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
206+ }
184207 Scaffold(
185208 topBar = {
186209 FileViewTopBar(
187210 filename = filename,
211+ subtitle = pathSubtitle,
188212 loading = state.loading,
189213 content = rawText,
190214 wrap = wrap,
@@ -203,6 +227,7 @@ fun FileViewScreen(
203227 }
204228 },
205229 onToggleWrap = { wrap = !wrap },
230+ onGoToLine = { showGoToLine = true },
206231 onCopy = { copyToClipboard(context, filename, rawText) },
207232 onCopyPath = { copyToClipboard(context, context.getString(R.string.clip_label_path), path) },
208233 onShare = {
@@ -270,11 +295,81 @@ fun FileViewScreen(
270295 highlightLineIndex = highlightLine.value,
271296 listState = listState,
272297 onRetry = { vm.reload() },
298+ caseSensitive = caseSensitive,
299+ onToggleCaseSensitive = { caseSensitive = !caseSensitive },
300+ onShareFull = {
301+ val send = Intent(Intent.ACTION_SEND).apply {
302+ type = "text/plain"
303+ putExtra(Intent.EXTRA_SUBJECT, filename)
304+ putExtra(Intent.EXTRA_TEXT, rawText)
305+ }
306+ runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
307+ .onFailure { scope.launch { snackbar.showSnackbar(context.getString(R.string.no_share_app)) } }
308+ },
273309 )
274310 }
275311 }
276312 }
277313 }
314+
315+ GoToLineLauncher(
316+ visible = showGoToLine,
317+ maxLine = lines.size,
318+ onConfirm = { n -> showGoToLine = false; goToLine(n) },
319+ onDismiss = { showGoToLine = false },
320+ )
321+}
322+
323+// Wraps the go-to-line dialog's visibility gate so the branch stays out of FileViewScreen
324+// (keeping that composable under the cyclomatic-complexity threshold).
325+@Composable
326+private fun GoToLineLauncher(
327+ visible: Boolean,
328+ maxLine: Int,
329+ onConfirm: (Int) -> Unit,
330+ onDismiss: () -> Unit,
331+) {
332+ if (visible) {
333+ GoToLineDialog(maxLine = maxLine, onConfirm = onConfirm, onDismiss = onDismiss)
334+ }
335+}
336+
337+@Composable
338+private fun GoToLineDialog(maxLine: Int, onConfirm: (Int) -> Unit, onDismiss: () -> Unit) {
339+ var text by rememberSaveable { mutableStateOf("") }
340+ val parsed = text.trim().toIntOrNull()
341+ val valid = parsed != null && parsed >= 1
342+ AlertDialog(
343+ onDismissRequest = onDismiss,
344+ title = { Text(stringResource(R.string.go_to_line)) },
345+ text = {
346+ OutlinedTextField(
347+ value = text,
348+ onValueChange = { v -> text = v.filter { it.isDigit() } },
349+ singleLine = true,
350+ isError = text.isNotEmpty() && !valid,
351+ supportingText = {
352+ Text(
353+ stringResource(R.string.go_to_line_hint, maxLine),
354+ style = MaterialTheme.typography.bodySmall,
355+ )
356+ },
357+ keyboardOptions = KeyboardOptions(
358+ keyboardType = KeyboardType.Number,
359+ imeAction = ImeAction.Done,
360+ ),
361+ keyboardActions = KeyboardActions(onDone = { parsed?.let { onConfirm(it) } }),
362+ )
363+ },
364+ confirmButton = {
365+ TextButton(onClick = { parsed?.let { onConfirm(it) } }, enabled = valid) {
366+ Text(stringResource(R.string.go_to_line))
367+ }
368+ },
369+ dismissButton = {
370+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
371+ },
372+ )
278373}
279374
280375// Owns the "jump to a line from a search hit" behaviour: scroll to the target once the raw
@@ -312,6 +407,7 @@ private fun rememberJumpToLineHighlight(
312407@Composable
313408private fun FileViewTopBar(
314409 filename: String,
410+ subtitle: String,
315411 loading: Boolean,
316412 content: String,
317413 wrap: Boolean,
@@ -320,12 +416,29 @@ private fun FileViewTopBar(
320416 onReload: () -> Unit,
321417 onToggleFind: () -> Unit,
322418 onToggleWrap: () -> Unit,
419+ onGoToLine: () -> Unit,
323420 onCopy: () -> Unit,
324421 onCopyPath: () -> Unit,
325422 onShare: () -> Unit,
326423) {
327424 TopAppBar(
328− title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
425+ title = {
426+ // Two-line title: filename (primary) + the directory portion of the path
427+ // (secondary), so a file opened from deep in a workspace keeps its location
428+ // visible without an extra breadcrumb row.
429+ Column {
430+ Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis)
431+ if (subtitle.isNotBlank()) {
432+ Text(
433+ subtitle,
434+ maxLines = 1,
435+ overflow = TextOverflow.Ellipsis,
436+ style = MaterialTheme.typography.bodySmall,
437+ color = MaterialTheme.colorScheme.onSurfaceVariant,
438+ )
439+ }
440+ }
441+ },
329442 navigationIcon = {
330443 IconButton(onClick = onBack) {
331444 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
@@ -365,6 +478,11 @@ private fun FileViewTopBar(
365478 // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
366479 // in diff mode because DiffView ignores wrap, so an active-looking toggle there
367480 // would be a silent no-op.
481+ DropdownMenuItem(
482+ text = { Text(stringResource(R.string.go_to_line)) },
483+ onClick = { onGoToLine(); menuExpanded = false },
484+ leadingIcon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
485+ )
368486 DropdownMenuItem(
369487 text = { Text(stringResource(R.string.wrap_lines)) },
370488 onClick = { onToggleWrap(); menuExpanded = false },
@@ -419,6 +537,9 @@ private fun BoxScope.FileViewStateContent(
419537 highlightLineIndex: Int?,
420538 listState: LazyListState,
421539 onRetry: () -> Unit,
540+ onShareFull: () -> Unit,
541+ caseSensitive: Boolean,
542+ onToggleCaseSensitive: () -> Unit,
422543) {
423544 when {
424545 // Full-screen spinner only on the initial load (no content yet). A reload keeps the
@@ -461,6 +582,9 @@ private fun BoxScope.FileViewStateContent(
461582 truncated = truncated,
462583 highlightLineIndex = highlightLineIndex,
463584 listState = listState,
585+ onShareFull = onShareFull,
586+ caseSensitive = caseSensitive,
587+ onToggleCaseSensitive = onToggleCaseSensitive,
464588 )
465589 }
466590}
@@ -485,6 +609,9 @@ private fun BoxScope.FileViewContentBody(
485609 truncated: Boolean,
486610 highlightLineIndex: Int?,
487611 listState: LazyListState,
612+ onShareFull: () -> Unit,
613+ caseSensitive: Boolean,
614+ onToggleCaseSensitive: () -> Unit,
488615) {
489616 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
490617 // reachable while scrolling. Each contributes a top inset so the content below
@@ -543,6 +670,8 @@ private fun BoxScope.FileViewContentBody(
543670 onPrev = onPrev,
544671 onNext = onNext,
545672 onClose = onCloseFind,
673+ caseSensitive = caseSensitive,
674+ onToggleCaseSensitive = onToggleCaseSensitive,
546675 )
547676 }
548677 }
@@ -575,6 +704,7 @@ private fun BoxScope.FileViewContentBody(
575704 matchIndices = matchIndices,
576705 highlightLineIndex = highlightLineIndex,
577706 listState = listState,
707+ onShareFull = onShareFull,
578708 )
579709 }
580710 }
@@ -589,8 +719,11 @@ private fun FindBar(
589719 onPrev: () -> Unit,
590720 onNext: () -> Unit,
591721 onClose: () -> Unit,
722+ caseSensitive: Boolean,
723+ onToggleCaseSensitive: () -> Unit,
592724) {
593725 val keyboardController = LocalSoftwareKeyboardController.current
726+ val matchCaseLabel = stringResource(R.string.match_case)
594727 Row(
595728 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
596729 verticalAlignment = Alignment.CenterVertically,
@@ -604,22 +737,33 @@ private fun FindBar(
604737 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
605738 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
606739 trailingIcon = {
607− val countText = if (query.isBlank()) ""
608− else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
609− else stringResource(R.string.match_count, matchPos + 1, matchCount)
610− if (countText.isNotEmpty()) {
611− // Use error color for "no matches" so the zero-result state is
612− // distinguishable from a positive match count at a glance.
613− val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
614− else MaterialTheme.colorScheme.onSurfaceVariant
615− Text(
616− countText,
617− // labelMedium (not labelSmall) so the counter is legible for low-vision
618− // users at large font scales; the error color still distinguishes zero
619− // matches at a glance.
620− style = MaterialTheme.typography.labelMedium,
621− color = color,
740+ Row(verticalAlignment = Alignment.CenterVertically) {
741+ // Case-sensitivity toggle. A small "Aa" chip keeps the bar compact while
742+ // surfacing a previously substring-only, always-case-insensitive search.
743+ FilterChip(
744+ selected = caseSensitive,
745+ onClick = onToggleCaseSensitive,
746+ label = { Text("Aa", style = MaterialTheme.typography.labelSmall) },
747+ modifier = Modifier.semantics { contentDescription = matchCaseLabel },
622748 )
749+ Spacer(Modifier.size(8.dp))
750+ val countText = if (query.isBlank()) ""
751+ else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
752+ else stringResource(R.string.match_count, matchPos + 1, matchCount)
753+ if (countText.isNotEmpty()) {
754+ // Use error color for "no matches" so the zero-result state is
755+ // distinguishable from a positive match count at a glance.
756+ val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
757+ else MaterialTheme.colorScheme.onSurfaceVariant
758+ Text(
759+ countText,
760+ // labelMedium (not labelSmall) so the counter is legible for low-vision
761+ // users at large font scales; the error color still distinguishes zero
762+ // matches at a glance.
763+ style = MaterialTheme.typography.labelMedium,
764+ color = color,
765+ )
766+ }
623767 }
624768 },
625769 )
@@ -646,6 +790,7 @@ private fun FileTextContent(
646790 matchIndices: List<Int>,
647791 highlightLineIndex: Int?,
648792 listState: LazyListState,
793+ onShareFull: () -> Unit,
649794) {
650795 // Honor the user's chat text-size preference (the same one the chat markdown honors) so
651796 // a user who bumped the scale for readability gets the same scale in the code viewer.
@@ -674,49 +819,67 @@ private fun FileTextContent(
674819 MaterialTheme(typography = scaled) {
675820 Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
676821 if (truncated) {
677− Text(
678− stringResource(R.string.file_truncated, MAX_RENDERED_LINES),
679− style = MaterialTheme.typography.labelSmall,
680− color = MaterialTheme.colorScheme.onSurfaceVariant,
822+ androidx.compose.foundation.layout.Row(
681823 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
682− )
824+ verticalAlignment = Alignment.CenterVertically,
825+ horizontalArrangement = Arrangement.SpaceBetween,
826+ ) {
827+ Text(
828+ stringResource(R.string.file_truncated, MAX_RENDERED_LINES),
829+ style = MaterialTheme.typography.labelSmall,
830+ color = MaterialTheme.colorScheme.onSurfaceVariant,
831+ modifier = Modifier.weight(1f),
832+ )
833+ // Escape hatch: when only the first N lines render, offer the (already
834+ // full-content) Share action inline so the user isn't stranded without the
835+ // rest of the file. The overflow Share shares the same full content.
836+ TextButton(onClick = onShareFull) {
837+ Text(stringResource(R.string.share_full_file))
838+ }
839+ }
683840 }
684− LazyColumn(
685− state = listState,
686− modifier = Modifier
687− .weight(1f)
688− .fillMaxWidth()
689− .padding(8.dp),
690− ) {
691− itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
692− val isMatch = q.isNotEmpty() && index in matchSet
693− // The jump-to-line target keeps the same emphasis as a find match so the line
694− // the viewer landed on is obvious until the user scrolls or starts a find.
695− val isTarget = index == highlightLineIndex
696− Row(
697− modifier = Modifier
698− .fillMaxWidth()
699− .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
700− .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
701− ) {
702− Text(
703− "${index + 1}",
704− modifier = Modifier.width(gutterWidth),
705− style = MaterialTheme.typography.bodySmall,
706− fontFamily = FontFamily.Monospace,
707− color = MaterialTheme.colorScheme.onSurfaceVariant,
708− )
709− // Heuristic syntax highlighting per line. Falls back to plain text for
710− // unknown extensions, so non-code files render unchanged. Memoized so the
711− // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
712− // file, or palette actually changes — not on every recomposition (e.g. every
713− // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
714− val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
715− Text(
716− highlighted,
717− modifier = Modifier.padding(start = 8.dp),
718− style = MaterialTheme.typography.bodySmall,
719− )
841+ // SelectionContainer lets the user select/copy a portion of the rendered code
842+ // (not just the whole-file Copy in the overflow). Selection across recycled
843+ // LazyColumn items can drop spans that have scrolled away, but partial selection
844+ // of the visible region — the common case — works.
845+ SelectionContainer {
846+ LazyColumn(
847+ state = listState,
848+ modifier = Modifier
849+ .weight(1f)
850+ .fillMaxWidth()
851+ .padding(8.dp),
852+ ) {
853+ itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
854+ val isMatch = q.isNotEmpty() && index in matchSet
855+ // The jump-to-line target keeps the same emphasis as a find match so the line
856+ // the viewer landed on is obvious until the user scrolls or starts a find.
857+ val isTarget = index == highlightLineIndex
858+ Row(
859+ modifier = Modifier
860+ .fillMaxWidth()
861+ .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
862+ .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
863+ ) {
864+ Text(
865+ "${index + 1}",
866+ modifier = Modifier.width(gutterWidth),
867+ style = MaterialTheme.typography.bodySmall,
868+ fontFamily = FontFamily.Monospace,
869+ color = MaterialTheme.colorScheme.onSurfaceVariant,
870+ )
871+ // Heuristic syntax highlighting per line. Falls back to plain text for
872+ // unknown extensions, so non-code files render unchanged. Memoized so the
873+ // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
874+ // file, or palette actually changes — not on every recomposition (e.g. every
875+ // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
876+ val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
877+ Text(
878+ highlighted,
879+ modifier = Modifier.padding(start = 8.dp),
880+ style = MaterialTheme.typography.bodySmall,
881+ )
882+ }
720883 }
721884 }
722885 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+26−4
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.imePadding
1414import androidx.compose.foundation.layout.padding
1515import androidx.compose.foundation.layout.size
1616import androidx.compose.foundation.layout.widthIn
17+import androidx.compose.foundation.layout.wrapContentWidth
1718import androidx.compose.foundation.rememberScrollState
1819import androidx.compose.foundation.text.KeyboardActions
1920import androidx.compose.foundation.text.KeyboardOptions
@@ -56,6 +57,7 @@ import androidx.compose.ui.platform.testTag
5657import androidx.compose.ui.semantics.Role
5758import androidx.compose.ui.semantics.contentDescription
5859import androidx.compose.ui.semantics.semantics
60+import androidx.compose.ui.semantics.stateDescription
5961import androidx.compose.ui.text.input.ImeAction
6062import androidx.compose.ui.text.input.KeyboardType
6163import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -181,6 +183,8 @@ private fun ServerEditForm(
181183 modifier = Modifier
182184 .padding(padding)
183185 .imePadding()
186+ .fillMaxWidth()
187+ .wrapContentWidth(Alignment.CenterHorizontally)
184188 .widthIn(max = 600.dp)
185189 .padding(16.dp)
186190 .verticalScroll(rememberScrollState()),
@@ -197,15 +201,18 @@ private fun ServerEditForm(
197201 isError = state.baseUrl.isNotBlank() && !urlValid,
198202 supportingText = {
199203 if (state.baseUrl.isNotBlank() && !urlValid) {
200− // Offer a one-tap "http://" fix for bare host:port input instead of a
204+ // Offer a one-tap scheme fix for bare host:port input instead of a
201205 // generic error, so the user doesn't have to know the URL needs a scheme.
206+ // suggestUrlScheme() picks https:// for TLS-shaped hosts (port 443, public
207+ // domains) so the quick-fix doesn't nudge users onto cleartext.
202208 val suggestion = suggestUrlScheme(state.baseUrl)
203209 if (suggestion != null) {
210+ val labelRes = if (suggestion.startsWith("https://")) R.string.suggest_scheme_https else R.string.suggest_scheme
204211 TextButton(
205212 onClick = { vm.update { it.copy(baseUrl = suggestion) } },
206213 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
207214 ) {
208− Text(stringResource(R.string.suggest_scheme))
215+ Text(stringResource(labelRes))
209216 }
210217 } else {
211218 Text(stringResource(R.string.invalid_url))
@@ -213,6 +220,7 @@ private fun ServerEditForm(
213220 }
214221 },
215222 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
223+ keyboardActions = KeyboardActions(onDone = { keyboard?.hide() }),
216224 modifier = Modifier.fillMaxWidth().focusRequester(baseUrlFocus).testTag("server_url"),
217225 )
218226 // Auto-expand LAN discovery only on a fresh form (no URL yet) so tapping a found
@@ -304,6 +312,15 @@ private fun AuthFields(
304312 // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on
305313 // every keystroke's recomposition (see ServerEditForm).
306314 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
315+ val usernameFocus = remember { FocusRequester() }
316+ val passwordFocus = remember { FocusRequester() }
317+ // AuthFields only enters composition once the server is known to require credentials
318+ // (AnimatedVisibility in ServerEditForm), so a Unit-keyed effect fires once on reveal
319+ // and focuses the username — otherwise the user has to tap into it after the probe flips
320+ // the auth fields open.
321+ LaunchedEffect(Unit) {
322+ runCatching { usernameFocus.requestFocus() }
323+ }
307324 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
308325 Text(
309326 stringResource(R.string.auth_required),
@@ -316,7 +333,8 @@ private fun AuthFields(
316333 label = { Text(stringResource(R.string.username_optional)) },
317334 singleLine = true,
318335 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
319− modifier = Modifier.fillMaxWidth().testTag("server_username"),
336+ keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
337+ modifier = Modifier.fillMaxWidth().focusRequester(usernameFocus).testTag("server_username"),
320338 )
321339 OutlinedTextField(
322340 value = state.password,
@@ -334,7 +352,7 @@ private fun AuthFields(
334352 },
335353 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
336354 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
337− modifier = Modifier.fillMaxWidth().testTag("server_password"),
355+ modifier = Modifier.fillMaxWidth().focusRequester(passwordFocus).testTag("server_password"),
338356 )
339357 OutlinedButton(
340358 onClick = onTestCredentials,
@@ -377,9 +395,11 @@ private fun AdvancedSection(
377395 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
378396) {
379397 var expanded by rememberSaveable { mutableStateOf(state.requireHttps || state.certPin.isNotBlank()) }
398+ val stateLabel = stringResource(if (expanded) R.string.state_expanded else R.string.state_collapsed)
380399 Column(modifier = Modifier.fillMaxWidth()) {
381400 TextButton(
382401 onClick = { expanded = !expanded },
402+ modifier = Modifier.semantics { stateDescription = stateLabel },
383403 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
384404 ) {
385405 Icon(
@@ -448,9 +468,11 @@ private fun SecurityFields(
448468private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolean = false) {
449469 val context = LocalContext.current
450470 var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) }
471+ val stateLabel = stringResource(if (expanded) R.string.state_expanded else R.string.state_collapsed)
451472 Column(modifier = Modifier.fillMaxWidth()) {
452473 TextButton(
453474 onClick = { expanded = !expanded },
475+ modifier = Modifier.semantics { stateDescription = stateLabel },
454476 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
455477 ) {
456478 Icon(Icons.Filled.Wifi, contentDescription = null, modifier = Modifier.size(18.dp))
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+39−4
@@ -97,16 +97,51 @@ fun isValidUrl(url: String): Boolean {
9797
9898/**
9999 * If [input] looks like a bare host (optionally with a port/path) and lacks a scheme,
100− * return the suggested `http://`-prefixed form (only when it parses to a valid URL).
101− * Lets the UI offer a one-tap fix instead of a generic "invalid URL" error.
100+ * return a scheme-prefixed form (only when it parses to a valid URL). Lets the UI offer a
101+ * one-tap fix instead of a generic "invalid URL" error.
102+ *
103+ * Picks `https://` for shapes that imply TLS by convention (explicit :443 port, or a
104+ * public-looking domain with no port) so the quick-fix doesn't nudge users onto cleartext.
105+ * Local/LAN hosts (localhost, private IP ranges, `*.local`) stay `http://` — the common
106+ * `opencode serve` case over the LAN.
102107 */
103108fun suggestUrlScheme(input: String): String? {
104109 val trimmed = input.trim()
105110 if (trimmed.isEmpty() || "://" in trimmed) return null
106− val candidate = "http://$trimmed"
111+ val scheme = if (looksLikeTls(trimmed)) "https" else "http"
112+ val candidate = "$scheme://$trimmed"
107113 return if (isValidUrl(candidate)) candidate else null
108114}
109115
116+private fun looksLikeTls(input: String): Boolean {
117+ val host = input.substringBefore('/').lowercase()
118+ val hostNoPort = host.substringBefore(':')
119+ val port = host.substringAfter(':', missingDelimiterValue = "")
120+ if (port == "443") return true
121+ if (port.isNotEmpty()) return false
122+ if (hostNoPort == "localhost") return false
123+ if (isPrivateHost(hostNoPort)) return false
124+ // A dotless single-label host (e.g. an mDNS/Bonjour name on some setups) is ambiguous;
125+ // keep cleartext as the LAN-friendly default rather than suggesting TLS.
126+ if ('.' !in hostNoPort) return false
127+ return true
128+}
129+
130+private fun isPrivateHost(host: String): Boolean {
131+ if (host.endsWith(".local")) return true
132+ val octets = host.split('.')
133+ if (octets.size == 4 && octets.all { it.toIntOrNull() in 0..255 }) {
134+ val a = octets[0].toInt()
135+ val b = octets[1].toInt()
136+ if (a == 10) return true
137+ if (a == 192 && b == 168) return true
138+ if (a == 172 && b in 16..31) return true
139+ if (a == 127) return true
140+ if (a == 169 && b == 254) return true
141+ }
142+ return false
143+}
144+
110145class ServerEditViewModel(
111146 private val container: AppContainer,
112147 private val profileId: String?,
@@ -371,7 +406,7 @@ class ServerEditViewModel(
371406 // stranded on an empty screen with no error.
372407 val pingOk = runCatchingCancellable { container.activeConnection.value?.api?.ping() }.isSuccess
373408 if (!pingOk) {
374− throw java.io.IOException("Server reachable but did not respond to ping")
409+ throw java.io.IOException(container.string(R.string.error_ping_failed))
375410 }
376411 }
377412 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+46−23
@@ -33,6 +33,7 @@ import androidx.compose.material.icons.filled.Check
3333import androidx.compose.material.icons.filled.CheckCircle
3434import androidx.compose.material.icons.filled.Close
3535import androidx.compose.material.icons.filled.Delete
36+import androidx.compose.material.icons.filled.DoneAll
3637import androidx.compose.material.icons.filled.Folder
3738import androidx.compose.material.icons.filled.History
3839import androidx.compose.material.icons.filled.Link
@@ -284,6 +285,14 @@ fun SessionListScreen(
284285 Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
285286 }
286287 DropdownMenu(expanded = showMainMenu, onDismissRequest = { showMainMenu = false }) {
288+ // Only offer bulk mark-read when there's actually something unread.
289+ if (unread.isNotEmpty()) {
290+ DropdownMenuItem(
291+ text = { Text(stringResource(R.string.mark_all_read)) },
292+ leadingIcon = { Icon(Icons.Filled.DoneAll, contentDescription = null) },
293+ onClick = { showMainMenu = false; container.clearAllUnread() },
294+ )
295+ }
287296 DropdownMenuItem(
288297 text = { Text(stringResource(R.string.files)) },
289298 leadingIcon = { Icon(Icons.Filled.Folder, contentDescription = null) },
@@ -491,23 +500,28 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
491500 )
492501 else -> Column(modifier = Modifier.fillMaxSize()) {
493502 val keyboardController = LocalSoftwareKeyboardController.current
494− OutlinedTextField(
495− value = state.query,
496− onValueChange = onQueryChange,
497− modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).testTag("session_search"),
498− label = { Text(stringResource(R.string.search_sessions)) },
499− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
500− trailingIcon = if (state.query.isNotEmpty()) {
501− {
502− IconButton(onClick = { onQueryChange("") }) {
503− Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
503+ // Match the sibling screens' convention: hide the search field until the list is
504+ // long enough that typing beats scanning by eye (NetworkConfig.sessionListSearchThreshold).
505+ val showSearch = state.sessions.size >= NetworkConfig.sessionListSearchThreshold
506+ if (showSearch) {
507+ OutlinedTextField(
508+ value = state.query,
509+ onValueChange = onQueryChange,
510+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).testTag("session_search"),
511+ label = { Text(stringResource(R.string.search_sessions)) },
512+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
513+ trailingIcon = if (state.query.isNotEmpty()) {
514+ {
515+ IconButton(onClick = { onQueryChange("") }) {
516+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
517+ }
504518 }
505− }
506− } else null,
507− singleLine = true,
508− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
509− keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
510− )
519+ } else null,
520+ singleLine = true,
521+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
522+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
523+ )
524+ }
511525 val sessions = remember(
512526 state.sessions, state.query, state.previews,
513527 state.pinnedIds, state.archivedIds, state.showArchived,
@@ -771,6 +785,21 @@ private fun RenameSessionDialog(
771785 )
772786}
773787
788+@Composable
789+private fun SessionTitle(text: String, unread: Boolean) {
790+ // Unread is conveyed by both color AND weight so the distinction survives colorblindness /
791+ // low-contrast themes, not color alone. Extracted from SessionCard to keep that function
792+ // under the cyclomatic-complexity threshold.
793+ Text(
794+ text = text,
795+ style = MaterialTheme.typography.titleMedium,
796+ color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
797+ fontWeight = if (unread) FontWeight.SemiBold else FontWeight.Normal,
798+ maxLines = 1,
799+ overflow = TextOverflow.Ellipsis,
800+ )
801+}
802+
774803@OptIn(ExperimentalMaterial3Api::class)
775804@Composable
776805private fun SessionCard(
@@ -887,13 +916,7 @@ private fun SessionCard(
887916 tint = MaterialTheme.colorScheme.onSurfaceVariant,
888917 )
889918 }
890− Text(
891− session.displayTitle,
892− style = MaterialTheme.typography.titleMedium,
893− color = if (unreadCount > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
894− maxLines = 1,
895− overflow = TextOverflow.Ellipsis,
896− )
919+ SessionTitle(session.displayTitle, unreadCount > 0)
897920 }
898921 // Long-press the relative label to reveal the full absolute timestamp.
899922 RelativeTimeText(
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+2−1
@@ -56,7 +56,8 @@ fun TwoPaneSessionChat(
5656 // Bumped by the empty-detail "New session" button to open the left pane's new-session
5757 // directory picker (the same dialog the FAB uses) instead of creating a directory-less
5858 // session; the created session then opens into the detail pane via onOpenSession.
59− var newSessionTrigger by remember { mutableStateOf(0) }
59+ // Saveable so a rotation between the tap and the dialog-opening effect doesn't drop it.
60+ var newSessionTrigger by rememberSaveable { mutableStateOf(0) }
6061 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
6162 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
6263 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+110−71
@@ -9,12 +9,9 @@ import androidx.compose.foundation.layout.Arrangement
99import androidx.compose.foundation.layout.Box
1010import androidx.compose.foundation.layout.Column
1111import androidx.compose.foundation.layout.Row
12−import androidx.compose.foundation.layout.Spacer
1312import androidx.compose.foundation.layout.fillMaxSize
1413import androidx.compose.foundation.layout.fillMaxWidth
15−import androidx.compose.foundation.layout.heightIn
1614import androidx.compose.foundation.layout.padding
17−import androidx.compose.foundation.layout.size
1815import androidx.compose.foundation.lazy.LazyColumn
1916import androidx.compose.foundation.lazy.items
2017import androidx.compose.foundation.rememberScrollState
@@ -23,11 +20,16 @@ import androidx.compose.foundation.verticalScroll
2320import androidx.compose.material.icons.Icons
2421import androidx.compose.material.icons.filled.BugReport
2522import androidx.compose.material.icons.filled.Close
23+import androidx.compose.material.icons.filled.ContentCopy
24+import androidx.compose.material.icons.filled.Delete
2625import androidx.compose.material.icons.filled.DeleteSweep
26+import androidx.compose.material.icons.filled.MoreVert
2727import androidx.compose.material.icons.filled.Search
2828import androidx.compose.material.icons.filled.Share
2929import androidx.compose.material3.AlertDialog
3030import androidx.compose.material3.CircularProgressIndicator
31+import androidx.compose.material3.DropdownMenu
32+import androidx.compose.material3.DropdownMenuItem
3133import androidx.compose.material3.ExperimentalMaterial3Api
3234import androidx.compose.material3.HorizontalDivider
3335import androidx.compose.material3.Icon
@@ -39,10 +41,12 @@ import androidx.compose.material3.SnackbarDuration
3941import androidx.compose.material3.SnackbarHost
4042import androidx.compose.material3.SnackbarHostState
4143import androidx.compose.material3.SnackbarResult
44+import androidx.compose.material3.Surface
4245import androidx.compose.material3.SwipeToDismissBox
4346import androidx.compose.material3.SwipeToDismissBoxValue
4447import androidx.compose.material3.Text
4548import androidx.compose.material3.TextButton
49+import androidx.compose.material3.TopAppBar
4650import androidx.compose.material3.rememberSwipeToDismissBoxState
4751import androidx.compose.runtime.Composable
4852import androidx.compose.runtime.CompositionLocalProvider
@@ -78,6 +82,8 @@ import androidx.compose.ui.semantics.contentDescription
7882import androidx.compose.ui.semantics.heading
7983import androidx.compose.ui.semantics.semantics
8084import androidx.compose.ui.unit.dp
85+import androidx.compose.ui.window.Dialog
86+import androidx.compose.ui.window.DialogProperties
8187import androidx.lifecycle.compose.collectAsStateWithLifecycle
8288import soy.iko.opencode.R
8389import soy.iko.opencode.data.network.NetworkConfig
@@ -211,6 +217,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
211217 EmptyState(
212218 icon = Icons.Filled.BugReport,
213219 title = stringResource(R.string.no_crash_reports),
220+ description = stringResource(R.string.no_crash_reports_desc),
214221 modifier = Modifier.fillMaxWidth().padding(24.dp),
215222 )
216223 } else {
@@ -220,6 +227,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
220227 crashQuery = it
221228 }
222229 items(filtered, key = { it.fileName }) { report ->
230+ var menuExpanded by remember { mutableStateOf(false) }
223231 // Swipe end-to-start reveals a delete affordance. The delete is
224232 // deferred for an undo window (matching the session/server lists);
225233 // confirmValueChange snaps back so the row remains while the undo
@@ -296,6 +304,29 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
296304 }) {
297305 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
298306 }
307+ Box {
308+ IconButton(onClick = { menuExpanded = true }) {
309+ Icon(
310+ Icons.Filled.MoreVert,
311+ contentDescription = stringResource(R.string.more_options),
312+ )
313+ }
314+ DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
315+ DropdownMenuItem(
316+ leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) },
317+ text = {
318+ Text(
319+ stringResource(R.string.delete),
320+ color = MaterialTheme.colorScheme.error,
321+ )
322+ },
323+ onClick = {
324+ menuExpanded = false
325+ pendingReportDelete = report.fileName
326+ },
327+ )
328+ }
329+ }
299330 }
300331 }
301332 HorizontalDivider()
@@ -320,86 +351,94 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
320351 if (loaded != null) reportContent = loaded else loadFailed = true
321352 }
322353 val content = reportContent
323− AlertDialog(
354+ Dialog(
324355 onDismissRequest = { viewing = null },
325− confirmButton = {
326− TextButton(onClick = { viewing = null }) { Text(stringResource(R.string.close)) }
327− },
328− dismissButton = {
329− Row {
330− TextButton(
331− // Disable Copy until the report has loaded so it can't copy an empty body.
332− enabled = content != null,
333− onClick = {
334− copyToClipboard(context, context.getString(R.string.crash_report), content.orEmpty())
335− },
336− ) { Text(stringResource(R.string.copy)) }
337− Spacer(Modifier.size(8.dp))
338− TextButton(
339− // Disable Share until the report has loaded — otherwise tapping it
340− // while the async read is still in flight (content == null) shares an
341− // empty body instead of the report.
342− enabled = content != null,
343− onClick = {
344− val send = Intent(Intent.ACTION_SEND).apply {
345− type = "text/plain"
346− putExtra(Intent.EXTRA_SUBJECT, shareSubject)
347− putExtra(Intent.EXTRA_TEXT, content.orEmpty())
348− }
349− runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
350− .onFailure {
351− Log.w("Diagnostics", "Failed to share crash report", it)
352− // FIX 20: "no app to share" only fits ActivityNotFoundException; other failures
353− // (e.g. an oversized report exceeding the Binder limit → TransactionTooLargeException)
354− // get a generic toast, not that misleading one.
355− val msg = if (it is ActivityNotFoundException) R.string.no_share_app else R.string.error_generic
356− showToast(context, context.getString(msg))
356+ properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
357+ ) {
358+ Surface(modifier = Modifier.fillMaxSize()) {
359+ Scaffold(
360+ topBar = {
361+ TopAppBar(
362+ title = { Text(stringResource(R.string.crash_report)) },
363+ navigationIcon = {
364+ IconButton(onClick = { viewing = null }) {
365+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
357366 }
358− },
359− ) { Text(stringResource(R.string.share)) }
360− Spacer(Modifier.size(8.dp))
361− TextButton(onClick = {
362− // Confirm before deleting a single report, matching clear-all.
363− pendingReportDelete = reportName
364− }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
365− }
366− },
367− title = { Text(stringResource(R.string.crash_report)) },
368− text = {
369− when {
370− content != null -> Box(
371− modifier = Modifier
372− .fillMaxWidth()
373− .heightIn(max = 400.dp)
374− .verticalScroll(rememberScrollState()),
375− ) {
376− // Wrap in SelectionContainer so a user can select/copy just the
377− // relevant stack snippet instead of the whole-report Copy button.
378− SelectionContainer {
367+ },
368+ actions = {
369+ IconButton(
370+ // Disable Copy until the report has loaded so it can't copy an empty body.
371+ enabled = content != null,
372+ onClick = {
373+ copyToClipboard(context, context.getString(R.string.crash_report), content.orEmpty())
374+ },
375+ ) { Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy)) }
376+ IconButton(
377+ // Disable Share until the report has loaded — otherwise tapping it
378+ // while the async read is still in flight (content == null) shares an
379+ // empty body instead of the report.
380+ enabled = content != null,
381+ onClick = {
382+ val send = Intent(Intent.ACTION_SEND).apply {
383+ type = "text/plain"
384+ putExtra(Intent.EXTRA_SUBJECT, shareSubject)
385+ putExtra(Intent.EXTRA_TEXT, content.orEmpty())
386+ }
387+ runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
388+ .onFailure {
389+ Log.w("Diagnostics", "Failed to share crash report", it)
390+ // FIX 20: "no app to share" only fits ActivityNotFoundException; other failures
391+ // (e.g. an oversized report exceeding the Binder limit → TransactionTooLargeException)
392+ // get a generic toast, not that misleading one.
393+ val msg = if (it is ActivityNotFoundException) R.string.no_share_app else R.string.error_generic
394+ showToast(context, context.getString(msg))
395+ }
396+ },
397+ ) { Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share)) }
398+ IconButton(onClick = {
399+ // Confirm before deleting a single report, matching clear-all.
400+ pendingReportDelete = reportName
401+ }) { Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete)) }
402+ },
403+ )
404+ },
405+ ) { padding ->
406+ when {
407+ content != null -> SelectionContainer {
408+ // Wrap in SelectionContainer so a user can select/copy just the
409+ // relevant stack snippet instead of the whole-report Copy button.
379410 Text(
380411 content,
381412 style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
382− modifier = Modifier.fillMaxWidth(),
413+ modifier = Modifier
414+ .fillMaxSize()
415+ .verticalScroll(rememberScrollState())
416+ .padding(padding)
417+ .padding(16.dp),
383418 )
384419 }
385− }
386− loadFailed -> Text(
387− stringResource(R.string.report_load_failed),
388− color = MaterialTheme.colorScheme.error,
389− modifier = Modifier.padding(vertical = 24.dp),
390− )
391− else -> {
392− val loadingLabel = stringResource(R.string.loading)
393− Box(
394− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
420+ loadFailed -> Box(
421+ modifier = Modifier.fillMaxSize().padding(padding),
395422 contentAlignment = Alignment.Center,
396423 ) {
397− CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
424+ Text(
425+ stringResource(R.string.report_load_failed),
426+ color = MaterialTheme.colorScheme.error,
427+ )
428+ }
429+ else -> {
430+ val loadingLabel = stringResource(R.string.loading)
431+ Box(
432+ modifier = Modifier.fillMaxSize().padding(padding),
433+ contentAlignment = Alignment.Center,
434+ ) {
435+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
436+ }
398437 }
399438 }
400439 }
401− },
402− )
440+ }
441+ }
403442 }
404443
405444 pendingReportDelete?.let { name ->
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+87−27
@@ -1,7 +1,9 @@
11package soy.iko.opencode.ui.settings
22
3+import android.content.Intent
34import android.content.pm.PackageManager
45import android.os.Build
6+import android.provider.Settings
57import androidx.activity.compose.rememberLauncherForActivityResult
68import androidx.activity.result.contract.ActivityResultContracts
79import androidx.compose.foundation.background
@@ -16,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
1618import androidx.compose.foundation.layout.padding
1719import androidx.compose.foundation.layout.size
1820import androidx.compose.foundation.layout.widthIn
21+import androidx.compose.foundation.layout.wrapContentWidth
1922import androidx.compose.foundation.rememberScrollState
2023import androidx.compose.foundation.selection.selectable
2124import androidx.compose.foundation.selection.toggleable
@@ -24,25 +27,31 @@ import androidx.compose.foundation.shape.CircleShape
2427import androidx.compose.material.icons.Icons
2528import androidx.compose.material.icons.automirrored.filled.ArrowBack
2629import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
27−import androidx.compose.material.icons.filled.ArrowDropDown
2830import androidx.compose.material.icons.filled.BugReport
2931import androidx.compose.material.icons.filled.Dns
3032import androidx.compose.material.icons.filled.Download
3133import androidx.compose.material.icons.filled.Hub
34+import androidx.compose.material.icons.filled.Notifications
3235import androidx.compose.material.icons.filled.QueryStats
3336import androidx.compose.material.icons.filled.Upload
3437import androidx.compose.material3.AlertDialog
3538import androidx.compose.material3.ExperimentalMaterial3Api
3639import androidx.compose.material3.CircularProgressIndicator
40+import androidx.compose.material3.DropdownMenuItem
41+import androidx.compose.material3.ExposedDropdownMenuBox
42+import androidx.compose.material3.ExposedDropdownMenuDefaults
3743import androidx.compose.material3.HorizontalDivider
3844import androidx.compose.material3.Icon
3945import androidx.compose.material3.IconButton
4046import androidx.compose.material3.MaterialTheme
47+import androidx.compose.material3.MenuAnchorType
48+import androidx.compose.material3.OutlinedTextField
4149import androidx.compose.material3.RadioButton
4250import androidx.compose.material3.Scaffold
4351import androidx.compose.material3.SnackbarHost
4452import androidx.compose.material3.SnackbarHostState
4553import androidx.compose.material3.Slider
54+import androidx.compose.material3.SnackbarResult
4655import androidx.compose.material3.Switch
4756import androidx.compose.material3.Text
4857import androidx.compose.material3.TextButton
@@ -182,6 +191,8 @@ fun SettingsScreen(
182191 val exportedMsg = stringResource(R.string.backup_exported)
183192 val exportFailedMsg = stringResource(R.string.backup_export_failed)
184193 val importedMsg = stringResource(R.string.backup_imported)
194+ val restartLabel = stringResource(R.string.restart)
195+ val couldNotOpenLinkMsg = stringResource(R.string.could_not_open_link)
185196 val importFailedMsg = stringResource(R.string.backup_import_failed)
186197 val exportLauncher = rememberLauncherForActivityResult(
187198 ActivityResultContracts.CreateDocument("application/json"),
@@ -224,7 +235,15 @@ fun SettingsScreen(
224235 },
225236 snackbarHost = { SnackbarHost(snackbar) },
226237 ) { padding ->
227− Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).verticalScroll(rememberScrollState()).padding(16.dp)) {
238+ Column(
239+ modifier = Modifier
240+ .padding(padding)
241+ .fillMaxWidth()
242+ .wrapContentWidth(Alignment.CenterHorizontally)
243+ .widthIn(max = 600.dp)
244+ .verticalScroll(rememberScrollState())
245+ .padding(16.dp),
246+ ) {
228247 val s = settings
229248 Text(
230249 stringResource(R.string.appearance),
@@ -248,11 +267,13 @@ fun SettingsScreen(
248267 // Dynamic color (Material You) only works on Android 12+, so hide the toggle
249268 // on older devices instead of offering a control that silently does nothing.
250269 if (dynamicColorAvailable) {
270+ val amoledActive = s.themeMode == ThemeMode.AMOLED
251271 Row(
252272 modifier = Modifier
253273 .fillMaxWidth()
254274 .toggleable(
255275 value = s.dynamicColor,
276+ enabled = !amoledActive,
256277 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
257278 role = Role.Switch,
258279 )
@@ -266,10 +287,18 @@ fun SettingsScreen(
266287 style = MaterialTheme.typography.bodySmall,
267288 color = MaterialTheme.colorScheme.onSurfaceVariant,
268289 )
290+ if (amoledActive) {
291+ Text(
292+ stringResource(R.string.dynamic_color_disabled_amoled),
293+ style = MaterialTheme.typography.bodySmall,
294+ color = MaterialTheme.colorScheme.onSurfaceVariant,
295+ )
296+ }
269297 }
270298 Switch(
271299 checked = s.dynamicColor,
272300 onCheckedChange = null,
301+ enabled = !amoledActive,
273302 )
274303 }
275304 }
@@ -468,6 +497,21 @@ fun SettingsScreen(
468497 enabled = activeProfile != null,
469498 onClick = onOpenMcp,
470499 )
500+ NavRow(
501+ icon = Icons.Filled.Notifications,
502+ label = stringResource(R.string.notification_settings),
503+ enabled = true,
504+ onClick = {
505+ scope.launch {
506+ val opened = runCatchingCancellable {
507+ val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
508+ .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
509+ context.startActivity(intent)
510+ }.isSuccess
511+ if (!opened) snackbar.showSnackbar(couldNotOpenLinkMsg)
512+ }
513+ },
514+ )
471515
472516 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
473517
@@ -583,7 +627,17 @@ fun SettingsScreen(
583627 ?: error("no input stream")
584628 container.backupManager.import(text)
585629 }.isSuccess
586− snackbar.showSnackbar(if (ok) importedMsg else importFailedMsg)
630+ if (ok) {
631+ val result = snackbar.showSnackbar(
632+ message = importedMsg,
633+ actionLabel = restartLabel,
634+ )
635+ if (result == SnackbarResult.ActionPerformed) {
636+ android.os.Process.killProcess(android.os.Process.myPid())
637+ }
638+ } else {
639+ snackbar.showSnackbar(importFailedMsg)
640+ }
587641 }
588642 }) { Text(stringResource(R.string.import_confirm_replace), color = MaterialTheme.colorScheme.error) }
589643 },
@@ -808,36 +862,38 @@ private fun Badge(count: Int) {
808862/** Dropdown selecting how long after backgrounding the app re-locks. Only shown when app lock
809863 * is on. The grace period avoids a re-prompt on every quick app-switch (the most common reason
810864 * users disable biometric locks), while still re-locking immediately by default. */
865+@OptIn(ExperimentalMaterial3Api::class)
811866@Composable
812867private fun AppLockReLockSelector(seconds: Int, onSelect: (Int) -> Unit) {
813868 var expanded by remember { mutableStateOf(false) }
814− val label = relockLabel(seconds)
815− Row(
869+ ExposedDropdownMenuBox(
870+ expanded = expanded,
871+ onExpandedChange = { expanded = it },
816872 modifier = Modifier
817873 .fillMaxWidth()
818− .padding(start = 56.dp, top = 4.dp)
819− .clickable(role = Role.Button) { expanded = true },
820− verticalAlignment = Alignment.CenterVertically,
874+ .padding(start = 56.dp, top = 4.dp),
821875 ) {
822− Text(
823− stringResource(R.string.app_lock_relock),
824− style = MaterialTheme.typography.bodyMedium,
825− color = MaterialTheme.colorScheme.onSurfaceVariant,
826− modifier = Modifier.weight(1f),
827− )
828− Text(label, style = MaterialTheme.typography.bodyMedium)
829− Icon(
830− Icons.Filled.ArrowDropDown,
831− contentDescription = null,
832− tint = MaterialTheme.colorScheme.onSurfaceVariant,
876+ OutlinedTextField(
877+ value = relockLabel(seconds),
878+ onValueChange = {},
879+ readOnly = true,
880+ singleLine = true,
881+ label = { Text(stringResource(R.string.app_lock_relock)) },
882+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
883+ modifier = Modifier
884+ .menuAnchor(MenuAnchorType.PrimaryNotEditable, true)
885+ .fillMaxWidth(),
833886 )
834− }
835− androidx.compose.material3.DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
836− SettingsStore.APP_LOCK_RELOCK_OPTIONS_SECONDS.forEach { option ->
837− androidx.compose.material3.DropdownMenuItem(
838− text = { Text(relockLabel(option)) },
839− onClick = { expanded = false; onSelect(option) },
840− )
887+ ExposedDropdownMenu(
888+ expanded = expanded,
889+ onDismissRequest = { expanded = false },
890+ ) {
891+ SettingsStore.APP_LOCK_RELOCK_OPTIONS_SECONDS.forEach { option ->
892+ DropdownMenuItem(
893+ text = { Text(relockLabel(option)) },
894+ onClick = { expanded = false; onSelect(option) },
895+ )
896+ }
841897 }
842898 }
843899}
@@ -846,8 +902,12 @@ private fun AppLockReLockSelector(seconds: Int, onSelect: (Int) -> Unit) {
846902private fun relockLabel(seconds: Int): String = when {
847903 seconds <= 0 -> stringResource(R.string.app_lock_relock_immediately)
848904 seconds < 60 -> pluralStringResource(R.plurals.app_lock_relock_seconds, seconds, seconds)
849− else -> {
905+ seconds < 3600 -> {
850906 val mins = seconds / 60
851907 pluralStringResource(R.plurals.app_lock_relock_minutes, mins, mins)
852908 }
909+ else -> {
910+ val hours = seconds / 3600
911+ pluralStringResource(R.plurals.app_lock_relock_hours, hours, hours)
912+ }
853913}
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+10−2
@@ -117,9 +117,12 @@ fun OpencodeTheme(
117117) {
118118 val context = LocalContext.current
119119 val colorScheme = when {
120+ // AMOLED takes precedence over dynamic color: a user who explicitly chose pure-black
121+ // surfaces wants OLED-black even with Material You on, rather than silently getting the
122+ // wallpaper-derived palette (which left the AMOLED choice appearing to do nothing).
123+ darkTheme && amoled -> AmoledColors
120124 dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
121125 if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
122− darkTheme && amoled -> AmoledColors
123126 darkTheme -> DarkColors
124127 else -> LightColors
125128 }
@@ -127,7 +130,12 @@ fun OpencodeTheme(
127130 if (!view.isInEditMode) {
128131 SideEffect {
129132 val window = view.context.findActivity()?.window ?: return@SideEffect
130− WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
133+ val controller = WindowCompat.getInsetsController(window, view)
134+ controller.isAppearanceLightStatusBars = !darkTheme
135+ // Keep the navigation-bar icon/affordance contrast in sync with the status bar so
136+ // 3-button-nav and gesture-nav devices don't end up with invisible controls on one
137+ // theme. The system only guesses when this isn't set explicitly.
138+ controller.isAppearanceLightNavigationBars = !darkTheme
131139 }
132140 }
133141 MaterialTheme(
app/src/main/java/soy/iko/opencode/ui/theme/Typography.kt+38
@@ -21,6 +21,23 @@ import androidx.compose.ui.unit.sp
2121 * (counts, timestamps' siblings, state text) don't inherit a monospace look.
2222 */
2323val OpencodeTypography = Typography(
24+ // Large display roles are rarely used here, but declaring them explicitly (FontFamily.Default,
25+ // same weight intent as the rest of the scale) keeps any M3 component that reads them —
26+ // e.g. a future large top bar — from falling back to stock purple-grey Roboto defaults that
27+ // clash with Tokyo Night.
28+ displayLarge = TextStyle(
29+ fontFamily = FontFamily.Default,
30+ fontWeight = FontWeight.Normal,
31+ fontSize = 57.sp,
32+ lineHeight = 64.sp,
33+ letterSpacing = (-0.25).sp,
34+ ),
35+ displayMedium = TextStyle(
36+ fontFamily = FontFamily.Default,
37+ fontWeight = FontWeight.Normal,
38+ fontSize = 45.sp,
39+ lineHeight = 52.sp,
40+ ),
2441 displaySmall = TextStyle(
2542 fontFamily = FontFamily.Default,
2643 fontWeight = FontWeight.Normal,
@@ -28,12 +45,26 @@ val OpencodeTypography = Typography(
2845 lineHeight = 40.sp,
2946 letterSpacing = 0.sp,
3047 ),
48+ headlineLarge = TextStyle(
49+ fontFamily = FontFamily.Default,
50+ fontWeight = FontWeight.SemiBold,
51+ fontSize = 32.sp,
52+ lineHeight = 40.sp,
53+ ),
3154 headlineMedium = TextStyle(
3255 fontFamily = FontFamily.Default,
3356 fontWeight = FontWeight.SemiBold,
3457 fontSize = 26.sp,
3558 lineHeight = 34.sp,
3659 ),
60+ // headlineSmall and titleSmall back AlertDialog titles and ListItem rows; override them so
61+ // those stock M3 components match the brand family instead of Roboto defaults.
62+ headlineSmall = TextStyle(
63+ fontFamily = FontFamily.Default,
64+ fontWeight = FontWeight.SemiBold,
65+ fontSize = 22.sp,
66+ lineHeight = 28.sp,
67+ ),
3768 titleLarge = TextStyle(
3869 fontFamily = FontFamily.Default,
3970 fontWeight = FontWeight.SemiBold,
@@ -47,6 +78,13 @@ val OpencodeTypography = Typography(
4778 lineHeight = 22.sp,
4879 letterSpacing = 0.1.sp,
4980 ),
81+ titleSmall = TextStyle(
82+ fontFamily = FontFamily.Default,
83+ fontWeight = FontWeight.Medium,
84+ fontSize = 14.sp,
85+ lineHeight = 20.sp,
86+ letterSpacing = 0.1.sp,
87+ ),
5088 bodyLarge = TextStyle(
5189 fontFamily = FontFamily.Default,
5290 fontWeight = FontWeight.Normal,
app/src/main/res/drawable/ic_action_block.xmlnew file+10
@@ -0,0 +1,10 @@
1+<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+ android:width="24dp"
3+ android:height="24dp"
4+ android:viewportWidth="24"
5+ android:viewportHeight="24">
6+ <path
7+ android:fillColor="#FFFFFFFF"
8+ android:fillType="evenOdd"
9+ android:pathData="M12,2 C6.48,2 2,6.48 2,12 C2,17.52 6.48,22 12,22 C17.52,22 22,17.52 22,12 C22,6.48 17.52,2 12,2 Z M4,12 C4,7.58 7.58,4 12,4 C13.85,4 15.55,4.63 16.9,5.69 L5.69,16.9 C4.63,15.55 4,13.85 4,12 Z M12,20 C10.15,20 8.45,19.37 7.1,18.31 L18.31,7.1 C19.37,8.45 20,10.15 20,12 C20,16.42 16.42,20 12,20 Z" />
10+</vector>
app/src/main/res/drawable/ic_action_check.xmlnew file+9
@@ -0,0 +1,9 @@
1+<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+ android:width="24dp"
3+ android:height="24dp"
4+ android:viewportWidth="24"
5+ android:viewportHeight="24">
6+ <path
7+ android:fillColor="#FFFFFFFF"
8+ android:pathData="M9,16.17 L4.83,12 L3.41,13.41 L9,19 L21,7 L19.59,5.59 Z" />
9+</vector>
app/src/main/res/drawable/ic_action_reply.xmlnew file+9
@@ -0,0 +1,9 @@
1+<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+ android:width="24dp"
3+ android:height="24dp"
4+ android:viewportWidth="24"
5+ android:viewportHeight="24">
6+ <path
7+ android:fillColor="#FFFFFFFF"
8+ android:pathData="M3,10 L13,10 L13,6 L21,12 L13,18 L13,14 L3,14 Z" />
9+</vector>
app/src/main/res/drawable/ic_action_stop.xmlnew file+9
@@ -0,0 +1,9 @@
1+<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+ android:width="24dp"
3+ android:height="24dp"
4+ android:viewportWidth="24"
5+ android:viewportHeight="24">
6+ <path
7+ android:fillColor="#FFFFFFFF"
8+ android:pathData="M5,5 L19,5 L19,19 L5,19 Z" />
9+</vector>
app/src/main/res/drawable/ic_stat_notify.xmlnew file+9
@@ -0,0 +1,9 @@
1+<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+ android:width="24dp"
3+ android:height="24dp"
4+ android:viewportWidth="24"
5+ android:viewportHeight="24">
6+ <path
7+ android:fillColor="#FFFFFFFF"
8+ android:pathData="M4,4 L13,12 L4,20 L9,20 L18,12 L9,4 Z" />
9+</vector>
app/src/main/res/values-es/strings.xml+11−3
@@ -95,6 +95,7 @@
9595 <string name="directory_loading">Cargando directorios…</string>
9696 <string name="session_directory_desc">Directorio: %1$s</string>
9797 <string name="unread">Sin leer</string>
98+ <string name="mark_all_read">Marcar todo leído</string>
9899 <string name="unread_count">%1$d sin leer</string>
99100 <string name="no_sessions_yet">Aún no hay sesiones</string>
100101 <string name="no_sessions_hint">Inicia una conversación con opencode.</string>
@@ -149,6 +150,7 @@
149150 <string name="error_timeout">La solicitud agotó el tiempo de espera. Comprueba la conexión e inténtalo de nuevo.</string>
150151 <string name="error_server">El servidor informó de un error. Inténtalo más tarde.</string>
151152 <string name="error_load_timeout">La carga tardó demasiado. Inténtalo de nuevo.</string>
153+ <string name="error_ping_failed">Se pudo contactar con el servidor, pero no respondió. Comprueba la URL y la versión de opencode.</string>
152154 <string name="error_switch_and_restore_failed">No se pudo conectar con el nuevo servidor (%1$s), y tampoco se pudo restaurar el servidor anterior (%2$s).</string>
153155
154156 <!-- Part renderer -->
@@ -203,6 +205,7 @@
203205 <string name="binary_file">Archivo binario — vista previa no compatible.</string>
204206 <string name="empty_folder">Esta carpeta está vacía</string>
205207 <string name="empty_file">Este archivo está vacío</string>
208+ <string name="show_hidden">Mostrar ocultos</string>
206209 <string name="parent_dir">Directorio superior</string>
207210 <string name="folder">Carpeta: %1$s</string>
208211 <string name="file_label">Archivo: %1$s</string>
@@ -289,6 +292,7 @@
289292 <string name="unknown_part">Contenido no compatible</string>
290293 <string name="tool_preview">🔧 %1$s</string>
291294 <string name="suggest_scheme">Añadir prefijo http://</string>
295+ <string name="suggest_scheme_https">Añadir prefijo https://</string>
292296 <string name="sort">Ordenar</string>
293297 <string name="sort_recent">Actividad reciente</string>
294298 <string name="sort_title">Título</string>
@@ -420,9 +424,9 @@
420424 <!-- Message actions -->
421425 <string name="message_actions">Acciones del mensaje</string>
422426 <string name="quote_reply">Responder citando</string>
423− <string name="branch_session">Ramificar en sesión nueva</string>
424− <string name="branch_created">Ramificado en una sesión nueva</string>
425− <string name="branch_failed">No se pudo ramificar la sesión</string>
427+ <string name="branch_session">Iniciar sesión nueva desde aquí</string>
428+ <string name="branch_created">Se inició una sesión nueva desde este mensaje</string>
429+ <string name="branch_failed">No se pudo iniciar una sesión nueva</string>
426430
427431 <!-- Offline outbox -->
428432 <string name="outbox_queued">Sin conexión: mensaje en cola, se enviará al reconectar</string>
@@ -543,6 +547,10 @@
543547
544548 <!-- File view -->
545549 <string name="switch_to_raw_to_search">Cambia a Sin formato para buscar</string>
550+ <string name="go_to_line">Ir a la línea</string>
551+ <string name="go_to_line_hint">Número de línea (1–%1$d)</string>
552+ <string name="match_case">Coincidir mayúsculas</string>
553+ <string name="share_full_file">Compartir archivo completo</string>
546554
547555 <!-- Settings backup/restore -->
548556 <string name="import_confirm_title">¿Reemplazar todos los datos?</string>
app/src/main/res/values-es/strings_cross_ux.xmlnew file+7
@@ -0,0 +1,7 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <!-- UX transversal: mensajes 4xx diferenciados y avisos de cola de salida. -->
4+ <string name="error_not_found">No encontrado — puede que se haya eliminado.</string>
5+ <string name="outbox_dropped_title">No se pudo enviar el mensaje</string>
6+ <string name="outbox_dropped_text">No se pudo entregar una respuesta en cola — puede que la sesión se haya eliminado.</string>
7+</resources>
app/src/main/res/values-es/strings_diag_ux.xmlnew file+4
@@ -0,0 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <string name="no_crash_reports_desc">Los informes se guardan solo en este dispositivo y se recopilan automáticamente si la aplicación falla.</string>
4+</resources>
app/src/main/res/values-es/strings_notif_ux.xmlnew file+5
@@ -0,0 +1,5 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <string name="notif_permission_public">Se necesita aprobación</string>
4+ <string name="notif_error_public">La ejecución falló</string>
5+</resources>
app/src/main/res/values-es/strings_settings_ux.xmlnew file+12
@@ -0,0 +1,12 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <!-- Añadidos de UX de Ajustes (en un archivo dedicado para no editar strings.xml compartido). -->
4+ <string name="restart">Reiniciar</string>
5+ <string name="notification_settings">Ajustes de notificaciones</string>
6+ <string name="could_not_open_link">No se pudo abrir el enlace</string>
7+ <string name="dynamic_color_disabled_amoled">Se ignora con Negro AMOLED seleccionado</string>
8+ <plurals name="app_lock_relock_hours">
9+ <item quantity="one">%1$d hora</item>
10+ <item quantity="other">%1$d horas</item>
11+ </plurals>
12+</resources>
app/src/main/res/values-night/colors.xml+2
@@ -3,4 +3,6 @@
33 <!-- A darker splash background at night so the cold-start flash isn't a bright rectangle. -->
44 <color name="splash_background">#1A1B26</color>
55 <color name="ic_launcher_background">#34548A</color>
6+ <!-- Matches the Compose dark colorScheme background (#1A1B26). -->
7+ <color name="window_background">#1A1B26</color>
68</resources>
app/src/main/res/values-night/themes.xmlnew file+8
@@ -0,0 +1,8 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<!-- Night variant: dark Material parent + dark windowBackground so the post-splash
3+ window matches the Compose dark palette instead of flashing a light frame. -->
4+<resources>
5+ <style name="Theme.OpencodeAndroid" parent="android:Theme.Material.NoActionBar">
6+ <item name="android:windowBackground">@color/window_background</item>
7+ </style>
8+</resources>
app/src/main/res/values/colors.xml+3
@@ -2,4 +2,7 @@
22<resources>
33 <color name="splash_background">#34548A</color>
44 <color name="ic_launcher_background">#34548A</color>
5+ <!-- Matches the Compose light colorScheme background so the pre-Compose window
6+ doesn't flash a different shade than the UI paints. -->
7+ <color name="window_background">#F6F7FB</color>
58</resources>
app/src/main/res/values/strings.xml+11−3
@@ -104,6 +104,7 @@
104104 <string name="directory_loading">Loading directories…</string>
105105 <string name="session_directory_desc">Directory: %1$s</string>
106106 <string name="unread">Unread</string>
107+ <string name="mark_all_read">Mark all read</string>
107108 <string name="unread_count">%1$d unread</string>
108109 <string name="no_sessions_yet">No sessions yet</string>
109110 <string name="no_sessions_hint">Start a conversation with opencode.</string>
@@ -158,6 +159,7 @@
158159 <string name="error_timeout">The request timed out. Check the connection and try again.</string>
159160 <string name="error_server">The server reported an error. Try again later.</string>
160161 <string name="error_load_timeout">Loading took too long. Try again.</string>
162+ <string name="error_ping_failed">Server was reachable but did not respond. Check the URL and opencode version.</string>
161163 <string name="error_switch_and_restore_failed">Could not connect to the new server (%1$s), and restoring the previous server also failed (%2$s).</string>
162164
163165 <!-- Part renderer -->
@@ -212,6 +214,7 @@
212214 <string name="binary_file">Binary file — preview not supported.</string>
213215 <string name="empty_folder">This folder is empty</string>
214216 <string name="empty_file">This file is empty</string>
217+ <string name="show_hidden">Show hidden</string>
215218 <string name="parent_dir">Parent directory</string>
216219 <string name="folder">Folder: %1$s</string>
217220 <string name="file_label">File: %1$s</string>
@@ -298,6 +301,7 @@
298301 <string name="unknown_part">Unsupported content</string>
299302 <string name="tool_preview">🔧 %1$s</string>
300303 <string name="suggest_scheme">Add http:// prefix</string>
304+ <string name="suggest_scheme_https">Add https:// prefix</string>
301305 <string name="sort">Sort</string>
302306 <string name="sort_recent">Recent activity</string>
303307 <string name="sort_title">Title</string>
@@ -442,9 +446,9 @@
442446 <!-- Message actions -->
443447 <string name="message_actions">Message actions</string>
444448 <string name="quote_reply">Quote reply</string>
445− <string name="branch_session">Branch new session</string>
446− <string name="branch_created">Branched to a new session</string>
447− <string name="branch_failed">Couldn\'t branch session</string>
449+ <string name="branch_session">Start new session from here</string>
450+ <string name="branch_created">Started a new session from this message</string>
451+ <string name="branch_failed">Couldn\'t start a new session</string>
448452
449453 <!-- Offline outbox -->
450454 <string name="outbox_queued">Offline — message queued, will send when reconnected</string>
@@ -565,6 +569,10 @@
565569
566570 <!-- File view -->
567571 <string name="switch_to_raw_to_search">Switch to Raw to search</string>
572+ <string name="go_to_line">Go to line</string>
573+ <string name="go_to_line_hint">Line number (1–%1$d)</string>
574+ <string name="match_case">Match case</string>
575+ <string name="share_full_file">Share full file</string>
568576
569577 <!-- Settings backup/restore -->
570578 <string name="import_confirm_title">Replace all data?</string>
app/src/main/res/values/strings_cross_ux.xmlnew file+7
@@ -0,0 +1,7 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <!-- Cross-cutting UX: differentiated 4xx messages + outbox permanent-drop surfacing. -->
4+ <string name="error_not_found">Not found — it may have been deleted.</string>
5+ <string name="outbox_dropped_title">Message couldn\'t be sent</string>
6+ <string name="outbox_dropped_text">A queued reply couldn\'t be delivered — the session may have been deleted.</string>
7+</resources>
app/src/main/res/values/strings_diag_ux.xmlnew file+4
@@ -0,0 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <string name="no_crash_reports_desc">Reports are stored only on this device and collected automatically if the app crashes.</string>
4+</resources>
app/src/main/res/values/strings_notif_ux.xmlnew file+8
@@ -0,0 +1,8 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <!-- Generic, detail-free text for the lock-screen (public) version of notifications whose
4+ detail line may carry sensitive file paths or commands, so that content isn't shown
5+ on a locked device. -->
6+ <string name="notif_permission_public">Approval needed</string>
7+ <string name="notif_error_public">Run failed</string>
8+</resources>
app/src/main/res/values/strings_settings_ux.xmlnew file+12
@@ -0,0 +1,12 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <!-- Settings UX additions (kept in a dedicated file to avoid editing the shared strings.xml). -->
4+ <string name="restart">Restart</string>
5+ <string name="notification_settings">Notification settings</string>
6+ <string name="could_not_open_link">Couldn\'t open link</string>
7+ <string name="dynamic_color_disabled_amoled">Ignored while AMOLED Black is selected</string>
8+ <plurals name="app_lock_relock_hours">
9+ <item quantity="one">%1$d hour</item>
10+ <item quantity="other">%1$d hours</item>
11+ </plurals>
12+</resources>
app/src/main/res/values/themes.xml+8−1
@@ -2,9 +2,16 @@
22<!--
33 The Compose theme (OpencodeTheme) takes over once the UI inflates; this XML
44 theme only governs the window before Compose and hosts the SplashScreen API.
5+
6+ Parents off the framework light Material theme (not the dark one) and sets a
7+ windowBackground matching the app's light surface, so the brief window between
8+ splash dismiss and Compose's first frame isn't a dark flash on a light UI. The
9+ values-night variant swaps to the dark Material parent + dark windowBackground.
510-->
611<resources>
7− <style name="Theme.OpencodeAndroid" parent="android:Theme.Material.NoActionBar" />
12+ <style name="Theme.OpencodeAndroid" parent="android:Theme.Material.Light.NoActionBar">
13+ <item name="android:windowBackground">@color/window_background</item>
14+ </style>
815
916 <!-- Shown by core-splashscreen on cold start, then swapped for the theme above. -->
1017 <style name="Theme.OpencodeAndroid.Splash" parent="Theme.SplashScreen">