This commit is contained in:
2026-07-29 16:21:59 +03:00
parent b83a40f4bd
commit 6485f4ec26
41 changed files with 797 additions and 224 deletions
@@ -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>
@@ -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)
}
}
@@ -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)
}
}
}
}