From 545b45c5db0e1cd467abbe42e8dd91160caeda7f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 02:10:52 +0300 Subject: [PATCH] android: implement message screen ui with compose actions --- android/CHANGELOG.md | 7 + .../messenger/ui/chat/ChatScreen.kt | 225 ++++++++++++++++++ .../ui/chat/ChatScreenPlaceholder.kt | 30 --- .../messenger/ui/chat/ChatViewModel.kt | 189 +++++++++++++++ .../messenger/ui/chat/MessageUiState.kt | 16 ++ .../messenger/ui/navigation/AppNavGraph.kt | 7 +- 6 files changed, 441 insertions(+), 33 deletions(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt delete mode 100644 android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreenPlaceholder.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 29fb60a..d34df5b 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt new file mode 100644 index 0000000..60aea0e --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -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, + ) + } + } + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreenPlaceholder.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreenPlaceholder.kt deleted file mode 100644 index ef7eab5..0000000 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreenPlaceholder.kt +++ /dev/null @@ -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, - ) - } -} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt new file mode 100644 index 0000000..1b90224 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt @@ -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 = _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." + } + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt new file mode 100644 index 0000000..ef785cb --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt @@ -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 = emptyList(), + val inputText: String = "", + val replyToMessage: MessageItem? = null, + val editingMessage: MessageItem? = null, + val selectedMessage: MessageItem? = null, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt index 29c0d3d..0e8f671 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt @@ -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() }, + ) } } }