chat ui
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="chat_title">Чат</string>
|
||||
<string name="chat_load_error">Не удалось загрузить сообщения</string>
|
||||
<string name="chat_send_error">Не удалось отправить сообщение</string>
|
||||
<string name="chat_retry_button">Повторить</string>
|
||||
<string name="chat_input_placeholder">Сообщение...</string>
|
||||
<string name="chat_send_button">Отправить</string>
|
||||
<string name="chat_ok">ОК</string>
|
||||
</resources>
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package ru.shadowsparky.chat.presentation
|
||||
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.value.MutableValue
|
||||
import com.arkivanov.decompose.value.Value
|
||||
import com.arkivanov.essenty.lifecycle.doOnDestroy
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
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.Message
|
||||
|
||||
sealed interface ChatState {
|
||||
object Loading : ChatState
|
||||
|
||||
data class Error(val message: String) : 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
|
||||
) : ComponentContext by componentContext {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()).also { ctx ->
|
||||
lifecycle.doOnDestroy { ctx.cancel() }
|
||||
}
|
||||
|
||||
private val _state = MutableValue<ChatState>(ChatState.Loading)
|
||||
val state: Value<ChatState> = _state
|
||||
|
||||
init {
|
||||
loadInitialMessages()
|
||||
}
|
||||
|
||||
fun loadInitialMessages() {
|
||||
_state.value = ChatState.Loading
|
||||
scope.launch {
|
||||
try {
|
||||
val dbList = repository.query(afterId = null)
|
||||
_state.value = ChatState.Data(messages = dbList)
|
||||
} catch (e: Exception) {
|
||||
_state.value = ChatState.Error(e.message ?: e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
_state.value = currentState.copy(isSending = true, sendError = null)
|
||||
scope.launch {
|
||||
try {
|
||||
repository.send(Message(role = ChatRoles.USER, content = content, timestamp = 0L))
|
||||
val latestMessageId = currentState.messages.lastOrNull()?.id
|
||||
val freshMessages = repository.query(afterId = latestMessageId)
|
||||
_state.value = currentState.copy(
|
||||
messages = currentState.messages + freshMessages,
|
||||
isSending = false
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = currentState.copy(
|
||||
isSending = false,
|
||||
sendError = e.message ?: e.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSendError() {
|
||||
val currentState = _state.value as? ChatState.Data ?: return
|
||||
_state.value = currentState.copy(sendError = null)
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
package ru.shadowsparky.chat.presentation
|
||||
|
||||
import androiddev.feature.chat.chat_client.generated.resources.Res
|
||||
import androiddev.feature.chat.chat_client.generated.resources.chat_input_placeholder
|
||||
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.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.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.MaterialTheme
|
||||
import androidx.compose.material3.Snackbar
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.ui.BlurState
|
||||
import ru.shadowsparky.ui.applyBlurEffect
|
||||
import ru.shadowsparky.ui.applyBlurSource
|
||||
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)
|
||||
)
|
||||
},
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
is ChatState.Data -> {
|
||||
ChatContent(
|
||||
paddingValues,
|
||||
blur,
|
||||
state = currentState,
|
||||
onLoadMore = { component.loadMoreMessages() },
|
||||
onSendMessage = { text -> component.sendMessage(text) },
|
||||
onDismissError = { component.clearSendError() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatContent(
|
||||
paddingValues: PaddingValues,
|
||||
blurState: BlurState?,
|
||||
state: ChatState.Data,
|
||||
onLoadMore: () -> 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()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.messages.size) {
|
||||
if (state.messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(state.messages.lastIndex)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.applyBlurSource(blurState)
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = paddingValues,
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.sendError?.let { error ->
|
||||
Snackbar(
|
||||
action = {
|
||||
TextButton(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
|
||||
)
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageBubble(message: Message) {
|
||||
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
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = alignment) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = containerColor),
|
||||
modifier = Modifier.widthIn(max = 280.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(text = message.content, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user