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,
}