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
7496a78iko
app/src/main/java/soy/iko/opencode/MainActivity.kt+59−19
@@ -14,9 +14,15 @@ import androidx.activity.compose.setContent
14import androidx.activity.enableEdgeToEdge
14import androidx.activity.enableEdgeToEdge
15import androidx.activity.result.contract.ActivityResultContracts
15import androidx.activity.result.contract.ActivityResultContracts
16import androidx.compose.foundation.isSystemInDarkTheme
16import androidx.compose.foundation.isSystemInDarkTheme
17+import androidx.compose.material3.AlertDialog
18+import androidx.compose.material3.Text
19+import androidx.compose.material3.TextButton
17import androidx.compose.runtime.LaunchedEffect
20import androidx.compose.runtime.LaunchedEffect
18import androidx.compose.runtime.getValue
21import androidx.compose.runtime.getValue
22+import androidx.compose.runtime.mutableStateOf
19import androidx.compose.runtime.remember
23import androidx.compose.runtime.remember
24+import androidx.compose.runtime.setValue
25+import androidx.compose.ui.res.stringResource
20import kotlinx.coroutines.flow.combine
26import kotlinx.coroutines.flow.combine
21import androidx.core.content.ContextCompat
27import androidx.core.content.ContextCompat
22import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
28import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@@ -40,6 +46,16 @@ class MainActivity : FragmentActivity() {
40 private var shareIntentHandled = false
46 private var shareIntentHandled = false
41 private var openSessionHandled = false
47 private var openSessionHandled = false
42 private var newSessionHandled = false
48 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)
43 // Track whether the notification-permission prompt has been shown for this Activity
59 // Track whether the notification-permission prompt has been shown for this Activity
44 // instance. onCreate only requests when savedInstanceState == null (a fresh process),
60 // instance. onCreate only requests when savedInstanceState == null (a fresh process),
45 // but an Activity recreated by a config change NOT listed in the manifest's configChanges
61 // but an Activity recreated by a config change NOT listed in the manifest's configChanges
@@ -104,6 +120,47 @@ class MainActivity : FragmentActivity() {
104 AppLockGate(enabled = locked, reLockDelaySeconds = appLockReLockSeconds) {
120 AppLockGate(enabled = locked, reLockDelaySeconds = appLockReLockSeconds) {
105 OpencodeAppUi(container = container)
121 OpencodeAppUi(container = container)
106 }
122 }
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+ }
107 }
164 }
108 }
165 }
109 }
166 }
@@ -117,15 +174,7 @@ class MainActivity : FragmentActivity() {
117 val crashLogger = soy.iko.opencode.data.repo.CrashLogger.get(this)
174 val crashLogger = soy.iko.opencode.data.repo.CrashLogger.get(this)
118 if (!crashLogger.hasUnacknowledgedCrash()) return
175 if (!crashLogger.hasUnacknowledgedCrash()) return
119 crashLogger.acknowledgeCrashes()
176 crashLogger.acknowledgeCrashes()
120− android.app.AlertDialog.Builder(this)
177+ showCrashPrompt = true
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()
129 }
178 }
130
179
131 override fun onNewIntent(intent: Intent) {
180 override fun onNewIntent(intent: Intent) {
@@ -272,15 +321,6 @@ class MainActivity : FragmentActivity() {
272 }
321 }
273
322
274 private fun showNotificationRationaleDialog() {
323 private fun showNotificationRationaleDialog() {
275− val ctx = this
324+ showNotifRationale = true
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()
285 }
325 }
286}
326}
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+5
@@ -233,6 +233,11 @@ object NetworkConfig {
233 /** Minimum number of sessions in the usage report before the per-session list shows its
233 /** Minimum number of sessions in the usage report before the per-session list shows its
234 * filter field. A short history is faster to scan than type into. */
234 * filter field. A short history is faster to scan than type into. */
235 const val usageSessionSearchThreshold = 8
235 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
236 /** Extra bottom content padding (dp) for the chat message list, reserving room for the
241 /** Extra bottom content padding (dp) for the chat message list, reserving room for the
237 * jump-to-latest FAB so it never floats over the last message. */
242 * jump-to-latest FAB so it never floats over the last message. */
238 const val chatListFabInsetDp = 80
243 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) {
34 const val DEFAULT_CHAT_TEXT_SCALE = 1.0f
34 const val DEFAULT_CHAT_TEXT_SCALE = 1.0f
35 /** 0 means re-lock the instant the app backgrounds. Other entries are the grace
35 /** 0 means re-lock the instant the app backgrounds. Other entries are the grace
36 * period (seconds) during which a quick app-switch does NOT re-prompt for auth. */
36 * period (seconds) during which a quick app-switch does NOT re-prompt for auth. */
37− val APP_LOCK_RELOCK_OPTIONS_SECONDS: List<Int> = listOf(0, 30, 60, 300)
37+ val APP_LOCK_RELOCK_OPTIONS_SECONDS: List<Int> = listOf(0, 30, 60, 300, 1800, 3600)
38 const val DEFAULT_APP_LOCK_RELOCK_SECONDS = 0
38 const val DEFAULT_APP_LOCK_RELOCK_SECONDS = 0
39 }
39 }
40
40
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+39−3
@@ -276,6 +276,11 @@ open class AppContainer private constructor(
276 /** Mark a session as read (clear its unread badge) — the "Mark read" notification action. */
276 /** Mark a session as read (clear its unread badge) — the "Mark read" notification action. */
277 open fun markSessionRead(id: String) = clearUnread(id)
277 open fun markSessionRead(id: String) = clearUnread(id)
278
278
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+
279 /** Restore a session's unread badge after a failed server switch reconnects.
284 /** Restore a session's unread badge after a failed server switch reconnects.
280 * Preserves the prior [count] so a session badged with "5 unread" before the
285 * Preserves the prior [count] so a session badged with "5 unread" before the
281 * switch attempt still shows 5 (not 1) after the restore. No-op for the session
286 * switch attempt still shows 5 (not 1) after the restore. No-op for the session
@@ -314,8 +319,18 @@ open class AppContainer private constructor(
314 // Show only the HTTP status (e.g. 401, 404) — never the request URL,
319 // Show only the HTTP status (e.g. 401, 404) — never the request URL,
315 // which a ClientRequestException carries in its message and which we
320 // which a ClientRequestException carries in its message and which we
316 // promised not to leak (it can include auth or internal paths).
321 // promised not to leak (it can include auth or internal paths).
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)
317− ErrorKind.CLIENT -> responseStatusCode(t)?.let { string(R.string.error_client_status, it) }
330+ else -> code?.let { string(R.string.error_client_status, it) }
318− ?: string(R.string.error_generic)
331+ ?: string(R.string.error_generic)
332+ }
333+ }
319 ErrorKind.UNKNOWN -> string(R.string.error_generic)
334 ErrorKind.UNKNOWN -> string(R.string.error_generic)
320 }
335 }
321
336
@@ -483,8 +498,29 @@ open class AppContainer private constructor(
483 else -> false
498 else -> false
484 }
499 }
485 if (permanent) {
500 if (permanent) {
501+ Log.w(
502+ "AppContainer",
486− Log.w("AppContainer", "Dropping undeliverable outbox message ${msg.id} for ${msg.sessionId} (HTTP $status)")
503+ "Dropping undeliverable outbox message ${msg.id} for ${msg.sessionId} (HTTP $status): ${string(R.string.outbox_dropped_text)}",
504+ )
487 outboxStore.remove(msg.id)
505 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+ }
488 } else {
524 } else {
489 break
525 break
490 }
526 }
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+14−5
@@ -58,18 +58,23 @@ class RunForegroundService : Service() {
58 return START_NOT_STICKY
58 return START_NOT_STICKY
59 }
59 }
60
60
61− private fun buildNotification(sessionTitle: String?, sessionId: String?, progress: String? = null): Notification {
61+ private fun mainContentIntent(): PendingIntent {
62 // Tapping the notification opens the app so the user can see the running session.
62 // Tapping the notification opens the app so the user can see the running session.
63 val openIntent = Intent(this, MainActivity::class.java).apply {
63 val openIntent = Intent(this, MainActivity::class.java).apply {
64 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
64 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
65 }
65 }
66− val pendingIntent = PendingIntent.getActivity(
66+ return PendingIntent.getActivity(
67 this, 0, openIntent,
67 this, 0, openIntent,
68 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
68 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
69 )
69 )
70+ }
71+
72+ private fun buildNotification(sessionTitle: String?, sessionId: String?, progress: String? = null): Notification {
73+ val pendingIntent = mainContentIntent()
70 val contentText = progress ?: sessionTitle ?: getString(R.string.notif_running_text)
74 val contentText = progress ?: sessionTitle ?: getString(R.string.notif_running_text)
71 val builder = NotificationCompat.Builder(this, NotificationChannels.STATUS)
75 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)
73 .setContentTitle(getString(R.string.notif_running_title))
78 .setContentTitle(getString(R.string.notif_running_title))
74 .setContentText(contentText)
79 .setContentText(contentText)
75 .setContentIntent(pendingIntent)
80 .setContentIntent(pendingIntent)
@@ -94,7 +99,7 @@ class RunForegroundService : Service() {
94 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
99 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
95 )
100 )
96 builder.addAction(
101 builder.addAction(
97− R.drawable.ic_launcher_foreground,
102+ R.drawable.ic_action_stop,
98 getString(R.string.notif_action_stop),
103 getString(R.string.notif_action_stop),
99 stopPending,
104 stopPending,
100 )
105 )
@@ -126,9 +131,11 @@ class RunForegroundService : Service() {
126 private fun postTimeoutNotification() {
131 private fun postTimeoutNotification() {
127 runCatching {
132 runCatching {
128 val notification = NotificationCompat.Builder(this, NotificationChannels.STATUS)
133 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)
130 .setContentTitle(getString(R.string.notif_running_title))
136 .setContentTitle(getString(R.string.notif_running_title))
131 .setContentText(getString(R.string.notif_fg_timeout_text))
137 .setContentText(getString(R.string.notif_fg_timeout_text))
138+ .setContentIntent(mainContentIntent())
132 .setAutoCancel(true)
139 .setAutoCancel(true)
133 .setPriority(NotificationCompat.PRIORITY_LOW)
140 .setPriority(NotificationCompat.PRIORITY_LOW)
134 .build()
141 .build()
@@ -149,6 +156,8 @@ class RunForegroundService : Service() {
149 private const val TAG = "RunForegroundService"
156 private const val TAG = "RunForegroundService"
150 private const val NOTIF_ID = 1
157 private const val NOTIF_ID = 1
151 private const val NOTIF_TIMEOUT_ID = 2
158 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()
152 const val EXTRA_SESSION_TITLE = "soy.iko.opencode.extra.SESSION_TITLE"
161 const val EXTRA_SESSION_TITLE = "soy.iko.opencode.extra.SESSION_TITLE"
153 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
162 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
154 const val EXTRA_PROGRESS = "soy.iko.opencode.extra.PROGRESS"
163 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 @@
1package soy.iko.opencode.notification
1package soy.iko.opencode.notification
2
2
3import android.annotation.SuppressLint
3import android.annotation.SuppressLint
4+import android.app.Notification
5+import android.app.NotificationManager
4import android.app.PendingIntent
6import android.app.PendingIntent
5import android.content.Context
7import android.content.Context
6import android.content.Intent
8import android.content.Intent
@@ -72,10 +74,6 @@ object SessionNotifications {
72 context, android.Manifest.permission.POST_NOTIFICATIONS,
74 context, android.Manifest.permission.POST_NOTIFICATIONS,
73 ) == android.content.pm.PackageManager.PERMISSION_GRANTED
75 ) == android.content.pm.PackageManager.PERMISSION_GRANTED
74
76
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−
79 /** A PendingIntent that opens [sessionId] in the app when the notification body is tapped.
77 /** A PendingIntent that opens [sessionId] in the app when the notification body is tapped.
80 * [profileId] (the originating server) is embedded so a tap after the user has switched
78 * [profileId] (the originating server) is embedded so a tap after the user has switched
81 * servers routes back to the server that ran the session, not whichever is active —
79 * servers routes back to the server that ran the session, not whichever is active —
@@ -83,9 +81,12 @@ object SessionNotifications {
83 private fun openSessionIntent(
81 private fun openSessionIntent(
84 context: Context, sessionId: String, requestCode: Int, profileId: String?,
82 context: Context, sessionId: String, requestCode: Int, profileId: String?,
85 ): PendingIntent {
83 ): 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, "")
86 val openIntent = Intent(context, MainActivity::class.java).apply {
87 val openIntent = Intent(context, MainActivity::class.java).apply {
87 action = Intent.ACTION_VIEW
88 action = Intent.ACTION_VIEW
88− data = android.net.Uri.parse("opencode://session/${safeId(sessionId)}")
89+ data = android.net.Uri.parse("opencode://session/$safeId")
89 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
90 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
90 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
91 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
91 }
92 }
@@ -120,7 +121,7 @@ object SessionNotifications {
120 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
121 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
121 )
122 )
122 val replyAction = NotificationCompat.Action.Builder(
123 val replyAction = NotificationCompat.Action.Builder(
123− R.drawable.ic_launcher_foreground,
124+ R.drawable.ic_action_reply,
124 context.getString(R.string.notif_action_reply),
125 context.getString(R.string.notif_action_reply),
125 replyPending,
126 replyPending,
126 ).addRemoteInput(remoteInput).setAllowGeneratedReplies(true).build()
127 ).addRemoteInput(remoteInput).setAllowGeneratedReplies(true).build()
@@ -136,19 +137,20 @@ object SessionNotifications {
136 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
137 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
137 )
138 )
138 val markReadAction = NotificationCompat.Action.Builder(
139 val markReadAction = NotificationCompat.Action.Builder(
139− R.drawable.ic_launcher_foreground,
140+ R.drawable.ic_action_check,
140 context.getString(R.string.notif_action_mark_read),
141 context.getString(R.string.notif_action_mark_read),
141 markReadPending,
142 markReadPending,
142 ).build()
143 ).build()
143
144
144 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
145 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
145− .setSmallIcon(R.drawable.ic_launcher_foreground)
146+ .setSmallIcon(R.drawable.ic_stat_notify)
146 .setContentTitle(context.getString(R.string.notif_completed_title))
147 .setContentTitle(context.getString(R.string.notif_completed_title))
147 // No % escaping here: getString(id, arg) inserts the argument verbatim and
148 // No % escaping here: getString(id, arg) inserts the argument verbatim and
148 // never re-scans it for format specifiers, so escaping the title would show
149 // never re-scans it for format specifiers, so escaping the title would show
149 // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
150 // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
150 .setContentText(context.getString(R.string.notif_completed_text, title))
151 .setContentText(context.getString(R.string.notif_completed_text, title))
151 .setAutoCancel(true)
152 .setAutoCancel(true)
153+ .setOnlyAlertOnce(true)
152 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
154 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
153 .addAction(replyAction)
155 .addAction(replyAction)
154 .addAction(markReadAction)
156 .addAction(markReadAction)
@@ -171,10 +173,10 @@ object SessionNotifications {
171 @SuppressLint("MissingPermission")
173 @SuppressLint("MissingPermission")
172 private fun postCompletedSummary(context: Context) {
174 private fun postCompletedSummary(context: Context) {
173 val summary = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
175 val summary = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
174− .setSmallIcon(R.drawable.ic_launcher_foreground)
176+ .setSmallIcon(R.drawable.ic_stat_notify)
175 .setContentTitle(context.getString(R.string.notif_completed_title))
177 .setContentTitle(context.getString(R.string.notif_completed_title))
176 .setContentText(context.getString(R.string.app_name))
178 .setContentText(context.getString(R.string.app_name))
177− .setSmallIcon(R.drawable.ic_launcher_foreground)
179+ .setSmallIcon(R.drawable.ic_stat_notify)
178 .setAutoCancel(true)
180 .setAutoCancel(true)
179 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
181 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
180 .setGroup(GROUP_COMPLETED)
182 .setGroup(GROUP_COMPLETED)
@@ -201,23 +203,29 @@ object SessionNotifications {
201 ?: context.getString(R.string.notif_permission_fallback)
203 ?: context.getString(R.string.notif_permission_fallback)
202
204
203 val builder = NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
205 val builder = NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
204− .setSmallIcon(R.drawable.ic_launcher_foreground)
206+ .setSmallIcon(R.drawable.ic_stat_notify)
205 .setContentTitle(context.getString(R.string.notif_permission_title, sessionTitle))
207 .setContentTitle(context.getString(R.string.notif_permission_title, sessionTitle))
206 .setContentText(detail)
208 .setContentText(detail)
207 .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
209 .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
208 .setAutoCancel(true)
210 .setAutoCancel(true)
209 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
211 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
210− .setCategory(NotificationCompat.CATEGORY_CALL)
211 .setPriority(NotificationCompat.PRIORITY_HIGH)
212 .setPriority(NotificationCompat.PRIORITY_HIGH)
212 .setGroup(GROUP_PERMISSION)
213 .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+ )
213
221
214 // One action per response. Each PendingIntent needs a distinct request code or
222 // One action per response. Each PendingIntent needs a distinct request code or
215 // FLAG_UPDATE_CURRENT would collapse them into one (the last extras win).
223 // FLAG_UPDATE_CURRENT would collapse them into one (the last extras win).
216 listOf(
224 listOf(
217− PermissionResponse.ONCE to R.string.notif_action_allow_once,
225+ Triple(PermissionResponse.ONCE, R.string.notif_action_allow_once, R.drawable.ic_action_check),
218− PermissionResponse.ALWAYS to R.string.notif_action_always,
226+ Triple(PermissionResponse.ALWAYS, R.string.notif_action_always, R.drawable.ic_action_check),
219− PermissionResponse.REJECT to R.string.notif_action_reject,
227+ Triple(PermissionResponse.REJECT, R.string.notif_action_reject, R.drawable.ic_action_block),
220− ).forEach { (response, labelRes) ->
228+ ).forEach { (response, labelRes, iconRes) ->
221 val intent = Intent(context, NotificationActionReceiver::class.java).apply {
229 val intent = Intent(context, NotificationActionReceiver::class.java).apply {
222 action = NotificationActionReceiver.ACTION_PERMISSION
230 action = NotificationActionReceiver.ACTION_PERMISSION
223 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
231 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
@@ -229,7 +237,7 @@ object SessionNotifications {
229 context, notifId + response.ordinal + 1, intent,
237 context, notifId + response.ordinal + 1, intent,
230 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
238 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
231 )
239 )
232− builder.addAction(R.drawable.ic_launcher_foreground, context.getString(labelRes), pending)
240+ builder.addAction(iconRes, context.getString(labelRes), pending)
233 }
241 }
234
242
235 runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
243 runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
@@ -246,15 +254,23 @@ object SessionNotifications {
246 if (!canPost(context)) return
254 if (!canPost(context)) return
247 val notifId = notifId(NS_ERROR, sessionId)
255 val notifId = notifId(NS_ERROR, sessionId)
248 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
256 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
249− .setSmallIcon(R.drawable.ic_launcher_foreground)
257+ .setSmallIcon(R.drawable.ic_stat_notify)
250 .setContentTitle(context.getString(R.string.notif_error_title))
258 .setContentTitle(context.getString(R.string.notif_error_title))
251 .setContentText(context.getString(R.string.notif_error_text, title))
259 .setContentText(context.getString(R.string.notif_error_text, title))
252 .setAutoCancel(true)
260 .setAutoCancel(true)
261+ .setOnlyAlertOnce(true)
253 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
262 .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
254 .setCategory(NotificationCompat.CATEGORY_ERROR)
263 .setCategory(NotificationCompat.CATEGORY_ERROR)
255 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
264 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
256 // Group error notifications so a burst of failures collapses into a stackable group.
265 // Group error notifications so a burst of failures collapses into a stackable group.
257 .setGroup(GROUP_ERROR)
266 .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+ )
258 .build()
274 .build()
259 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
275 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
260 .onFailure { Log.w(TAG, "Failed to post error notification", it) }
276 .onFailure { Log.w(TAG, "Failed to post error notification", it) }
@@ -273,7 +289,7 @@ object SessionNotifications {
273 }
289 }
274 val notifId = notifId(NS_COMPLETED, sessionId)
290 val notifId = notifId(NS_COMPLETED, sessionId)
275 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
291 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
276− .setSmallIcon(R.drawable.ic_launcher_foreground)
292+ .setSmallIcon(R.drawable.ic_stat_notify)
277 .setContentTitle(context.getString(R.string.notif_reply_sent))
293 .setContentTitle(context.getString(R.string.notif_reply_sent))
278 .setAutoCancel(true)
294 .setAutoCancel(true)
279 .setPriority(NotificationCompat.PRIORITY_LOW)
295 .setPriority(NotificationCompat.PRIORITY_LOW)
@@ -290,10 +306,11 @@ object SessionNotifications {
290 @SuppressLint("MissingPermission")
306 @SuppressLint("MissingPermission")
291 fun postReplyFailed(context: Context, sessionId: String, profileId: String? = null) {
307 fun postReplyFailed(context: Context, sessionId: String, profileId: String? = null) {
292 NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
308 NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
309+ maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
293 if (!canPost(context)) return
310 if (!canPost(context)) return
294 val notifId = notifId(NS_ERROR, sessionId)
311 val notifId = notifId(NS_ERROR, sessionId)
295 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
312 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
296− .setSmallIcon(R.drawable.ic_launcher_foreground)
313+ .setSmallIcon(R.drawable.ic_stat_notify)
297 .setContentTitle(context.getString(R.string.notif_reply_failed_title))
314 .setContentTitle(context.getString(R.string.notif_reply_failed_title))
298 .setContentText(context.getString(R.string.notif_reply_failed_text))
315 .setContentText(context.getString(R.string.notif_reply_failed_text))
299 .setAutoCancel(true)
316 .setAutoCancel(true)
@@ -312,11 +329,13 @@ object SessionNotifications {
312 /** Cancel a session's completion notification (e.g. when the user opens it). */
329 /** Cancel a session's completion notification (e.g. when the user opens it). */
313 fun cancel(context: Context, sessionId: String) {
330 fun cancel(context: Context, sessionId: String) {
314 NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
331 NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
332+ maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
315 }
333 }
316
334
317 /** Cancel a session's permission notification (on reply, or when the user opens it). */
335 /** Cancel a session's permission notification (on reply, or when the user opens it). */
318 fun cancelPermission(context: Context, sessionId: String) {
336 fun cancelPermission(context: Context, sessionId: String) {
319 NotificationManagerCompat.from(context).cancel(notifId(NS_PERMISSION, sessionId))
337 NotificationManagerCompat.from(context).cancel(notifId(NS_PERMISSION, sessionId))
338+ maybeCancelSummary(context, GROUP_PERMISSION, SUMMARY_PERMISSION_ID)
320 }
339 }
321
340
322 /** Cancel a session's error notification (e.g. when the user opens it). Without this a
341 /** Cancel a session's error notification (e.g. when the user opens it). Without this a
@@ -324,6 +343,31 @@ object SessionNotifications {
324 * than tapping the notification itself (setAutoCancel only clears it on a direct tap). */
343 * than tapping the notification itself (setAutoCancel only clears it on a direct tap). */
325 fun cancelError(context: Context, sessionId: String) {
344 fun cancelError(context: Context, sessionId: String) {
326 NotificationManagerCompat.from(context).cancel(notifId(NS_ERROR, sessionId))
345 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+ }
327 }
371 }
328
372
329 private const val GROUP_COMPLETED = "soy.iko.opencode.COMPLETED"
373 private const val GROUP_COMPLETED = "soy.iko.opencode.COMPLETED"
@@ -339,7 +383,7 @@ object SessionNotifications {
339 private fun postGroupSummary(context: Context, channelId: String, group: String, summaryId: Int) {
383 private fun postGroupSummary(context: Context, channelId: String, group: String, summaryId: Int) {
340 if (!canPost(context)) return
384 if (!canPost(context)) return
341 val summary = NotificationCompat.Builder(context, channelId)
385 val summary = NotificationCompat.Builder(context, channelId)
342− .setSmallIcon(R.drawable.ic_launcher_foreground)
386+ .setSmallIcon(R.drawable.ic_stat_notify)
343 .setContentTitle(context.getString(R.string.app_name))
387 .setContentTitle(context.getString(R.string.app_name))
344 .setAutoCancel(true)
388 .setAutoCancel(true)
345 .setGroup(group)
389 .setGroup(group)
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+91−15
@@ -11,6 +11,12 @@ import androidx.activity.compose.rememberLauncherForActivityResult
11import androidx.activity.result.PickVisualMediaRequest
11import androidx.activity.result.PickVisualMediaRequest
12import androidx.activity.result.contract.ActivityResultContracts
12import androidx.activity.result.contract.ActivityResultContracts
13import androidx.compose.animation.AnimatedVisibility
13import 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
14import androidx.compose.foundation.clickable
20import androidx.compose.foundation.clickable
15import androidx.compose.foundation.layout.Arrangement
21import androidx.compose.foundation.layout.Arrangement
16import androidx.compose.foundation.layout.Box
22import androidx.compose.foundation.layout.Box
@@ -22,6 +28,7 @@ import androidx.compose.foundation.layout.Spacer
22import androidx.compose.foundation.layout.WindowInsets
28import androidx.compose.foundation.layout.WindowInsets
23import androidx.compose.foundation.layout.fillMaxSize
29import androidx.compose.foundation.layout.fillMaxSize
24import androidx.compose.foundation.layout.fillMaxWidth
30import androidx.compose.foundation.layout.fillMaxWidth
31+import androidx.compose.foundation.layout.height
25import androidx.compose.foundation.layout.imePadding
32import androidx.compose.foundation.layout.imePadding
26import androidx.compose.foundation.layout.navigationBars
33import androidx.compose.foundation.layout.navigationBars
27import androidx.compose.foundation.layout.padding
34import androidx.compose.foundation.layout.padding
@@ -33,6 +40,7 @@ import androidx.compose.foundation.layout.wrapContentWidth
33import androidx.compose.foundation.lazy.LazyColumn
40import androidx.compose.foundation.lazy.LazyColumn
34import androidx.compose.foundation.lazy.items
41import androidx.compose.foundation.lazy.items
35import androidx.compose.foundation.lazy.rememberLazyListState
42import androidx.compose.foundation.lazy.rememberLazyListState
43+import androidx.compose.foundation.shape.RoundedCornerShape
36import androidx.compose.material.icons.Icons
44import androidx.compose.material.icons.Icons
37import androidx.compose.material.icons.automirrored.filled.ArrowBack
45import androidx.compose.material.icons.automirrored.filled.ArrowBack
38import androidx.compose.material.icons.automirrored.filled.Send
46import androidx.compose.material.icons.automirrored.filled.Send
@@ -89,7 +97,9 @@ import androidx.compose.runtime.LaunchedEffect
89import androidx.compose.runtime.derivedStateOf
97import androidx.compose.runtime.derivedStateOf
90import androidx.compose.runtime.getValue
98import androidx.compose.runtime.getValue
91import androidx.compose.runtime.mutableIntStateOf
99import androidx.compose.runtime.mutableIntStateOf
100+import androidx.compose.runtime.mutableLongStateOf
92import androidx.compose.runtime.mutableStateOf
101import androidx.compose.runtime.mutableStateOf
102+import androidx.compose.runtime.produceState
93import androidx.compose.runtime.remember
103import androidx.compose.runtime.remember
94import androidx.compose.runtime.rememberCoroutineScope
104import androidx.compose.runtime.rememberCoroutineScope
95import androidx.compose.runtime.rememberUpdatedState
105import androidx.compose.runtime.rememberUpdatedState
@@ -101,6 +111,8 @@ import androidx.compose.foundation.text.KeyboardOptions
101import androidx.compose.ui.text.input.KeyboardCapitalization
111import androidx.compose.ui.text.input.KeyboardCapitalization
102import androidx.compose.ui.Alignment
112import androidx.compose.ui.Alignment
103import androidx.compose.ui.Modifier
113import androidx.compose.ui.Modifier
114+import androidx.compose.ui.draw.alpha
115+import androidx.compose.ui.draw.clip
104import androidx.compose.ui.focus.focusRequester
116import androidx.compose.ui.focus.focusRequester
105import androidx.compose.ui.input.key.Key
117import androidx.compose.ui.input.key.Key
106import androidx.compose.ui.input.key.KeyEventType
118import androidx.compose.ui.input.key.KeyEventType
@@ -953,23 +965,55 @@ fun ChatScreen(
953 // first message / state content. (topPad computed above.)
965 // first message / state content. (topPad computed above.)
954 if (loading && messages.isEmpty()) {
966 if (loading && messages.isEmpty()) {
955 val loadingLabel = stringResource(R.string.loading)
967 val loadingLabel = stringResource(R.string.loading)
956− // Attach the a11y label directly to the spinner (a detached zero-size Box
968+ // Skeleton bubbles pre-structure the layout so the perceived load is faster
957− // isn't reliably focused/announced by TalkBack) and show a visible label so
969+ // than a bare centered spinner and the first real messages pop into an
958− // sighted users get "Loading…" rather than a bare spinner.
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+ }
959 Column(
993 Column(
960− modifier = Modifier.align(Alignment.Center).padding(top = topPad),
994+ modifier = Modifier
961− horizontalAlignment = Alignment.CenterHorizontally,
995+ .align(Alignment.TopCenter)
996+ .padding(top = topPad)
997+ .padding(16.dp)
998+ .fillMaxWidth()
999+ .semantics { contentDescription = loadingLabel },
1000+ verticalArrangement = Arrangement.spacedBy(14.dp),
962 ) {
1001 ) {
963− CircularProgressIndicator(
1002+ repeat(4) { i ->
964− Modifier.semantics { contentDescription = loadingLabel },
1003+ val left = i % 2 == 0
965− strokeWidth = 2.dp,
1004+ Row(
966− )
1005+ modifier = Modifier.fillMaxWidth(),
967− Spacer(Modifier.size(8.dp))
1006+ horizontalArrangement = if (left) Arrangement.Start else Arrangement.End,
968− Text(
1007+ ) {
969− loadingLabel,
1008+ Column(
970− style = MaterialTheme.typography.labelMedium,
1009+ modifier = Modifier.fillMaxWidth(if (left) 0.7f else 0.55f),
971− color = MaterialTheme.colorScheme.onSurfaceVariant,
1010+ verticalArrangement = Arrangement.spacedBy(6.dp),
972− )
1011+ ) {
1012+ bar(1f)
1013+ bar(0.8f)
1014+ }
1015+ }
1016+ }
973 }
1017 }
974 } else if (loadError && messages.isEmpty()) {
1018 } else if (loadError && messages.isEmpty()) {
975 // A failed load with nothing to show. Distinct from the empty
1019 // A failed load with nothing to show. Distinct from the empty
@@ -1150,6 +1194,24 @@ fun ChatScreen(
1150 if (running) {
1194 if (running) {
1151 item(key = "__typing") {
1195 item(key = "__typing") {
1152 val workingText = stringResource(R.string.working)
1196 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+ }
1153 Row(
1215 Row(
1154 verticalAlignment = Alignment.CenterVertically,
1216 verticalAlignment = Alignment.CenterVertically,
1155 modifier = Modifier.semantics {
1217 modifier = Modifier.semantics {
@@ -1158,6 +1220,20 @@ fun ChatScreen(
1158 ) {
1220 ) {
1159 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
1221 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
1160 Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1222 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+ )
1161 }
1237 }
1162 }
1238 }
1163 }
1239 }
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+3−3
@@ -14,8 +14,8 @@ import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.layout.widthIn
14import androidx.compose.foundation.layout.widthIn
15import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.automirrored.filled.HelpOutline
16import androidx.compose.material.icons.automirrored.filled.HelpOutline
17+import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
17import androidx.compose.material.icons.automirrored.filled.VolumeUp
18import androidx.compose.material.icons.automirrored.filled.VolumeUp
18−import androidx.compose.material.icons.filled.CallSplit
19import androidx.compose.material.icons.filled.Close
19import androidx.compose.material.icons.filled.Close
20import androidx.compose.material.icons.filled.ContentCopy
20import androidx.compose.material.icons.filled.ContentCopy
21import androidx.compose.material.icons.filled.Edit
21import androidx.compose.material.icons.filled.Edit
@@ -145,7 +145,7 @@ private fun MessageLongPressMenu(
145 }
145 }
146 if (text != null && onBranch != null) {
146 if (text != null && onBranch != null) {
147 DropdownMenuItem(
147 DropdownMenuItem(
148− leadingIcon = { Icon(Icons.Filled.CallSplit, contentDescription = null) },
148+ leadingIcon = { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = null) },
149 text = { Text(stringResource(R.string.branch_session)) },
149 text = { Text(stringResource(R.string.branch_session)) },
150 onClick = { onDismiss(); onBranch(text) },
150 onClick = { onDismiss(); onBranch(text) },
151 )
151 )
@@ -196,7 +196,7 @@ private fun MessageOverflow(
196 }
196 }
197 onBranch?.let { branch ->
197 onBranch?.let { branch ->
198 DropdownMenuItem(
198 DropdownMenuItem(
199− leadingIcon = { Icon(Icons.Filled.CallSplit, contentDescription = null) },
199+ leadingIcon = { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = null) },
200 text = { Text(stringResource(R.string.branch_session)) },
200 text = { Text(stringResource(R.string.branch_session)) },
201 onClick = {
201 onClick = {
202 expanded = false
202 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
14import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.fillMaxWidth
15import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.padding
16import androidx.compose.foundation.layout.size
16import androidx.compose.foundation.layout.size
17+import androidx.compose.foundation.layout.widthIn
17import androidx.compose.foundation.lazy.LazyColumn
18import androidx.compose.foundation.lazy.LazyColumn
18import androidx.compose.foundation.lazy.items
19import androidx.compose.foundation.lazy.items
19import androidx.compose.foundation.lazy.itemsIndexed
20import androidx.compose.foundation.lazy.itemsIndexed
@@ -30,6 +31,7 @@ import androidx.compose.material.icons.filled.Image
30import androidx.compose.material.icons.filled.Code
31import androidx.compose.material.icons.filled.Code
31import androidx.compose.material.icons.filled.Article
32import androidx.compose.material.icons.filled.Article
32import androidx.compose.material.icons.filled.Link
33import androidx.compose.material.icons.filled.Link
34+import androidx.compose.material.icons.filled.MoreVert
33import androidx.compose.material.icons.filled.Search
35import androidx.compose.material.icons.filled.Search
34import androidx.compose.material3.CircularProgressIndicator
36import androidx.compose.material3.CircularProgressIndicator
35import androidx.compose.material3.DropdownMenu
37import androidx.compose.material3.DropdownMenu
@@ -266,11 +268,19 @@ private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Mo
266 tint = MaterialTheme.colorScheme.onSurfaceVariant,
268 tint = MaterialTheme.colorScheme.onSurfaceVariant,
267 )
269 )
268 val target = acc
270 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
269 Text(
276 Text(
270 segment,
277 segment,
271 style = MaterialTheme.typography.bodyMedium,
278 style = MaterialTheme.typography.bodyMedium,
272 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
279 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
280+ maxLines = 1,
281+ overflow = TextOverflow.Ellipsis,
273 modifier = Modifier
282 modifier = Modifier
283+ .widthIn(max = segMaxWidth)
274 .clickable(role = Role.Button) { onNavigate(target) }
284 .clickable(role = Role.Button) { onNavigate(target) }
275 .defaultMinSize(minHeight = 48.dp)
285 .defaultMinSize(minHeight = 48.dp)
276 .padding(horizontal = 6.dp, vertical = 14.dp),
286 .padding(horizontal = 6.dp, vertical = 14.dp),
@@ -500,10 +510,17 @@ private fun DirectoryListing(
500 // Sort preference for the directory listing. Folders-first + name sort is the most useful
510 // Sort preference for the directory listing. Folders-first + name sort is the most useful
501 // default; the toggle flips between folders-first name sort and the server's raw order.
511 // default; the toggle flips between folders-first name sort and the server's raw order.
502 var foldersFirst by rememberSaveable { mutableStateOf(true) }
512 var foldersFirst by rememberSaveable { mutableStateOf(true) }
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+ }
503− val sorted = remember(state.entries, foldersFirst) {
520+ val sorted = remember(visible, foldersFirst) {
504− if (!foldersFirst) state.entries
521+ if (!foldersFirst) visible
505 else {
522 else {
506− val (dirs, files) = state.entries.partition { it.isDirectory }
523+ val (dirs, files) = visible.partition { it.isDirectory }
507 dirs.sortedBy { it.name.lowercase() } + files.sortedBy { it.name.lowercase() }
524 dirs.sortedBy { it.name.lowercase() } + files.sortedBy { it.name.lowercase() }
508 }
525 }
509 }
526 }
@@ -517,9 +534,14 @@ private fun DirectoryListing(
517 item(key = "__sort") {
534 item(key = "__sort") {
518 Row(
535 Row(
519 modifier = Modifier.fillMaxWidth().padding(end = 8.dp, top = 2.dp, bottom = 2.dp),
536 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),
521 verticalAlignment = Alignment.CenterVertically,
538 verticalAlignment = Alignment.CenterVertically,
522 ) {
539 ) {
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+ )
523 androidx.compose.material3.FilterChip(
545 androidx.compose.material3.FilterChip(
524 selected = foldersFirst,
546 selected = foldersFirst,
525 onClick = { foldersFirst = !foldersFirst },
547 onClick = { foldersFirst = !foldersFirst },
@@ -623,6 +645,14 @@ private fun FileRow(
623 }
645 }
624 }
646 }
625 if (status != null) StatusBadge(status)
647 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+ }
626 DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
656 DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
627 DropdownMenuItem(
657 DropdownMenuItem(
628 leadingIcon = { Icon(Icons.Filled.Link, contentDescription = null) },
658 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
27import androidx.compose.foundation.rememberScrollState
27import androidx.compose.foundation.rememberScrollState
28import androidx.compose.foundation.text.KeyboardActions
28import androidx.compose.foundation.text.KeyboardActions
29import androidx.compose.foundation.text.KeyboardOptions
29import androidx.compose.foundation.text.KeyboardOptions
30+import androidx.compose.foundation.text.selection.SelectionContainer
30import androidx.compose.foundation.verticalScroll
31import androidx.compose.foundation.verticalScroll
31import androidx.compose.material.icons.Icons
32import androidx.compose.material.icons.Icons
32import androidx.compose.material.icons.automirrored.filled.ArrowBack
33import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -40,6 +41,7 @@ import androidx.compose.material.icons.filled.MoreVert
40import androidx.compose.material.icons.filled.Refresh
41import androidx.compose.material.icons.filled.Refresh
41import androidx.compose.material.icons.filled.Search
42import androidx.compose.material.icons.filled.Search
42import androidx.compose.material.icons.filled.Share
43import androidx.compose.material.icons.filled.Share
44+import androidx.compose.material3.AlertDialog
43import androidx.compose.material3.CircularProgressIndicator
45import androidx.compose.material3.CircularProgressIndicator
44import androidx.compose.material3.DropdownMenu
46import androidx.compose.material3.DropdownMenu
45import androidx.compose.material3.DropdownMenuItem
47import androidx.compose.material3.DropdownMenuItem
@@ -76,6 +78,7 @@ import androidx.compose.ui.semantics.contentDescription
76import androidx.compose.ui.semantics.semantics
78import androidx.compose.ui.semantics.semantics
77import androidx.compose.ui.text.font.FontFamily
79import androidx.compose.ui.text.font.FontFamily
78import androidx.compose.ui.text.input.ImeAction
80import androidx.compose.ui.text.input.ImeAction
81+import androidx.compose.ui.text.input.KeyboardType
79import androidx.compose.ui.text.style.TextOverflow
82import androidx.compose.ui.text.style.TextOverflow
80import androidx.compose.ui.unit.dp
83import androidx.compose.ui.unit.dp
81import androidx.compose.ui.res.stringResource
84import androidx.compose.ui.res.stringResource
@@ -141,6 +144,13 @@ fun FileViewScreen(
141 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
144 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
142 val listState = rememberLazyListState()
145 val listState = rememberLazyListState()
143 val rawText = state.content?.content.orEmpty()
146 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+ }
144 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
154 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
145 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
155 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
146 val allLines = remember(rawText) { rawText.split("\n") }
156 val allLines = remember(rawText) { rawText.split("\n") }
@@ -158,6 +168,9 @@ fun FileViewScreen(
158 lineCount = lines.size,
168 lineCount = lines.size,
159 listState = listState,
169 listState = listState,
160 )
170 )
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) }
161 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
174 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
162 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
175 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
163 var debouncedFind by remember { mutableStateOf("") }
176 var debouncedFind by remember { mutableStateOf("") }
@@ -165,11 +178,13 @@ fun FileViewScreen(
165 val q = findQuery.trim()
178 val q = findQuery.trim()
166 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
179 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
167 }
180 }
168− val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind) {
181+ val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind, caseSensitive) {
169 val q = debouncedFind
182 val q = debouncedFind
170 value = if (q.isEmpty()) emptyList()
183 value = if (q.isEmpty()) emptyList()
171 else withContext(Dispatchers.Default) {
184 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+ }
173 }
188 }
174 }
189 }
175 // Keep the current match index in range when the match set shrinks (e.g. the file
190 // Keep the current match index in range when the match set shrinks (e.g. the file
@@ -181,10 +196,19 @@ fun FileViewScreen(
181 LaunchedEffect(matchPos, matchIndices) {
196 LaunchedEffect(matchPos, matchIndices) {
182 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
197 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
183 }
198 }
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+ }
184 Scaffold(
207 Scaffold(
185 topBar = {
208 topBar = {
186 FileViewTopBar(
209 FileViewTopBar(
187 filename = filename,
210 filename = filename,
211+ subtitle = pathSubtitle,
188 loading = state.loading,
212 loading = state.loading,
189 content = rawText,
213 content = rawText,
190 wrap = wrap,
214 wrap = wrap,
@@ -203,6 +227,7 @@ fun FileViewScreen(
203 }
227 }
204 },
228 },
205 onToggleWrap = { wrap = !wrap },
229 onToggleWrap = { wrap = !wrap },
230+ onGoToLine = { showGoToLine = true },
206 onCopy = { copyToClipboard(context, filename, rawText) },
231 onCopy = { copyToClipboard(context, filename, rawText) },
207 onCopyPath = { copyToClipboard(context, context.getString(R.string.clip_label_path), path) },
232 onCopyPath = { copyToClipboard(context, context.getString(R.string.clip_label_path), path) },
208 onShare = {
233 onShare = {
@@ -270,11 +295,81 @@ fun FileViewScreen(
270 highlightLineIndex = highlightLine.value,
295 highlightLineIndex = highlightLine.value,
271 listState = listState,
296 listState = listState,
272 onRetry = { vm.reload() },
297 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+ },
273 )
309 )
274 }
310 }
275 }
311 }
276 }
312 }
277 }
313 }
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+ )
278}
373}
279
374
280// Owns the "jump to a line from a search hit" behaviour: scroll to the target once the raw
375// 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(
312@Composable
407@Composable
313private fun FileViewTopBar(
408private fun FileViewTopBar(
314 filename: String,
409 filename: String,
410+ subtitle: String,
315 loading: Boolean,
411 loading: Boolean,
316 content: String,
412 content: String,
317 wrap: Boolean,
413 wrap: Boolean,
@@ -320,12 +416,29 @@ private fun FileViewTopBar(
320 onReload: () -> Unit,
416 onReload: () -> Unit,
321 onToggleFind: () -> Unit,
417 onToggleFind: () -> Unit,
322 onToggleWrap: () -> Unit,
418 onToggleWrap: () -> Unit,
419+ onGoToLine: () -> Unit,
323 onCopy: () -> Unit,
420 onCopy: () -> Unit,
324 onCopyPath: () -> Unit,
421 onCopyPath: () -> Unit,
325 onShare: () -> Unit,
422 onShare: () -> Unit,
326) {
423) {
327 TopAppBar(
424 TopAppBar(
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 {
328− title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
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+ },
329 navigationIcon = {
442 navigationIcon = {
330 IconButton(onClick = onBack) {
443 IconButton(onClick = onBack) {
331 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
444 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
@@ -365,6 +478,11 @@ private fun FileViewTopBar(
365 // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
478 // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
366 // in diff mode because DiffView ignores wrap, so an active-looking toggle there
479 // in diff mode because DiffView ignores wrap, so an active-looking toggle there
367 // would be a silent no-op.
480 // 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+ )
368 DropdownMenuItem(
486 DropdownMenuItem(
369 text = { Text(stringResource(R.string.wrap_lines)) },
487 text = { Text(stringResource(R.string.wrap_lines)) },
370 onClick = { onToggleWrap(); menuExpanded = false },
488 onClick = { onToggleWrap(); menuExpanded = false },
@@ -419,6 +537,9 @@ private fun BoxScope.FileViewStateContent(
419 highlightLineIndex: Int?,
537 highlightLineIndex: Int?,
420 listState: LazyListState,
538 listState: LazyListState,
421 onRetry: () -> Unit,
539 onRetry: () -> Unit,
540+ onShareFull: () -> Unit,
541+ caseSensitive: Boolean,
542+ onToggleCaseSensitive: () -> Unit,
422) {
543) {
423 when {
544 when {
424 // Full-screen spinner only on the initial load (no content yet). A reload keeps the
545 // Full-screen spinner only on the initial load (no content yet). A reload keeps the
@@ -461,6 +582,9 @@ private fun BoxScope.FileViewStateContent(
461 truncated = truncated,
582 truncated = truncated,
462 highlightLineIndex = highlightLineIndex,
583 highlightLineIndex = highlightLineIndex,
463 listState = listState,
584 listState = listState,
585+ onShareFull = onShareFull,
586+ caseSensitive = caseSensitive,
587+ onToggleCaseSensitive = onToggleCaseSensitive,
464 )
588 )
465 }
589 }
466}
590}
@@ -485,6 +609,9 @@ private fun BoxScope.FileViewContentBody(
485 truncated: Boolean,
609 truncated: Boolean,
486 highlightLineIndex: Int?,
610 highlightLineIndex: Int?,
487 listState: LazyListState,
611 listState: LazyListState,
612+ onShareFull: () -> Unit,
613+ caseSensitive: Boolean,
614+ onToggleCaseSensitive: () -> Unit,
488) {
615) {
489 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
616 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
490 // reachable while scrolling. Each contributes a top inset so the content below
617 // reachable while scrolling. Each contributes a top inset so the content below
@@ -543,6 +670,8 @@ private fun BoxScope.FileViewContentBody(
543 onPrev = onPrev,
670 onPrev = onPrev,
544 onNext = onNext,
671 onNext = onNext,
545 onClose = onCloseFind,
672 onClose = onCloseFind,
673+ caseSensitive = caseSensitive,
674+ onToggleCaseSensitive = onToggleCaseSensitive,
546 )
675 )
547 }
676 }
548 }
677 }
@@ -575,6 +704,7 @@ private fun BoxScope.FileViewContentBody(
575 matchIndices = matchIndices,
704 matchIndices = matchIndices,
576 highlightLineIndex = highlightLineIndex,
705 highlightLineIndex = highlightLineIndex,
577 listState = listState,
706 listState = listState,
707+ onShareFull = onShareFull,
578 )
708 )
579 }
709 }
580 }
710 }
@@ -589,8 +719,11 @@ private fun FindBar(
589 onPrev: () -> Unit,
719 onPrev: () -> Unit,
590 onNext: () -> Unit,
720 onNext: () -> Unit,
591 onClose: () -> Unit,
721 onClose: () -> Unit,
722+ caseSensitive: Boolean,
723+ onToggleCaseSensitive: () -> Unit,
592) {
724) {
593 val keyboardController = LocalSoftwareKeyboardController.current
725 val keyboardController = LocalSoftwareKeyboardController.current
726+ val matchCaseLabel = stringResource(R.string.match_case)
594 Row(
727 Row(
595 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
728 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
596 verticalAlignment = Alignment.CenterVertically,
729 verticalAlignment = Alignment.CenterVertically,
@@ -604,22 +737,33 @@ private fun FindBar(
604 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
737 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
605 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
738 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
606 trailingIcon = {
739 trailingIcon = {
607− val countText = if (query.isBlank()) ""
740+ Row(verticalAlignment = Alignment.CenterVertically) {
608− else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
741+ // Case-sensitivity toggle. A small "Aa" chip keeps the bar compact while
609− else stringResource(R.string.match_count, matchPos + 1, matchCount)
742+ // surfacing a previously substring-only, always-case-insensitive search.
610− if (countText.isNotEmpty()) {
743+ FilterChip(
611− // Use error color for "no matches" so the zero-result state is
744+ selected = caseSensitive,
612− // distinguishable from a positive match count at a glance.
745+ onClick = onToggleCaseSensitive,
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,
746+ label = { Text("Aa", style = MaterialTheme.typography.labelSmall) },
621− color = color,
747+ modifier = Modifier.semantics { contentDescription = matchCaseLabel },
622 )
748 )
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+ }
623 }
767 }
624 },
768 },
625 )
769 )
@@ -646,6 +790,7 @@ private fun FileTextContent(
646 matchIndices: List<Int>,
790 matchIndices: List<Int>,
647 highlightLineIndex: Int?,
791 highlightLineIndex: Int?,
648 listState: LazyListState,
792 listState: LazyListState,
793+ onShareFull: () -> Unit,
649) {
794) {
650 // Honor the user's chat text-size preference (the same one the chat markdown honors) so
795 // Honor the user's chat text-size preference (the same one the chat markdown honors) so
651 // a user who bumped the scale for readability gets the same scale in the code viewer.
796 // a user who bumped the scale for readability gets the same scale in the code viewer.
@@ -674,49 +819,67 @@ private fun FileTextContent(
674 MaterialTheme(typography = scaled) {
819 MaterialTheme(typography = scaled) {
675 Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
820 Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
676 if (truncated) {
821 if (truncated) {
677− Text(
822+ androidx.compose.foundation.layout.Row(
678− stringResource(R.string.file_truncated, MAX_RENDERED_LINES),
679− style = MaterialTheme.typography.labelSmall,
680− color = MaterialTheme.colorScheme.onSurfaceVariant,
681 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
823 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
824+ verticalAlignment = Alignment.CenterVertically,
825+ horizontalArrangement = Arrangement.SpaceBetween,
682− )
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+ }
683 }
840 }
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 {
684− LazyColumn(
846+ LazyColumn(
685− state = listState,
847+ state = listState,
686− modifier = Modifier
848+ modifier = Modifier
687− .weight(1f)
849+ .weight(1f)
688− .fillMaxWidth()
850+ .fillMaxWidth()
689− .padding(8.dp),
851+ .padding(8.dp),
690− ) {
852+ ) {
691− itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
853+ itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
692− val isMatch = q.isNotEmpty() && index in matchSet
854+ val isMatch = q.isNotEmpty() && index in matchSet
693− // The jump-to-line target keeps the same emphasis as a find match so the line
855+ // 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.
856+ // the viewer landed on is obvious until the user scrolls or starts a find.
695− val isTarget = index == highlightLineIndex
857+ val isTarget = index == highlightLineIndex
696− Row(
858+ Row(
697− modifier = Modifier
859+ modifier = Modifier
698− .fillMaxWidth()
860+ .fillMaxWidth()
699− .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
861+ .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
700− .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
862+ .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
701− ) {
863+ ) {
702− Text(
864+ Text(
703− "${index + 1}",
865+ "${index + 1}",
704− modifier = Modifier.width(gutterWidth),
866+ modifier = Modifier.width(gutterWidth),
705− style = MaterialTheme.typography.bodySmall,
867+ style = MaterialTheme.typography.bodySmall,
706− fontFamily = FontFamily.Monospace,
868+ fontFamily = FontFamily.Monospace,
707− color = MaterialTheme.colorScheme.onSurfaceVariant,
869+ color = MaterialTheme.colorScheme.onSurfaceVariant,
708− )
870+ )
709− // Heuristic syntax highlighting per line. Falls back to plain text for
871+ // Heuristic syntax highlighting per line. Falls back to plain text for
710− // unknown extensions, so non-code files render unchanged. Memoized so the
872+ // unknown extensions, so non-code files render unchanged. Memoized so the
711− // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
873+ // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
712− // file, or palette actually changes — not on every recomposition (e.g. every
874+ // 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).
875+ // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
714− val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
876+ val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
715− Text(
877+ Text(
716− highlighted,
878+ highlighted,
717− modifier = Modifier.padding(start = 8.dp),
879+ modifier = Modifier.padding(start = 8.dp),
718− style = MaterialTheme.typography.bodySmall,
880+ style = MaterialTheme.typography.bodySmall,
719− )
881+ )
882+ }
720 }
883 }
721 }
884 }
722 }
885 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+26−4
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.imePadding
14import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.size
15import androidx.compose.foundation.layout.size
16import androidx.compose.foundation.layout.widthIn
16import androidx.compose.foundation.layout.widthIn
17+import androidx.compose.foundation.layout.wrapContentWidth
17import androidx.compose.foundation.rememberScrollState
18import androidx.compose.foundation.rememberScrollState
18import androidx.compose.foundation.text.KeyboardActions
19import androidx.compose.foundation.text.KeyboardActions
19import androidx.compose.foundation.text.KeyboardOptions
20import androidx.compose.foundation.text.KeyboardOptions
@@ -56,6 +57,7 @@ import androidx.compose.ui.platform.testTag
56import androidx.compose.ui.semantics.Role
57import androidx.compose.ui.semantics.Role
57import androidx.compose.ui.semantics.contentDescription
58import androidx.compose.ui.semantics.contentDescription
58import androidx.compose.ui.semantics.semantics
59import androidx.compose.ui.semantics.semantics
60+import androidx.compose.ui.semantics.stateDescription
59import androidx.compose.ui.text.input.ImeAction
61import androidx.compose.ui.text.input.ImeAction
60import androidx.compose.ui.text.input.KeyboardType
62import androidx.compose.ui.text.input.KeyboardType
61import androidx.compose.ui.text.input.PasswordVisualTransformation
63import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -181,6 +183,8 @@ private fun ServerEditForm(
181 modifier = Modifier
183 modifier = Modifier
182 .padding(padding)
184 .padding(padding)
183 .imePadding()
185 .imePadding()
186+ .fillMaxWidth()
187+ .wrapContentWidth(Alignment.CenterHorizontally)
184 .widthIn(max = 600.dp)
188 .widthIn(max = 600.dp)
185 .padding(16.dp)
189 .padding(16.dp)
186 .verticalScroll(rememberScrollState()),
190 .verticalScroll(rememberScrollState()),
@@ -197,15 +201,18 @@ private fun ServerEditForm(
197 isError = state.baseUrl.isNotBlank() && !urlValid,
201 isError = state.baseUrl.isNotBlank() && !urlValid,
198 supportingText = {
202 supportingText = {
199 if (state.baseUrl.isNotBlank() && !urlValid) {
203 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
201 // generic error, so the user doesn't have to know the URL needs a scheme.
205 // 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.
202 val suggestion = suggestUrlScheme(state.baseUrl)
208 val suggestion = suggestUrlScheme(state.baseUrl)
203 if (suggestion != null) {
209 if (suggestion != null) {
210+ val labelRes = if (suggestion.startsWith("https://")) R.string.suggest_scheme_https else R.string.suggest_scheme
204 TextButton(
211 TextButton(
205 onClick = { vm.update { it.copy(baseUrl = suggestion) } },
212 onClick = { vm.update { it.copy(baseUrl = suggestion) } },
206 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
213 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
207 ) {
214 ) {
208− Text(stringResource(R.string.suggest_scheme))
215+ Text(stringResource(labelRes))
209 }
216 }
210 } else {
217 } else {
211 Text(stringResource(R.string.invalid_url))
218 Text(stringResource(R.string.invalid_url))
@@ -213,6 +220,7 @@ private fun ServerEditForm(
213 }
220 }
214 },
221 },
215 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
222 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
223+ keyboardActions = KeyboardActions(onDone = { keyboard?.hide() }),
216 modifier = Modifier.fillMaxWidth().focusRequester(baseUrlFocus).testTag("server_url"),
224 modifier = Modifier.fillMaxWidth().focusRequester(baseUrlFocus).testTag("server_url"),
217 )
225 )
218 // Auto-expand LAN discovery only on a fresh form (no URL yet) so tapping a found
226 // Auto-expand LAN discovery only on a fresh form (no URL yet) so tapping a found
@@ -304,6 +312,15 @@ private fun AuthFields(
304 // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on
312 // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on
305 // every keystroke's recomposition (see ServerEditForm).
313 // every keystroke's recomposition (see ServerEditForm).
306 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
314 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+ }
307 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
324 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
308 Text(
325 Text(
309 stringResource(R.string.auth_required),
326 stringResource(R.string.auth_required),
@@ -316,7 +333,8 @@ private fun AuthFields(
316 label = { Text(stringResource(R.string.username_optional)) },
333 label = { Text(stringResource(R.string.username_optional)) },
317 singleLine = true,
334 singleLine = true,
318 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
335 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
336+ keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
319− modifier = Modifier.fillMaxWidth().testTag("server_username"),
337+ modifier = Modifier.fillMaxWidth().focusRequester(usernameFocus).testTag("server_username"),
320 )
338 )
321 OutlinedTextField(
339 OutlinedTextField(
322 value = state.password,
340 value = state.password,
@@ -334,7 +352,7 @@ private fun AuthFields(
334 },
352 },
335 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
353 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
336 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
354 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
337− modifier = Modifier.fillMaxWidth().testTag("server_password"),
355+ modifier = Modifier.fillMaxWidth().focusRequester(passwordFocus).testTag("server_password"),
338 )
356 )
339 OutlinedButton(
357 OutlinedButton(
340 onClick = onTestCredentials,
358 onClick = onTestCredentials,
@@ -377,9 +395,11 @@ private fun AdvancedSection(
377 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
395 onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
378) {
396) {
379 var expanded by rememberSaveable { mutableStateOf(state.requireHttps || state.certPin.isNotBlank()) }
397 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)
380 Column(modifier = Modifier.fillMaxWidth()) {
399 Column(modifier = Modifier.fillMaxWidth()) {
381 TextButton(
400 TextButton(
382 onClick = { expanded = !expanded },
401 onClick = { expanded = !expanded },
402+ modifier = Modifier.semantics { stateDescription = stateLabel },
383 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
403 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
384 ) {
404 ) {
385 Icon(
405 Icon(
@@ -448,9 +468,11 @@ private fun SecurityFields(
448private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolean = false) {
468private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolean = false) {
449 val context = LocalContext.current
469 val context = LocalContext.current
450 var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) }
470 var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) }
471+ val stateLabel = stringResource(if (expanded) R.string.state_expanded else R.string.state_collapsed)
451 Column(modifier = Modifier.fillMaxWidth()) {
472 Column(modifier = Modifier.fillMaxWidth()) {
452 TextButton(
473 TextButton(
453 onClick = { expanded = !expanded },
474 onClick = { expanded = !expanded },
475+ modifier = Modifier.semantics { stateDescription = stateLabel },
454 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
476 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
455 ) {
477 ) {
456 Icon(Icons.Filled.Wifi, contentDescription = null, modifier = Modifier.size(18.dp))
478 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 {
97
97
98/**
98/**
99 * If [input] looks like a bare host (optionally with a port/path) and lacks a scheme,
99 * 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).
100+ * return a scheme-prefixed form (only when it parses to a valid URL). Lets the UI offer a
101− * Lets the UI offer a one-tap fix instead of a generic "invalid URL" error.
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.
102 */
107 */
103fun suggestUrlScheme(input: String): String? {
108fun suggestUrlScheme(input: String): String? {
104 val trimmed = input.trim()
109 val trimmed = input.trim()
105 if (trimmed.isEmpty() || "://" in trimmed) return null
110 if (trimmed.isEmpty() || "://" in trimmed) return null
111+ val scheme = if (looksLikeTls(trimmed)) "https" else "http"
106− val candidate = "http://$trimmed"
112+ val candidate = "$scheme://$trimmed"
107 return if (isValidUrl(candidate)) candidate else null
113 return if (isValidUrl(candidate)) candidate else null
108}
114}
109
115
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+
110class ServerEditViewModel(
145class ServerEditViewModel(
111 private val container: AppContainer,
146 private val container: AppContainer,
112 private val profileId: String?,
147 private val profileId: String?,
@@ -371,7 +406,7 @@ class ServerEditViewModel(
371 // stranded on an empty screen with no error.
406 // stranded on an empty screen with no error.
372 val pingOk = runCatchingCancellable { container.activeConnection.value?.api?.ping() }.isSuccess
407 val pingOk = runCatchingCancellable { container.activeConnection.value?.api?.ping() }.isSuccess
373 if (!pingOk) {
408 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))
375 }
410 }
376 }
411 }
377 }
412 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+46−23
@@ -33,6 +33,7 @@ import androidx.compose.material.icons.filled.Check
33import androidx.compose.material.icons.filled.CheckCircle
33import androidx.compose.material.icons.filled.CheckCircle
34import androidx.compose.material.icons.filled.Close
34import androidx.compose.material.icons.filled.Close
35import androidx.compose.material.icons.filled.Delete
35import androidx.compose.material.icons.filled.Delete
36+import androidx.compose.material.icons.filled.DoneAll
36import androidx.compose.material.icons.filled.Folder
37import androidx.compose.material.icons.filled.Folder
37import androidx.compose.material.icons.filled.History
38import androidx.compose.material.icons.filled.History
38import androidx.compose.material.icons.filled.Link
39import androidx.compose.material.icons.filled.Link
@@ -284,6 +285,14 @@ fun SessionListScreen(
284 Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
285 Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
285 }
286 }
286 DropdownMenu(expanded = showMainMenu, onDismissRequest = { showMainMenu = false }) {
287 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+ }
287 DropdownMenuItem(
296 DropdownMenuItem(
288 text = { Text(stringResource(R.string.files)) },
297 text = { Text(stringResource(R.string.files)) },
289 leadingIcon = { Icon(Icons.Filled.Folder, contentDescription = null) },
298 leadingIcon = { Icon(Icons.Filled.Folder, contentDescription = null) },
@@ -491,23 +500,28 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
491 )
500 )
492 else -> Column(modifier = Modifier.fillMaxSize()) {
501 else -> Column(modifier = Modifier.fillMaxSize()) {
493 val keyboardController = LocalSoftwareKeyboardController.current
502 val keyboardController = LocalSoftwareKeyboardController.current
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) {
494− OutlinedTextField(
507+ OutlinedTextField(
495− value = state.query,
508+ value = state.query,
496− onValueChange = onQueryChange,
509+ onValueChange = onQueryChange,
497− modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).testTag("session_search"),
510+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).testTag("session_search"),
498− label = { Text(stringResource(R.string.search_sessions)) },
511+ label = { Text(stringResource(R.string.search_sessions)) },
499− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
512+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
500− trailingIcon = if (state.query.isNotEmpty()) {
513+ trailingIcon = if (state.query.isNotEmpty()) {
501− {
514+ {
502− IconButton(onClick = { onQueryChange("") }) {
515+ IconButton(onClick = { onQueryChange("") }) {
503− Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
516+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
517+ }
504 }
518 }
505− }
506− } else null,
519+ } else null,
507− singleLine = true,
520+ singleLine = true,
508− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
521+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
509− keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
522+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
510− )
523+ )
524+ }
511 val sessions = remember(
525 val sessions = remember(
512 state.sessions, state.query, state.previews,
526 state.sessions, state.query, state.previews,
513 state.pinnedIds, state.archivedIds, state.showArchived,
527 state.pinnedIds, state.archivedIds, state.showArchived,
@@ -771,6 +785,21 @@ private fun RenameSessionDialog(
771 )
785 )
772}
786}
773
787
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+
774@OptIn(ExperimentalMaterial3Api::class)
803@OptIn(ExperimentalMaterial3Api::class)
775@Composable
804@Composable
776private fun SessionCard(
805private fun SessionCard(
@@ -887,13 +916,7 @@ private fun SessionCard(
887 tint = MaterialTheme.colorScheme.onSurfaceVariant,
916 tint = MaterialTheme.colorScheme.onSurfaceVariant,
888 )
917 )
889 }
918 }
890− Text(
891− session.displayTitle,
919+ SessionTitle(session.displayTitle, unreadCount > 0)
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− )
897 }
920 }
898 // Long-press the relative label to reveal the full absolute timestamp.
921 // Long-press the relative label to reveal the full absolute timestamp.
899 RelativeTimeText(
922 RelativeTimeText(
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+2−1
@@ -56,7 +56,8 @@ fun TwoPaneSessionChat(
56 // Bumped by the empty-detail "New session" button to open the left pane's new-session
56 // Bumped by the empty-detail "New session" button to open the left pane's new-session
57 // directory picker (the same dialog the FAB uses) instead of creating a directory-less
57 // directory picker (the same dialog the FAB uses) instead of creating a directory-less
58 // session; the created session then opens into the detail pane via onOpenSession.
58 // session; the created session then opens into the detail pane via onOpenSession.
59+ // Saveable so a rotation between the tap and the dialog-opening effect doesn't drop it.
59− var newSessionTrigger by remember { mutableStateOf(0) }
60+ var newSessionTrigger by rememberSaveable { mutableStateOf(0) }
60 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
61 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
61 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
62 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
62 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
63 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
9import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Box
10import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Column
11import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Row
12−import androidx.compose.foundation.layout.Spacer
13import androidx.compose.foundation.layout.fillMaxSize
12import androidx.compose.foundation.layout.fillMaxSize
14import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.fillMaxWidth
15−import androidx.compose.foundation.layout.heightIn
16import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.padding
17−import androidx.compose.foundation.layout.size
18import androidx.compose.foundation.lazy.LazyColumn
15import androidx.compose.foundation.lazy.LazyColumn
19import androidx.compose.foundation.lazy.items
16import androidx.compose.foundation.lazy.items
20import androidx.compose.foundation.rememberScrollState
17import androidx.compose.foundation.rememberScrollState
@@ -23,11 +20,16 @@ import androidx.compose.foundation.verticalScroll
23import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.Icons
24import androidx.compose.material.icons.filled.BugReport
21import androidx.compose.material.icons.filled.BugReport
25import androidx.compose.material.icons.filled.Close
22import androidx.compose.material.icons.filled.Close
23+import androidx.compose.material.icons.filled.ContentCopy
24+import androidx.compose.material.icons.filled.Delete
26import androidx.compose.material.icons.filled.DeleteSweep
25import androidx.compose.material.icons.filled.DeleteSweep
26+import androidx.compose.material.icons.filled.MoreVert
27import androidx.compose.material.icons.filled.Search
27import androidx.compose.material.icons.filled.Search
28import androidx.compose.material.icons.filled.Share
28import androidx.compose.material.icons.filled.Share
29import androidx.compose.material3.AlertDialog
29import androidx.compose.material3.AlertDialog
30import androidx.compose.material3.CircularProgressIndicator
30import androidx.compose.material3.CircularProgressIndicator
31+import androidx.compose.material3.DropdownMenu
32+import androidx.compose.material3.DropdownMenuItem
31import androidx.compose.material3.ExperimentalMaterial3Api
33import androidx.compose.material3.ExperimentalMaterial3Api
32import androidx.compose.material3.HorizontalDivider
34import androidx.compose.material3.HorizontalDivider
33import androidx.compose.material3.Icon
35import androidx.compose.material3.Icon
@@ -39,10 +41,12 @@ import androidx.compose.material3.SnackbarDuration
39import androidx.compose.material3.SnackbarHost
41import androidx.compose.material3.SnackbarHost
40import androidx.compose.material3.SnackbarHostState
42import androidx.compose.material3.SnackbarHostState
41import androidx.compose.material3.SnackbarResult
43import androidx.compose.material3.SnackbarResult
44+import androidx.compose.material3.Surface
42import androidx.compose.material3.SwipeToDismissBox
45import androidx.compose.material3.SwipeToDismissBox
43import androidx.compose.material3.SwipeToDismissBoxValue
46import androidx.compose.material3.SwipeToDismissBoxValue
44import androidx.compose.material3.Text
47import androidx.compose.material3.Text
45import androidx.compose.material3.TextButton
48import androidx.compose.material3.TextButton
49+import androidx.compose.material3.TopAppBar
46import androidx.compose.material3.rememberSwipeToDismissBoxState
50import androidx.compose.material3.rememberSwipeToDismissBoxState
47import androidx.compose.runtime.Composable
51import androidx.compose.runtime.Composable
48import androidx.compose.runtime.CompositionLocalProvider
52import androidx.compose.runtime.CompositionLocalProvider
@@ -78,6 +82,8 @@ import androidx.compose.ui.semantics.contentDescription
78import androidx.compose.ui.semantics.heading
82import androidx.compose.ui.semantics.heading
79import androidx.compose.ui.semantics.semantics
83import androidx.compose.ui.semantics.semantics
80import androidx.compose.ui.unit.dp
84import androidx.compose.ui.unit.dp
85+import androidx.compose.ui.window.Dialog
86+import androidx.compose.ui.window.DialogProperties
81import androidx.lifecycle.compose.collectAsStateWithLifecycle
87import androidx.lifecycle.compose.collectAsStateWithLifecycle
82import soy.iko.opencode.R
88import soy.iko.opencode.R
83import soy.iko.opencode.data.network.NetworkConfig
89import soy.iko.opencode.data.network.NetworkConfig
@@ -211,6 +217,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
211 EmptyState(
217 EmptyState(
212 icon = Icons.Filled.BugReport,
218 icon = Icons.Filled.BugReport,
213 title = stringResource(R.string.no_crash_reports),
219 title = stringResource(R.string.no_crash_reports),
220+ description = stringResource(R.string.no_crash_reports_desc),
214 modifier = Modifier.fillMaxWidth().padding(24.dp),
221 modifier = Modifier.fillMaxWidth().padding(24.dp),
215 )
222 )
216 } else {
223 } else {
@@ -220,6 +227,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
220 crashQuery = it
227 crashQuery = it
221 }
228 }
222 items(filtered, key = { it.fileName }) { report ->
229 items(filtered, key = { it.fileName }) { report ->
230+ var menuExpanded by remember { mutableStateOf(false) }
223 // Swipe end-to-start reveals a delete affordance. The delete is
231 // Swipe end-to-start reveals a delete affordance. The delete is
224 // deferred for an undo window (matching the session/server lists);
232 // deferred for an undo window (matching the session/server lists);
225 // confirmValueChange snaps back so the row remains while the undo
233 // confirmValueChange snaps back so the row remains while the undo
@@ -296,6 +304,29 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
296 }) {
304 }) {
297 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
305 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
298 }
306 }
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+ }
299 }
330 }
300 }
331 }
301 HorizontalDivider()
332 HorizontalDivider()
@@ -320,86 +351,94 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
320 if (loaded != null) reportContent = loaded else loadFailed = true
351 if (loaded != null) reportContent = loaded else loadFailed = true
321 }
352 }
322 val content = reportContent
353 val content = reportContent
323− AlertDialog(
354+ Dialog(
324 onDismissRequest = { viewing = null },
355 onDismissRequest = { viewing = null },
325− confirmButton = {
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 }) {
326− TextButton(onClick = { viewing = null }) { Text(stringResource(R.string.close)) }
365+ Icon(Icons.Filled.Close, contentDescription = 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))
357 }
366 }
358− },
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+ },
359− ) { Text(stringResource(R.string.share)) }
375+ ) { Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy)) }
360− Spacer(Modifier.size(8.dp))
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)) }
361− TextButton(onClick = {
398+ IconButton(onClick = {
362− // Confirm before deleting a single report, matching clear-all.
399+ // Confirm before deleting a single report, matching clear-all.
363− pendingReportDelete = reportName
400+ pendingReportDelete = reportName
364− }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
401+ }) { Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete)) }
365− }
366− },
402+ },
367− title = { Text(stringResource(R.string.crash_report)) },
403+ )
368− text = {
404+ },
405+ ) { padding ->
369− when {
406+ when {
370− content != null -> Box(
407+ content != null -> SelectionContainer {
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
408+ // Wrap in SelectionContainer so a user can select/copy just the
377− // relevant stack snippet instead of the whole-report Copy button.
409+ // relevant stack snippet instead of the whole-report Copy button.
378− SelectionContainer {
379 Text(
410 Text(
380 content,
411 content,
381 style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
412 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),
383 )
418 )
384 }
419 }
385− }
386− loadFailed -> Text(
420+ loadFailed -> Box(
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),
421+ modifier = Modifier.fillMaxSize().padding(padding),
395 contentAlignment = Alignment.Center,
422 contentAlignment = Alignment.Center,
396 ) {
423 ) {
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+ ) {
397− CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
435+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
436+ }
398 }
437 }
399 }
438 }
400 }
439 }
401− },
440+ }
402− )
441+ }
403 }
442 }
404
443
405 pendingReportDelete?.let { name ->
444 pendingReportDelete?.let { name ->
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+87−27
@@ -1,7 +1,9 @@
1package soy.iko.opencode.ui.settings
1package soy.iko.opencode.ui.settings
2
2
3+import android.content.Intent
3import android.content.pm.PackageManager
4import android.content.pm.PackageManager
4import android.os.Build
5import android.os.Build
6+import android.provider.Settings
5import androidx.activity.compose.rememberLauncherForActivityResult
7import androidx.activity.compose.rememberLauncherForActivityResult
6import androidx.activity.result.contract.ActivityResultContracts
8import androidx.activity.result.contract.ActivityResultContracts
7import androidx.compose.foundation.background
9import androidx.compose.foundation.background
@@ -16,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
16import androidx.compose.foundation.layout.padding
18import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.size
19import androidx.compose.foundation.layout.size
18import androidx.compose.foundation.layout.widthIn
20import androidx.compose.foundation.layout.widthIn
21+import androidx.compose.foundation.layout.wrapContentWidth
19import androidx.compose.foundation.rememberScrollState
22import androidx.compose.foundation.rememberScrollState
20import androidx.compose.foundation.selection.selectable
23import androidx.compose.foundation.selection.selectable
21import androidx.compose.foundation.selection.toggleable
24import androidx.compose.foundation.selection.toggleable
@@ -24,25 +27,31 @@ import androidx.compose.foundation.shape.CircleShape
24import androidx.compose.material.icons.Icons
27import androidx.compose.material.icons.Icons
25import androidx.compose.material.icons.automirrored.filled.ArrowBack
28import androidx.compose.material.icons.automirrored.filled.ArrowBack
26import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
29import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
27−import androidx.compose.material.icons.filled.ArrowDropDown
28import androidx.compose.material.icons.filled.BugReport
30import androidx.compose.material.icons.filled.BugReport
29import androidx.compose.material.icons.filled.Dns
31import androidx.compose.material.icons.filled.Dns
30import androidx.compose.material.icons.filled.Download
32import androidx.compose.material.icons.filled.Download
31import androidx.compose.material.icons.filled.Hub
33import androidx.compose.material.icons.filled.Hub
34+import androidx.compose.material.icons.filled.Notifications
32import androidx.compose.material.icons.filled.QueryStats
35import androidx.compose.material.icons.filled.QueryStats
33import androidx.compose.material.icons.filled.Upload
36import androidx.compose.material.icons.filled.Upload
34import androidx.compose.material3.AlertDialog
37import androidx.compose.material3.AlertDialog
35import androidx.compose.material3.ExperimentalMaterial3Api
38import androidx.compose.material3.ExperimentalMaterial3Api
36import androidx.compose.material3.CircularProgressIndicator
39import androidx.compose.material3.CircularProgressIndicator
40+import androidx.compose.material3.DropdownMenuItem
41+import androidx.compose.material3.ExposedDropdownMenuBox
42+import androidx.compose.material3.ExposedDropdownMenuDefaults
37import androidx.compose.material3.HorizontalDivider
43import androidx.compose.material3.HorizontalDivider
38import androidx.compose.material3.Icon
44import androidx.compose.material3.Icon
39import androidx.compose.material3.IconButton
45import androidx.compose.material3.IconButton
40import androidx.compose.material3.MaterialTheme
46import androidx.compose.material3.MaterialTheme
47+import androidx.compose.material3.MenuAnchorType
48+import androidx.compose.material3.OutlinedTextField
41import androidx.compose.material3.RadioButton
49import androidx.compose.material3.RadioButton
42import androidx.compose.material3.Scaffold
50import androidx.compose.material3.Scaffold
43import androidx.compose.material3.SnackbarHost
51import androidx.compose.material3.SnackbarHost
44import androidx.compose.material3.SnackbarHostState
52import androidx.compose.material3.SnackbarHostState
45import androidx.compose.material3.Slider
53import androidx.compose.material3.Slider
54+import androidx.compose.material3.SnackbarResult
46import androidx.compose.material3.Switch
55import androidx.compose.material3.Switch
47import androidx.compose.material3.Text
56import androidx.compose.material3.Text
48import androidx.compose.material3.TextButton
57import androidx.compose.material3.TextButton
@@ -182,6 +191,8 @@ fun SettingsScreen(
182 val exportedMsg = stringResource(R.string.backup_exported)
191 val exportedMsg = stringResource(R.string.backup_exported)
183 val exportFailedMsg = stringResource(R.string.backup_export_failed)
192 val exportFailedMsg = stringResource(R.string.backup_export_failed)
184 val importedMsg = stringResource(R.string.backup_imported)
193 val importedMsg = stringResource(R.string.backup_imported)
194+ val restartLabel = stringResource(R.string.restart)
195+ val couldNotOpenLinkMsg = stringResource(R.string.could_not_open_link)
185 val importFailedMsg = stringResource(R.string.backup_import_failed)
196 val importFailedMsg = stringResource(R.string.backup_import_failed)
186 val exportLauncher = rememberLauncherForActivityResult(
197 val exportLauncher = rememberLauncherForActivityResult(
187 ActivityResultContracts.CreateDocument("application/json"),
198 ActivityResultContracts.CreateDocument("application/json"),
@@ -224,7 +235,15 @@ fun SettingsScreen(
224 },
235 },
225 snackbarHost = { SnackbarHost(snackbar) },
236 snackbarHost = { SnackbarHost(snackbar) },
226 ) { padding ->
237 ) { padding ->
238+ Column(
239+ modifier = Modifier
240+ .padding(padding)
241+ .fillMaxWidth()
242+ .wrapContentWidth(Alignment.CenterHorizontally)
243+ .widthIn(max = 600.dp)
227− Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).verticalScroll(rememberScrollState()).padding(16.dp)) {
244+ .verticalScroll(rememberScrollState())
245+ .padding(16.dp),
246+ ) {
228 val s = settings
247 val s = settings
229 Text(
248 Text(
230 stringResource(R.string.appearance),
249 stringResource(R.string.appearance),
@@ -248,11 +267,13 @@ fun SettingsScreen(
248 // Dynamic color (Material You) only works on Android 12+, so hide the toggle
267 // Dynamic color (Material You) only works on Android 12+, so hide the toggle
249 // on older devices instead of offering a control that silently does nothing.
268 // on older devices instead of offering a control that silently does nothing.
250 if (dynamicColorAvailable) {
269 if (dynamicColorAvailable) {
270+ val amoledActive = s.themeMode == ThemeMode.AMOLED
251 Row(
271 Row(
252 modifier = Modifier
272 modifier = Modifier
253 .fillMaxWidth()
273 .fillMaxWidth()
254 .toggleable(
274 .toggleable(
255 value = s.dynamicColor,
275 value = s.dynamicColor,
276+ enabled = !amoledActive,
256 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
277 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
257 role = Role.Switch,
278 role = Role.Switch,
258 )
279 )
@@ -266,10 +287,18 @@ fun SettingsScreen(
266 style = MaterialTheme.typography.bodySmall,
287 style = MaterialTheme.typography.bodySmall,
267 color = MaterialTheme.colorScheme.onSurfaceVariant,
288 color = MaterialTheme.colorScheme.onSurfaceVariant,
268 )
289 )
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+ }
269 }
297 }
270 Switch(
298 Switch(
271 checked = s.dynamicColor,
299 checked = s.dynamicColor,
272 onCheckedChange = null,
300 onCheckedChange = null,
301+ enabled = !amoledActive,
273 )
302 )
274 }
303 }
275 }
304 }
@@ -468,6 +497,21 @@ fun SettingsScreen(
468 enabled = activeProfile != null,
497 enabled = activeProfile != null,
469 onClick = onOpenMcp,
498 onClick = onOpenMcp,
470 )
499 )
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+ )
471
515
472 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
516 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
473
517
@@ -583,7 +627,17 @@ fun SettingsScreen(
583 ?: error("no input stream")
627 ?: error("no input stream")
584 container.backupManager.import(text)
628 container.backupManager.import(text)
585 }.isSuccess
629 }.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+ }
587 }
641 }
588 }) { Text(stringResource(R.string.import_confirm_replace), color = MaterialTheme.colorScheme.error) }
642 }) { Text(stringResource(R.string.import_confirm_replace), color = MaterialTheme.colorScheme.error) }
589 },
643 },
@@ -808,36 +862,38 @@ private fun Badge(count: Int) {
808/** Dropdown selecting how long after backgrounding the app re-locks. Only shown when app lock
862/** Dropdown selecting how long after backgrounding the app re-locks. Only shown when app lock
809 * is on. The grace period avoids a re-prompt on every quick app-switch (the most common reason
863 * is on. The grace period avoids a re-prompt on every quick app-switch (the most common reason
810 * users disable biometric locks), while still re-locking immediately by default. */
864 * users disable biometric locks), while still re-locking immediately by default. */
865+@OptIn(ExperimentalMaterial3Api::class)
811@Composable
866@Composable
812private fun AppLockReLockSelector(seconds: Int, onSelect: (Int) -> Unit) {
867private fun AppLockReLockSelector(seconds: Int, onSelect: (Int) -> Unit) {
813 var expanded by remember { mutableStateOf(false) }
868 var expanded by remember { mutableStateOf(false) }
814− val label = relockLabel(seconds)
869+ ExposedDropdownMenuBox(
815− Row(
870+ expanded = expanded,
871+ onExpandedChange = { expanded = it },
816 modifier = Modifier
872 modifier = Modifier
817 .fillMaxWidth()
873 .fillMaxWidth()
818− .padding(start = 56.dp, top = 4.dp)
874+ .padding(start = 56.dp, top = 4.dp),
819− .clickable(role = Role.Button) { expanded = true },
820− verticalAlignment = Alignment.CenterVertically,
821 ) {
875 ) {
822− Text(
876+ OutlinedTextField(
877+ value = relockLabel(seconds),
878+ onValueChange = {},
879+ readOnly = true,
880+ singleLine = true,
823− stringResource(R.string.app_lock_relock),
881+ label = { Text(stringResource(R.string.app_lock_relock)) },
824− style = MaterialTheme.typography.bodyMedium,
882+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
825− color = MaterialTheme.colorScheme.onSurfaceVariant,
826− modifier = Modifier.weight(1f),
883+ modifier = Modifier
827− )
884+ .menuAnchor(MenuAnchorType.PrimaryNotEditable, true)
828− Text(label, style = MaterialTheme.typography.bodyMedium)
885+ .fillMaxWidth(),
829− Icon(
830− Icons.Filled.ArrowDropDown,
831− contentDescription = null,
832− tint = MaterialTheme.colorScheme.onSurfaceVariant,
833 )
886 )
834− }
887+ ExposedDropdownMenu(
888+ expanded = expanded,
835− androidx.compose.material3.DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
889+ onDismissRequest = { expanded = false },
890+ ) {
836− SettingsStore.APP_LOCK_RELOCK_OPTIONS_SECONDS.forEach { option ->
891+ SettingsStore.APP_LOCK_RELOCK_OPTIONS_SECONDS.forEach { option ->
837− androidx.compose.material3.DropdownMenuItem(
892+ DropdownMenuItem(
838− text = { Text(relockLabel(option)) },
893+ text = { Text(relockLabel(option)) },
839− onClick = { expanded = false; onSelect(option) },
894+ onClick = { expanded = false; onSelect(option) },
840− )
895+ )
896+ }
841 }
897 }
842 }
898 }
843}
899}
@@ -846,8 +902,12 @@ private fun AppLockReLockSelector(seconds: Int, onSelect: (Int) -> Unit) {
846private fun relockLabel(seconds: Int): String = when {
902private fun relockLabel(seconds: Int): String = when {
847 seconds <= 0 -> stringResource(R.string.app_lock_relock_immediately)
903 seconds <= 0 -> stringResource(R.string.app_lock_relock_immediately)
848 seconds < 60 -> pluralStringResource(R.plurals.app_lock_relock_seconds, seconds, seconds)
904 seconds < 60 -> pluralStringResource(R.plurals.app_lock_relock_seconds, seconds, seconds)
849− else -> {
905+ seconds < 3600 -> {
850 val mins = seconds / 60
906 val mins = seconds / 60
851 pluralStringResource(R.plurals.app_lock_relock_minutes, mins, mins)
907 pluralStringResource(R.plurals.app_lock_relock_minutes, mins, mins)
852 }
908 }
909+ else -> {
910+ val hours = seconds / 3600
911+ pluralStringResource(R.plurals.app_lock_relock_hours, hours, hours)
912+ }
853}
913}
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+10−2
@@ -117,9 +117,12 @@ fun OpencodeTheme(
117) {
117) {
118 val context = LocalContext.current
118 val context = LocalContext.current
119 val colorScheme = when {
119 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
120 dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
124 dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
121 if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
125 if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
122− darkTheme && amoled -> AmoledColors
123 darkTheme -> DarkColors
126 darkTheme -> DarkColors
124 else -> LightColors
127 else -> LightColors
125 }
128 }
@@ -127,7 +130,12 @@ fun OpencodeTheme(
127 if (!view.isInEditMode) {
130 if (!view.isInEditMode) {
128 SideEffect {
131 SideEffect {
129 val window = view.context.findActivity()?.window ?: return@SideEffect
132 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
131 }
139 }
132 }
140 }
133 MaterialTheme(
141 MaterialTheme(
app/src/main/java/soy/iko/opencode/ui/theme/Typography.kt+38
@@ -21,6 +21,23 @@ import androidx.compose.ui.unit.sp
21 * (counts, timestamps' siblings, state text) don't inherit a monospace look.
21 * (counts, timestamps' siblings, state text) don't inherit a monospace look.
22 */
22 */
23val OpencodeTypography = Typography(
23val 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+ ),
24 displaySmall = TextStyle(
41 displaySmall = TextStyle(
25 fontFamily = FontFamily.Default,
42 fontFamily = FontFamily.Default,
26 fontWeight = FontWeight.Normal,
43 fontWeight = FontWeight.Normal,
@@ -28,12 +45,26 @@ val OpencodeTypography = Typography(
28 lineHeight = 40.sp,
45 lineHeight = 40.sp,
29 letterSpacing = 0.sp,
46 letterSpacing = 0.sp,
30 ),
47 ),
48+ headlineLarge = TextStyle(
49+ fontFamily = FontFamily.Default,
50+ fontWeight = FontWeight.SemiBold,
51+ fontSize = 32.sp,
52+ lineHeight = 40.sp,
53+ ),
31 headlineMedium = TextStyle(
54 headlineMedium = TextStyle(
32 fontFamily = FontFamily.Default,
55 fontFamily = FontFamily.Default,
33 fontWeight = FontWeight.SemiBold,
56 fontWeight = FontWeight.SemiBold,
34 fontSize = 26.sp,
57 fontSize = 26.sp,
35 lineHeight = 34.sp,
58 lineHeight = 34.sp,
36 ),
59 ),
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+ ),
37 titleLarge = TextStyle(
68 titleLarge = TextStyle(
38 fontFamily = FontFamily.Default,
69 fontFamily = FontFamily.Default,
39 fontWeight = FontWeight.SemiBold,
70 fontWeight = FontWeight.SemiBold,
@@ -47,6 +78,13 @@ val OpencodeTypography = Typography(
47 lineHeight = 22.sp,
78 lineHeight = 22.sp,
48 letterSpacing = 0.1.sp,
79 letterSpacing = 0.1.sp,
49 ),
80 ),
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+ ),
50 bodyLarge = TextStyle(
88 bodyLarge = TextStyle(
51 fontFamily = FontFamily.Default,
89 fontFamily = FontFamily.Default,
52 fontWeight = FontWeight.Normal,
90 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 @@
95 <string name="directory_loading">Cargando directorios…</string>
95 <string name="directory_loading">Cargando directorios…</string>
96 <string name="session_directory_desc">Directorio: %1$s</string>
96 <string name="session_directory_desc">Directorio: %1$s</string>
97 <string name="unread">Sin leer</string>
97 <string name="unread">Sin leer</string>
98+ <string name="mark_all_read">Marcar todo leído</string>
98 <string name="unread_count">%1$d sin leer</string>
99 <string name="unread_count">%1$d sin leer</string>
99 <string name="no_sessions_yet">Aún no hay sesiones</string>
100 <string name="no_sessions_yet">Aún no hay sesiones</string>
100 <string name="no_sessions_hint">Inicia una conversación con opencode.</string>
101 <string name="no_sessions_hint">Inicia una conversación con opencode.</string>
@@ -149,6 +150,7 @@
149 <string name="error_timeout">La solicitud agotó el tiempo de espera. Comprueba la conexión e inténtalo de nuevo.</string>
150 <string name="error_timeout">La solicitud agotó el tiempo de espera. Comprueba la conexión e inténtalo de nuevo.</string>
150 <string name="error_server">El servidor informó de un error. Inténtalo más tarde.</string>
151 <string name="error_server">El servidor informó de un error. Inténtalo más tarde.</string>
151 <string name="error_load_timeout">La carga tardó demasiado. Inténtalo de nuevo.</string>
152 <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>
152 <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>
154 <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>
153
155
154 <!-- Part renderer -->
156 <!-- Part renderer -->
@@ -203,6 +205,7 @@
203 <string name="binary_file">Archivo binario — vista previa no compatible.</string>
205 <string name="binary_file">Archivo binario — vista previa no compatible.</string>
204 <string name="empty_folder">Esta carpeta está vacía</string>
206 <string name="empty_folder">Esta carpeta está vacía</string>
205 <string name="empty_file">Este archivo está vacío</string>
207 <string name="empty_file">Este archivo está vacío</string>
208+ <string name="show_hidden">Mostrar ocultos</string>
206 <string name="parent_dir">Directorio superior</string>
209 <string name="parent_dir">Directorio superior</string>
207 <string name="folder">Carpeta: %1$s</string>
210 <string name="folder">Carpeta: %1$s</string>
208 <string name="file_label">Archivo: %1$s</string>
211 <string name="file_label">Archivo: %1$s</string>
@@ -289,6 +292,7 @@
289 <string name="unknown_part">Contenido no compatible</string>
292 <string name="unknown_part">Contenido no compatible</string>
290 <string name="tool_preview">🔧 %1$s</string>
293 <string name="tool_preview">🔧 %1$s</string>
291 <string name="suggest_scheme">Añadir prefijo http://</string>
294 <string name="suggest_scheme">Añadir prefijo http://</string>
295+ <string name="suggest_scheme_https">Añadir prefijo https://</string>
292 <string name="sort">Ordenar</string>
296 <string name="sort">Ordenar</string>
293 <string name="sort_recent">Actividad reciente</string>
297 <string name="sort_recent">Actividad reciente</string>
294 <string name="sort_title">Título</string>
298 <string name="sort_title">Título</string>
@@ -420,9 +424,9 @@
420 <!-- Message actions -->
424 <!-- Message actions -->
421 <string name="message_actions">Acciones del mensaje</string>
425 <string name="message_actions">Acciones del mensaje</string>
422 <string name="quote_reply">Responder citando</string>
426 <string name="quote_reply">Responder citando</string>
423− <string name="branch_session">Ramificar en sesión nueva</string>
427+ <string name="branch_session">Iniciar sesión nueva desde aquí</string>
424− <string name="branch_created">Ramificado en una sesión nueva</string>
428+ <string name="branch_created">Se inició una sesión nueva desde este mensaje</string>
425− <string name="branch_failed">No se pudo ramificar la sesión</string>
429+ <string name="branch_failed">No se pudo iniciar una sesión nueva</string>
426
430
427 <!-- Offline outbox -->
431 <!-- Offline outbox -->
428 <string name="outbox_queued">Sin conexión: mensaje en cola, se enviará al reconectar</string>
432 <string name="outbox_queued">Sin conexión: mensaje en cola, se enviará al reconectar</string>
@@ -543,6 +547,10 @@
543
547
544 <!-- File view -->
548 <!-- File view -->
545 <string name="switch_to_raw_to_search">Cambia a Sin formato para buscar</string>
549 <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>
546
554
547 <!-- Settings backup/restore -->
555 <!-- Settings backup/restore -->
548 <string name="import_confirm_title">¿Reemplazar todos los datos?</string>
556 <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 @@
3 <!-- A darker splash background at night so the cold-start flash isn't a bright rectangle. -->
3 <!-- A darker splash background at night so the cold-start flash isn't a bright rectangle. -->
4 <color name="splash_background">#1A1B26</color>
4 <color name="splash_background">#1A1B26</color>
5 <color name="ic_launcher_background">#34548A</color>
5 <color name="ic_launcher_background">#34548A</color>
6+ <!-- Matches the Compose dark colorScheme background (#1A1B26). -->
7+ <color name="window_background">#1A1B26</color>
6</resources>
8</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 @@
2<resources>
2<resources>
3 <color name="splash_background">#34548A</color>
3 <color name="splash_background">#34548A</color>
4 <color name="ic_launcher_background">#34548A</color>
4 <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>
5</resources>
8</resources>
app/src/main/res/values/strings.xml+11−3
@@ -104,6 +104,7 @@
104 <string name="directory_loading">Loading directories…</string>
104 <string name="directory_loading">Loading directories…</string>
105 <string name="session_directory_desc">Directory: %1$s</string>
105 <string name="session_directory_desc">Directory: %1$s</string>
106 <string name="unread">Unread</string>
106 <string name="unread">Unread</string>
107+ <string name="mark_all_read">Mark all read</string>
107 <string name="unread_count">%1$d unread</string>
108 <string name="unread_count">%1$d unread</string>
108 <string name="no_sessions_yet">No sessions yet</string>
109 <string name="no_sessions_yet">No sessions yet</string>
109 <string name="no_sessions_hint">Start a conversation with opencode.</string>
110 <string name="no_sessions_hint">Start a conversation with opencode.</string>
@@ -158,6 +159,7 @@
158 <string name="error_timeout">The request timed out. Check the connection and try again.</string>
159 <string name="error_timeout">The request timed out. Check the connection and try again.</string>
159 <string name="error_server">The server reported an error. Try again later.</string>
160 <string name="error_server">The server reported an error. Try again later.</string>
160 <string name="error_load_timeout">Loading took too long. Try again.</string>
161 <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>
161 <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>
163 <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>
162
164
163 <!-- Part renderer -->
165 <!-- Part renderer -->
@@ -212,6 +214,7 @@
212 <string name="binary_file">Binary file — preview not supported.</string>
214 <string name="binary_file">Binary file — preview not supported.</string>
213 <string name="empty_folder">This folder is empty</string>
215 <string name="empty_folder">This folder is empty</string>
214 <string name="empty_file">This file is empty</string>
216 <string name="empty_file">This file is empty</string>
217+ <string name="show_hidden">Show hidden</string>
215 <string name="parent_dir">Parent directory</string>
218 <string name="parent_dir">Parent directory</string>
216 <string name="folder">Folder: %1$s</string>
219 <string name="folder">Folder: %1$s</string>
217 <string name="file_label">File: %1$s</string>
220 <string name="file_label">File: %1$s</string>
@@ -298,6 +301,7 @@
298 <string name="unknown_part">Unsupported content</string>
301 <string name="unknown_part">Unsupported content</string>
299 <string name="tool_preview">🔧 %1$s</string>
302 <string name="tool_preview">🔧 %1$s</string>
300 <string name="suggest_scheme">Add http:// prefix</string>
303 <string name="suggest_scheme">Add http:// prefix</string>
304+ <string name="suggest_scheme_https">Add https:// prefix</string>
301 <string name="sort">Sort</string>
305 <string name="sort">Sort</string>
302 <string name="sort_recent">Recent activity</string>
306 <string name="sort_recent">Recent activity</string>
303 <string name="sort_title">Title</string>
307 <string name="sort_title">Title</string>
@@ -442,9 +446,9 @@
442 <!-- Message actions -->
446 <!-- Message actions -->
443 <string name="message_actions">Message actions</string>
447 <string name="message_actions">Message actions</string>
444 <string name="quote_reply">Quote reply</string>
448 <string name="quote_reply">Quote reply</string>
445− <string name="branch_session">Branch new session</string>
449+ <string name="branch_session">Start new session from here</string>
446− <string name="branch_created">Branched to a new session</string>
450+ <string name="branch_created">Started a new session from this message</string>
447− <string name="branch_failed">Couldn\'t branch session</string>
451+ <string name="branch_failed">Couldn\'t start a new session</string>
448
452
449 <!-- Offline outbox -->
453 <!-- Offline outbox -->
450 <string name="outbox_queued">Offline — message queued, will send when reconnected</string>
454 <string name="outbox_queued">Offline — message queued, will send when reconnected</string>
@@ -565,6 +569,10 @@
565
569
566 <!-- File view -->
570 <!-- File view -->
567 <string name="switch_to_raw_to_search">Switch to Raw to search</string>
571 <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>
568
576
569 <!-- Settings backup/restore -->
577 <!-- Settings backup/restore -->
570 <string name="import_confirm_title">Replace all data?</string>
578 <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 @@
2<!--
2<!--
3 The Compose theme (OpencodeTheme) takes over once the UI inflates; this XML
3 The Compose theme (OpencodeTheme) takes over once the UI inflates; this XML
4 theme only governs the window before Compose and hosts the SplashScreen API.
4 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.
5-->
10-->
6<resources>
11<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>
8
15
9 <!-- Shown by core-splashscreen on cold start, then swapped for the theme above. -->
16 <!-- Shown by core-splashscreen on cold start, then swapped for the theme above. -->
10 <style name="Theme.OpencodeAndroid.Splash" parent="Theme.SplashScreen">
17 <style name="Theme.OpencodeAndroid.Splash" parent="Theme.SplashScreen">