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 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.

View File

@@ -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,

View File

@@ -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