android: improve chat list row parity with avatar time and fab
Some checks are pending
CI / test (push) Has started running

This commit is contained in:
Codex
2026-03-09 14:28:16 +03:00
parent a5a940b749
commit ce585f62d2
3 changed files with 131 additions and 74 deletions

View File

@@ -253,3 +253,9 @@
- Added context actions from long-press: reply, edit, forward, delete, select, close. - 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. - 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. - 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.

View File

@@ -1,12 +1,15 @@
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.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.size
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
@@ -31,11 +34,17 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import ru.daemonlord.messenger.domain.chat.model.ChatItem import ru.daemonlord.messenger.domain.chat.model.ChatItem
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable @Composable
fun ChatListRoute( fun ChatListRoute(
@@ -134,49 +143,59 @@ fun ChatListScreen(
) )
} }
PullToRefreshBox( Box(modifier = Modifier.fillMaxSize()) {
isRefreshing = state.isRefreshing, PullToRefreshBox(
onRefresh = onRefresh, isRefreshing = state.isRefreshing,
modifier = Modifier.fillMaxSize(), onRefresh = onRefresh,
) { modifier = Modifier.fillMaxSize(),
when { ) {
state.isLoading -> { when {
CenterState(text = "Loading chats...", loading = true) state.isLoading -> {
} CenterState(text = "Loading chats...", loading = true)
}
!state.errorMessage.isNullOrBlank() -> { !state.errorMessage.isNullOrBlank() -> {
CenterState(text = state.errorMessage, loading = false) CenterState(text = state.errorMessage, loading = false)
} }
state.chats.isEmpty() -> { state.chats.isEmpty() -> {
CenterState(text = "No chats found", loading = false) CenterState(text = "No chats found", loading = false)
} }
else -> { else -> {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
if (state.selectedTab == ChatTab.ALL && state.archivedChatsCount > 0) { if (state.selectedTab == ChatTab.ALL && state.archivedChatsCount > 0) {
item(key = "archive_row") { item(key = "archive_row") {
ArchiveRow( ArchiveRow(
count = state.archivedChatsCount, count = state.archivedChatsCount,
unreadCount = state.archivedUnreadCount, unreadCount = state.archivedUnreadCount,
onClick = { onTabSelected(ChatTab.ARCHIVED) }, 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { if (!chat.avatarUrl.isNullOrBlank()) {
Text( AsyncImage(
text = chat.displayTitle, model = chat.avatarUrl,
style = MaterialTheme.typography.titleMedium, contentDescription = "Avatar",
fontWeight = if (chat.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal, 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(
text = " [PIN]", text = chat.displayTitle,
style = MaterialTheme.typography.bodySmall, 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(
text = " [MUTE]", text = presence,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 2.dp),
) )
} }
} }
Row( Column(
verticalAlignment = Alignment.CenterVertically, horizontalAlignment = Alignment.End,
horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
Text(
text = formatChatTime(chat.lastMessageCreatedAt),
style = MaterialTheme.typography.labelSmall,
)
if (chat.unreadMentionsCount > 0) { if (chat.unreadMentionsCount > 0) {
BadgeChip(label = "@") 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 @Composable
private fun FilterChip( private fun FilterChip(
label: String, label: String,

View File

@@ -47,10 +47,10 @@
## P1 — Chat List Screen Parity ## P1 — Chat List Screen Parity
- [x] Верхние фильтр-чипы/табы ("Все", "Люди", ...), компактные rounded chips. - [x] Верхние фильтр-чипы/табы ("Все", "Люди", ...), компактные rounded chips.
- [ ] Список чатов: - [ ] Список чатов:
- [ ] Аватар, title, preview, date/time. - [x] Аватар, title, preview, date/time.
- [x] Badge unread справа. - [x] Badge unread справа.
- [x] Иконки delivery/camera/attachments в preview строке. - [x] Иконки delivery/camera/attachments в preview строке.
- [ ] Плавающий FAB (compose/new chat) справа снизу. - [x] Плавающий FAB (compose/new chat) справа снизу.
- [ ] Floating bottom navigation с blur/dark container и активным фиолетовым tab. - [ ] Floating bottom navigation с blur/dark container и активным фиолетовым tab.
## P2 — Motion & Polish ## P2 — Motion & Polish