From 18844ec06a73bbb4e3947457435b0c108ae00a6e Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 22:03:43 +0300 Subject: [PATCH] android: redesign chats search as fullscreen telegram-like flow --- android/CHANGELOG.md | 10 + .../messenger/ui/chats/ChatListScreen.kt | 619 +++++++++++++----- 2 files changed, 453 insertions(+), 176 deletions(-) diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 8a30da8..d9c7de5 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -567,3 +567,13 @@ - create group/channel -> open management panel, - saved -> open saved chat if present, - 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). 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 ade46e2..042ebd2 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 @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding @@ -34,6 +35,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text 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.Search import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.filled.Clear import kotlinx.coroutines.flow.collectLatest import coil.compose.AsyncImage import ru.daemonlord.messenger.domain.chat.model.ChatItem @@ -231,7 +234,7 @@ fun ChatListScreen( Text( when { selectedChatIds.isNotEmpty() -> selectedChatIds.size.toString() - isSearchMode -> "Search" + isSearchMode -> "" state.isConnecting -> "Connecting..." state.selectedTab == ChatTab.ARCHIVED -> "Archived" else -> "Chats" @@ -311,14 +314,7 @@ fun ChatListScreen( ) } } - } else if (isSearchMode) { - IconButton(onClick = { localSearchQuery = "" }) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = "Clear search", - ) - } - } else { + } else if (!isSearchMode) { IconButton( onClick = { 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) { Row( modifier = Modifier @@ -536,73 +419,85 @@ fun ChatListScreen( 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()) { - PullToRefreshBox( - isRefreshing = state.isRefreshing, - onRefresh = onRefresh, - modifier = Modifier.fillMaxSize(), - ) { - when { - state.isLoading -> { - CenterState(text = "Loading chats...", loading = true) - } + !state.errorMessage.isNullOrBlank() -> { + CenterState(text = state.errorMessage, loading = false) + } - !state.errorMessage.isNullOrBlank() -> { - CenterState(text = state.errorMessage, loading = false) - } + state.chats.isEmpty() -> { + CenterState(text = "No chats found", loading = false) + } - state.chats.isEmpty() -> { - CenterState(text = "No chats found", loading = false) - } - - else -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - ) { - if (state.selectedTab == ChatTab.ALL && state.archivedChatsCount > 0) { - item(key = "archive_row") { - ArchiveRow( - count = state.archivedChatsCount, - unreadCount = state.archivedUnreadCount, - onClick = { onTabSelected(ChatTab.ARCHIVED) }, - ) + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + ) { + if (state.selectedTab == ChatTab.ALL && state.archivedChatsCount > 0) { + item(key = "archive_row") { + ArchiveRow( + count = state.archivedChatsCount, + unreadCount = state.archivedUnreadCount, + onClick = { onTabSelected(ChatTab.ARCHIVED) }, + ) + } } - } - items( - items = state.chats, - key = { it.id }, - ) { chat -> - ChatRow( - chat = chat, - isSelecting = selectedChatIds.isNotEmpty(), - isSelected = selectedChatIds.contains(chat.id), - onClick = { - if (selectedChatIds.isNotEmpty()) { + items( + items = state.chats, + key = { it.id }, + ) { chat -> + ChatRow( + chat = chat, + 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 - chat.id + selectedChatIds } else { 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) { 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 @OptIn(ExperimentalFoundationApi::class) private fun ChatRow(