android: tune chats list visuals closer to telegram
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 21:38:13 +03:00
parent 6a1961e045
commit e717888d8e
2 changed files with 85 additions and 38 deletions

View File

@@ -530,3 +530,10 @@
- Added `ChatDao.markChatRead(chatId)` to clear `unread_count` and `unread_mentions_count` in Room. - Added `ChatDao.markChatRead(chatId)` to clear `unread_count` and `unread_mentions_count` in Room.
- Applied optimistic local unread reset on `markMessageRead(...)` in message repository. - Applied optimistic local unread reset on `markMessageRead(...)` in message repository.
- Fixed realtime unread logic: incoming messages in currently active chat no longer increment unread badge. - Fixed realtime unread logic: incoming messages in currently active chat no longer increment unread badge.
### Step 86 - Chats list visual pass toward Telegram reference
- Updated chats list row density: tighter vertical rhythm, larger avatar, stronger title hierarchy, cleaner secondary text.
- Restyled archive as dedicated list row with leading archive icon avatar, subtitle, and unread badge.
- Kept search in top app bar action and changed search field default to collapsed (opens via search icon).
- Returned message-type emoji markers in chat previews:
- `🖼` photo, `🎤` voice, `🎵` audio, `🎥` video, `⭕` circle video, `🔗` links.

View File

@@ -53,6 +53,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Inventory2
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@@ -134,7 +135,7 @@ fun ChatListScreen(
onUnbanMember: (Long, Long) -> Unit, onUnbanMember: (Long, Long) -> Unit,
) { ) {
var managementExpanded by remember { mutableStateOf(false) } var managementExpanded by remember { mutableStateOf(false) }
var searchExpanded by remember { mutableStateOf(true) } var searchExpanded by remember { mutableStateOf(false) }
var createTitle by remember { mutableStateOf("") } var createTitle by remember { mutableStateOf("") }
var createMemberIds by remember { mutableStateOf("") } var createMemberIds by remember { mutableStateOf("") }
var createHandle by remember { mutableStateOf("") } var createHandle by remember { mutableStateOf("") }
@@ -539,49 +540,56 @@ private fun ChatRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 14.dp, vertical = 8.dp),
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
if (!chat.avatarUrl.isNullOrBlank()) { if (!chat.avatarUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = chat.avatarUrl, model = chat.avatarUrl,
contentDescription = "Avatar for ${chat.displayTitle}", contentDescription = "Avatar for ${chat.displayTitle}",
modifier = Modifier modifier = Modifier
.size(44.dp) .size(52.dp)
.clip(CircleShape), .clip(CircleShape),
) )
} else { } else {
Box( Box(
modifier = Modifier modifier = Modifier
.size(44.dp) .size(52.dp)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text(chat.displayTitle.firstOrNull()?.uppercase() ?: "?") Text(
text = chat.displayTitle.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.titleMedium,
)
} }
} }
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text( Text(
text = chat.displayTitle, text = chat.displayTitle,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleLarge,
fontWeight = if (chat.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal, fontWeight = if (chat.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal,
maxLines = 1,
) )
if (chat.pinned) { if (chat.pinned) {
Text( Text(
text = " [PIN]", text = "📌",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.labelMedium,
) )
} }
if (chat.muted) { if (chat.muted) {
Text( Text(
text = " [MUTE]", text = "🔕",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.labelMedium,
) )
} }
} }
@@ -589,9 +597,10 @@ private fun ChatRow(
if (preview.isNotBlank()) { if (preview.isNotBlank()) {
Text( Text(
text = preview, text = preview,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp), maxLines = 1,
modifier = Modifier.padding(top = 1.dp),
) )
} }
if (chat.type == "private") { if (chat.type == "private") {
@@ -602,19 +611,19 @@ private fun ChatRow(
} }
Text( Text(
text = presence, text = presence,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 2.dp), modifier = Modifier.padding(top = 1.dp),
) )
} }
} }
Column( Column(
horizontalAlignment = Alignment.End, horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(5.dp),
) { ) {
Text( Text(
text = formatChatTime(chat.lastMessageCreatedAt), text = formatChatTime(chat.lastMessageCreatedAt),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelMedium,
) )
if (chat.unreadMentionsCount > 0) { if (chat.unreadMentionsCount > 0) {
BadgeChip(label = "@") BadgeChip(label = "@")
@@ -678,15 +687,35 @@ private fun ArchiveRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp), .padding(horizontal = 14.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box(
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Inventory2,
contentDescription = "Archived chats",
)
}
Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = "Archive ($count)", text = "Archive",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
Text(
text = "$count chats",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
if (unreadCount > 0) { if (unreadCount > 0) {
BadgeChip(label = unreadCount.toString()) BadgeChip(label = unreadCount.toString())
} }
@@ -716,22 +745,33 @@ private fun CenterState(
private fun ChatItem.previewText(): String { private fun ChatItem.previewText(): String {
val raw = lastMessageText.orEmpty().trim() val raw = lastMessageText.orEmpty().trim()
val prefix = when (lastMessageType) { val prefix = when (lastMessageType) {
"image" -> "Photo" "image" -> "🖼"
"video" -> "Video" "video" -> "🎥"
"audio" -> "Audio" "audio" -> "🎵"
"voice" -> "Voice" "voice" -> "🎤"
"file" -> "File" "file" -> "📎"
"circle_video" -> "Video message" "circle_video", "video_note" -> ""
else -> "" else -> ""
} }
if (raw.isNotEmpty()) return if (prefix.isBlank()) raw else "$prefix: $raw" val linkPrefix = if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) {
"🔗"
} else {
""
}
if (raw.isNotEmpty()) {
return when {
prefix.isNotBlank() -> "$prefix $raw"
linkPrefix.isNotBlank() -> "$linkPrefix $raw"
else -> raw
}
}
return when (lastMessageType) { return when (lastMessageType) {
"image" -> "Photo" "image" -> "🖼 Photo"
"video" -> "Video" "video" -> "🎥 Video"
"audio" -> "Audio" "audio" -> "🎵 Audio"
"voice" -> "Voice message" "voice" -> "🎤 Voice message"
"file" -> "File" "file" -> "📎 File"
"circle_video" -> "Video message" "circle_video", "video_note" -> "⭕ Circle video"
null, "text" -> "" null, "text" -> ""
else -> "Media" else -> "Media"
} }