diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatScreen.kt index de9f6db..6f4b67c 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatScreen.kt @@ -6,7 +6,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import com.arkivanov.decompose.extensions.compose.subscribeAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import ru.shadowsparky.chat.presentation.ChatComponent import ru.shadowsparky.chat.presentation.ChatContent @@ -17,7 +17,7 @@ import ru.shadowsparky.vbox.shared.presentation.nav.Route @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen(component: ChatComponent, route: Route, onNavigate: (Route) -> Unit) { - val state by component.state.subscribeAsState() + val state by component.state.collectAsStateWithLifecycle() MainScreenScaffold( appBar = { TopAppBar(title = { Text(stringResource(Res.string.chat_title_app_bar)) }) diff --git a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatComponent.kt b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatComponent.kt index 2b77be3..0689868 100644 --- a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatComponent.kt +++ b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatComponent.kt @@ -1,8 +1,6 @@ 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.coroutines.repeatOnLifecycle import com.arkivanov.essenty.lifecycle.doOnDestroy import kotlinx.coroutines.CoroutineExceptionHandler @@ -11,6 +9,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel 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 ru.shadowsparky.chat.domain.ChatRepository import ru.shadowsparky.chat.domain.ChatRoles @@ -26,7 +27,9 @@ sealed interface ChatState { data class Data( val messages: List, val isSending: Boolean = false, - val sendError: String? = null + val sendError: String? = null, + val scrollToFirstMessage: Boolean = false, + val clearInput: Boolean = false ) : ChatState } @@ -37,6 +40,7 @@ class ChatComponent( val onLinkClicked: (String) -> Unit, val refreshFlow: Flow ) : ComponentContext by componentContext { + private var needClearInput = false private val errorHandler = CoroutineExceptionHandler { _, throwable -> _state.value = ChatState.Error(throwable.message ?: throwable.toString()) } @@ -45,13 +49,16 @@ class ChatComponent( lifecycle.doOnDestroy { ctx.cancel() } } - private val _state = MutableValue(ChatState.Loading) - val state: Value = _state + private val _state = MutableStateFlow(ChatState.Loading) + val state: StateFlow = _state.asStateFlow() + init { loadInitialMessages() scope.launch { - repeatOnLifecycle { refreshFlow.collect { refreshList() } } + repeatOnLifecycle { + refreshFlow.collect { refreshList() } + } } } @@ -72,6 +79,7 @@ class ChatComponent( if (content.isBlank() || currentState.isSending) return _state.value = currentState.copy(isSending = true, sendError = null) scope.launch { + needClearInput = true repository.send(Message(role = ChatRoles.USER, content = content, timestamp = 0L)) } } @@ -84,7 +92,9 @@ class ChatComponent( val freshMessages = repository.query(afterId = latestMessageId) _state.value = currentState.copy( 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 _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 + ) + } } diff --git a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt index b120b0b..a48d967 100644 --- a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt +++ b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.withFrameNanos @@ -124,7 +125,10 @@ fun ChatContent(component: ChatComponent, paddingValues: PaddingValues, state: C onParseMessage = { component.parseMessage(it) }, onClickLink = { component.onLinkClicked(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, onClickLink: (String) -> Unit, onSendMessage: (String) -> Unit, - onDismissError: () -> Unit + onDismissError: () -> Unit, + resetState: (needScroll: Boolean, needClearInput: Boolean) -> Unit ) { val listState = rememberLazyListState() - var textInput by remember { mutableStateOf("") } + var textInput by rememberSaveable { mutableStateOf("") } val textFieldFocusRequester = remember { FocusRequester() } - LaunchedEffect(state.messages.size) { - if (state.messages.isNotEmpty()) { + LaunchedEffect(state.scrollToFirstMessage) { + if (state.scrollToFirstMessage) { 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()) { val scope = rememberCoroutineScope() val focusRequesters = remember { mutableMapOf() }