git.iko.soy/opencode-android
on master help sign in
Batch of UI/UX improvements: add a TalkBack live region to streaming markdown, theme the home-screen widget for light/dark, fix the typing-row elapsed timer resetting on recycle by driving it from the VM, highlight in-conversation search matches inside message text, intercept markdown link taps with a scheme-validated open/copy dialog, and localize the destructive-command a11y state
0d4b9d8iko
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+13−6
@@ -165,6 +165,7 @@ import soy.iko.opencode.ui.components.ConnectionBanner
165import soy.iko.opencode.ui.components.DiffView
165import soy.iko.opencode.ui.components.DiffView
166import soy.iko.opencode.ui.components.PaletteAction
166import soy.iko.opencode.ui.components.PaletteAction
167import soy.iko.opencode.ui.components.LocalReducedMotion
167import soy.iko.opencode.ui.components.LocalReducedMotion
168+import soy.iko.opencode.ui.components.LocalSearchHighlight
168import soy.iko.opencode.ui.components.reducedMotionAnimateItem
169import soy.iko.opencode.ui.components.reducedMotionAnimateItem
169import soy.iko.opencode.ui.components.copyToClipboard
170import soy.iko.opencode.ui.components.copyToClipboard
170import soy.iko.opencode.ui.components.LocalRelativeTimeTick
171import soy.iko.opencode.ui.components.LocalRelativeTimeTick
@@ -1138,6 +1139,11 @@ fun ChatScreen(
1138 val tts = rememberTtsController()
1139 val tts = rememberTtsController()
1139 val speakingMessageId by tts.speakingId
1140 val speakingMessageId by tts.speakingId
1140 val ttsState by tts.state
1141 val ttsState by tts.state
1142+ // Provide the in-conversation search query to the markdown renderer so matching
1143+ // text spans are highlighted in-place (in addition to the focused-row highlight
1144+ // above). Scoped to the list so cleared search stops highlighting immediately.
1145+ val activeSearchQuery = searchQuery.takeIf { searchActive && it.isNotEmpty() }
1146+ CompositionLocalProvider(LocalSearchHighlight provides activeSearchQuery) {
1141 LazyColumn(
1147 LazyColumn(
1142 state = listState,
1148 state = listState,
1143 modifier = Modifier.fillMaxSize(),
1149 modifier = Modifier.fillMaxSize(),
@@ -1325,13 +1331,13 @@ fun ChatScreen(
1325 if (running) {
1331 if (running) {
1326 item(key = "__typing") {
1332 item(key = "__typing") {
1327 val workingText = stringResource(R.string.working)
1333 val workingText = stringResource(R.string.working)
1328− // Elapsed-since-run chip: the typing row only composes while a run is
1334+ // Elapsed-since-run chip driven by the VM's runStartMs so the timer
1329− // active, so anchoring the timer to first composition approximates the
1335+ // survives LazyColumn recycling — a local remember reset to 0:00 when
1330− // run start and gives the user a sense of how long the agent has been
1336+ // the row was disposed and scrolled back into view. The VM stamps the
1331− // working — distinguishing a healthy long run from a hung one. Updates
1337+ // start on the real false→true transition (send/run/init), not on
1338+ // SSE-reconnect relight, so reconnects don't restart the clock. Updates
1332 // once per second; the value is decorative (the a11y label is the row's).
1339 // once per second; the value is decorative (the a11y label is the row's).
1333− var startMs by remember { mutableLongStateOf(0L) }
1340+ val startMs by vm.runStartMs.collectAsStateWithLifecycle()
1334− LaunchedEffect(Unit) { startMs = System.currentTimeMillis() }
1335 val elapsedMs by produceState(0L, startMs) {
1341 val elapsedMs by produceState(0L, startMs) {
1336 if (startMs == 0L) return@produceState
1342 if (startMs == 0L) return@produceState
1337 while (true) {
1343 while (true) {
@@ -1400,6 +1406,7 @@ fun ChatScreen(
1400 }
1406 }
1401 }
1407 }
1402 }
1408 }
1409+ }
1403 // Jump-to-latest affordance when the user has scrolled away during a stream.
1410 // Jump-to-latest affordance when the user has scrolled away during a stream.
1404 val fabMotion = rememberVisibilityTransitions()
1411 val fabMotion = rememberVisibilityTransitions()
1405 AnimatedVisibility(
1412 AnimatedVisibility(
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+27−5
@@ -329,6 +329,28 @@ class ChatViewModel(
329 private val _running = MutableStateFlow(false)
329 private val _running = MutableStateFlow(false)
330 val running: StateFlow<Boolean> = _running.asStateFlow()
330 val running: StateFlow<Boolean> = _running.asStateFlow()
331
331
332+ /**
333+ * Wall-clock millis at which the current run started (0L when no run is active). Drives
334+ * the typing row's elapsed timer so it survives LazyColumn recycling — a local `remember`
335+ * in the row resets to 0:00 when the row is disposed and scrolled back into view. Stamped
336+ * only on a real false→[beginRun] transition (send/run/init), not on SSE-reconnect relight
337+ * (which continues an existing run and should keep its original elapsed time).
338+ */
339+ private val _runStartMs = MutableStateFlow(0L)
340+ val runStartMs: StateFlow<Long> = _runStartMs.asStateFlow()
341+
342+ /**
343+ * Atomically transition `_running` false→true and stamp the run-start timestamp. Returns
344+ * false (with no state change) when a run is already in flight, so callers can surface a
345+ * "busy" message instead of silently dropping the action. Centralizes the transition so
346+ * every run-start path captures [runStartMs] consistently.
347+ */
348+ private fun beginRun(): Boolean {
349+ if (!_running.compareAndSet(false, true)) return false
350+ _runStartMs.value = System.currentTimeMillis()
351+ return true
352+ }
353+
332 /** True once the current run ended via SessionIdle/SessionError, until the next run starts
354 /** True once the current run ended via SessionIdle/SessionError, until the next run starts
333 * (a send) or an SSE reconnect. Gates the run-indicator re-light below: isRunActivity
355 * (a send) or an SSE reconnect. Gates the run-indicator re-light below: isRunActivity
334 * returns true for ANY message.part.updated, so without this a trailing/replayed part
356 * returns true for ANY message.part.updated, so without this a trailing/replayed part
@@ -931,7 +953,7 @@ class ChatViewModel(
931 if (conn == null || !container.isOnline.value) {
953 if (conn == null || !container.isOnline.value) {
932 return enqueueOffline(trimmed, attachments)
954 return enqueueOffline(trimmed, attachments)
933 }
955 }
934− if (!_running.compareAndSet(false, true)) return false
956+ if (!beginRun()) return false
935 // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
957 // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
936 // indicator lit (and a later SessionIdle re-closes it).
958 // indicator lit (and a later SessionIdle re-closes it).
937 runEndedByIdle = false
959 runEndedByIdle = false
@@ -1202,7 +1224,7 @@ class ChatViewModel(
1202 val conn = connection ?: return
1224 val conn = connection ?: return
1203 // A run is already in flight — surface feedback instead of silently dropping the
1225 // A run is already in flight — surface feedback instead of silently dropping the
1204 // command (the picker stays openable during a run and dismisses on select).
1226 // command (the picker stays openable during a run and dismisses on select).
1205− if (!_running.compareAndSet(false, true)) {
1227+ if (!beginRun()) {
1206 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1228 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1207 return
1229 return
1208 }
1230 }
@@ -1416,7 +1438,7 @@ class ChatViewModel(
1416 _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
1438 _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
1417 return
1439 return
1418 }
1440 }
1419− if (!_running.compareAndSet(false, true)) {
1441+ if (!beginRun()) {
1420 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1442 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1421 return
1443 return
1422 }
1444 }
@@ -1435,7 +1457,7 @@ class ChatViewModel(
1435 /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
1457 /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
1436 fun initProject() {
1458 fun initProject() {
1437 val conn = connection ?: return
1459 val conn = connection ?: return
1438− if (!_running.compareAndSet(false, true)) {
1460+ if (!beginRun()) {
1439 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1461 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1440 return
1462 return
1441 }
1463 }
@@ -1461,7 +1483,7 @@ class ChatViewModel(
1461 ?: _agents.value.firstOrNull { it.isPrimary }?.name
1483 ?: _agents.value.firstOrNull { it.isPrimary }?.name
1462 ?: _agents.value.firstOrNull()?.name
1484 ?: _agents.value.firstOrNull()?.name
1463 ?: DEFAULT_SHELL_AGENT
1485 ?: DEFAULT_SHELL_AGENT
1464− if (!_running.compareAndSet(false, true)) {
1486+ if (!beginRun()) {
1465 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1487 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1466 return
1488 return
1467 }
1489 }
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+4−1
@@ -119,6 +119,9 @@ fun CommandPickerSheet(
119 var query by rememberSaveable { mutableStateOf("") }
119 var query by rememberSaveable { mutableStateOf("") }
120 val keyboardController = LocalSoftwareKeyboardController.current
120 val keyboardController = LocalSoftwareKeyboardController.current
121 val haptics = LocalHapticFeedback.current
121 val haptics = LocalHapticFeedback.current
122+ // Resolved once (not per-row): the semantics lambda below isn't a composable scope,
123+ // so stringResource can't be called inside it.
124+ val destructiveStateLabel = stringResource(R.string.command_confirmation_required)
122 // Auto-focus the search field (and raise the keyboard) when the sheet opens, but skip
125 // Auto-focus the search field (and raise the keyboard) when the sheet opens, but skip
123 // it for a short list that's faster to scan by eye than to filter.
126 // it for a short list that's faster to scan by eye than to filter.
124 val searchFocus = remember { FocusRequester() }
127 val searchFocus = remember { FocusRequester() }
@@ -183,7 +186,7 @@ fun CommandPickerSheet(
183 }
186 }
184 }
187 }
185 .semantics {
188 .semantics {
186− if (destructive) stateDescription = "destructive"
189+ if (destructive) stateDescription = destructiveStateLabel
187 }
190 }
188 .padding(horizontal = 24.dp, vertical = 12.dp),
191 .padding(horizontal = 24.dp, vertical = 12.dp),
189 ) {
192 ) {
app/src/main/java/soy/iko/opencode/ui/components/ChatTextPrefs.kt+3
@@ -9,12 +9,15 @@ import androidx.compose.material3.Typography
9 *
9 *
10 * - [LocalChatTextScale]: a multiplier applied to chat/message/code font sizes.
10 * - [LocalChatTextScale]: a multiplier applied to chat/message/code font sizes.
11 * - [LocalCodeWrap]: whether code blocks soft-wrap long lines (vs. horizontal scroll).
11 * - [LocalCodeWrap]: whether code blocks soft-wrap long lines (vs. horizontal scroll).
12+ * - [LocalSearchHighlight]: an in-conversation search query; when non-null, matching
13+ * spans in completed (non-streaming) markdown are highlighted in-place.
12 *
14 *
13 * Provided once near the app root from the persisted [soy.iko.opencode.data.repo.SettingsStore]
15 * Provided once near the app root from the persisted [soy.iko.opencode.data.repo.SettingsStore]
14 * values. Defaults keep the design behavior when no provider is present (e.g. previews/tests).
16 * values. Defaults keep the design behavior when no provider is present (e.g. previews/tests).
15 */
17 */
16val LocalChatTextScale = compositionLocalOf { 1f }
18val LocalChatTextScale = compositionLocalOf { 1f }
17val LocalCodeWrap = compositionLocalOf { false }
19val LocalCodeWrap = compositionLocalOf { false }
20+val LocalSearchHighlight = compositionLocalOf<String?> { null }
18
21
19/**
22/**
20 * Return a copy of this [Typography] with every text role's font size and line height
23 * Return a copy of this [Typography] with every text role's font size and line height
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+183−11
@@ -28,6 +28,7 @@ import androidx.compose.material.icons.filled.Check
28import androidx.compose.material.icons.filled.ContentCopy
28import androidx.compose.material.icons.filled.ContentCopy
29import androidx.compose.material.icons.filled.ExpandLess
29import androidx.compose.material.icons.filled.ExpandLess
30import androidx.compose.material.icons.filled.ExpandMore
30import androidx.compose.material.icons.filled.ExpandMore
31+import androidx.compose.material3.AlertDialog
31import androidx.compose.material3.Icon
32import androidx.compose.material3.Icon
32import androidx.compose.material3.IconButton
33import androidx.compose.material3.IconButton
33import androidx.compose.material3.MaterialTheme
34import androidx.compose.material3.MaterialTheme
@@ -36,6 +37,7 @@ import androidx.compose.material3.Text
36import androidx.compose.material3.TextButton
37import androidx.compose.material3.TextButton
37import androidx.compose.material3.Typography
38import androidx.compose.material3.Typography
38import androidx.compose.runtime.Composable
39import androidx.compose.runtime.Composable
40+import androidx.compose.runtime.CompositionLocalProvider
39import androidx.compose.runtime.LaunchedEffect
41import androidx.compose.runtime.LaunchedEffect
40import androidx.compose.runtime.getValue
42import androidx.compose.runtime.getValue
41import androidx.compose.runtime.mutableStateOf
43import androidx.compose.runtime.mutableStateOf
@@ -47,19 +49,28 @@ import androidx.compose.runtime.snapshotFlow
47import androidx.compose.ui.Alignment
49import androidx.compose.ui.Alignment
48import androidx.compose.ui.Modifier
50import androidx.compose.ui.Modifier
49import androidx.compose.ui.draw.clip
51import androidx.compose.ui.draw.clip
52+import androidx.compose.ui.graphics.Color
50import androidx.compose.ui.platform.LocalContext
53import androidx.compose.ui.platform.LocalContext
51import androidx.compose.ui.platform.LocalHapticFeedback
54import androidx.compose.ui.platform.LocalHapticFeedback
55+import androidx.compose.ui.platform.LocalUriHandler
56+import androidx.compose.ui.platform.UriHandler
52import androidx.compose.ui.res.pluralStringResource
57import androidx.compose.ui.res.pluralStringResource
53import androidx.compose.ui.res.stringResource
58import androidx.compose.ui.res.stringResource
59+import androidx.compose.ui.semantics.LiveRegionMode
60+import androidx.compose.ui.semantics.liveRegion
61+import androidx.compose.ui.semantics.semantics
54import androidx.compose.ui.text.AnnotatedString
62import androidx.compose.ui.text.AnnotatedString
63+import androidx.compose.ui.text.SpanStyle
55import androidx.compose.ui.text.TextStyle
64import androidx.compose.ui.text.TextStyle
56import androidx.compose.ui.text.font.FontFamily
65import androidx.compose.ui.text.font.FontFamily
57import androidx.compose.ui.unit.dp
66import androidx.compose.ui.unit.dp
58import kotlinx.coroutines.flow.conflate
67import kotlinx.coroutines.flow.conflate
59import kotlinx.coroutines.delay
68import kotlinx.coroutines.delay
69+import com.mikepenz.markdown.compose.LocalMarkdownAnnotator
60import com.mikepenz.markdown.compose.components.MarkdownComponentModel
70import com.mikepenz.markdown.compose.components.MarkdownComponentModel
61import com.mikepenz.markdown.compose.components.markdownComponents
71import com.mikepenz.markdown.compose.components.markdownComponents
62import com.mikepenz.markdown.m3.Markdown
72import com.mikepenz.markdown.m3.Markdown
73+import com.mikepenz.markdown.model.DefaultMarkdownAnnotator
63import org.intellij.markdown.MarkdownElementTypes
74import org.intellij.markdown.MarkdownElementTypes
64import org.intellij.markdown.ast.ASTNode
75import org.intellij.markdown.ast.ASTNode
65import org.intellij.markdown.ast.getTextInNode
76import org.intellij.markdown.ast.getTextInNode
@@ -146,6 +157,54 @@ private fun MarkdownBody(
146 )
157 )
147 }
158 }
148 }
159 }
160+ val context = LocalContext.current
161+ // Intercept link taps so the scheme can be validated before the library fires an
162+ // implicit ACTION_VIEW. A hallucinated javascript:/file:/intent: URL must not launch
163+ // an unexpected intent chooser. Taps are routed to a dialog offering Open (only for
164+ // http/https/mailto) and Copy link. The default handler is captured here and invoked
165+ // from the dialog's Open action.
166+ val defaultUriHandler = LocalUriHandler.current
167+ var pendingLink by remember { mutableStateOf<String?>(null) }
168+ val safeUriHandler = remember {
169+ object : UriHandler {
170+ override fun openUri(uri: String) {
171+ pendingLink = uri
172+ }
173+ }
174+ }
175+ // In-conversation search highlighting: when LocalSearchHighlight carries a query,
176+ // wrap the default MarkdownAnnotator to add a translucent background span over each
177+ // case-insensitive match in the rendered text. Skipped while streaming so the
178+ // throttled re-render isn't invalidated on every token. Offsets are derived from the
179+ // builder's actual rendered output (not the raw markdown), so highlights land on the
180+ // right characters even inside formatted spans.
181+ val searchQuery = LocalSearchHighlight.current?.takeIf { it.isNotBlank() }
182+ val highlightColor = MaterialTheme.colorScheme.secondaryContainer
183+ val baseAnnotator = LocalMarkdownAnnotator.current
184+ val highlightAnnotator = if (searchQuery != null && !streaming) {
185+ remember(baseAnnotator, searchQuery, highlightColor) {
186+ DefaultMarkdownAnnotator(
187+ annotate = { text, node ->
188+ // `this` is the AnnotatedString.Builder being filled for this node.
189+ val startLen = this.length
190+ val handled = baseAnnotator.annotate?.invoke(this, text, node) ?: false
191+ val endLen = this.length
192+ if (endLen > startLen) {
193+ addSearchHighlights(
194+ this,
195+ this.toString().substring(startLen, endLen),
196+ startLen,
197+ searchQuery,
198+ highlightColor,
199+ )
200+ }
201+ handled
202+ },
203+ )
204+ }
205+ } else {
206+ null
207+ }
149 if (streaming) {
208 if (streaming) {
150 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
209 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
151 val markdownState = rememberUpdatedState(markdown)
210 val markdownState = rememberUpdatedState(markdown)
@@ -182,24 +241,137 @@ private fun MarkdownBody(
182 // selection handles would flicker) on each throttle commit. Partial selection
241 // selection handles would flicker) on each throttle commit. Partial selection
183 // becomes available once the stream finishes; the per-message Copy button in
242 // becomes available once the stream finishes; the per-message Copy button in
184 // MessageBubble copies all TextParts at any time.
243 // MessageBubble copies all TextParts at any time.
244+ // A polite live region announces streaming content to TalkBack users. The throttle
245+ // (50ms) plus TalkBack's own polite-queue coalescing keeps announcements from
246+ // interrupting on every token; the reader hears the reply grow in measured chunks.
247+ CompositionLocalProvider(LocalUriHandler provides safeUriHandler) {
185− Markdown(
248+ Markdown(
186− content = renderedContent,
249+ content = renderedContent,
187− modifier = modifier,
250+ modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
188− components = components,
251+ components = components,
189− )
252+ )
253+ }
190 // A blinking caret at the end of a streaming reply gives a "live" typewriter feel.
254 // A blinking caret at the end of a streaming reply gives a "live" typewriter feel.
191 // Skipped under reduced motion (where blinking is discouraged). It's a tiny separate
255 // Skipped under reduced motion (where blinking is discouraged). It's a tiny separate
192 // element so the markdown AST isn't re-parsed to toggle it.
256 // element so the markdown AST isn't re-parsed to toggle it.
193 if (!LocalReducedMotion.current) StreamingCaret()
257 if (!LocalReducedMotion.current) StreamingCaret()
194 } else {
258 } else {
259+ val content: @Composable () -> Unit = {
195− SelectionContainer {
260+ SelectionContainer {
196− Markdown(
261+ Markdown(
197− content = markdown,
262+ content = markdown,
198− modifier = modifier,
263+ modifier = modifier,
199− components = components,
264+ components = components,
200− )
265+ )
266+ }
267+ }
268+ CompositionLocalProvider(LocalUriHandler provides safeUriHandler) {
269+ if (highlightAnnotator != null) {
270+ CompositionLocalProvider(LocalMarkdownAnnotator provides highlightAnnotator) {
271+ content()
272+ }
273+ } else {
274+ content()
275+ }
201 }
276 }
202 }
277 }
278+ // Open/copy dialog for the most recently tapped markdown link. Offer Open only for
279+ // schemes the app is willing to hand to the system (http/https/mailto); for anything
280+ // else, copy is the only escape so a model hallucination can't fire an unexpected intent.
281+ pendingLink?.let { url ->
282+ LinkActionDialog(
283+ url = url,
284+ canOpen = isSafeLinkScheme(url),
285+ onDismiss = { pendingLink = null },
286+ onOpen = {
287+ pendingLink = null
288+ runCatching { defaultUriHandler.openUri(url) }
289+ },
290+ onCopy = {
291+ pendingLink = null
292+ copyToClipboard(context, context.getString(R.string.copy_link), url)
293+ },
294+ )
295+ }
296+}
297+
298+/**
299+ * Adds a translucent background span over each case-insensitive occurrence of [query] found
300+ * in [chunk], mapping the chunk-relative offsets into the builder via [baseOffset]. Called
301+ * from the search-highlight annotator wrapper; [chunk] is the text the default annotator
302+ * appended to the builder for a single markdown node, so the offsets line up with what the
303+ * user actually sees (markdown syntax already stripped).
304+ */
305+private fun addSearchHighlights(
306+ builder: AnnotatedString.Builder,
307+ chunk: String,
308+ baseOffset: Int,
309+ query: String,
310+ color: Color,
311+) {
312+ val q = query.trim()
313+ if (q.isEmpty()) return
314+ var idx = chunk.indexOf(q, ignoreCase = true)
315+ while (idx >= 0) {
316+ builder.addStyle(
317+ SpanStyle(background = color),
318+ baseOffset + idx,
319+ baseOffset + idx + q.length,
320+ )
321+ idx = chunk.indexOf(q, idx + q.length, ignoreCase = true)
322+ }
323+}
324+
325+/** Schemes the app is willing to forward to the system's URI handler from a markdown link. */
326+private fun isSafeLinkScheme(uri: String): Boolean {
327+ val scheme = uri.substringBefore(':', "").lowercase()
328+ return scheme == "http" || scheme == "https" || scheme == "mailto"
329+}
330+
331+/**
332+ * Dialog shown when the user taps a markdown link. For http/https/mailto URIs, offers Open
333+ * (via the platform default handler) and Copy link. For any other scheme, only Copy is
334+ * offered (with an "unsupported link type" note) so a hallucinated javascript:/file: URL
335+ * can't launch an unexpected intent.
336+ */
337+@Composable
338+private fun LinkActionDialog(
339+ url: String,
340+ canOpen: Boolean,
341+ onDismiss: () -> Unit,
342+ onOpen: () -> Unit,
343+ onCopy: () -> Unit,
344+) {
345+ AlertDialog(
346+ onDismissRequest = onDismiss,
347+ title = {
348+ Text(
349+ text = url,
350+ style = MaterialTheme.typography.bodySmall,
351+ color = MaterialTheme.colorScheme.onSurfaceVariant,
352+ )
353+ },
354+ text = {
355+ if (!canOpen) {
356+ Text(
357+ stringResource(R.string.link_unsupported),
358+ style = MaterialTheme.typography.bodyMedium,
359+ color = MaterialTheme.colorScheme.error,
360+ )
361+ }
362+ },
363+ confirmButton = {
364+ androidx.compose.foundation.layout.Row {
365+ TextButton(onClick = onCopy) { Text(stringResource(R.string.copy_link)) }
366+ if (canOpen) {
367+ TextButton(onClick = onOpen) { Text(stringResource(R.string.open_link)) }
368+ }
369+ }
370+ },
371+ dismissButton = {
372+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
373+ },
374+ )
203}
375}
204
376
205/**
377/**
app/src/main/res/drawable/widget_background.xml+4−2
@@ -1,6 +1,8 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2−<!-- Rounded, slightly translucent Tokyo Night surface for the sessions widget. -->
2+<!-- Rounded, slightly translucent surface for the sessions widget. References a color
3+ resource so it follows the light/dark theme (values/ vs values-night/) instead of
4+ always rendering the dark Tokyo Night surface. -->
3<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
5<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
4− <solid android:color="#EE1F2335" />
6+ <solid android:color="@color/widget_background" />
5 <corners android:radius="16dp" />
7 <corners android:radius="16dp" />
6</shape>
8</shape>
app/src/main/res/layout/widget_session_item.xml+1−1
@@ -16,6 +16,6 @@
16 android:layout_height="wrap_content"
16 android:layout_height="wrap_content"
17 android:maxLines="1"
17 android:maxLines="1"
18 android:ellipsize="end"
18 android:ellipsize="end"
19− android:textColor="#C0CAF5"
19+ android:textColor="@color/widget_on_background"
20 android:textSize="14sp" />
20 android:textSize="14sp" />
21</LinearLayout>
21</LinearLayout>
app/src/main/res/layout/widget_sessions.xml+4−4
@@ -21,7 +21,7 @@
21 android:layout_height="wrap_content"
21 android:layout_height="wrap_content"
22 android:layout_weight="1"
22 android:layout_weight="1"
23 android:text="@string/widget_title"
23 android:text="@string/widget_title"
24− android:textColor="#C0CAF5"
24+ android:textColor="@color/widget_on_background"
25 android:textSize="15sp"
25 android:textSize="15sp"
26 android:textStyle="bold" />
26 android:textStyle="bold" />
27
27
@@ -30,7 +30,7 @@
30 android:layout_width="wrap_content"
30 android:layout_width="wrap_content"
31 android:layout_height="wrap_content"
31 android:layout_height="wrap_content"
32 android:text="@string/widget_new_session"
32 android:text="@string/widget_new_session"
33− android:textColor="#7AA2F7"
33+ android:textColor="@color/widget_primary"
34 android:textSize="13sp"
34 android:textSize="13sp"
35 android:textStyle="bold"
35 android:textStyle="bold"
36 android:paddingStart="12dp"
36 android:paddingStart="12dp"
@@ -48,7 +48,7 @@
48 android:id="@+id/widget_list"
48 android:id="@+id/widget_list"
49 android:layout_width="match_parent"
49 android:layout_width="match_parent"
50 android:layout_height="match_parent"
50 android:layout_height="match_parent"
51− android:divider="#33565F89"
51+ android:divider="@color/widget_divider"
52 android:dividerHeight="1dp" />
52 android:dividerHeight="1dp" />
53
53
54 <TextView
54 <TextView
@@ -57,7 +57,7 @@
57 android:layout_height="match_parent"
57 android:layout_height="match_parent"
58 android:gravity="center"
58 android:gravity="center"
59 android:text="@string/widget_empty"
59 android:text="@string/widget_empty"
60− android:textColor="#9AA5CE"
60+ android:textColor="@color/widget_on_surface_variant"
61 android:textSize="13sp"
61 android:textSize="13sp"
62 android:visibility="gone" />
62 android:visibility="gone" />
63 </FrameLayout>
63 </FrameLayout>
app/src/main/res/values-es/strings.xml+5
@@ -643,6 +643,11 @@
643 <string name="shortcut_close">Cerrar diálogo / paleta</string>
643 <string name="shortcut_close">Cerrar diálogo / paleta</string>
644 <string name="open_externally">Abrir externamente</string>
644 <string name="open_externally">Abrir externamente</string>
645 <string name="open_externally_failed">No hay ninguna app para abrir este archivo</string>
645 <string name="open_externally_failed">No hay ninguna app para abrir este archivo</string>
646+ <!-- Markdown links -->
647+ <string name="open_link">Abrir</string>
648+ <string name="copy_link">Copiar enlace</string>
649+ <string name="link_unsupported">Tipo de enlace no admitido</string>
650+ <string name="command_confirmation_required">Confirmación requerida</string>
646 <string name="sort_by">Ordenar por</string>
651 <string name="sort_by">Ordenar por</string>
647 <string name="sort_size">Tamaño</string>
652 <string name="sort_size">Tamaño</string>
648 <string name="sort_modified">Modificación</string>
653 <string name="sort_modified">Modificación</string>
app/src/main/res/values-night/colors.xml+7
@@ -5,4 +5,11 @@
5 <color name="ic_launcher_background">#34548A</color>
5 <color name="ic_launcher_background">#34548A</color>
6 <!-- Matches the Compose dark colorScheme background (#1A1B26). -->
6 <!-- Matches the Compose dark colorScheme background (#1A1B26). -->
7 <color name="window_background">#1A1B26</color>
7 <color name="window_background">#1A1B26</color>
8+
9+ <!-- Home-screen widget palette (dark). Mirrors the Tokyo Night dark colorScheme. -->
10+ <color name="widget_on_background">#C0CAF5</color>
11+ <color name="widget_primary">#7AA2F7</color>
12+ <color name="widget_on_surface_variant">#9AA5CE</color>
13+ <color name="widget_divider">#33565F89</color>
14+ <color name="widget_background">#EE1F2335</color>
8</resources>
15</resources>
app/src/main/res/values/colors.xml+9
@@ -5,4 +5,13 @@
5 <!-- Matches the Compose light colorScheme background so the pre-Compose window
5 <!-- Matches the Compose light colorScheme background so the pre-Compose window
6 doesn't flash a different shade than the UI paints. -->
6 doesn't flash a different shade than the UI paints. -->
7 <color name="window_background">#F6F7FB</color>
7 <color name="window_background">#F6F7FB</color>
8+
9+ <!-- Home-screen widget palette (light). Mirrors the Tokyo Night Light colorScheme so
10+ the widget matches the app's theme instead of always showing the dark palette.
11+ values-night/colors.xml carries the dark counterparts. -->
12+ <color name="widget_on_background">#1A1B26</color>
13+ <color name="widget_primary">#34548A</color>
14+ <color name="widget_on_surface_variant">#444B6E</color>
15+ <color name="widget_divider">#33747DAA</color>
16+ <color name="widget_background">#EEF1F3FA</color>
8</resources>
17</resources>
app/src/main/res/values/strings.xml+5
@@ -644,6 +644,11 @@
644 <string name="shortcut_close">Close dialog / palette</string>
644 <string name="shortcut_close">Close dialog / palette</string>
645 <string name="open_externally">Open externally</string>
645 <string name="open_externally">Open externally</string>
646 <string name="open_externally_failed">No app available to open this file</string>
646 <string name="open_externally_failed">No app available to open this file</string>
647+ <!-- Markdown links -->
648+ <string name="open_link">Open</string>
649+ <string name="copy_link">Copy link</string>
650+ <string name="link_unsupported">Unsupported link type</string>
651+ <string name="command_confirmation_required">Confirmation required</string>
647 <string name="sort_by">Sort by</string>
652 <string name="sort_by">Sort by</string>
648 <string name="sort_size">Size</string>
653 <string name="sort_size">Size</string>
649 <string name="sort_modified">Modified</string>
654 <string name="sort_modified">Modified</string>