From dfd4a00490ba321ac51239e9c89dfca5a4360096 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 14:10:14 +0300 Subject: [PATCH] android: add chat list chips and archive top-row state --- android/CHANGELOG.md | 6 + .../messenger/ui/chats/ChatListScreen.kt | 108 ++++++++++++++++-- .../messenger/ui/chats/ChatListUiState.kt | 3 + .../messenger/ui/chats/ChatListViewModel.kt | 36 +++++- .../daemonlord/messenger/ui/chats/ChatTab.kt | 7 ++ docs/android-ui-batch-2-checklist.md | 6 +- docs/android-ui-batch-3-checklist.md | 10 +- 7 files changed, 156 insertions(+), 20 deletions(-) diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index b77dc7d..375ceb0 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -222,3 +222,9 @@ - Split message selection UX into dedicated top selection bar (count/close/delete/edit/reactions) and bottom action bar (reply/forward). - Enhanced selected bubble visual state with explicit selected marker text. - Updated Telegram UI batch-2 checklist items for multi-select mode. + +### Step 36 - Chat list / advanced states baseline +- Added chat-list local type filters (`All`, `People`, `Groups`, `Channels`) with new `ChatListFilter` UI state. +- Added archive statistics stream in `ChatListViewModel` and special archive top-row entry in `All` tab. +- Extended list preview formatting with media-type markers and retained unread/mention/pinned indicators. +- Updated Telegram UI checklists for chat-list advanced states (batch 2 and batch 3). 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 index 59ab26f..05b5e1f 100644 --- 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 @@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.horizontalScroll import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Button @@ -49,6 +51,7 @@ fun ChatListRoute( ChatListScreen( state = state, onTabSelected = viewModel::onTabSelected, + onFilterSelected = viewModel::onFilterSelected, onSearchChanged = viewModel::onSearchChanged, onInviteTokenChanged = viewModel::onInviteTokenChanged, onJoinByInvite = viewModel::onJoinByInvite, @@ -62,6 +65,7 @@ fun ChatListRoute( fun ChatListScreen( state: ChatListUiState, onTabSelected: (ChatTab) -> Unit, + onFilterSelected: (ChatListFilter) -> Unit, onSearchChanged: (String) -> Unit, onInviteTokenChanged: (String) -> Unit, onJoinByInvite: () -> Unit, @@ -97,6 +101,34 @@ fun ChatListScreen( .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), ) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + label = "All", + selected = state.selectedFilter == ChatListFilter.ALL, + onClick = { onFilterSelected(ChatListFilter.ALL) }, + ) + FilterChip( + label = "People", + selected = state.selectedFilter == ChatListFilter.PEOPLE, + onClick = { onFilterSelected(ChatListFilter.PEOPLE) }, + ) + FilterChip( + label = "Groups", + selected = state.selectedFilter == ChatListFilter.GROUPS, + onClick = { onFilterSelected(ChatListFilter.GROUPS) }, + ) + FilterChip( + label = "Channels", + selected = state.selectedFilter == ChatListFilter.CHANNELS, + onClick = { onFilterSelected(ChatListFilter.CHANNELS) }, + ) + } Row( modifier = Modifier @@ -142,6 +174,15 @@ fun ChatListScreen( LazyColumn( modifier = Modifier.fillMaxSize(), ) { + if (state.selectedTab == ChatTab.ALL && state.archivedChatsCount > 0) { + item(key = "archive_row") { + ArchiveRow( + count = state.archivedChatsCount, + unreadCount = state.archivedUnreadCount, + onClick = { onTabSelected(ChatTab.ARCHIVED) }, + ) + } + } items( items = state.chats, key = { it.id }, @@ -245,6 +286,50 @@ private fun BadgeChip(label: String) { ) } +@Composable +private fun FilterChip( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + AssistChip( + onClick = onClick, + label = { Text(text = label) }, + colors = AssistChipDefaults.assistChipColors( + containerColor = if (selected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + ) +} + +@Composable +private fun ArchiveRow( + count: Int, + unreadCount: Int, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Archive ($count)", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + if (unreadCount > 0) { + BadgeChip(label = unreadCount.toString()) + } + } +} + @Composable private fun CenterState( text: String?, @@ -267,14 +352,23 @@ private fun CenterState( private fun ChatItem.previewText(): String { val raw = lastMessageText.orEmpty().trim() - if (raw.isNotEmpty()) return raw + val prefix = when (lastMessageType) { + "image" -> "\uD83D\uDDBC" + "video" -> "\uD83C\uDFA5" + "audio" -> "\uD83C\uDFB5" + "voice" -> "\uD83C\uDFA4" + "file" -> "\uD83D\uDCCE" + "circle_video" -> "\u25EF" + else -> "" + } + if (raw.isNotEmpty()) return if (prefix.isBlank()) raw else "$prefix $raw" return when (lastMessageType) { - "image" -> "Photo" - "video" -> "Video" - "audio" -> "Audio" - "voice" -> "Voice message" - "file" -> "File" - "circle_video" -> "Video message" + "image" -> "\uD83D\uDDBC Photo" + "video" -> "\uD83C\uDFA5 Video" + "audio" -> "\uD83C\uDFB5 Audio" + "voice" -> "\uD83C\uDFA4 Voice message" + "file" -> "\uD83D\uDCCE File" + "circle_video" -> "\u25EF 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 index eb1e71d..6c6bce2 100644 --- 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 @@ -4,11 +4,14 @@ import ru.daemonlord.messenger.domain.chat.model.ChatItem data class ChatListUiState( val selectedTab: ChatTab = ChatTab.ALL, + val selectedFilter: ChatListFilter = ChatListFilter.ALL, val searchQuery: String = "", val isLoading: Boolean = true, val isRefreshing: Boolean = false, val errorMessage: String? = null, val chats: List = emptyList(), + val archivedChatsCount: Int = 0, + val archivedUnreadCount: Int = 0, val inviteTokenInput: String = "", val isJoiningInvite: Boolean = false, val pendingOpenChatId: Long? = null, 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 index 1787073..e93fe9b 100644 --- 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 @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import ru.daemonlord.messenger.domain.chat.model.ChatItem @@ -29,6 +30,7 @@ class ChatListViewModel @Inject constructor( ) : ViewModel() { private val selectedTab = MutableStateFlow(ChatTab.ALL) + private val selectedFilter = MutableStateFlow(ChatListFilter.ALL) private val searchQuery = MutableStateFlow("") private val _uiState = MutableStateFlow(ChatListUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -50,6 +52,12 @@ class ChatListViewModel @Inject constructor( _uiState.update { it.copy(searchQuery = value) } } + fun onFilterSelected(filter: ChatListFilter) { + if (selectedFilter.value == filter) return + selectedFilter.value = filter + _uiState.update { it.copy(selectedFilter = filter) } + } + fun onPullToRefresh() { refreshCurrentTab(forceRefresh = true) } @@ -92,20 +100,32 @@ class ChatListViewModel @Inject constructor( private fun observeChatStream() { viewModelScope.launch { + val archiveStatsFlow = observeChatsUseCase(archived = true) + .map { archived -> + archived.size to archived.sumOf { it.unreadCount } + } selectedTab .flatMapLatest { tab -> observeChatsUseCase(archived = tab == ChatTab.ARCHIVED) } .combine(searchQuery) { chats, query -> - chats.filterByQuery(query) + chats to query } - .collectLatest { filtered -> + .combine(selectedFilter) { (chats, query), filter -> + chats.filterByQueryAndType(query = query, filter = filter) + } + .combine(archiveStatsFlow) { filtered, stats -> + filtered to stats + } + .collectLatest { (filtered, stats) -> _uiState.update { it.copy( isLoading = false, isRefreshing = false, errorMessage = null, chats = filtered, + archivedChatsCount = stats.first, + archivedUnreadCount = stats.second, ) } } @@ -133,10 +153,16 @@ class ChatListViewModel @Inject constructor( } } - private fun List.filterByQuery(query: String): List { + private fun List.filterByQueryAndType(query: String, filter: ChatListFilter): List { val normalized = query.trim().lowercase() - if (normalized.isBlank()) return this - return filter { chat -> + val byType = when (filter) { + ChatListFilter.ALL -> this + ChatListFilter.PEOPLE -> filter { it.type.equals("private", ignoreCase = true) } + ChatListFilter.GROUPS -> filter { it.type.equals("group", ignoreCase = true) } + ChatListFilter.CHANNELS -> filter { it.type.equals("channel", ignoreCase = true) } + } + if (normalized.isBlank()) return byType + return byType.filter { chat -> chat.displayTitle.lowercase().contains(normalized) || (chat.counterpartUsername?.lowercase()?.contains(normalized) == true) || (chat.handle?.lowercase()?.contains(normalized) == true) 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 index 15fb302..f67c56b 100644 --- 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 @@ -4,3 +4,10 @@ enum class ChatTab { ALL, ARCHIVED, } + +enum class ChatListFilter { + ALL, + PEOPLE, + GROUPS, + CHANNELS, +} diff --git a/docs/android-ui-batch-2-checklist.md b/docs/android-ui-batch-2-checklist.md index 0810a3d..18db1fb 100644 --- a/docs/android-ui-batch-2-checklist.md +++ b/docs/android-ui-batch-2-checklist.md @@ -45,11 +45,11 @@ - [ ] Карточка "о пользователе" в истории чата (не в контактах, страна/дата регистрации и т.д.). ## P1 — Chat List Screen Parity -- [ ] Верхние фильтр-чипы/табы ("Все", "Люди", ...), компактные rounded chips. +- [x] Верхние фильтр-чипы/табы ("Все", "Люди", ...), компактные rounded chips. - [ ] Список чатов: - [ ] Аватар, title, preview, date/time. -- [ ] Badge unread справа. -- [ ] Иконки delivery/camera/attachments в preview строке. +- [x] Badge unread справа. +- [x] Иконки delivery/camera/attachments в preview строке. - [ ] Плавающий FAB (compose/new chat) справа снизу. - [ ] Floating bottom navigation с blur/dark container и активным фиолетовым tab. diff --git a/docs/android-ui-batch-3-checklist.md b/docs/android-ui-batch-3-checklist.md index 0a7102c..2fb0e77 100644 --- a/docs/android-ui-batch-3-checklist.md +++ b/docs/android-ui-batch-3-checklist.md @@ -13,10 +13,10 @@ - [ ] Комментарии к посту: отдельная строка/кнопка "N комментария". ## P0 — Chat List Advanced States -- [ ] Архив как специальный top-row элемент. -- [ ] Pinned marker в строке чата. -- [ ] Большие unread counts (например 367) корректно отображаются в badge. -- [ ] Фильтр-чипы сверху списка ("Все", "Люди", ...). +- [x] Архив как специальный top-row элемент. +- [x] Pinned marker в строке чата. +- [x] Большие unread counts (например 367) корректно отображаются в badge. +- [x] Фильтр-чипы сверху списка ("Все", "Люди", ...). ## P0 — Media Selection Mode - [ ] Multi-select overlay в медиасетке: @@ -50,4 +50,4 @@ - [ ] Посты канала визуально и структурно соответствуют референсу (preview/CTA/footer). - [ ] Multi-select media режим полноценный (верх/низ action bars + selection states). - [ ] Circle video capture соответствует основному UX Telegram. -- [ ] Продвинутые состояния списка чатов (архив/pinned/крупные badges) реализованы. +- [x] Продвинутые состояния списка чатов (архив/pinned/крупные badges) реализованы.