android: refine chat header and top pinned/audio strips
Some checks failed
Android CI / android (push) Failing after 5m15s
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-10 01:42:17 +03:00
parent 5a0bb9ff08
commit 7781cf83e4
2 changed files with 114 additions and 7 deletions

View File

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

View File

@@ -276,6 +276,7 @@ fun ChatScreen(
}
var viewerImageIndex by remember { mutableStateOf<Int?>(null) }
var dismissedPinnedMessageId by remember { mutableStateOf<Long?>(null) }
var dismissedTopAudioMessageId by remember { mutableStateOf<Long?>(null) }
var actionMenuMessage by remember { mutableStateOf<MessageItem?>(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<MessageItem>): List<ChatInfoEntr
}
return entries
}
private data class TopAudioStrip(
val messageId: Long,
val title: String,
val subtitle: String,
)
private fun findTopAudioStrip(
messages: List<MessageItem>,
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,
)
}