From 4502fdf9e9f7c7fe356d84c0217c2ea43c9ea9e1 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 21:43:11 +0300 Subject: [PATCH] android: add chats menu select and search interaction states --- android/CHANGELOG.md | 10 + .../messenger/ui/chats/ChatListScreen.kt | 321 +++++++++++++++--- 2 files changed, 287 insertions(+), 44 deletions(-) diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 9e57f39..4180cfc 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -546,3 +546,13 @@ - today: `HH:mm`, - this week: localized short weekday, - older: `dd.MM.yy`. + +### Step 88 - Chats list interaction states (menu/select/search) +- Added default overflow menu (`⋮`) state in chats header with Telegram-like quick actions UI. +- Added long-press multi-select mode for chat rows with: + - top selection bar (`count`, action icons), + - dedicated overflow menu for selected chats. +- Added dedicated search-mode state in chats screen: + - search field + section chips (`Chats/Channels/Apps/Posts`), + - horizontal recent avatars strip, + - list filtered by active query. 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 6ec88d0..b4bdefc 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 @@ -1,7 +1,9 @@ package ru.daemonlord.messenger.ui.chats import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.background +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,6 +26,8 @@ import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -54,9 +58,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.Inventory2 +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.NotificationsOff +import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.DragHandle import kotlinx.coroutines.flow.collectLatest import coil.compose.AsyncImage import ru.daemonlord.messenger.domain.chat.model.ChatItem @@ -139,7 +150,12 @@ fun ChatListScreen( onUnbanMember: (Long, Long) -> Unit, ) { var managementExpanded by remember { mutableStateOf(false) } - var searchExpanded by remember { mutableStateOf(false) } + var showDefaultMenu by remember { mutableStateOf(false) } + var showSelectionMenu by remember { mutableStateOf(false) } + var isSearchMode by remember { mutableStateOf(false) } + var searchSection by remember { mutableStateOf(SearchSection.Chats) } + var localSearchQuery by remember { mutableStateOf("") } + var selectedChatIds by remember { mutableStateOf>(emptySet()) } var createTitle by remember { mutableStateOf("") } var createMemberIds by remember { mutableStateOf("") } var createHandle by remember { mutableStateOf("") } @@ -185,7 +201,21 @@ fun ChatListScreen( ) { TopAppBar( navigationIcon = { - if (state.selectedTab == ChatTab.ARCHIVED) { + if (selectedChatIds.isNotEmpty()) { + IconButton(onClick = { selectedChatIds = emptySet() }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Cancel selection", + ) + } + } else if (isSearchMode) { + IconButton(onClick = { isSearchMode = false }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + } else if (state.selectedTab == ChatTab.ARCHIVED) { IconButton(onClick = { onTabSelected(ChatTab.ALL) }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, @@ -197,6 +227,8 @@ fun ChatListScreen( title = { Text( when { + selectedChatIds.isNotEmpty() -> selectedChatIds.size.toString() + isSearchMode -> "Search" state.isConnecting -> "Connecting..." state.selectedTab == ChatTab.ARCHIVED -> "Archived" else -> "Chats" @@ -204,24 +236,127 @@ fun ChatListScreen( ) }, actions = { - IconButton(onClick = { searchExpanded = !searchExpanded }) { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = if (searchExpanded) "Hide search" else "Show search", - ) - } - IconButton(onClick = { managementExpanded = !managementExpanded }) { - Icon( - imageVector = if (managementExpanded) Icons.Filled.Close else Icons.Filled.MoreVert, - contentDescription = if (managementExpanded) "Close menu" else "Open menu", - ) + if (selectedChatIds.isNotEmpty()) { + IconButton(onClick = {}) { + Icon( + imageVector = Icons.Filled.NotificationsOff, + contentDescription = "Mute selected", + ) + } + IconButton(onClick = {}) { + Icon( + imageVector = Icons.Filled.FolderOpen, + contentDescription = "Archive selected", + ) + } + IconButton(onClick = {}) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = "Delete selected", + ) + } + Box { + IconButton(onClick = { showSelectionMenu = true }) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = "Selection menu", + ) + } + DropdownMenu( + expanded = showSelectionMenu, + onDismissRequest = { showSelectionMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Unpin") }, + leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) }, + onClick = { showSelectionMenu = false }, + ) + DropdownMenuItem( + text = { Text("Add to folder") }, + leadingIcon = { Icon(Icons.Filled.FolderOpen, contentDescription = null) }, + onClick = { showSelectionMenu = false }, + ) + DropdownMenuItem( + text = { Text("Mark unread") }, + leadingIcon = { Icon(Icons.Filled.DoneAll, contentDescription = null) }, + onClick = { showSelectionMenu = false }, + ) + DropdownMenuItem( + text = { Text("Clear cache") }, + leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) }, + onClick = { showSelectionMenu = false }, + ) + } + } + } else if (isSearchMode) { + IconButton(onClick = { localSearchQuery = "" }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Clear search", + ) + } + } else { + IconButton( + onClick = { + isSearchMode = true + localSearchQuery = state.searchQuery + }, + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = "Show search", + ) + } + IconButton(onClick = { managementExpanded = !managementExpanded }) { + Icon( + imageVector = if (managementExpanded) Icons.Filled.Close else Icons.Filled.MoreVert, + contentDescription = if (managementExpanded) "Close menu" else "Open menu", + ) + } + Box { + IconButton(onClick = { showDefaultMenu = true }) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = "Open menu", + ) + } + DropdownMenu( + expanded = showDefaultMenu, + onDismissRequest = { showDefaultMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Day mode") }, + leadingIcon = { Icon(Icons.Filled.LightMode, contentDescription = null) }, + onClick = { showDefaultMenu = false }, + ) + DropdownMenuItem( + text = { Text("Create group") }, + leadingIcon = { Icon(Icons.Filled.FolderOpen, contentDescription = null) }, + onClick = { + showDefaultMenu = false + managementExpanded = true + }, + ) + DropdownMenuItem( + text = { Text("Saved") }, + leadingIcon = { Icon(Icons.Filled.Inventory2, contentDescription = null) }, + onClick = { showDefaultMenu = false }, + ) + DropdownMenuItem( + text = { Text("Proxy") }, + leadingIcon = { Icon(Icons.Filled.NotificationsOff, contentDescription = null) }, + onClick = { showDefaultMenu = false }, + ) + } + } } }, ) - if (searchExpanded) { + if (isSearchMode) { OutlinedTextField( - value = state.searchQuery, + value = localSearchQuery, onValueChange = { + localSearchQuery = it onSearchChanged(it) onGlobalSearchChanged(it) }, @@ -237,6 +372,63 @@ fun ChatListScreen( .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), ) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SearchSection.entries.forEach { section -> + FilterChip( + label = section.label, + selected = searchSection == section, + onClick = { searchSection = section }, + ) + } + } + if (searchSection == SearchSection.Chats) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + state.chats.take(8).forEach { chat -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.widthIn(max = 72.dp), + ) { + if (!chat.avatarUrl.isNullOrBlank()) { + AsyncImage( + model = chat.avatarUrl, + contentDescription = "Recent ${chat.displayTitle}", + modifier = Modifier + .size(56.dp) + .clip(CircleShape), + ) + } else { + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Text(chat.displayTitle.firstOrNull()?.uppercase() ?: "?") + } + } + Text( + text = chat.displayTitle, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } } if (state.globalSearchQuery.trim().length >= 2) { Surface( @@ -273,33 +465,35 @@ fun ChatListScreen( } } } - 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) }, - ) + if (!isSearchMode) { + 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) }, + ) + } } Box(modifier = Modifier.fillMaxSize()) { @@ -341,7 +535,26 @@ fun ChatListScreen( ) { chat -> ChatRow( chat = chat, - onClick = { onOpenChat(chat.id) }, + isSelecting = selectedChatIds.isNotEmpty(), + isSelected = selectedChatIds.contains(chat.id), + onClick = { + if (selectedChatIds.isNotEmpty()) { + selectedChatIds = if (selectedChatIds.contains(chat.id)) { + selectedChatIds - chat.id + } else { + selectedChatIds + chat.id + } + } else { + onOpenChat(chat.id) + } + }, + onLongClick = { + selectedChatIds = if (selectedChatIds.contains(chat.id)) { + selectedChatIds + } else { + selectedChatIds + chat.id + } + }, ) } } @@ -536,14 +749,21 @@ fun ChatListScreen( } @Composable +@OptIn(ExperimentalFoundationApi::class) private fun ChatRow( chat: ChatItem, + isSelecting: Boolean, + isSelected: Boolean, onClick: () -> Unit, + onLongClick: () -> Unit, ) { Column( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ) .padding(horizontal = 14.dp, vertical = 8.dp), ) { Row( @@ -637,11 +857,24 @@ private fun ChatRow( if (chat.unreadCount > 0) { BadgeChip(label = chat.unreadCount.toString()) } + if (isSelecting) { + Icon( + imageVector = if (isSelected) Icons.Filled.DoneAll else Icons.Filled.DragHandle, + contentDescription = if (isSelected) "Selected" else "Selectable", + ) + } } } } } +private enum class SearchSection(val label: String) { + Chats("Chats"), + Channels("Channels"), + Apps("Apps"), + Posts("Posts"), +} + private val chatTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") private val chatDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yy") private val chatWeekdayFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("EEE", Locale.getDefault())