android: add chat list compose screen and chat placeholder navigation
This commit is contained in:
@@ -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)`.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package ru.daemonlord.messenger.ui.chats
|
||||
|
||||
enum class ChatTab {
|
||||
ALL,
|
||||
ARCHIVED,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user