android: implement message screen ui with compose actions
Some checks failed
CI / test (push) Failing after 2m8s
Some checks failed
CI / test (push) Failing after 2m8s
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user