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