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,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)
}
}
)
}
}
}
}