From 7c7d9246e778d0e9eeb8fce4894e1bb0923b624e Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 15 Jul 2026 00:12:48 +0000 Subject: feat(widget): add description linkification for URLs and phone numbers --- .../java/org/terst/doot/widget/ui/LinkifiedText.kt | 92 ++++++++++++++++++++++ .../org/terst/doot/widget/ui/LinkifiedTextTest.kt | 44 +++++++++++ 2 files changed, 136 insertions(+) create mode 100644 android/app/src/main/java/org/terst/doot/widget/ui/LinkifiedText.kt create mode 100644 android/app/src/test/java/org/terst/doot/widget/ui/LinkifiedTextTest.kt (limited to 'android') diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/LinkifiedText.kt b/android/app/src/main/java/org/terst/doot/widget/ui/LinkifiedText.kt new file mode 100644 index 0000000..2e8a67a --- /dev/null +++ b/android/app/src/main/java/org/terst/doot/widget/ui/LinkifiedText.kt @@ -0,0 +1,92 @@ +package org.terst.doot.widget.ui + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.sp + +enum class LinkKind { URL, PHONE } + +data class TextLink(val range: IntRange, val kind: LinkKind, val target: String) + +private val URL_REGEX = Regex("""https?://[^\s<>"']+""") +private val PHONE_REGEX = Regex("""\+?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}""") + +/** + * Finds URL and phone-number spans in text. Phone matches that overlap an + * already-found URL span are dropped (a URL's digits could coincidentally + * match the phone pattern; the URL match wins). Intentionally simple -- + * US-style phone numbers, not a full i18n phone-number parser. + */ +internal fun findLinks(text: String): List { + val links = mutableListOf() + for (match in URL_REGEX.findAll(text)) { + links.add(TextLink(match.range, LinkKind.URL, match.value)) + } + for (match in PHONE_REGEX.findAll(text)) { + val overlaps = links.any { it.range.first <= match.range.last && match.range.first <= it.range.last } + if (!overlaps) { + links.add(TextLink(match.range, LinkKind.PHONE, match.value)) + } + } + return links.sortedBy { it.range.first } +} + +/** + * Renders text with URLs and phone numbers underlined and tappable. + * Doesn't use Compose's LinkAnnotation API (unconfirmed whether it's fully + * wired into Text's click handling at this project's resolved Compose UI + * version, 1.6.1) -- instead resolves taps manually via + * TextLayoutResult.getOffsetForPosition, which works on any Compose version. + */ +@Composable +fun LinkifiedText( + text: String, + modifier: Modifier = Modifier, + onOpenUrl: (String) -> Unit, + onDialPhone: (String) -> Unit +) { + val links = remember(text) { findLinks(text) } + val annotated = remember(text, links) { + buildAnnotatedString { + append(text) + for (link in links) { + addStyle( + SpanStyle(color = Color(0xFF60A5FA), textDecoration = TextDecoration.Underline), + link.range.first, + link.range.last + 1 + ) + } + } + } + var layoutResult by remember { mutableStateOf(null) } + BasicText( + text = annotated, + modifier = modifier.pointerInput(links) { + detectTapGestures { offset -> + val layout = layoutResult ?: return@detectTapGestures + val charOffset = layout.getOffsetForPosition(offset) + links.firstOrNull { charOffset in it.range }?.let { link -> + when (link.kind) { + LinkKind.URL -> onOpenUrl(link.target) + LinkKind.PHONE -> onDialPhone(link.target) + } + } + } + }, + style = TextStyle(color = Color.White.copy(alpha = 0.85f), fontSize = 14.sp), + onTextLayout = { layoutResult = it } + ) +} diff --git a/android/app/src/test/java/org/terst/doot/widget/ui/LinkifiedTextTest.kt b/android/app/src/test/java/org/terst/doot/widget/ui/LinkifiedTextTest.kt new file mode 100644 index 0000000..b3cfa5b --- /dev/null +++ b/android/app/src/test/java/org/terst/doot/widget/ui/LinkifiedTextTest.kt @@ -0,0 +1,44 @@ +package org.terst.doot.widget.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LinkifiedTextTest { + + @Test + fun `findLinks finds a plain URL`() { + val links = findLinks("Check out https://example.com/path for details") + assertEquals(1, links.size) + assertEquals(LinkKind.URL, links[0].kind) + assertEquals("https://example.com/path", links[0].target) + } + + @Test + fun `findLinks finds a phone number`() { + val links = findLinks("Call me at 555-123-4567 tomorrow") + assertEquals(1, links.size) + assertEquals(LinkKind.PHONE, links[0].kind) + assertEquals("555-123-4567", links[0].target) + } + + @Test + fun `findLinks finds both a URL and a phone number in the same text`() { + val links = findLinks("See https://example.com or call 555-123-4567") + assertEquals(2, links.size) + assertEquals(LinkKind.URL, links[0].kind) + assertEquals(LinkKind.PHONE, links[1].kind) + } + + @Test + fun `findLinks returns empty list for plain text`() { + val links = findLinks("Just a regular description with no links.") + assertEquals(0, links.size) + } + + @Test + fun `findLinks does not double-count phone-like digits inside a URL`() { + val links = findLinks("https://example.com/555-123-4567") + assertEquals(1, links.size) + assertEquals(LinkKind.URL, links[0].kind) + } +} -- cgit v1.2.3