android: add chat menu and info tabs shell
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user