1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
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 }
)
}
|