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).
|
- 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.
|
- Enhanced selected bubble visual state with explicit selected marker text.
|
||||||
- Updated Telegram UI batch-2 checklist items for multi-select mode.
|
- 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.layout.safeDrawing
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
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.AssistChip
|
||||||
import androidx.compose.material3.AssistChipDefaults
|
import androidx.compose.material3.AssistChipDefaults
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -49,6 +51,7 @@ fun ChatListRoute(
|
|||||||
ChatListScreen(
|
ChatListScreen(
|
||||||
state = state,
|
state = state,
|
||||||
onTabSelected = viewModel::onTabSelected,
|
onTabSelected = viewModel::onTabSelected,
|
||||||
|
onFilterSelected = viewModel::onFilterSelected,
|
||||||
onSearchChanged = viewModel::onSearchChanged,
|
onSearchChanged = viewModel::onSearchChanged,
|
||||||
onInviteTokenChanged = viewModel::onInviteTokenChanged,
|
onInviteTokenChanged = viewModel::onInviteTokenChanged,
|
||||||
onJoinByInvite = viewModel::onJoinByInvite,
|
onJoinByInvite = viewModel::onJoinByInvite,
|
||||||
@@ -62,6 +65,7 @@ fun ChatListRoute(
|
|||||||
fun ChatListScreen(
|
fun ChatListScreen(
|
||||||
state: ChatListUiState,
|
state: ChatListUiState,
|
||||||
onTabSelected: (ChatTab) -> Unit,
|
onTabSelected: (ChatTab) -> Unit,
|
||||||
|
onFilterSelected: (ChatListFilter) -> Unit,
|
||||||
onSearchChanged: (String) -> Unit,
|
onSearchChanged: (String) -> Unit,
|
||||||
onInviteTokenChanged: (String) -> Unit,
|
onInviteTokenChanged: (String) -> Unit,
|
||||||
onJoinByInvite: () -> Unit,
|
onJoinByInvite: () -> Unit,
|
||||||
@@ -97,6 +101,34 @@ fun ChatListScreen(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -142,6 +174,15 @@ fun ChatListScreen(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
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(
|
||||||
items = state.chats,
|
items = state.chats,
|
||||||
key = { it.id },
|
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
|
@Composable
|
||||||
private fun CenterState(
|
private fun CenterState(
|
||||||
text: String?,
|
text: String?,
|
||||||
@@ -267,14 +352,23 @@ private fun CenterState(
|
|||||||
|
|
||||||
private fun ChatItem.previewText(): String {
|
private fun ChatItem.previewText(): String {
|
||||||
val raw = lastMessageText.orEmpty().trim()
|
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) {
|
return when (lastMessageType) {
|
||||||
"image" -> "Photo"
|
"image" -> "\uD83D\uDDBC Photo"
|
||||||
"video" -> "Video"
|
"video" -> "\uD83C\uDFA5 Video"
|
||||||
"audio" -> "Audio"
|
"audio" -> "\uD83C\uDFB5 Audio"
|
||||||
"voice" -> "Voice message"
|
"voice" -> "\uD83C\uDFA4 Voice message"
|
||||||
"file" -> "File"
|
"file" -> "\uD83D\uDCCE File"
|
||||||
"circle_video" -> "Video message"
|
"circle_video" -> "\u25EF Video message"
|
||||||
null, "text" -> ""
|
null, "text" -> ""
|
||||||
else -> "Media"
|
else -> "Media"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
|||||||
|
|
||||||
data class ChatListUiState(
|
data class ChatListUiState(
|
||||||
val selectedTab: ChatTab = ChatTab.ALL,
|
val selectedTab: ChatTab = ChatTab.ALL,
|
||||||
|
val selectedFilter: ChatListFilter = ChatListFilter.ALL,
|
||||||
val searchQuery: String = "",
|
val searchQuery: String = "",
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val isRefreshing: Boolean = false,
|
val isRefreshing: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val chats: List<ChatItem> = emptyList(),
|
val chats: List<ChatItem> = emptyList(),
|
||||||
|
val archivedChatsCount: Int = 0,
|
||||||
|
val archivedUnreadCount: Int = 0,
|
||||||
val inviteTokenInput: String = "",
|
val inviteTokenInput: String = "",
|
||||||
val isJoiningInvite: Boolean = false,
|
val isJoiningInvite: Boolean = false,
|
||||||
val pendingOpenChatId: Long? = null,
|
val pendingOpenChatId: Long? = null,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||||
@@ -29,6 +30,7 @@ class ChatListViewModel @Inject constructor(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val selectedTab = MutableStateFlow(ChatTab.ALL)
|
private val selectedTab = MutableStateFlow(ChatTab.ALL)
|
||||||
|
private val selectedFilter = MutableStateFlow(ChatListFilter.ALL)
|
||||||
private val searchQuery = MutableStateFlow("")
|
private val searchQuery = MutableStateFlow("")
|
||||||
private val _uiState = MutableStateFlow(ChatListUiState())
|
private val _uiState = MutableStateFlow(ChatListUiState())
|
||||||
val uiState: StateFlow<ChatListUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ChatListUiState> = _uiState.asStateFlow()
|
||||||
@@ -50,6 +52,12 @@ class ChatListViewModel @Inject constructor(
|
|||||||
_uiState.update { it.copy(searchQuery = value) }
|
_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() {
|
fun onPullToRefresh() {
|
||||||
refreshCurrentTab(forceRefresh = true)
|
refreshCurrentTab(forceRefresh = true)
|
||||||
}
|
}
|
||||||
@@ -92,20 +100,32 @@ class ChatListViewModel @Inject constructor(
|
|||||||
|
|
||||||
private fun observeChatStream() {
|
private fun observeChatStream() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val archiveStatsFlow = observeChatsUseCase(archived = true)
|
||||||
|
.map { archived ->
|
||||||
|
archived.size to archived.sumOf { it.unreadCount }
|
||||||
|
}
|
||||||
selectedTab
|
selectedTab
|
||||||
.flatMapLatest { tab ->
|
.flatMapLatest { tab ->
|
||||||
observeChatsUseCase(archived = tab == ChatTab.ARCHIVED)
|
observeChatsUseCase(archived = tab == ChatTab.ARCHIVED)
|
||||||
}
|
}
|
||||||
.combine(searchQuery) { chats, query ->
|
.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 {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isRefreshing = false,
|
isRefreshing = false,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
chats = filtered,
|
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()
|
val normalized = query.trim().lowercase()
|
||||||
if (normalized.isBlank()) return this
|
val byType = when (filter) {
|
||||||
return filter { chat ->
|
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.displayTitle.lowercase().contains(normalized) ||
|
||||||
(chat.counterpartUsername?.lowercase()?.contains(normalized) == true) ||
|
(chat.counterpartUsername?.lowercase()?.contains(normalized) == true) ||
|
||||||
(chat.handle?.lowercase()?.contains(normalized) == true)
|
(chat.handle?.lowercase()?.contains(normalized) == true)
|
||||||
|
|||||||
@@ -4,3 +4,10 @@ enum class ChatTab {
|
|||||||
ALL,
|
ALL,
|
||||||
ARCHIVED,
|
ARCHIVED,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class ChatListFilter {
|
||||||
|
ALL,
|
||||||
|
PEOPLE,
|
||||||
|
GROUPS,
|
||||||
|
CHANNELS,
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,11 +45,11 @@
|
|||||||
- [ ] Карточка "о пользователе" в истории чата (не в контактах, страна/дата регистрации и т.д.).
|
- [ ] Карточка "о пользователе" в истории чата (не в контактах, страна/дата регистрации и т.д.).
|
||||||
|
|
||||||
## P1 — Chat List Screen Parity
|
## P1 — Chat List Screen Parity
|
||||||
- [ ] Верхние фильтр-чипы/табы ("Все", "Люди", ...), компактные rounded chips.
|
- [x] Верхние фильтр-чипы/табы ("Все", "Люди", ...), компактные rounded chips.
|
||||||
- [ ] Список чатов:
|
- [ ] Список чатов:
|
||||||
- [ ] Аватар, title, preview, date/time.
|
- [ ] Аватар, title, preview, date/time.
|
||||||
- [ ] Badge unread справа.
|
- [x] Badge unread справа.
|
||||||
- [ ] Иконки delivery/camera/attachments в preview строке.
|
- [x] Иконки delivery/camera/attachments в preview строке.
|
||||||
- [ ] Плавающий FAB (compose/new chat) справа снизу.
|
- [ ] Плавающий FAB (compose/new chat) справа снизу.
|
||||||
- [ ] Floating bottom navigation с blur/dark container и активным фиолетовым tab.
|
- [ ] Floating bottom navigation с blur/dark container и активным фиолетовым tab.
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,10 @@
|
|||||||
- [ ] Комментарии к посту: отдельная строка/кнопка "N комментария".
|
- [ ] Комментарии к посту: отдельная строка/кнопка "N комментария".
|
||||||
|
|
||||||
## P0 — Chat List Advanced States
|
## P0 — Chat List Advanced States
|
||||||
- [ ] Архив как специальный top-row элемент.
|
- [x] Архив как специальный top-row элемент.
|
||||||
- [ ] Pinned marker в строке чата.
|
- [x] Pinned marker в строке чата.
|
||||||
- [ ] Большие unread counts (например 367) корректно отображаются в badge.
|
- [x] Большие unread counts (например 367) корректно отображаются в badge.
|
||||||
- [ ] Фильтр-чипы сверху списка ("Все", "Люди", ...).
|
- [x] Фильтр-чипы сверху списка ("Все", "Люди", ...).
|
||||||
|
|
||||||
## P0 — Media Selection Mode
|
## P0 — Media Selection Mode
|
||||||
- [ ] Multi-select overlay в медиасетке:
|
- [ ] Multi-select overlay в медиасетке:
|
||||||
@@ -50,4 +50,4 @@
|
|||||||
- [ ] Посты канала визуально и структурно соответствуют референсу (preview/CTA/footer).
|
- [ ] Посты канала визуально и структурно соответствуют референсу (preview/CTA/footer).
|
||||||
- [ ] Multi-select media режим полноценный (верх/низ action bars + selection states).
|
- [ ] Multi-select media режим полноценный (верх/низ action bars + selection states).
|
||||||
- [ ] Circle video capture соответствует основному UX Telegram.
|
- [ ] Circle video capture соответствует основному UX Telegram.
|
||||||
- [ ] Продвинутые состояния списка чатов (архив/pinned/крупные badges) реализованы.
|
- [x] Продвинутые состояния списка чатов (архив/pinned/крупные badges) реализованы.
|
||||||
|
|||||||
Reference in New Issue
Block a user