From ce585f62d2f0252564a4038fdbaff3bef1e5a14f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 14:28:16 +0300 Subject: [PATCH] android: improve chat list row parity with avatar time and fab --- android/CHANGELOG.md | 6 + .../messenger/ui/chats/ChatListScreen.kt | 195 +++++++++++------- docs/android-ui-batch-2-checklist.md | 4 +- 3 files changed, 131 insertions(+), 74 deletions(-) diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index da32496..9259cb8 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -253,3 +253,9 @@ - Added context actions from long-press: reply, edit, forward, delete, select, close. - Added placeholder disabled pin action in the menu to keep action set consistent with Telegram-like flow. - Updated Telegram UI batch-2 checklist items for long-press reactions and context menu. + +### Step 42 - Chat list / row and FAB parity pass +- Updated chat list rows with avatar rendering, trailing message time, and richer right-side metadata layout. +- Kept unread/mention/pinned/muted indicators while aligning row structure closer to Telegram list pattern. +- Added floating compose FAB placeholder at bottom-right in chat list screen. +- Updated Telegram UI batch-2 checklist chat-list parity items. 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 4cd12aa..45b8508 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 @@ -1,12 +1,15 @@ package ru.daemonlord.messenger.ui.chats import androidx.compose.foundation.clickable +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.WindowInsets @@ -31,11 +34,17 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage import ru.daemonlord.messenger.domain.chat.model.ChatItem +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter @Composable fun ChatListRoute( @@ -134,49 +143,59 @@ fun ChatListScreen( ) } - 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(), - ) { - 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(), + ) { + 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, + onClick = { onOpenChat(chat.id) }, ) } } - items( - items = state.chats, - key = { it.id }, - ) { chat -> - ChatRow( - chat = chat, - onClick = { onOpenChat(chat.id) }, - ) - } } } } + Button( + onClick = {}, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + ) { + Text("+") + } } } } @@ -195,31 +214,78 @@ private fun ChatRow( Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = chat.displayTitle, - style = MaterialTheme.typography.titleMedium, - fontWeight = if (chat.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal, + if (!chat.avatarUrl.isNullOrBlank()) { + AsyncImage( + model = chat.avatarUrl, + contentDescription = "Avatar", + modifier = Modifier + .size(44.dp) + .clip(CircleShape), ) - if (chat.pinned) { + } else { + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Text(chat.displayTitle.firstOrNull()?.uppercase() ?: "?") + } + } + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = " [PIN]", - style = MaterialTheme.typography.bodySmall, + text = chat.displayTitle, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (chat.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal, + ) + if (chat.pinned) { + Text( + text = " [PIN]", + style = MaterialTheme.typography.bodySmall, + ) + } + if (chat.muted) { + Text( + text = " [MUTE]", + style = MaterialTheme.typography.bodySmall, + ) + } + } + val preview = chat.previewText() + if (preview.isNotBlank()) { + Text( + text = preview, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), ) } - if (chat.muted) { + if (chat.type == "private") { + val presence = if (chat.counterpartIsOnline == true) { + "online" + } else { + chat.counterpartLastSeenAt?.let { "last seen recently" } ?: "offline" + } Text( - text = " [MUTE]", + text = presence, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 2.dp), ) } } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(6.dp), ) { + Text( + text = formatChatTime(chat.lastMessageCreatedAt), + style = MaterialTheme.typography.labelSmall, + ) if (chat.unreadMentionsCount > 0) { BadgeChip(label = "@") } @@ -228,30 +294,6 @@ private fun ChatRow( } } } - - val preview = chat.previewText() - if (preview.isNotBlank()) { - Text( - text = preview, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp), - ) - } - - if (chat.type == "private") { - val presence = if (chat.counterpartIsOnline == true) { - "online" - } else { - chat.counterpartLastSeenAt?.let { "last seen recently" } ?: "offline" - } - Text( - text = presence, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 2.dp), - ) - } } } @@ -268,6 +310,15 @@ private fun BadgeChip(label: String) { ) } +private val chatTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") + +private fun formatChatTime(iso: String?): String { + if (iso.isNullOrBlank()) return "" + return runCatching { + Instant.parse(iso).atZone(ZoneId.systemDefault()).toLocalTime().format(chatTimeFormatter) + }.getOrElse { "" } +} + @Composable private fun FilterChip( label: String, diff --git a/docs/android-ui-batch-2-checklist.md b/docs/android-ui-batch-2-checklist.md index c572f65..a9ce9d7 100644 --- a/docs/android-ui-batch-2-checklist.md +++ b/docs/android-ui-batch-2-checklist.md @@ -47,10 +47,10 @@ ## P1 — Chat List Screen Parity - [x] Верхние фильтр-чипы/табы ("Все", "Люди", ...), компактные rounded chips. - [ ] Список чатов: -- [ ] Аватар, title, preview, date/time. +- [x] Аватар, title, preview, date/time. - [x] Badge unread справа. - [x] Иконки delivery/camera/attachments в preview строке. -- [ ] Плавающий FAB (compose/new chat) справа снизу. +- [x] Плавающий FAB (compose/new chat) справа снизу. - [ ] Floating bottom navigation с blur/dark container и активным фиолетовым tab. ## P2 — Motion & Polish