A cheap attempt at a native Bluesky client for Android

ComposeView: Highlight URLs, hashtags, and mentions

Highlight URLs, hashtags, and mentions in the compose text field with the primary theme color.

This changes the `OutlinedTextField` from using `TextFieldState` to a `MutableState<TextFieldValue>` in order to support `AnnotatedString` for styling.

+47 -15
+47 -15
app/src/main/java/industries/geesawra/monarch/ComposeView.kt
··· 2 2 3 3 import android.content.Context 4 4 import android.net.Uri 5 + import android.webkit.URLUtil 5 6 import android.widget.Toast 6 7 import androidx.activity.compose.ManagedActivityResultLauncher 7 8 import androidx.activity.compose.rememberLauncherForActivityResult ··· 32 33 import androidx.compose.foundation.layout.size 33 34 import androidx.compose.foundation.layout.windowInsetsPadding 34 35 import androidx.compose.foundation.text.KeyboardOptions 35 - import androidx.compose.foundation.text.input.TextFieldLineLimits 36 - import androidx.compose.foundation.text.input.clearText 37 - import androidx.compose.foundation.text.input.rememberTextFieldState 38 36 import androidx.compose.foundation.verticalScroll 39 37 import androidx.compose.material.icons.Icons 40 38 import androidx.compose.material.icons.automirrored.filled.Send ··· 66 64 import androidx.compose.ui.graphics.Color 67 65 import androidx.compose.ui.platform.LocalFocusManager 68 66 import androidx.compose.ui.platform.LocalSoftwareKeyboardController 67 + import androidx.compose.ui.text.AnnotatedString 68 + import androidx.compose.ui.text.SpanStyle 69 + import androidx.compose.ui.text.buildAnnotatedString 69 70 import androidx.compose.ui.text.input.KeyboardCapitalization 70 71 import androidx.compose.ui.text.input.KeyboardType 72 + import androidx.compose.ui.text.input.TextFieldValue 73 + import androidx.compose.ui.text.withStyle 71 74 import androidx.compose.ui.unit.dp 75 + import androidx.compose.ui.util.fastForEachIndexed 72 76 import com.atproto.repo.StrongRef 73 77 import industries.geesawra.monarch.datalayer.SkeetData 74 78 import industries.geesawra.monarch.datalayer.TimelineViewModel ··· 92 96 val charCount = remember { mutableIntStateOf(0) } 93 97 val wasEdited = remember { mutableStateOf(false) } 94 98 val maxChars = 300 95 - val composeFieldState = rememberTextFieldState( 96 - "" 97 - ) 99 + val composeFieldState = remember { mutableStateOf(TextFieldValue("")) } 98 100 val mediaSelected = remember { mutableStateOf(listOf<Uri>()) } 99 101 val mediaSelectedIsVideo = remember { mutableStateOf(false) } 100 102 101 103 LaunchedEffect(scaffoldState.bottomSheetState.targetValue) { 102 104 when (scaffoldState.bottomSheetState.targetValue) { 103 105 SheetValue.Hidden -> { 104 - composeFieldState.clearText() 106 + composeFieldState.value = TextFieldValue("") 105 107 keyboardController?.hide() 106 108 focusManager.clearFocus() 107 109 charCount.intValue = 0 ··· 222 224 } 223 225 } 224 226 225 - LaunchedEffect(composeFieldState.text) { 226 - if (composeFieldState.text.isEmpty()) { 227 + LaunchedEffect(composeFieldState.value.text) { 228 + if (composeFieldState.value.text.isEmpty()) { 227 229 wasEdited.value = false 228 230 } else { 229 231 wasEdited.value = true 230 - charCount.intValue = composeFieldState.text.length 232 + charCount.intValue = composeFieldState.value.text.length 231 233 } 232 234 } 235 + 236 + val urlColor = MaterialTheme.colorScheme.primary 233 237 234 238 OutlinedTextField( 235 239 modifier = Modifier ··· 237 241 .heightIn(min = 250.dp) 238 242 .focusRequester(focusRequester) 239 243 .contentReceiver(receiveContentListener), 244 + value = composeFieldState.value, 245 + onValueChange = { 246 + composeFieldState.value = 247 + it.copy(annotatedString = annotated(it.text, urlColor)) 248 + }, 240 249 keyboardOptions = KeyboardOptions( 241 250 capitalization = KeyboardCapitalization.Sentences, 242 251 autoCorrectEnabled = true, ··· 246 255 if (wasEdited.value) { 247 256 Text( 248 257 text = "${maxChars - charCount.intValue}", 249 - color = if (composeFieldState.text.length > maxChars) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface 258 + color = if (composeFieldState.value.text.length > maxChars) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface 250 259 ) 251 260 } else { 252 261 Text( ··· 254 263 ) 255 264 } 256 265 }, 257 - isError = composeFieldState.text.length > maxChars, 258 - lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10), 259 - state = composeFieldState 266 + isError = composeFieldState.value.text.length > maxChars, 267 + maxLines = 10, 260 268 ) 261 269 262 270 ActionRow( 263 271 context, 264 272 uploadingPost, 265 273 pickMedia, 266 - composeFieldState.text.toString(), 274 + composeFieldState.value.text, 267 275 mediaSelected, 268 276 mediaSelectedIsVideo, 269 277 coroutineScope, ··· 414 422 } 415 423 } 416 424 } 425 + 426 + fun annotated(data: String, urlColor: Color): AnnotatedString { 427 + return buildAnnotatedString { 428 + val split = data.split(" ") 429 + split.fastForEachIndexed { idx, s -> 430 + if (URLUtil.isHttpUrl(s) || URLUtil.isHttpsUrl(s) || s.startsWith("#") || s.startsWith("@")) { 431 + withStyle( 432 + SpanStyle( 433 + color = urlColor 434 + ) 435 + ) 436 + { 437 + append(s) 438 + } 439 + } else { 440 + append(s) 441 + } 442 + 443 + if (idx < split.size - 1) { 444 + append(" ") 445 + } 446 + } 447 + } 448 + }