properly update states in chat screen

This commit is contained in:
2026-08-17 19:34:50 +03:00
parent 4eeda5e2ee
commit db728f8ab5
3 changed files with 45 additions and 15 deletions
@@ -6,7 +6,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import com.arkivanov.decompose.extensions.compose.subscribeAsState import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.chat.presentation.ChatComponent import ru.shadowsparky.chat.presentation.ChatComponent
import ru.shadowsparky.chat.presentation.ChatContent import ru.shadowsparky.chat.presentation.ChatContent
@@ -17,7 +17,7 @@ import ru.shadowsparky.vbox.shared.presentation.nav.Route
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChatScreen(component: ChatComponent, route: Route, onNavigate: (Route) -> Unit) { fun ChatScreen(component: ChatComponent, route: Route, onNavigate: (Route) -> Unit) {
val state by component.state.subscribeAsState() val state by component.state.collectAsStateWithLifecycle()
MainScreenScaffold( MainScreenScaffold(
appBar = { appBar = {
TopAppBar(title = { Text(stringResource(Res.string.chat_title_app_bar)) }) TopAppBar(title = { Text(stringResource(Res.string.chat_title_app_bar)) })
@@ -1,8 +1,6 @@
package ru.shadowsparky.chat.presentation package ru.shadowsparky.chat.presentation
import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.value.MutableValue
import com.arkivanov.decompose.value.Value
import com.arkivanov.essenty.lifecycle.coroutines.repeatOnLifecycle import com.arkivanov.essenty.lifecycle.coroutines.repeatOnLifecycle
import com.arkivanov.essenty.lifecycle.doOnDestroy import com.arkivanov.essenty.lifecycle.doOnDestroy
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
@@ -11,6 +9,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.shadowsparky.chat.domain.ChatRepository import ru.shadowsparky.chat.domain.ChatRepository
import ru.shadowsparky.chat.domain.ChatRoles import ru.shadowsparky.chat.domain.ChatRoles
@@ -26,7 +27,9 @@ sealed interface ChatState {
data class Data( data class Data(
val messages: List<Message>, val messages: List<Message>,
val isSending: Boolean = false, val isSending: Boolean = false,
val sendError: String? = null val sendError: String? = null,
val scrollToFirstMessage: Boolean = false,
val clearInput: Boolean = false
) : ChatState ) : ChatState
} }
@@ -37,6 +40,7 @@ class ChatComponent(
val onLinkClicked: (String) -> Unit, val onLinkClicked: (String) -> Unit,
val refreshFlow: Flow<Unit> val refreshFlow: Flow<Unit>
) : ComponentContext by componentContext { ) : ComponentContext by componentContext {
private var needClearInput = false
private val errorHandler = CoroutineExceptionHandler { _, throwable -> private val errorHandler = CoroutineExceptionHandler { _, throwable ->
_state.value = ChatState.Error(throwable.message ?: throwable.toString()) _state.value = ChatState.Error(throwable.message ?: throwable.toString())
} }
@@ -45,13 +49,16 @@ class ChatComponent(
lifecycle.doOnDestroy { ctx.cancel() } lifecycle.doOnDestroy { ctx.cancel() }
} }
private val _state = MutableValue<ChatState>(ChatState.Loading) private val _state = MutableStateFlow<ChatState>(ChatState.Loading)
val state: Value<ChatState> = _state val state: StateFlow<ChatState> = _state.asStateFlow()
init { init {
loadInitialMessages() loadInitialMessages()
scope.launch { scope.launch {
repeatOnLifecycle { refreshFlow.collect { refreshList() } } repeatOnLifecycle {
refreshFlow.collect { refreshList() }
}
} }
} }
@@ -72,6 +79,7 @@ class ChatComponent(
if (content.isBlank() || currentState.isSending) return if (content.isBlank() || currentState.isSending) return
_state.value = currentState.copy(isSending = true, sendError = null) _state.value = currentState.copy(isSending = true, sendError = null)
scope.launch { scope.launch {
needClearInput = true
repository.send(Message(role = ChatRoles.USER, content = content, timestamp = 0L)) repository.send(Message(role = ChatRoles.USER, content = content, timestamp = 0L))
} }
} }
@@ -84,7 +92,9 @@ class ChatComponent(
val freshMessages = repository.query(afterId = latestMessageId) val freshMessages = repository.query(afterId = latestMessageId)
_state.value = currentState.copy( _state.value = currentState.copy(
messages = freshMessages + currentState.messages, messages = freshMessages + currentState.messages,
isSending = false isSending = false,
scrollToFirstMessage = freshMessages.isNotEmpty(),
clearInput = needClearInput
) )
} }
} }
@@ -93,4 +103,13 @@ class ChatComponent(
val currentState = _state.value as? ChatState.Data ?: return val currentState = _state.value as? ChatState.Data ?: return
_state.value = currentState.copy(sendError = null) _state.value = currentState.copy(sendError = null)
} }
fun resetState(needScroll: Boolean, needClearInput: Boolean) {
val currentState = _state.value as? ChatState.Data ?: return
this.needClearInput = needClearInput
_state.value = currentState.copy(
scrollToFirstMessage = needScroll,
clearInput = needClearInput
)
}
} }
@@ -45,6 +45,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
@@ -124,7 +125,10 @@ fun ChatContent(component: ChatComponent, paddingValues: PaddingValues, state: C
onParseMessage = { component.parseMessage(it) }, onParseMessage = { component.parseMessage(it) },
onClickLink = { component.onLinkClicked(it) }, onClickLink = { component.onLinkClicked(it) },
onSendMessage = { component.sendMessage(it) }, onSendMessage = { component.sendMessage(it) },
onDismissError = { component.clearSendError() } onDismissError = { component.clearSendError() },
resetState = { needScroll, needClearInput ->
component.resetState(needScroll, needClearInput)
}
) )
} }
} }
@@ -139,17 +143,24 @@ private fun ChatContent(
onParseMessage: (String) -> List<TextToken>, onParseMessage: (String) -> List<TextToken>,
onClickLink: (String) -> Unit, onClickLink: (String) -> Unit,
onSendMessage: (String) -> Unit, onSendMessage: (String) -> Unit,
onDismissError: () -> Unit onDismissError: () -> Unit,
resetState: (needScroll: Boolean, needClearInput: Boolean) -> Unit
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
var textInput by remember { mutableStateOf("") } var textInput by rememberSaveable { mutableStateOf("") }
val textFieldFocusRequester = remember { FocusRequester() } val textFieldFocusRequester = remember { FocusRequester() }
LaunchedEffect(state.messages.size) { LaunchedEffect(state.scrollToFirstMessage) {
if (state.messages.isNotEmpty()) { if (state.scrollToFirstMessage) {
listState.animateScrollToItem(0) listState.animateScrollToItem(0)
resetState(false, state.clearInput)
}
}
LaunchedEffect(state.clearInput) {
if (state.clearInput) {
textInput = ""
resetState(state.scrollToFirstMessage, false)
} }
} }
LaunchedEffect(state.messages) { textInput = "" }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val focusRequesters = remember { mutableMapOf<Any, FocusRequester>() } val focusRequesters = remember { mutableMapOf<Any, FocusRequester>() }