android: redesign chats search as fullscreen telegram-like flow
Some checks failed
Android CI / android (push) Has been cancelled
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 22:03:43 +03:00
parent 28f7da5f41
commit 18844ec06a
2 changed files with 453 additions and 176 deletions

View File

@@ -567,3 +567,13 @@
- create group/channel -> open management panel, - create group/channel -> open management panel,
- saved -> open saved chat if present, - saved -> open saved chat if present,
- unsupported items show clear feedback instead of silent no-op. - unsupported items show clear feedback instead of silent no-op.
### Step 90 - Fullscreen chats search redesign (Telegram-like)
- Reworked chats search mode into a fullscreen flow:
- top rounded search field with inline clear button,
- horizontal category chips (`Chats`, `Channels`, `Apps`, `Posts`),
- dedicated recent avatars row for the active category.
- Added search-mode content states:
- empty query -> `Recent` list block (history-style chat rows),
- non-empty query -> local matches + `Global search` and `Messages` sections.
- Kept search action in chats top bar; while search mode is active, app bar switches to back-navigation + empty title (content drives the page).

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
@@ -34,6 +35,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -70,6 +72,7 @@ import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.PushPin 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 androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Clear
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
@@ -231,7 +234,7 @@ fun ChatListScreen(
Text( Text(
when { when {
selectedChatIds.isNotEmpty() -> selectedChatIds.size.toString() selectedChatIds.isNotEmpty() -> selectedChatIds.size.toString()
isSearchMode -> "Search" isSearchMode -> ""
state.isConnecting -> "Connecting..." state.isConnecting -> "Connecting..."
state.selectedTab == ChatTab.ARCHIVED -> "Archived" state.selectedTab == ChatTab.ARCHIVED -> "Archived"
else -> "Chats" else -> "Chats"
@@ -311,14 +314,7 @@ fun ChatListScreen(
) )
} }
} }
} else if (isSearchMode) { } else if (!isSearchMode) {
IconButton(onClick = { localSearchQuery = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Clear search",
)
}
} else {
IconButton( IconButton(
onClick = { onClick = {
isSearchMode = true isSearchMode = true
@@ -394,119 +390,6 @@ fun ChatListScreen(
} }
}, },
) )
if (isSearchMode) {
OutlinedTextField(
value = localSearchQuery,
onValueChange = {
localSearchQuery = it
onSearchChanged(it)
onGlobalSearchChanged(it)
},
label = { Text(text = "Search chats") },
singleLine = true,
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = "Search",
)
},
modifier = Modifier
.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(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.75f),
shape = RoundedCornerShape(10.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
if (state.globalUsers.isNotEmpty()) {
Text("Users", fontWeight = FontWeight.SemiBold)
state.globalUsers.take(5).forEach { user ->
Text("${user.name}${user.username?.let { " (@$it)" } ?: ""}")
}
}
if (state.globalMessages.isNotEmpty()) {
Text("Messages", fontWeight = FontWeight.SemiBold)
state.globalMessages.take(5).forEach { message ->
Text(
text = "• [chat ${message.chatId}] ${(message.text ?: "[${message.type}]").take(60)}",
modifier = Modifier.clickable { onOpenChat(message.chatId) },
)
}
}
if (state.globalUsers.isEmpty() && state.globalMessages.isEmpty()) {
Text("No global results")
}
}
}
}
if (!isSearchMode) { if (!isSearchMode) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -536,73 +419,85 @@ fun ChatListScreen(
onClick = { onFilterSelected(ChatListFilter.CHANNELS) }, onClick = { onFilterSelected(ChatListFilter.CHANNELS) },
) )
} }
} Box(modifier = Modifier.fillMaxSize()) {
PullToRefreshBox(
isRefreshing = state.isRefreshing,
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize(),
) {
when {
state.isLoading -> {
CenterState(text = "Loading chats...", loading = true)
}
Box(modifier = Modifier.fillMaxSize()) { !state.errorMessage.isNullOrBlank() -> {
PullToRefreshBox( CenterState(text = state.errorMessage, loading = false)
isRefreshing = state.isRefreshing, }
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize(),
) {
when {
state.isLoading -> {
CenterState(text = "Loading chats...", loading = true)
}
!state.errorMessage.isNullOrBlank() -> { state.chats.isEmpty() -> {
CenterState(text = state.errorMessage, loading = false) CenterState(text = "No chats found", loading = false)
} }
state.chats.isEmpty() -> { else -> {
CenterState(text = "No chats found", loading = false) LazyColumn(
} modifier = Modifier.fillMaxSize(),
state = listState,
else -> { ) {
LazyColumn( if (state.selectedTab == ChatTab.ALL && state.archivedChatsCount > 0) {
modifier = Modifier.fillMaxSize(), item(key = "archive_row") {
state = listState, ArchiveRow(
) { count = state.archivedChatsCount,
if (state.selectedTab == ChatTab.ALL && state.archivedChatsCount > 0) { unreadCount = state.archivedUnreadCount,
item(key = "archive_row") { onClick = { onTabSelected(ChatTab.ARCHIVED) },
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 }, ) { chat ->
) { chat -> ChatRow(
ChatRow( chat = chat,
chat = chat, isSelecting = selectedChatIds.isNotEmpty(),
isSelecting = selectedChatIds.isNotEmpty(), isSelected = selectedChatIds.contains(chat.id),
isSelected = selectedChatIds.contains(chat.id), onClick = {
onClick = { if (selectedChatIds.isNotEmpty()) {
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 = if (selectedChatIds.contains(chat.id)) {
selectedChatIds - chat.id selectedChatIds
} else { } else {
selectedChatIds + chat.id selectedChatIds + chat.id
} }
} else { },
onOpenChat(chat.id) )
} }
},
onLongClick = {
selectedChatIds = if (selectedChatIds.contains(chat.id)) {
selectedChatIds
} else {
selectedChatIds + chat.id
}
},
)
} }
} }
} }
} }
} }
} else {
ChatSearchFullscreen(
state = state,
searchSection = searchSection,
searchQuery = localSearchQuery,
onSearchChanged = {
localSearchQuery = it
onSearchChanged(it)
onGlobalSearchChanged(it)
},
onSectionChanged = { searchSection = it },
onOpenChat = onOpenChat,
)
} }
if (managementExpanded) { if (managementExpanded) {
Surface( Surface(
@@ -790,6 +685,378 @@ fun ChatListScreen(
} }
} }
@Composable
private fun ChatSearchFullscreen(
state: ChatListUiState,
searchSection: SearchSection,
searchQuery: String,
onSearchChanged: (String) -> Unit,
onSectionChanged: (SearchSection) -> Unit,
onOpenChat: (Long) -> Unit,
) {
val trimmedQuery = searchQuery.trim()
val sectionChats = remember(state.chats, searchSection) {
when (searchSection) {
SearchSection.Chats -> state.chats
SearchSection.Channels -> state.chats.filter { it.type.equals("channel", ignoreCase = true) }
SearchSection.Apps -> emptyList()
SearchSection.Posts -> emptyList()
}
}
val localQueryResults = remember(sectionChats, trimmedQuery) {
if (trimmedQuery.isBlank()) {
emptyList()
} else {
sectionChats.filter {
it.displayTitle.contains(trimmedQuery, ignoreCase = true) ||
(it.counterpartUsername?.contains(trimmedQuery, ignoreCase = true) == true) ||
(it.handle?.contains(trimmedQuery, ignoreCase = true) == true) ||
(it.lastMessageText?.contains(trimmedQuery, ignoreCase = true) == true)
}
}
}
val recentCircleChats = remember(sectionChats) { sectionChats.take(8) }
val recentHistoryChats = remember(sectionChats) { sectionChats.take(14) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
OutlinedTextField(
value = searchQuery,
onValueChange = onSearchChanged,
placeholder = { Text("Поиск чатов") },
singleLine = true,
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = "Search",
)
},
trailingIcon = {
if (searchQuery.isNotBlank()) {
IconButton(onClick = { onSearchChanged("") }) {
Icon(
imageVector = Icons.Filled.Clear,
contentDescription = "Clear",
)
}
}
},
colors = OutlinedTextFieldDefaults.colors(),
shape = RoundedCornerShape(22.dp),
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
SearchSection.entries.forEach { section ->
FilterChip(
label = section.label,
selected = searchSection == section,
onClick = { onSectionChanged(section) },
)
}
}
Spacer(modifier = Modifier.height(8.dp))
if (trimmedQuery.isBlank()) {
if (recentCircleChats.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(bottom = 10.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
recentCircleChats.forEach { chat ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.widthIn(max = 74.dp)
.clickable { onOpenChat(chat.id) },
) {
ChatAvatar(chat = chat, size = 56.dp)
Text(
text = chat.displayTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Недавние",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "Очистить",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(recentHistoryChats, key = { "recent_${it.id}" }) { chat ->
SearchChatRow(chat = chat, onClick = { onOpenChat(chat.id) })
}
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
if (localQueryResults.isNotEmpty()) {
items(localQueryResults.take(6), key = { "local_${it.id}" }) { chat ->
SearchChatRow(chat = chat, onClick = { onOpenChat(chat.id) })
}
}
item(key = "global_header") {
SectionHeader(
title = "Глобальный поиск",
action = "Показать больше",
)
}
if (state.globalUsers.isNotEmpty()) {
items(state.globalUsers.take(8), key = { "user_${it.id}" }) { user ->
SearchUserRow(
title = user.name,
subtitle = buildString {
append("@")
append(user.username ?: user.id.toString())
},
)
}
} else {
item(key = "global_empty") {
Text(
text = "Нет результатов",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 10.dp),
)
}
}
item(key = "messages_header") {
SectionHeader(
title = "Сообщения",
action = "Из всех чатов",
)
}
items(state.globalMessages.take(12), key = { "msg_${it.id}" }) { message ->
SearchMessageRow(
state = state,
messageText = message.text?.take(70).orEmpty().ifBlank { "[${message.type}]" },
time = formatChatTime(message.createdAt),
onClick = { onOpenChat(message.chatId) },
chatId = message.chatId,
)
}
}
}
}
}
@Composable
private fun SectionHeader(
title: String,
action: String,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
text = action,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun SearchChatRow(
chat: ChatItem,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
ChatAvatar(chat = chat, size = 48.dp)
Column(modifier = Modifier.weight(1f)) {
Text(
text = chat.displayTitle,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = when {
chat.type.equals("private", ignoreCase = true) -> chat.counterpartUsername?.let { "@$it" } ?: "private"
chat.type.equals("channel", ignoreCase = true) -> "канал"
else -> "группа"
}
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (chat.unreadCount > 0) {
BadgeChip(label = chat.unreadCount.toString())
}
}
}
@Composable
private fun SearchUserRow(
title: String,
subtitle: String,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Text(
text = title.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.titleMedium,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun SearchMessageRow(
state: ChatListUiState,
messageText: String,
time: String,
chatId: Long,
onClick: () -> Unit,
) {
val chat = state.chats.firstOrNull { it.id == chatId }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (chat != null) {
ChatAvatar(chat = chat, size = 48.dp)
} else {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Text("M")
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = chat?.displayTitle ?: "Chat $chatId",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = messageText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = time,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ChatAvatar(
chat: ChatItem,
size: androidx.compose.ui.unit.Dp,
) {
if (!chat.avatarUrl.isNullOrBlank()) {
AsyncImage(
model = chat.avatarUrl,
contentDescription = "Avatar for ${chat.displayTitle}",
modifier = Modifier
.size(size)
.clip(CircleShape),
)
} else {
Box(
modifier = Modifier
.size(size)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Text(
text = chat.displayTitle.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.titleMedium,
)
}
}
}
@Composable @Composable
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
private fun ChatRow( private fun ChatRow(