impl chat user interface

This commit is contained in:
2026-07-29 18:52:20 +03:00
parent 725c78aa2d
commit ab14a86305
15 changed files with 265 additions and 167 deletions
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="#fff"
android:pathData="M668.5,428.5Q680,417 680,400Q680,383 668.5,371.5Q657,360 640,360Q623,360 611.5,371.5Q600,383 600,400Q600,417 611.5,428.5Q623,440 640,440Q657,440 668.5,428.5ZM320,360L520,360L520,280L320,280L320,360ZM180,840Q146,726 113,612.5Q80,499 80,380Q80,288 144,224Q208,160 300,160L500,160Q529,122 570.5,101Q612,80 660,80Q685,80 702.5,97.5Q720,115 720,140Q720,146 718.5,152Q717,158 715,163Q711,174 707.5,185.5Q704,197 702,209L793,300L880,300L880,579L767,616L700,840L480,840L480,760L400,760L400,840L180,840ZM240,760L320,760L320,680L560,680L560,760L640,760L702,554L800,521L800,380L760,380L620,240Q620,220 622.5,201.5Q625,183 630,164Q601,172 579,191.5Q557,211 547,240L300,240Q242,240 201,281Q160,322 160,380Q160,478 187,571.5Q214,665 240,760ZM480,462Q480,462 480,462Q480,462 480,462Q480,462 480,462Q480,462 480,462L480,462Q480,462 480,462Q480,462 480,462Q480,462 480,462Q480,462 480,462L480,462L480,462L480,462L480,462L480,462L480,462L480,462L480,462L480,462Z"/>
</vector>
@@ -68,4 +68,6 @@
<string name="movie_tags_empty">Настроить теги</string>
<string name="user_tags_filter_disable">Не фильтровать</string>
<string name="check_for_updates">Поиск обновлений</string>
<string name="chat_title_app_bar">Братан СвинGPT</string>
<string name="chat_title">СвинGPT</string>
</resources>
@@ -0,0 +1,27 @@
package ru.shadowsparky.vbox.shared.data.chat
import org.koin.core.annotation.Factory
import ru.shadowsparky.chat.domain.ChatTextParser
import ru.shadowsparky.chat.domain.TextToken
@Factory
class MovieTextParser : ChatTextParser {
private val regex = Regex("\\[MOVIE\\](.*?)\\[/MOVIE\\]")
override fun parse(content: String): List<TextToken> {
val tokens = mutableListOf<TextToken>()
var lastIndex = 0
regex.findAll(content).forEach { matchResult ->
if (matchResult.range.first > lastIndex) {
tokens.add(TextToken.Plain(content.substring(lastIndex, matchResult.range.first)))
}
val movieTitle = matchResult.groupValues[1]
tokens.add(TextToken.Link(text = movieTitle))
lastIndex = matchResult.range.last + 1
}
if (lastIndex < content.length) {
tokens.add(TextToken.Plain(content.substring(lastIndex)))
}
return tokens.ifEmpty { listOf(TextToken.Plain(content)) }
}
}
@@ -1,4 +1,4 @@
package ru.shadowsparky.vbox.shared.data
package ru.shadowsparky.vbox.shared.data.chat
import io.ktor.client.HttpClient
import io.ktor.client.call.body
@@ -8,6 +8,8 @@ import org.koin.core.annotation.Factory
import ru.shadowsparky.chat.domain.ChatRepository
import ru.shadowsparky.chat.domain.Message
import ru.shadowsparky.chat.domain.MessagesResponse
import ru.shadowsparky.vbox.shared.data.apply
import ru.shadowsparky.vbox.shared.data.get
import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
@@ -25,7 +27,7 @@ class RemoteChatRepository(
mapOf(ChatRepository.ARG_AFTER_ID to afterId.toString())
}
return httpClient.get<MessagesResponse>(
"$DYNAMIC_PREFIX/chat/query",
"${DYNAMIC_PREFIX}/chat/query",
args,
serverConfigurationRepository.getServerConfiguration()
).content
@@ -36,7 +38,7 @@ class RemoteChatRepository(
setBody(message)
serverConfigurationRepository.getServerConfiguration().apply(
this,
"$DYNAMIC_PREFIX/chat/send"
"${DYNAMIC_PREFIX}/chat/send"
)
}.body<Message>()
}
@@ -12,7 +12,6 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.seiko.imageloader.LocalImageLoader
import ru.shadowsparky.chat.presentation.ChatScreen
import ru.shadowsparky.koin
import ru.shadowsparky.ui.components.BackButton
import ru.shadowsparky.ui.nav.Nav
@@ -64,10 +63,10 @@ private fun NavInternal(root: VBoxRootComponent) {
is Child.EditPasswordChild -> EditPasswordScreen(child.component)
is Child.LoadingChild -> LoadingScreen(child.component)
is Child.OfflineVideoChild -> OfflineVideoScreen(child.component)
is Child.ChatChild -> ChatScreen(child.component)
is Child.VideosChild,
is Child.SearchChild,
is Child.SettingsChild,
is Child.ChatChild,
is Child.SavedMovieChild -> {
if (child is Child.VideosChild && child.component.search != null) {
VideoScreen(child.component)
@@ -1,12 +1,14 @@
package ru.shadowsparky.vbox.shared.presentation
import androiddev.apps.vbox.client.shared.generated.resources.Res
import androiddev.apps.vbox.client.shared.generated.resources.chat_title
import androiddev.apps.vbox.client.shared.generated.resources.home
import androiddev.apps.vbox.client.shared.generated.resources.recommendations
import androiddev.apps.vbox.client.shared.generated.resources.saved_movies
import androiddev.apps.vbox.client.shared.generated.resources.search
import androiddev.apps.vbox.client.shared.generated.resources.settings
import androiddev.apps.vbox.client.shared.generated.resources.star_unchecked
import androiddev.apps.vbox.client.shared.generated.resources.svingpt
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
@@ -31,6 +33,11 @@ fun createNavigationList(): List<NavigationEntry<Route>> {
stringResource(Res.string.search),
painterResource(Res.drawable.search)
),
NavigationEntry(
Route.Chat(),
stringResource(Res.string.chat_title),
painterResource(Res.drawable.svingpt)
),
NavigationEntry(
Route.Settings(),
stringResource(Res.string.settings),
@@ -14,6 +14,7 @@ import ru.shadowsparky.ui.LocalBlurState
import ru.shadowsparky.ui.applyBlurEffect
import ru.shadowsparky.ui.rememberBlurState
import ru.shadowsparky.ui.theme.ComponentDefaults
import ru.shadowsparky.vbox.shared.presentation.chat.ChatScreen
import ru.shadowsparky.vbox.shared.presentation.list.VideoContent
import ru.shadowsparky.vbox.shared.presentation.nav.Child
import ru.shadowsparky.vbox.shared.presentation.nav.Route
@@ -29,7 +30,7 @@ fun MainScreen(route: Route, child: Child, root: VBoxRootComponent) {
child.component,
route
) { root.navReplace(it) }
is Child.ChatChild -> ChatScreen(child.component, route) { root.navReplace(it) }
else -> MainScreenInternal(route, child) { root.navReplace(it) }
}
}
@@ -4,6 +4,7 @@ import com.arkivanov.decompose.ComponentContext
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.chat.domain.ChatRepository
import ru.shadowsparky.chat.domain.ChatTextParser
import ru.shadowsparky.chat.presentation.ChatComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator
@@ -14,7 +15,8 @@ import ru.shadowsparky.vbox.shared.presentation.nav.RouteIds
@Factory
@Named(RouteIds.CHAT)
class ChatComponentFactory(
private val chatRepository: ChatRepository
private val chatRepository: ChatRepository,
private val textParser: ChatTextParser
) : ComponentFactory<Child.ChatChild, Route.Chat, Route> {
override fun create(
route: Route.Chat,
@@ -23,7 +25,9 @@ class ChatComponentFactory(
): Child.ChatChild {
val comp = ChatComponent(
child,
chatRepository
chatRepository,
textParser,
onLinkClicked = { root.nav(Route.Videos(it)) }
)
return Child.ChatChild(comp)
}
@@ -0,0 +1,39 @@
package ru.shadowsparky.vbox.shared.presentation.chat
import androiddev.apps.vbox.client.shared.generated.resources.Res
import androiddev.apps.vbox.client.shared.generated.resources.chat_title_app_bar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.chat.presentation.ChatComponent
import ru.shadowsparky.chat.presentation.ChatContent
import ru.shadowsparky.ui.LocalBlurState
import ru.shadowsparky.ui.applyBlurEffect
import ru.shadowsparky.ui.theme.ComponentDefaults
import ru.shadowsparky.vbox.shared.presentation.BaseMainScreen
import ru.shadowsparky.vbox.shared.presentation.nav.Route
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(component: ChatComponent, route: Route, onNavigate: (Route) -> Unit) {
val state by component.state.subscribeAsState()
BaseMainScreen(
appBar = {
TopAppBar(
title = { Text(stringResource(Res.string.chat_title_app_bar)) },
colors = ComponentDefaults.topAppBarColors(),
modifier = Modifier.applyBlurEffect(LocalBlurState.current)
)
},
route,
onNavigate = onNavigate,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
content = { ChatContent(component, it, state) }
)
}
@@ -37,8 +37,7 @@ class SettingsComponent(
val doRegister: () -> Unit,
val editPassword: () -> Unit,
val showOffline: () -> Unit,
val showTags: () -> Unit,
val showChat: () -> Unit
val showTags: () -> Unit
) : ComponentContext by context {
val updateComponent = updateComponentFactory.create(childContext("UpdateComponent"))
@@ -100,8 +99,7 @@ class SettingsComponentFactory(
tokenStorage = tokenStorage,
showOffline = { root.nav(Route.OfflineVideo()) },
offlineSupport = offlineStorage != null,
showTags = { root.nav(Route.Settings.TagOverview()) },
showChat = { root.nav(Route.Chat())}
showTags = { root.nav(Route.Settings.TagOverview()) }
)
return Child.SettingsChild(comp)
}
@@ -82,12 +82,6 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) {
)
}
}
item {
TextPreference(
text = "chat",
modifier = Modifier.clickable { comp.showChat() }
)
}
}
section {
val needAuth = tokenInfo == null
@@ -0,0 +1,5 @@
package ru.shadowsparky.chat.domain
interface ChatTextParser {
fun parse(content: String): List<TextToken>
}
@@ -0,0 +1,6 @@
package ru.shadowsparky.chat.domain
sealed interface TextToken {
data class Plain(val text: String) : TextToken
data class Link(val text: String) : TextToken
}
@@ -11,7 +11,9 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import ru.shadowsparky.chat.domain.ChatRepository
import ru.shadowsparky.chat.domain.ChatRoles
import ru.shadowsparky.chat.domain.ChatTextParser
import ru.shadowsparky.chat.domain.Message
import ru.shadowsparky.chat.domain.TextToken
sealed interface ChatState {
object Loading : ChatState
@@ -21,14 +23,15 @@ sealed interface ChatState {
data class Data(
val messages: List<Message>,
val isSending: Boolean = false,
val isSubsequentLoading: Boolean = false,
val sendError: String? = null
) : ChatState
}
class ChatComponent(
componentContext: ComponentContext,
private val repository: ChatRepository
private val repository: ChatRepository,
private val textParser: ChatTextParser?,
val onLinkClicked: (String) -> Unit
) : ComponentContext by componentContext {
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()).also { ctx ->
@@ -42,6 +45,10 @@ class ChatComponent(
loadInitialMessages()
}
fun parseMessage(content: String): List<TextToken> {
return textParser?.parse(content) ?: listOf(TextToken.Plain(content))
}
fun loadInitialMessages() {
_state.value = ChatState.Loading
scope.launch {
@@ -54,28 +61,6 @@ class ChatComponent(
}
}
fun loadMoreMessages() {
val currentState = _state.value as? ChatState.Data ?: return
if (currentState.isSubsequentLoading || currentState.messages.isEmpty()) return
val latestMessageId = currentState.messages.last().id ?: return
_state.value = currentState.copy(isSubsequentLoading = true)
scope.launch {
try {
val newMessages = repository.query(afterId = latestMessageId)
if (newMessages.isNotEmpty()) {
_state.value = currentState.copy(
messages = currentState.messages + newMessages,
isSubsequentLoading = false
)
} else {
_state.value = currentState.copy(isSubsequentLoading = false)
}
} catch (e: Exception) {
_state.value = currentState.copy(isSubsequentLoading = false)
}
}
}
fun sendMessage(content: String) {
val currentState = _state.value as? ChatState.Data ?: return
if (content.isBlank() || currentState.isSending) return
@@ -5,100 +5,109 @@ import androiddev.feature.chat.chat_client.generated.resources.chat_input_placeh
import androiddev.feature.chat.chat_client.generated.resources.chat_load_error
import androiddev.feature.chat.chat_client.generated.resources.chat_ok
import androiddev.feature.chat.chat_client.generated.resources.chat_retry_button
import androiddev.feature.chat.chat_client.generated.resources.chat_send_button
import androiddev.feature.chat.chat_client.generated.resources.chat_title
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Snackbar
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.chat.domain.ChatRoles
import ru.shadowsparky.chat.domain.Message
import ru.shadowsparky.ui.AppScaffold
import ru.shadowsparky.chat.domain.TextToken
import ru.shadowsparky.ui.BlurState
import ru.shadowsparky.ui.applyBlurEffect
import ru.shadowsparky.ui.LocalBlurState
import ru.shadowsparky.ui.applyBlurSource
import ru.shadowsparky.ui.components.AlertContent
import ru.shadowsparky.ui.components.LazyColumn
import ru.shadowsparky.ui.components.Loading
import ru.shadowsparky.ui.rememberBlurState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(component: ChatComponent, modifier: Modifier = Modifier) {
val state by component.state.subscribeAsState()
val blur = rememberBlurState()
AppScaffold(
appBar = {
TopAppBar(
title = { Text(stringResource(Res.string.chat_title)) },
modifier = Modifier.applyBlurEffect(blur)
fun ChatContent(
component: ChatComponent,
paddingValues: PaddingValues,
state: ChatState
) {
Box(
modifier = Modifier.fillMaxSize()
.padding(
PaddingValues(
start = paddingValues.calculateStartPadding(LayoutDirection.Ltr),
top = 0.dp,
end = paddingValues.calculateEndPadding(LayoutDirection.Ltr),
bottom = paddingValues.calculateBottomPadding()
)
)
},
modifier = modifier
) { paddingValues ->
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
when (val currentState = state) {
is ChatState.Loading -> {
Loading(Modifier.padding(paddingValues).fillMaxSize())
}
is ChatState.Error -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(paddingValues)
) {
Text(text = stringResource(Res.string.chat_load_error), color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { component.loadInitialMessages() }) {
Text(stringResource(Res.string.chat_retry_button))
}
.padding(4.dp),
contentAlignment = Alignment.Center
) {
when (state) {
is ChatState.Loading -> {
Loading(Modifier.fillMaxSize())
}
is ChatState.Error -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(Res.string.chat_load_error),
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { component.loadInitialMessages() }) {
Text(stringResource(Res.string.chat_retry_button))
}
}
is ChatState.Data -> {
ChatContent(
paddingValues,
blur,
state = currentState,
onLoadMore = { component.loadMoreMessages() },
onSendMessage = { text -> component.sendMessage(text) },
onDismissError = { component.clearSendError() }
)
}
}
is ChatState.Data -> {
ChatContent(
contentPadding = PaddingValues(top = paddingValues.calculateTopPadding()),
LocalBlurState.current,
state = state,
onParseMessage = { component.parseMessage(it) },
onClickLink = { component.onLinkClicked(it) },
onSendMessage = { component.sendMessage(it) },
onDismissError = { component.clearSendError() }
)
}
}
}
@@ -106,31 +115,17 @@ fun ChatScreen(component: ChatComponent, modifier: Modifier = Modifier) {
@Composable
private fun ChatContent(
paddingValues: PaddingValues,
contentPadding: PaddingValues,
blurState: BlurState?,
state: ChatState.Data,
onLoadMore: () -> Unit,
onParseMessage: (String) -> List<TextToken>,
onClickLink: (String) -> Unit,
onSendMessage: (String) -> Unit,
onDismissError: () -> Unit
) {
val listState = rememberLazyListState()
var textInput by remember { mutableStateOf("") }
val shouldLoadMore by remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val totalItems = layoutInfo.totalItemsCount
val lastVisibleItemIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
totalItems > 0 && lastVisibleItemIndex >= totalItems - 2
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore) {
onLoadMore()
}
}
val requester = remember { FocusRequester() }
LaunchedEffect(state.messages.size) {
if (state.messages.isNotEmpty()) {
@@ -138,85 +133,109 @@ private fun ChatContent(
}
}
LaunchedEffect(state.messages) { textInput = "" }
Column(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier.applyBlurSource(blurState)
.weight(1f)
.fillMaxWidth(),
contentPadding = paddingValues,
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(items = state.messages, key = { it.id ?: it.hashCode() }) { message ->
MessageBubble(message = message)
}
if (state.isSubsequentLoading) {
item {
Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
MessageBubble(
message = message,
onParseMessage = onParseMessage,
onClickLink = onClickLink
)
}
item { Spacer(Modifier.height(4.dp)) }
}
state.sendError?.let { error ->
Snackbar(
action = {
TextButton(onClick = onDismissError) { Text(stringResource(Res.string.chat_ok)) }
AlertContent(
onDismissRequest = onDismissError,
content = {
Button(onClick = onDismissError) { Text(stringResource(Res.string.chat_ok)) }
},
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(error)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = textInput,
onValueChange = { textInput = it },
modifier = Modifier.weight(1f),
placeholder = { Text(stringResource(Res.string.chat_input_placeholder)) },
enabled = !state.isSending
title = error
)
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
if (textInput.isNotBlank()) {
onSendMessage(textInput)
textInput = ""
}
},
enabled = textInput.isNotBlank() && !state.isSending
) {
if (state.isSending) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text(stringResource(Res.string.chat_send_button))
}
}
}
if (state.isSending) {
LinearProgressIndicator(Modifier.fillMaxWidth())
}
OutlinedTextField(
value = textInput,
onValueChange = { textInput = it },
modifier = Modifier.focusRequester(requester).fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
placeholder = { Text(stringResource(Res.string.chat_input_placeholder)) },
enabled = !state.isSending,
keyboardActions = KeyboardActions(
onDone = {
if (textInput.isNotBlank()) onSendMessage(textInput)
}
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
)
}
LaunchedEffect(null) {
withFrameNanos {}
requester.requestFocus()
}
}
@Composable
private fun MessageBubble(message: Message) {
private fun MessageBubble(
message: Message,
onParseMessage: (String) -> List<TextToken>,
onClickLink: (String) -> Unit
) {
val isUser = message.role == ChatRoles.USER
val alignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart
val containerColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
val contentColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
val tokens = remember(message.content) { onParseMessage(message.content) }
val annotatedString = buildAnnotatedString {
tokens.forEachIndexed { index, token ->
when (token) {
is TextToken.Plain -> { append(token.text) }
is TextToken.Link -> {
pushStringAnnotation(tag = "LINK_INDEX", annotation = index.toString())
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
textDecoration = TextDecoration.Underline
)
) { append(token.text) }
pop()
}
}
}
}
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = alignment) {
Card(
colors = CardDefaults.cardColors(containerColor = containerColor),
colors = CardDefaults.cardColors(containerColor = containerColor, contentColor = contentColor),
modifier = Modifier.widthIn(max = 280.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(text = message.content, style = MaterialTheme.typography.bodyLarge)
ClickableText(
text = annotatedString,
style = MaterialTheme.typography.bodyLarge.copy(color = contentColor),
onClick = { offset ->
annotatedString.getStringAnnotations(tag = "LINK_INDEX", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
val tokenIndex = annotation.item.toIntOrNull() ?: return@ClickableText
val clickedToken = tokens.getOrNull(tokenIndex) as? TextToken.Link ?: return@ClickableText
onClickLink(clickedToken.text)
}
}
)
}
}
}
}