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 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. - Added use-case level realtime event handling that updates Room and triggers repository refreshes when needed.
- Wired realtime manager into DI. - 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 { dependencies {
implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") 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.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.activity:activity-compose:1.10.1") implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.navigation:navigation-compose:2.8.5") 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable @Composable
fun ChatsPlaceholderScreen() { fun ChatScreenPlaceholder(
chatId: Long,
) {
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) { ) {
Text( Text(
text = "Chats", text = "Chat Screen",
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineSmall,
) )
Text( Text(
text = "Phase 1 placeholder. Chats list comes next.", text = "chatId=$chatId",
style = MaterialTheme.typography.bodyMedium, 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavType
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.navArgument
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -19,12 +21,14 @@ import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import ru.daemonlord.messenger.ui.auth.AuthViewModel import ru.daemonlord.messenger.ui.auth.AuthViewModel
import ru.daemonlord.messenger.ui.auth.LoginScreen 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 { private object Routes {
const val AuthGraph = "auth_graph" const val AuthGraph = "auth_graph"
const val Login = "login" const val Login = "login"
const val Chats = "chats" const val Chats = "chats"
const val Chat = "chat"
} }
@Composable @Composable
@@ -78,7 +82,21 @@ fun MessengerNavHost(
} }
composable(route = Routes.Chats) { 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)
} }
} }
} }