From 2dfad1a624f67a7f54250627fd755110bf641ed5 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 8 Mar 2026 22:32:15 +0300 Subject: [PATCH] android: add chat list compose screen and chat placeholder navigation --- android/CHANGELOG.md | 7 + android/app/build.gradle.kts | 1 + .../ChatScreenPlaceholder.kt} | 20 +- .../messenger/ui/chats/ChatListScreen.kt | 241 ++++++++++++++++++ .../messenger/ui/chats/ChatListUiState.kt | 12 + .../messenger/ui/chats/ChatListViewModel.kt | 123 +++++++++ .../daemonlord/messenger/ui/chats/ChatTab.kt | 6 + .../messenger/ui/navigation/AppNavGraph.kt | 22 +- 8 files changed, 419 insertions(+), 13 deletions(-) rename android/app/src/main/java/ru/daemonlord/messenger/ui/{chats/ChatsPlaceholderScreen.kt => chat/ChatScreenPlaceholder.kt} (60%) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatTab.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 04d23b6..f36eab4 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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)`. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ebe86db..b321e05 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatsPlaceholderScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreenPlaceholder.kt similarity index 60% rename from android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatsPlaceholderScreen.kt rename to android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreenPlaceholder.kt index dc87746..ef7eab5 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatsPlaceholderScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreenPlaceholder.kt @@ -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, ) } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt new file mode 100644 index 0000000..4e0e570 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt @@ -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" + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt new file mode 100644 index 0000000..373e8da --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt @@ -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 = emptyList(), +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt new file mode 100644 index 0000000..18688cf --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt @@ -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 = _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.filterByQuery(query: String): List { + 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() + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatTab.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatTab.kt new file mode 100644 index 0000000..15fb302 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatTab.kt @@ -0,0 +1,6 @@ +package ru.daemonlord.messenger.ui.chats + +enum class ChatTab { + ALL, + ARCHIVED, +} 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 d7b9a5e..29c0d3d 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 @@ -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) } } }