android: add chat list compose screen and chat placeholder navigation

This commit is contained in:
Codex
2026-03-08 22:32:15 +03:00
parent 21aa11c342
commit 2dfad1a624
8 changed files with 419 additions and 13 deletions

View File

@@ -44,3 +44,10 @@
- Added realtime event parser for `receive_message`, `message_updated`, `message_deleted`, `chat_updated`, `chat_deleted`, `user_online`, `user_offline`.
- Added use-case level realtime event handling that updates Room and triggers repository refreshes when needed.
- Wired realtime manager into DI.
### Step 8 - Chat list UI and navigation
- Added Chat List screen with tabs (`All` / `Archived`), local search filter, pull-to-refresh, and state handling (loading/empty/error).
- Added chat row rendering for unread badge, mention badge (`@`), pinned/muted marks, and message preview by media type.
- Added private chat presence display (`online` / `last seen recently` fallback).
- Connected Chat List to ViewModel/use-cases with no business logic inside composables.
- Added chat click navigation to placeholder `ChatScreen(chatId)`.

View File

@@ -63,6 +63,7 @@ android {
dependencies {
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.navigation:navigation-compose:2.8.5")

View File

@@ -1,31 +1,29 @@
package ru.daemonlord.messenger.ui.chats
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.foundation.layout.padding
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
import androidx.compose.ui.unit.dp
@Composable
fun ChatsPlaceholderScreen() {
fun ChatScreenPlaceholder(
chatId: Long,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "Chats",
style = MaterialTheme.typography.headlineMedium,
text = "Chat Screen",
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = "Phase 1 placeholder. Chats list comes next.",
text = "chatId=$chatId",
style = MaterialTheme.typography.bodyMedium,
)
}

View File

@@ -0,0 +1,241 @@
package ru.daemonlord.messenger.ui.chats
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
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.chat.model.ChatItem
@Composable
fun ChatListRoute(
onOpenChat: (Long) -> Unit,
viewModel: ChatListViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
ChatListScreen(
state = state,
onTabSelected = viewModel::onTabSelected,
onSearchChanged = viewModel::onSearchChanged,
onRefresh = viewModel::onPullToRefresh,
onOpenChat = onOpenChat,
)
}
@Composable
fun ChatListScreen(
state: ChatListUiState,
onTabSelected: (ChatTab) -> Unit,
onSearchChanged: (String) -> Unit,
onRefresh: () -> Unit,
onOpenChat: (Long) -> Unit,
) {
Column(
modifier = Modifier.fillMaxSize(),
) {
TabRow(
selectedTabIndex = if (state.selectedTab == ChatTab.ALL) 0 else 1,
) {
Tab(
selected = state.selectedTab == ChatTab.ALL,
onClick = { onTabSelected(ChatTab.ALL) },
text = { Text(text = "All") },
)
Tab(
selected = state.selectedTab == ChatTab.ARCHIVED,
onClick = { onTabSelected(ChatTab.ARCHIVED) },
text = { Text(text = "Archived") },
)
}
OutlinedTextField(
value = state.searchQuery,
onValueChange = onSearchChanged,
label = { Text(text = "Search title / username / handle") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)
PullToRefreshBox(
isRefreshing = state.isRefreshing,
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize(),
) {
when {
state.isLoading -> {
CenterState(text = "Loading chats...", loading = true)
}
!state.errorMessage.isNullOrBlank() -> {
CenterState(text = state.errorMessage, loading = false)
}
state.chats.isEmpty() -> {
CenterState(text = "No chats found", loading = false)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(
items = state.chats,
key = { it.id },
) { chat ->
ChatRow(
chat = chat,
onClick = { onOpenChat(chat.id) },
)
}
}
}
}
}
}
}
@Composable
private fun ChatRow(
chat: ChatItem,
onClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = chat.displayTitle,
style = MaterialTheme.typography.titleMedium,
fontWeight = if (chat.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal,
)
if (chat.pinned) {
Text(
text = " [PIN]",
style = MaterialTheme.typography.bodySmall,
)
}
if (chat.muted) {
Text(
text = " [MUTE]",
style = MaterialTheme.typography.bodySmall,
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (chat.unreadMentionsCount > 0) {
BadgeChip(label = "@")
}
if (chat.unreadCount > 0) {
BadgeChip(label = chat.unreadCount.toString())
}
}
}
val preview = chat.previewText()
if (preview.isNotBlank()) {
Text(
text = preview,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
if (chat.type == "private") {
val presence = if (chat.counterpartIsOnline == true) {
"online"
} else {
chat.counterpartLastSeenAt?.let { "last seen recently" } ?: "offline"
}
Text(
text = presence,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
@Composable
private fun BadgeChip(label: String) {
AssistChip(
onClick = {},
enabled = false,
label = { Text(text = label) },
colors = AssistChipDefaults.assistChipColors(
disabledContainerColor = MaterialTheme.colorScheme.primaryContainer,
disabledLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
}
@Composable
private fun CenterState(
text: String?,
loading: Boolean,
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (loading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.padding(8.dp))
}
if (!text.isNullOrBlank()) {
Text(text = text)
}
}
}
private fun ChatItem.previewText(): String {
val raw = lastMessageText.orEmpty().trim()
if (raw.isNotEmpty()) return raw
return when (lastMessageType) {
"image" -> "Photo"
"video" -> "Video"
"audio" -> "Audio"
"voice" -> "Voice message"
"file" -> "File"
"circle_video" -> "Video message"
null, "text" -> ""
else -> "Media"
}
}

View File

@@ -0,0 +1,12 @@
package ru.daemonlord.messenger.ui.chats
import ru.daemonlord.messenger.domain.chat.model.ChatItem
data class ChatListUiState(
val selectedTab: ChatTab = ChatTab.ALL,
val searchQuery: String = "",
val isLoading: Boolean = true,
val isRefreshing: Boolean = false,
val errorMessage: String? = null,
val chats: List<ChatItem> = emptyList(),
)

View File

@@ -0,0 +1,123 @@
package ru.daemonlord.messenger.ui.chats
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.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
import ru.daemonlord.messenger.domain.chat.usecase.RefreshChatsUseCase
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
import javax.inject.Inject
@HiltViewModel
class ChatListViewModel @Inject constructor(
private val observeChatsUseCase: ObserveChatsUseCase,
private val refreshChatsUseCase: RefreshChatsUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
) : ViewModel() {
private val selectedTab = MutableStateFlow(ChatTab.ALL)
private val searchQuery = MutableStateFlow("")
private val _uiState = MutableStateFlow(ChatListUiState())
val uiState: StateFlow<ChatListUiState> = _uiState.asStateFlow()
init {
handleRealtimeEventsUseCase.start()
observeChatStream()
}
fun onTabSelected(tab: ChatTab) {
if (selectedTab.value == tab) return
selectedTab.value = tab
_uiState.update { it.copy(selectedTab = tab, isLoading = true, errorMessage = null) }
refreshCurrentTab()
}
fun onSearchChanged(value: String) {
searchQuery.value = value
_uiState.update { it.copy(searchQuery = value) }
}
fun onPullToRefresh() {
refreshCurrentTab(forceRefresh = true)
}
private fun observeChatStream() {
viewModelScope.launch {
selectedTab
.flatMapLatest { tab ->
observeChatsUseCase(archived = tab == ChatTab.ARCHIVED)
}
.combine(searchQuery.distinctUntilChanged()) { chats, query ->
chats.filterByQuery(query)
}
.collectLatest { filtered ->
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
errorMessage = null,
chats = filtered,
)
}
}
}
}
private fun refreshCurrentTab(forceRefresh: Boolean = false) {
viewModelScope.launch {
_uiState.update {
it.copy(
isRefreshing = forceRefresh,
errorMessage = null,
)
}
val result = refreshChatsUseCase(archived = selectedTab.value == ChatTab.ARCHIVED)
if (result is AppResult.Error) {
_uiState.update {
it.copy(
isRefreshing = false,
isLoading = false,
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
private fun List<ChatItem>.filterByQuery(query: String): List<ChatItem> {
val normalized = query.trim().lowercase()
if (normalized.isBlank()) return this
return filter { chat ->
chat.displayTitle.lowercase().contains(normalized) ||
(chat.counterpartUsername?.lowercase()?.contains(normalized) == true) ||
(chat.handle?.lowercase()?.contains(normalized) == true)
}
}
private fun AppError.toUiMessage(): String {
return when (this) {
AppError.Network -> "Network error while syncing chats."
AppError.Unauthorized -> "Session expired. Please log in again."
AppError.InvalidCredentials -> "Authorization failed."
is AppError.Server -> "Server error while loading chats."
is AppError.Unknown -> "Unknown error while loading chats."
}
}
override fun onCleared() {
handleRealtimeEventsUseCase.stop()
super.onCleared()
}
}

View File

@@ -0,0 +1,6 @@
package ru.daemonlord.messenger.ui.chats
enum class ChatTab {
ALL,
ARCHIVED,
}

View File

@@ -11,7 +11,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavType
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.navArgument
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@@ -19,12 +21,14 @@ 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.chats.ChatsPlaceholderScreen
import ru.daemonlord.messenger.ui.chat.ChatScreenPlaceholder
import ru.daemonlord.messenger.ui.chats.ChatListRoute
private object Routes {
const val AuthGraph = "auth_graph"
const val Login = "login"
const val Chats = "chats"
const val Chat = "chat"
}
@Composable
@@ -78,7 +82,21 @@ fun MessengerNavHost(
}
composable(route = Routes.Chats) {
ChatsPlaceholderScreen()
ChatListRoute(
onOpenChat = { chatId ->
navController.navigate("${Routes.Chat}/$chatId")
}
)
}
composable(
route = "${Routes.Chat}/{chatId}",
arguments = listOf(
navArgument("chatId") { type = NavType.LongType }
),
) { backStackEntry ->
val chatId = backStackEntry.arguments?.getLong("chatId") ?: 0L
ChatScreenPlaceholder(chatId = chatId)
}
}
}