summaryrefslogtreecommitdiff
path: root/android/app/src
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-15 00:12:48 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-16 02:44:52 +0000
commit7c7d9246e778d0e9eeb8fce4894e1bb0923b624e (patch)
treec86f0d6213e52f31bf6e91526f9267ef85c5f6f1 /android/app/src
parentffc56608ace1a80f020c53add081514a5428252d (diff)
feat(widget): add description linkification for URLs and phone numbers
Diffstat (limited to 'android/app/src')
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/LinkifiedText.kt92
-rw-r--r--android/app/src/test/java/org/terst/doot/widget/ui/LinkifiedTextTest.kt44
2 files changed, 136 insertions, 0 deletions
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<TextLink> {
+ val links = mutableListOf<TextLink>()
+ 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<TextLayoutResult?>(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)
+ }
+}