android: add chats menu select and search interaction states
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user