android: implement message screen ui with compose actions
Some checks failed
CI / test (push) Failing after 2m8s

This commit is contained in:
Codex
2026-03-09 02:10:52 +03:00
parent c63f063726
commit 545b45c5db
6 changed files with 441 additions and 33 deletions

View File

@@ -84,3 +84,10 @@
- Updated unified realtime handler to write `receive_message`, `message_updated`, `message_deleted` into `messages` Room state.
- Added delivery/read status updates in Room for message status events.
- Kept chat list sync updates in the same manager/use-case pipeline for consistency.
### Step 14 - Sprint A / 4) Message UI core
- Replaced chat placeholder with a real message screen route + ViewModel.
- Added message list rendering with Telegram-like bubble alignment and status hints.
- Added input composer with send flow, reply/edit modes, and inline action cancellation.
- Added long-press actions (`reply`, `edit`, `delete`) for baseline message operations.
- Added manual "load older" pagination trigger and chat back navigation integration.

View File

@@ -0,0 +1,225 @@
package ru.daemonlord.messenger.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.daemonlord.messenger.domain.message.model.MessageItem
@Composable
fun ChatRoute(
onBack: () -> Unit,
viewModel: ChatViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
ChatScreen(
state = state,
onBack = onBack,
onInputChanged = viewModel::onInputChanged,
onSendClick = viewModel::onSendClick,
onSelectMessage = viewModel::onSelectMessage,
onReplySelected = viewModel::onReplySelected,
onEditSelected = viewModel::onEditSelected,
onDeleteSelected = viewModel::onDeleteSelected,
onCancelComposeAction = viewModel::onCancelComposeAction,
onLoadMore = viewModel::loadMore,
)
}
@Composable
fun ChatScreen(
state: MessageUiState,
onBack: () -> Unit,
onInputChanged: (String) -> Unit,
onSendClick: () -> Unit,
onSelectMessage: (MessageItem?) -> Unit,
onReplySelected: (MessageItem) -> Unit,
onEditSelected: (MessageItem) -> Unit,
onDeleteSelected: (Boolean) -> Unit,
onCancelComposeAction: () -> Unit,
onLoadMore: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxSize(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Button(onClick = onBack) { Text("Back") }
Text(
text = "Chat #${state.chatId}",
style = MaterialTheme.typography.titleMedium,
)
Button(onClick = onLoadMore, enabled = !state.isLoadingMore) {
Text(if (state.isLoadingMore) "..." else "Load older")
}
}
when {
state.isLoading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
else -> {
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(state.messages, key = { it.id }) { message ->
MessageBubble(
message = message,
isSelected = state.selectedMessage?.id == message.id,
onLongPress = { onSelectMessage(message) },
)
}
}
}
}
if (state.selectedMessage != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(onClick = { onReplySelected(state.selectedMessage) }) { Text("Reply") }
Button(onClick = { onEditSelected(state.selectedMessage) }) { Text("Edit") }
Button(onClick = { onDeleteSelected(false) }) { Text("Delete") }
Button(onClick = { onSelectMessage(null) }) { Text("Close") }
}
}
if (state.replyToMessage != null || state.editingMessage != null) {
val header = if (state.editingMessage != null) {
"Editing message #${state.editingMessage.id}"
} else {
"Reply to #${state.replyToMessage?.id}"
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = header, style = MaterialTheme.typography.bodySmall)
Button(onClick = onCancelComposeAction) { Text("Cancel") }
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = state.inputText,
onValueChange = onInputChanged,
modifier = Modifier.weight(1f),
label = { Text("Message") },
maxLines = 4,
)
Button(
onClick = onSendClick,
enabled = !state.isSending && state.inputText.isNotBlank(),
) {
Text(if (state.isSending) "..." else "Send")
}
}
if (!state.errorMessage.isNullOrBlank()) {
Text(
text = state.errorMessage,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun MessageBubble(
message: MessageItem,
isSelected: Boolean,
onLongPress: () -> Unit,
) {
val isOutgoing = message.isOutgoing
val bubbleColor = if (isOutgoing) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
}
val alignment = if (isOutgoing) Alignment.End else Alignment.Start
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = alignment) {
Column(
modifier = Modifier
.fillMaxWidth(0.86f)
.background(
if (isSelected) MaterialTheme.colorScheme.tertiaryContainer else bubbleColor
)
.combinedClickable(
onClick = {},
onLongClick = onLongPress,
)
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
if (!isOutgoing && !message.senderDisplayName.isNullOrBlank()) {
Text(
text = message.senderDisplayName,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
)
}
Text(
text = message.text ?: "[${message.type}]",
style = MaterialTheme.typography.bodyMedium,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
val status = if (message.status.isNullOrBlank()) "" else " ${message.status}"
Text(
text = "${message.id}$status",
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}

View File

@@ -1,30 +0,0 @@
package ru.daemonlord.messenger.ui.chat
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun ChatScreenPlaceholder(
chatId: Long,
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "Chat Screen",
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = "chatId=$chatId",
style = MaterialTheme.typography.bodyMedium,
)
}
}

View File

@@ -0,0 +1,189 @@
package ru.daemonlord.messenger.ui.chat
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.message.model.MessageItem
import ru.daemonlord.messenger.domain.message.usecase.DeleteMessageUseCase
import ru.daemonlord.messenger.domain.message.usecase.EditMessageUseCase
import ru.daemonlord.messenger.domain.message.usecase.LoadMoreMessagesUseCase
import ru.daemonlord.messenger.domain.message.usecase.ObserveMessagesUseCase
import ru.daemonlord.messenger.domain.message.usecase.SendTextMessageUseCase
import ru.daemonlord.messenger.domain.message.usecase.SyncRecentMessagesUseCase
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
import javax.inject.Inject
@HiltViewModel
class ChatViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val observeMessagesUseCase: ObserveMessagesUseCase,
private val syncRecentMessagesUseCase: SyncRecentMessagesUseCase,
private val loadMoreMessagesUseCase: LoadMoreMessagesUseCase,
private val sendTextMessageUseCase: SendTextMessageUseCase,
private val editMessageUseCase: EditMessageUseCase,
private val deleteMessageUseCase: DeleteMessageUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
) : ViewModel() {
private val chatId: Long = checkNotNull(savedStateHandle["chatId"])
private val _uiState = MutableStateFlow(MessageUiState(chatId = chatId))
val uiState: StateFlow<MessageUiState> = _uiState.asStateFlow()
init {
handleRealtimeEventsUseCase.start()
observeMessages()
refresh()
}
fun onInputChanged(value: String) {
_uiState.update { it.copy(inputText = value) }
}
fun onSelectMessage(message: MessageItem?) {
_uiState.update { it.copy(selectedMessage = message) }
}
fun onReplySelected(message: MessageItem) {
_uiState.update {
it.copy(
replyToMessage = message,
editingMessage = null,
selectedMessage = null,
)
}
}
fun onEditSelected(message: MessageItem) {
_uiState.update {
it.copy(
editingMessage = message,
replyToMessage = null,
selectedMessage = null,
inputText = message.text.orEmpty(),
)
}
}
fun onCancelComposeAction() {
_uiState.update {
it.copy(
replyToMessage = null,
editingMessage = null,
)
}
}
fun onDeleteSelected(forAll: Boolean = false) {
val selected = uiState.value.selectedMessage ?: return
viewModelScope.launch {
when (val result = deleteMessageUseCase(selected.id, forAll = forAll)) {
is AppResult.Success -> _uiState.update { it.copy(selectedMessage = null) }
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun onSendClick() {
val text = uiState.value.inputText.trim()
if (text.isBlank()) return
viewModelScope.launch {
_uiState.update { it.copy(isSending = true, errorMessage = null) }
val editing = uiState.value.editingMessage
val result = if (editing != null) {
editMessageUseCase(messageId = editing.id, newText = text)
} else {
sendTextMessageUseCase(
chatId = chatId,
text = text,
replyToMessageId = uiState.value.replyToMessage?.id,
)
}
when (result) {
is AppResult.Success -> {
_uiState.update {
it.copy(
isSending = false,
inputText = "",
editingMessage = null,
replyToMessage = null,
)
}
}
is AppResult.Error -> {
_uiState.update {
it.copy(
isSending = false,
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
}
fun loadMore() {
val oldest = uiState.value.messages.firstOrNull() ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoadingMore = true) }
when (val result = loadMoreMessagesUseCase(chatId, beforeMessageId = oldest.id)) {
is AppResult.Success -> _uiState.update { it.copy(isLoadingMore = false) }
is AppResult.Error -> _uiState.update {
it.copy(
isLoadingMore = false,
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
private fun observeMessages() {
viewModelScope.launch {
observeMessagesUseCase(chatId).collectLatest { messages ->
_uiState.update {
it.copy(
isLoading = false,
messages = messages.sortedBy { msg -> msg.id },
)
}
}
}
}
private fun refresh() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
when (val result = syncRecentMessagesUseCase(chatId = chatId)) {
is AppResult.Success -> _uiState.update { it.copy(isLoading = false) }
is AppResult.Error -> _uiState.update {
it.copy(
isLoading = false,
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
private fun AppError.toUiMessage(): String {
return when (this) {
AppError.Network -> "Network error."
AppError.Unauthorized -> "Session expired."
AppError.InvalidCredentials -> "Authorization error."
is AppError.Server -> "Server error."
is AppError.Unknown -> "Unknown error."
}
}
}

View File

@@ -0,0 +1,16 @@
package ru.daemonlord.messenger.ui.chat
import ru.daemonlord.messenger.domain.message.model.MessageItem
data class MessageUiState(
val chatId: Long = 0L,
val isLoading: Boolean = true,
val isLoadingMore: Boolean = false,
val isSending: Boolean = false,
val errorMessage: String? = null,
val messages: List<MessageItem> = emptyList(),
val inputText: String = "",
val replyToMessage: MessageItem? = null,
val editingMessage: MessageItem? = null,
val selectedMessage: MessageItem? = null,
)

View File

@@ -21,7 +21,7 @@ import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import ru.daemonlord.messenger.ui.auth.AuthViewModel
import ru.daemonlord.messenger.ui.auth.LoginScreen
import ru.daemonlord.messenger.ui.chat.ChatScreenPlaceholder
import ru.daemonlord.messenger.ui.chat.ChatRoute
import ru.daemonlord.messenger.ui.chats.ChatListRoute
private object Routes {
@@ -95,8 +95,9 @@ fun MessengerNavHost(
navArgument("chatId") { type = NavType.LongType }
),
) { backStackEntry ->
val chatId = backStackEntry.arguments?.getLong("chatId") ?: 0L
ChatScreenPlaceholder(chatId = chatId)
ChatRoute(
onBack = { navController.popBackStack() },
)
}
}
}