impl chat user interface
This commit is contained in:
+5
@@ -0,0 +1,5 @@
|
||||
package ru.shadowsparky.chat.domain
|
||||
|
||||
interface ChatTextParser {
|
||||
fun parse(content: String): List<TextToken>
|
||||
}
|
||||
+6
@@ -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
|
||||
}
|
||||
+9
-24
@@ -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
|
||||
|
||||
+144
-125
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user