android: add chat list chips and archive top-row state
Some checks are pending
CI / test (push) Has started running

This commit is contained in:
Codex
2026-03-09 14:10:14 +03:00
parent 6c9501e624
commit dfd4a00490
7 changed files with 156 additions and 20 deletions

View File

@@ -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).

View File

@@ -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"
}

View File

@@ -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<ChatItem> = emptyList(),
val archivedChatsCount: Int = 0,
val archivedUnreadCount: Int = 0,
val inviteTokenInput: String = "",
val isJoiningInvite: Boolean = false,
val pendingOpenChatId: Long? = null,

View File

@@ -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<ChatListUiState> = _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<ChatItem>.filterByQuery(query: String): List<ChatItem> {
private fun List<ChatItem>.filterByQueryAndType(query: String, filter: ChatListFilter): List<ChatItem> {
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)

View File

@@ -4,3 +4,10 @@ enum class ChatTab {
ALL,
ARCHIVED,
}
enum class ChatListFilter {
ALL,
PEOPLE,
GROUPS,
CHANNELS,
}

View File

@@ -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.

View File

@@ -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) реализованы.