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`, - today: `HH:mm`,
- this week: localized short weekday, - this week: localized short weekday,
- older: `dd.MM.yy`. - 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 package ru.daemonlord.messenger.ui.chats
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -24,6 +26,8 @@ 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
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close 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.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.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.Search
import androidx.compose.material.icons.filled.DragHandle
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import coil.compose.AsyncImage import coil.compose.AsyncImage
import ru.daemonlord.messenger.domain.chat.model.ChatItem import ru.daemonlord.messenger.domain.chat.model.ChatItem
@@ -139,7 +150,12 @@ fun ChatListScreen(
onUnbanMember: (Long, Long) -> Unit, onUnbanMember: (Long, Long) -> Unit,
) { ) {
var managementExpanded by remember { mutableStateOf(false) } 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 createTitle by remember { mutableStateOf("") }
var createMemberIds by remember { mutableStateOf("") } var createMemberIds by remember { mutableStateOf("") }
var createHandle by remember { mutableStateOf("") } var createHandle by remember { mutableStateOf("") }
@@ -185,7 +201,21 @@ fun ChatListScreen(
) { ) {
TopAppBar( TopAppBar(
navigationIcon = { 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) }) { IconButton(onClick = { onTabSelected(ChatTab.ALL) }) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
@@ -197,6 +227,8 @@ fun ChatListScreen(
title = { title = {
Text( Text(
when { when {
selectedChatIds.isNotEmpty() -> selectedChatIds.size.toString()
isSearchMode -> "Search"
state.isConnecting -> "Connecting..." state.isConnecting -> "Connecting..."
state.selectedTab == ChatTab.ARCHIVED -> "Archived" state.selectedTab == ChatTab.ARCHIVED -> "Archived"
else -> "Chats" else -> "Chats"
@@ -204,24 +236,127 @@ fun ChatListScreen(
) )
}, },
actions = { actions = {
IconButton(onClick = { searchExpanded = !searchExpanded }) { if (selectedChatIds.isNotEmpty()) {
Icon( IconButton(onClick = {}) {
imageVector = Icons.Filled.Search, Icon(
contentDescription = if (searchExpanded) "Hide search" else "Show search", imageVector = Icons.Filled.NotificationsOff,
) contentDescription = "Mute selected",
} )
IconButton(onClick = { managementExpanded = !managementExpanded }) { }
Icon( IconButton(onClick = {}) {
imageVector = if (managementExpanded) Icons.Filled.Close else Icons.Filled.MoreVert, Icon(
contentDescription = if (managementExpanded) "Close menu" else "Open menu", 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( OutlinedTextField(
value = state.searchQuery, value = localSearchQuery,
onValueChange = { onValueChange = {
localSearchQuery = it
onSearchChanged(it) onSearchChanged(it)
onGlobalSearchChanged(it) onGlobalSearchChanged(it)
}, },
@@ -237,6 +372,63 @@ 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 = 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) { if (state.globalSearchQuery.trim().length >= 2) {
Surface( Surface(
@@ -273,33 +465,35 @@ fun ChatListScreen(
} }
} }
} }
Row( if (!isSearchMode) {
modifier = Modifier Row(
.fillMaxWidth() modifier = Modifier
.horizontalScroll(rememberScrollState()) .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 2.dp), .horizontalScroll(rememberScrollState())
horizontalArrangement = Arrangement.spacedBy(8.dp), .padding(horizontal = 16.dp, vertical = 2.dp),
) { horizontalArrangement = Arrangement.spacedBy(8.dp),
FilterChip( ) {
label = "All", FilterChip(
selected = state.selectedFilter == ChatListFilter.ALL, label = "All",
onClick = { onFilterSelected(ChatListFilter.ALL) }, selected = state.selectedFilter == ChatListFilter.ALL,
) onClick = { onFilterSelected(ChatListFilter.ALL) },
FilterChip( )
label = "People", FilterChip(
selected = state.selectedFilter == ChatListFilter.PEOPLE, label = "People",
onClick = { onFilterSelected(ChatListFilter.PEOPLE) }, selected = state.selectedFilter == ChatListFilter.PEOPLE,
) onClick = { onFilterSelected(ChatListFilter.PEOPLE) },
FilterChip( )
label = "Groups", FilterChip(
selected = state.selectedFilter == ChatListFilter.GROUPS, label = "Groups",
onClick = { onFilterSelected(ChatListFilter.GROUPS) }, selected = state.selectedFilter == ChatListFilter.GROUPS,
) onClick = { onFilterSelected(ChatListFilter.GROUPS) },
FilterChip( )
label = "Channels", FilterChip(
selected = state.selectedFilter == ChatListFilter.CHANNELS, label = "Channels",
onClick = { onFilterSelected(ChatListFilter.CHANNELS) }, selected = state.selectedFilter == ChatListFilter.CHANNELS,
) onClick = { onFilterSelected(ChatListFilter.CHANNELS) },
)
}
} }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@@ -341,7 +535,26 @@ fun ChatListScreen(
) { chat -> ) { chat ->
ChatRow( ChatRow(
chat = chat, 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 @Composable
@OptIn(ExperimentalFoundationApi::class)
private fun ChatRow( private fun ChatRow(
chat: ChatItem, chat: ChatItem,
isSelecting: Boolean,
isSelected: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit,
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick) .combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
.padding(horizontal = 14.dp, vertical = 8.dp), .padding(horizontal = 14.dp, vertical = 8.dp),
) { ) {
Row( Row(
@@ -637,11 +857,24 @@ private fun ChatRow(
if (chat.unreadCount > 0) { if (chat.unreadCount > 0) {
BadgeChip(label = chat.unreadCount.toString()) 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 chatTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
private val chatDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yy") private val chatDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yy")
private val chatWeekdayFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("EEE", Locale.getDefault()) private val chatWeekdayFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("EEE", Locale.getDefault())