android: add chats menu select and search interaction states
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 21:43:11 +03:00
parent 2324801f56
commit 4502fdf9e9
2 changed files with 287 additions and 44 deletions

View File

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

View File

@@ -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<Set<Long>>(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,10 +236,75 @@ fun ChatListScreen(
)
},
actions = {
IconButton(onClick = { searchExpanded = !searchExpanded }) {
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 = if (searchExpanded) "Hide search" else "Show search",
contentDescription = "Show search",
)
}
IconButton(onClick = { managementExpanded = !managementExpanded }) {
@@ -216,12 +313,50 @@ fun ChatListScreen(
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
},
)
if (searchExpanded) {
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 (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,6 +465,7 @@ fun ChatListScreen(
}
}
}
if (!isSearchMode) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -301,6 +494,7 @@ fun ChatListScreen(
onClick = { onFilterSelected(ChatListFilter.CHANNELS) },
)
}
}
Box(modifier = Modifier.fillMaxSize()) {
PullToRefreshBox(
@@ -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,10 +857,23 @@ 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")