android: add chat menu and info tabs shell
Some checks failed
Android CI / android (push) Has started running
CI / test (push) Has been cancelled
Android Release / release (push) Failing after 5m12s

This commit is contained in:
Codex
2026-03-10 01:28:39 +03:00
parent 580a6683e3
commit 2ed0e1f041
2 changed files with 329 additions and 2 deletions

View File

@@ -862,3 +862,17 @@
- richer reaction row (`❤️ 👍 👎 🔥 🥰 👏 😁`), - richer reaction row (`❤️ 👍 👎 🔥 🥰 👏 😁`),
- reply/edit/forward/delete actions kept in one sheet. - reply/edit/forward/delete actions kept in one sheet.
- Removed duplicate/conflicting selection controls between top and bottom action rows. - Removed duplicate/conflicting selection controls between top and bottom action rows.
### Step 122 - Chat 3-dot menu + chat info media tabs shell
- Added chat header `3-dot` popup menu with Telegram-like actions:
- `Chat info`
- `Search`
- `Notifications`
- `Change wallpaper`
- `Clear history`
- Added `Chat info` bottom sheet with tabbed sections:
- `Media`
- `Files`
- `Links`
- `Voice`
- Implemented local tab content from current loaded chat messages/attachments to provide immediate media/files/links/voice overview.

View File

@@ -45,6 +45,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@@ -96,6 +98,12 @@ import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.RadioButtonChecked import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Wallpaper
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@@ -105,6 +113,7 @@ import ru.daemonlord.messenger.ui.chat.voice.VoiceRecorder
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -262,10 +271,15 @@ fun ChatScreen(
var pendingDeleteForAll by remember { mutableStateOf(false) } var pendingDeleteForAll by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
var showInlineSearch by remember { mutableStateOf(false) } var showInlineSearch by remember { mutableStateOf(false) }
var showChatMenu by remember { mutableStateOf(false) }
var showChatInfoSheet by remember { mutableStateOf(false) }
var chatInfoTab by remember { mutableStateOf(ChatInfoTab.Media) }
val actionSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val actionSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val forwardSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val forwardSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val chatInfoSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
val adaptiveHorizontalPadding = if (isTabletLayout) 72.dp else 0.dp val adaptiveHorizontalPadding = if (isTabletLayout) 72.dp else 0.dp
val chatInfoEntries = remember(state.messages) { buildChatInfoEntries(state.messages) }
LaunchedEffect(state.isRecordingVoice) { LaunchedEffect(state.isRecordingVoice) {
if (!state.isRecordingVoice) return@LaunchedEffect if (!state.isRecordingVoice) return@LaunchedEffect
@@ -402,8 +416,49 @@ fun ChatScreen(
) { ) {
Icon(imageVector = Icons.Filled.Search, contentDescription = "Search in chat") Icon(imageVector = Icons.Filled.Search, contentDescription = "Search in chat")
} }
IconButton(onClick = onLoadMore, enabled = !state.isLoadingMore) { Box {
Icon(imageVector = Icons.Filled.MoreVert, contentDescription = "More") IconButton(
onClick = { showChatMenu = true },
enabled = !state.isLoadingMore,
) {
Icon(imageVector = Icons.Filled.MoreVert, contentDescription = "More")
}
DropdownMenu(
expanded = showChatMenu,
onDismissRequest = { showChatMenu = false },
) {
DropdownMenuItem(
text = { Text("Chat info") },
leadingIcon = { Icon(Icons.Filled.Info, contentDescription = null) },
onClick = {
showChatMenu = false
showChatInfoSheet = true
},
)
DropdownMenuItem(
text = { Text("Search") },
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
onClick = {
showChatMenu = false
showInlineSearch = true
},
)
DropdownMenuItem(
text = { Text("Notifications") },
leadingIcon = { Icon(Icons.Filled.Notifications, contentDescription = null) },
onClick = { showChatMenu = false },
)
DropdownMenuItem(
text = { Text("Change wallpaper") },
leadingIcon = { Icon(Icons.Filled.Wallpaper, contentDescription = null) },
onClick = { showChatMenu = false },
)
DropdownMenuItem(
text = { Text("Clear history") },
leadingIcon = { Icon(Icons.Filled.DeleteOutline, contentDescription = null) },
onClick = { showChatMenu = false },
)
}
} }
} }
} }
@@ -685,6 +740,100 @@ fun ChatScreen(
} }
} }
} }
if (showChatInfoSheet) {
ModalBottomSheet(
onDismissRequest = { showChatInfoSheet = false },
sheetState = chatInfoSheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (!state.chatAvatarUrl.isNullOrBlank()) {
AsyncImage(
model = state.chatAvatarUrl,
contentDescription = null,
modifier = Modifier
.size(44.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop,
)
} else {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = state.chatTitle.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.titleSmall,
)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = state.chatTitle.ifBlank { "Chat #${state.chatId}" },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
if (state.chatSubtitle.isNotBlank()) {
Text(
text = state.chatSubtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ChatInfoTab.entries.forEach { tab ->
val selected = chatInfoTab == tab
Surface(
shape = RoundedCornerShape(16.dp),
color = if (selected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.22f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
},
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(16.dp))
.clickable { chatInfoTab = tab },
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = tab.title,
style = MaterialTheme.typography.labelMedium,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
)
}
}
}
}
ChatInfoTabContent(
tab = chatInfoTab,
entries = chatInfoEntries,
)
}
}
}
if (state.replyToMessage != null || state.editingMessage != null) { if (state.replyToMessage != null || state.editingMessage != null) {
val header = if (state.editingMessage != null) { val header = if (state.editingMessage != null) {
@@ -1574,3 +1723,167 @@ private fun formatBytes(value: Long): String {
else -> "$value B" else -> "$value B"
} }
} }
private enum class ChatInfoTab(val title: String) {
Media("Media"),
Files("Files"),
Links("Links"),
Voice("Voice"),
}
private enum class ChatInfoEntryType {
Media,
File,
Link,
Voice,
}
private data class ChatInfoEntry(
val type: ChatInfoEntryType,
val title: String,
val subtitle: String,
val previewImageUrl: String? = null,
)
@Composable
private fun ChatInfoTabContent(
tab: ChatInfoTab,
entries: List<ChatInfoEntry>,
) {
val filtered = remember(tab, entries) {
entries.filter {
when (tab) {
ChatInfoTab.Media -> it.type == ChatInfoEntryType.Media
ChatInfoTab.Files -> it.type == ChatInfoEntryType.File
ChatInfoTab.Links -> it.type == ChatInfoEntryType.Link
ChatInfoTab.Voice -> it.type == ChatInfoEntryType.Voice
}
}
}
if (filtered.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 20.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "No ${tab.title.lowercase(Locale.getDefault())} yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.height(320.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(filtered.take(60)) { entry ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
.padding(10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (!entry.previewImageUrl.isNullOrBlank()) {
AsyncImage(
model = entry.previewImageUrl,
contentDescription = null,
modifier = Modifier
.size(46.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop,
)
} else {
Box(
modifier = Modifier
.size(46.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
val marker = when (entry.type) {
ChatInfoEntryType.Media -> Icons.Filled.Image
ChatInfoEntryType.File -> Icons.AutoMirrored.Filled.InsertDriveFile
ChatInfoEntryType.Link -> Icons.Filled.Link
ChatInfoEntryType.Voice -> Icons.Filled.Mic
}
Icon(
imageVector = marker,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
Text(
text = entry.subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
)
}
}
}
}
}
private val urlRegex = Regex("""https?://[^\s]+""")
private fun buildChatInfoEntries(messages: List<MessageItem>): List<ChatInfoEntry> {
val entries = mutableListOf<ChatInfoEntry>()
messages
.sortedByDescending { it.id }
.forEach { message ->
val time = formatMessageTime(message.createdAt)
message.attachments.forEach { attachment ->
val normalized = attachment.fileType.lowercase(Locale.getDefault())
when {
normalized.startsWith("image/") || normalized.startsWith("video/") -> {
entries += ChatInfoEntry(
type = ChatInfoEntryType.Media,
title = extractFileName(attachment.fileUrl),
subtitle = "$time${attachment.fileType}",
previewImageUrl = if (normalized.startsWith("image/")) attachment.fileUrl else null,
)
}
normalized.startsWith("audio/") && message.type.contains("voice", ignoreCase = true) -> {
entries += ChatInfoEntry(
type = ChatInfoEntryType.Voice,
title = extractFileName(attachment.fileUrl),
subtitle = "$time${formatBytes(attachment.fileSize)}",
)
}
else -> {
entries += ChatInfoEntry(
type = ChatInfoEntryType.File,
title = extractFileName(attachment.fileUrl),
subtitle = "$time${formatBytes(attachment.fileSize)}",
)
}
}
}
urlRegex.findAll(message.text.orEmpty()).forEach { match ->
entries += ChatInfoEntry(
type = ChatInfoEntryType.Link,
title = match.value,
subtitle = "${message.senderDisplayName ?: "Unknown"}$time",
)
}
}
return entries
}