From 7781cf83e4ff45ba06c2a750139e839e7cad9b44 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 10 Mar 2026 01:42:17 +0300 Subject: [PATCH] android: refine chat header and top pinned/audio strips --- android/CHANGELOG.md | 10 ++ .../messenger/ui/chat/ChatScreen.kt | 111 ++++++++++++++++-- 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index bc7be0a..f4ac199 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -891,3 +891,13 @@ - closing search now also clears active query/filter without re-entering chat. - Added automatic inline-search collapse when entering multi-select mode. - Reworked message tap action sheet to Telegram-like list actions with icons and clearer destructive `Delete` row styling. + +### Step 125 - Chat header/top strips visual refinement +- Refined chat header density and typography to be closer to Telegram-like proportions. +- Updated pinned strip visual: + - accent vertical marker, + - tighter spacing, + - cleaner title/content hierarchy. +- Added top mini audio strip under pinned area: + - shows latest audio/voice context from loaded chat, + - includes play affordance, speed badge, and dismiss action. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index 3f460f2..6df7839 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -276,6 +276,7 @@ fun ChatScreen( } var viewerImageIndex by remember { mutableStateOf(null) } var dismissedPinnedMessageId by remember { mutableStateOf(null) } + var dismissedTopAudioMessageId by remember { mutableStateOf(null) } var actionMenuMessage by remember { mutableStateOf(null) } var pendingDeleteForAll by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) } @@ -289,6 +290,9 @@ fun ChatScreen( val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 val adaptiveHorizontalPadding = if (isTabletLayout) 72.dp else 0.dp val chatInfoEntries = remember(state.messages) { buildChatInfoEntries(state.messages) } + val topAudioStrip = remember(state.messages, dismissedTopAudioMessageId) { + findTopAudioStrip(state.messages, dismissedTopAudioMessageId) + } LaunchedEffect(state.isRecordingVoice) { if (!state.isRecordingVoice) return@LaunchedEffect @@ -378,7 +382,7 @@ fun ChatScreen( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)) - .padding(horizontal = 8.dp, vertical = 8.dp), + .padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -414,13 +418,14 @@ fun ChatScreen( Column(modifier = Modifier.weight(1f)) { Text( text = state.chatTitle.ifBlank { "Chat #${state.chatId}" }, - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, maxLines = 1, ) if (state.chatSubtitle.isNotBlank()) { Text( text = state.chatSubtitle, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -521,20 +526,27 @@ fun ChatScreen( Row( modifier = Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.88f)) - .padding(horizontal = 12.dp, vertical = 8.dp), + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.92f)) + .padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { + Box( + modifier = Modifier + .width(3.dp) + .height(30.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.primary), + ) Column(modifier = Modifier.weight(1f)) { Text( text = "Pinned message", - style = MaterialTheme.typography.labelMedium, + style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, ) Text( text = pinnedMessage.text?.takeIf { it.isNotBlank() } ?: "[${pinnedMessage.type}]", - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.labelMedium, maxLines = 2, ) } @@ -543,6 +555,56 @@ fun ChatScreen( } } } + if (topAudioStrip != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)) + .padding(horizontal = 10.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + modifier = Modifier.size(30.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = "Play top audio", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = topAudioStrip.title, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + ) + Text( + text = topAudioStrip.subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f), + ) { + Text( + text = "1x", + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + ) + } + IconButton(onClick = { dismissedTopAudioMessageId = topAudioStrip.messageId }) { + Icon(imageVector = Icons.Filled.Close, contentDescription = "Hide top audio") + } + } + } when { state.isLoading -> { @@ -2042,3 +2104,38 @@ private fun buildChatInfoEntries(messages: List): List, + dismissedMessageId: Long?, +): TopAudioStrip? { + val candidate = messages + .asReversed() + .firstOrNull { message -> + if (dismissedMessageId == message.id) return@firstOrNull false + val isVoice = message.type.contains("voice", ignoreCase = true) + val isAudio = message.type.contains("audio", ignoreCase = true) + isVoice || isAudio || message.attachments.any { + it.fileType.lowercase(Locale.getDefault()).startsWith("audio/") + } + } ?: return null + val title = candidate.senderDisplayName?.ifBlank { null } + ?: candidate.text?.takeIf { it.isNotBlank() }?.take(36) + ?: "Audio" + val subtitle = if (candidate.type.contains("voice", ignoreCase = true)) { + "Voice message • ${formatMessageTime(candidate.createdAt)}" + } else { + "Audio • ${formatMessageTime(candidate.createdAt)}" + } + return TopAudioStrip( + messageId = candidate.id, + title = title, + subtitle = subtitle, + ) +}