android: add chat list chips and archive top-row state
Some checks are pending
CI / test (push) Has started running
Some checks are pending
CI / test (push) Has started running
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,3 +4,10 @@ enum class ChatTab {
|
||||
ALL,
|
||||
ARCHIVED,
|
||||
}
|
||||
|
||||
enum class ChatListFilter {
|
||||
ALL,
|
||||
PEOPLE,
|
||||
GROUPS,
|
||||
CHANNELS,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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) реализованы.
|
||||
|
||||
Reference in New Issue
Block a user