Android chat UX: video viewer, emoji/gif/sticker picker, day separators
Some checks failed
Android CI / android (push) Failing after 5m21s
Android Release / release (push) Failing after 5m16s
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-10 08:38:54 +03:00
parent 0beb52e438
commit 78934a5f28
5 changed files with 396 additions and 10 deletions

View File

@@ -943,3 +943,17 @@
- Notification delivery polish (foundation):
- foreground notification body now respects preview setting and falls back to media-type labels when previews are disabled.
- Verified with successful `:app:compileDebugKotlin` and `:app:assembleDebug`.
### Step 130 - Chat UX pack: video viewer, emoji/GIF/sticker picker, day separators
- Added chat timeline day separators with Telegram-like chips:
- `Сегодня`, `Вчера`, or localized date labels.
- Added fullscreen video viewer:
- video attachments now open in a fullscreen overlay with close action.
- Added composer media picker sheet:
- tabs: `Эмодзи`, `GIF`, `Стикеры`,
- emoji insertion at cursor,
- remote GIF/sticker selection with download+send flow.
- Extended media type mapping in message send pipeline:
- GIFs now sent as `gif`,
- sticker-like payloads sent as `sticker` (filename/mime detection).
- Added missing fallback resource theme declarations (`themes.xml`) to stabilize clean debug assembly on local builds.

View File

@@ -490,11 +490,15 @@ class NetworkMessageRepository @Inject constructor(
mimeType: String,
fileName: String,
): String {
val normalizedMime = mimeType.lowercase()
val normalizedName = fileName.lowercase()
return when {
mimeType.startsWith("image/") -> "image"
mimeType.startsWith("video/") -> "video"
mimeType.startsWith("audio/") && fileName.startsWith("voice_", ignoreCase = true) -> "voice"
mimeType.startsWith("audio/") -> "audio"
normalizedMime == "image/gif" || normalizedName.endsWith(".gif") || normalizedName.startsWith("gif_") -> "gif"
normalizedName.startsWith("sticker_") || normalizedMime == "image/webp" -> "sticker"
normalizedMime.startsWith("image/") -> "image"
normalizedMime.startsWith("video/") -> "video"
normalizedMime.startsWith("audio/") && normalizedName.startsWith("voice_") -> "voice"
normalizedMime.startsWith("audio/") -> "audio"
else -> "file"
}
}

View File

@@ -67,6 +67,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
@@ -127,12 +128,17 @@ import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import coil.compose.AsyncImage
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.daemonlord.messenger.core.audio.AppAudioFocusCoordinator
import ru.daemonlord.messenger.domain.message.model.MessageItem
import ru.daemonlord.messenger.ui.chat.voice.VoiceRecorder
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.util.Locale
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
@@ -208,6 +214,13 @@ fun ChatRoute(
onCancelComposeAction = viewModel::onCancelComposeAction,
onLoadMore = viewModel::loadMore,
onPickMedia = { pickMediaLauncher.launch("*/*") },
onSendRemoteMedia = { fileName, mimeType, bytes ->
viewModel.onMediaPicked(
fileName = fileName,
mimeType = mimeType,
bytes = bytes,
)
},
onVoiceRecordStart = {
if (!hasAudioPermission) {
startRecordingAfterPermissionGrant = true
@@ -267,6 +280,7 @@ fun ChatScreen(
onCancelComposeAction: () -> Unit,
onLoadMore: () -> Unit,
onPickMedia: () -> Unit,
onSendRemoteMedia: (String, String, ByteArray) -> Unit,
onVoiceRecordStart: () -> Unit,
onVoiceRecordTick: () -> Unit,
onVoiceRecordLock: () -> Unit,
@@ -277,6 +291,7 @@ fun ChatScreen(
onVisibleIncomingMessageId: (Long?) -> Unit,
) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val allImageUrls = remember(state.messages) {
state.messages
.flatMap { message -> message.attachments }
@@ -285,7 +300,9 @@ fun ChatScreen(
.map { (url, _) -> url }
.distinct()
}
val timelineItems = remember(state.messages) { buildChatTimelineItems(state.messages) }
var viewerImageIndex by remember { mutableStateOf<Int?>(null) }
var viewerVideoUrl by remember { mutableStateOf<String?>(null) }
var dismissedPinnedMessageId by remember { mutableStateOf<Long?>(null) }
var topAudioStrip by remember { mutableStateOf<TopAudioStrip?>(null) }
var forceStopAudioSourceId by remember { mutableStateOf<String?>(null) }
@@ -293,6 +310,9 @@ fun ChatScreen(
var pendingDeleteForAll by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
var showInlineSearch by remember { mutableStateOf(false) }
var showEmojiPicker by remember { mutableStateOf(false) }
var emojiPickerTab by remember { mutableStateOf(ComposerPickerTab.Emoji) }
var isPickerSending by remember { mutableStateOf(false) }
var showChatMenu by remember { mutableStateOf(false) }
var showChatInfoSheet by remember { mutableStateOf(false) }
var chatInfoTab by remember { mutableStateOf(ChatInfoTab.Media) }
@@ -311,9 +331,11 @@ fun ChatScreen(
delay(120)
}
}
LaunchedEffect(state.highlightedMessageId, state.messages) {
LaunchedEffect(state.highlightedMessageId, timelineItems) {
val messageId = state.highlightedMessageId ?: return@LaunchedEffect
val index = state.messages.indexOfFirst { it.id == messageId }
val index = timelineItems.indexOfFirst { item ->
item is ChatTimelineItem.MessageEntry && item.message.id == messageId
}
if (index >= 0) {
listState.animateScrollToItem(index = index)
}
@@ -333,10 +355,13 @@ fun ChatScreen(
onInlineSearchChanged("")
}
}
LaunchedEffect(listState, state.messages) {
LaunchedEffect(listState, timelineItems) {
snapshotFlow {
listState.layoutInfo.visibleItemsInfo
.mapNotNull { info -> state.messages.getOrNull(info.index) }
.mapNotNull { info ->
val item = timelineItems.getOrNull(info.index)
(item as? ChatTimelineItem.MessageEntry)?.message
}
.filter { !it.isOutgoing }
.maxOfOrNull { it.id }
}
@@ -647,7 +672,20 @@ fun ChatScreen(
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(state.messages, key = { it.id }) { message ->
items(
items = timelineItems,
key = { item ->
when (item) {
is ChatTimelineItem.DayHeader -> "day:${item.dateKey}"
is ChatTimelineItem.MessageEntry -> "msg:${item.message.id}"
}
},
) { item ->
if (item is ChatTimelineItem.DayHeader) {
DaySeparatorChip(label = item.label)
return@items
}
val message = (item as ChatTimelineItem.MessageEntry).message
val isSelected = state.actionState.selectedMessageIds.contains(message.id)
MessageBubble(
message = message,
@@ -659,6 +697,9 @@ fun ChatScreen(
val idx = allImageUrls.indexOf(imageUrl)
viewerImageIndex = if (idx >= 0) idx else null
},
onAttachmentVideoClick = { videoUrl ->
viewerVideoUrl = videoUrl
},
onAudioPlaybackChanged = { playback ->
if (playback.isPlaying) {
topAudioStrip = playback
@@ -1145,7 +1186,7 @@ fun ChatScreen(
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f),
) {
IconButton(
onClick = { /* emoji picker step */ },
onClick = { showEmojiPicker = true },
enabled = state.canSendMessages,
) {
Icon(
@@ -1365,6 +1406,189 @@ fun ChatScreen(
}
}
}
if (viewerVideoUrl != null) {
val videoUrl = viewerVideoUrl.orEmpty()
Surface(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.78f)),
color = Color.Black.copy(alpha = 0.9f),
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.End,
) {
IconButton(onClick = { viewerVideoUrl = null }) {
Icon(imageVector = Icons.Filled.Close, contentDescription = "Close video viewer")
}
}
AndroidView(
factory = { context ->
VideoView(context).apply {
setVideoPath(videoUrl)
setOnPreparedListener { player ->
player.isLooping = true
start()
}
}
},
update = { view ->
if (!view.isPlaying) {
runCatching { view.start() }
}
},
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 10.dp),
)
Text(
text = extractFileName(videoUrl),
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
style = MaterialTheme.typography.bodyMedium,
color = Color.White,
)
}
}
}
if (showEmojiPicker) {
ModalBottomSheet(
onDismissRequest = { if (!isPickerSending) showEmojiPicker = false },
sheetState = actionSheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ComposerPickerTab.entries.forEach { tab ->
val selected = emojiPickerTab == tab
Surface(
shape = RoundedCornerShape(16.dp),
color = if (selected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.22f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.52f)
},
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clickable { emojiPickerTab = tab },
) {
Text(
text = tab.title,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelLarge,
)
}
}
}
when (emojiPickerTab) {
ComposerPickerTab.Emoji -> {
val emojiSet = listOf(
"😀", "😁", "😂", "🤣", "😊", "😍", "😘", "😎",
"🤔", "🙃", "😴", "😡", "🥳", "😭", "👍", "👎",
"🔥", "❤️", "💔", "🙏", "🎉", "🤝", "💯", "",
)
LazyVerticalGrid(
columns = GridCells.Fixed(8),
modifier = Modifier
.fillMaxWidth()
.height(240.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
items(emojiSet) { emoji ->
Surface(
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.clickable {
composerValue = composerValue.insertAtCursor(emoji)
onInputChanged(composerValue.text)
},
) {
Box(modifier = Modifier.padding(vertical = 8.dp), contentAlignment = Alignment.Center) {
Text(text = emoji)
}
}
}
}
}
ComposerPickerTab.Gif,
ComposerPickerTab.Sticker,
-> {
val remoteItems = if (emojiPickerTab == ComposerPickerTab.Gif) {
defaultGifItems
} else {
defaultStickerItems
}
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier
.fillMaxWidth()
.height(320.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
items(remoteItems) { remote ->
Surface(
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.clickable(enabled = !isPickerSending) {
scope.launch {
isPickerSending = true
val payload = downloadRemoteMedia(remote.url, remote.fileNamePrefix)
if (payload != null) {
val stickerAdjustedName = if (emojiPickerTab == ComposerPickerTab.Sticker) {
"sticker_${payload.fileName}"
} else {
payload.fileName
}
onSendRemoteMedia(stickerAdjustedName, payload.mimeType, payload.bytes)
showEmojiPicker = false
}
isPickerSending = false
}
},
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
AsyncImage(
model = remote.url,
contentDescription = remote.title,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentScale = ContentScale.Crop,
)
}
}
}
}
}
}
if (isPickerSending) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
}
}
}
}
}
}
}
@@ -1405,6 +1629,7 @@ private fun MessageBubble(
isInlineHighlighted: Boolean,
reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
onAttachmentImageClick: (String) -> Unit,
onAttachmentVideoClick: (String) -> Unit,
onAudioPlaybackChanged: (TopAudioStrip) -> Unit,
forceStopAudioSourceId: String?,
onForceStopAudioSourceHandled: (String) -> Unit,
@@ -1597,6 +1822,7 @@ private fun MessageBubble(
VideoAttachmentCard(
url = attachment.fileUrl,
fileType = attachment.fileType,
onOpenViewer = { onAttachmentVideoClick(attachment.fileUrl) },
)
}
}
@@ -1664,11 +1890,13 @@ private fun MessageBubble(
private fun VideoAttachmentCard(
url: String,
fileType: String,
onOpenViewer: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f), RoundedCornerShape(10.dp))
.clickable(onClick = onOpenViewer)
.padding(8.dp),
) {
Row(
@@ -1755,6 +1983,138 @@ private fun FileAttachmentRow(
}
}
private sealed interface ChatTimelineItem {
data class DayHeader(
val dateKey: String,
val label: String,
) : ChatTimelineItem
data class MessageEntry(
val message: MessageItem,
) : ChatTimelineItem
}
private enum class ComposerPickerTab(val title: String) {
Emoji("Эмодзи"),
Gif("GIF"),
Sticker("Стикеры"),
}
private data class RemotePickerItem(
val title: String,
val url: String,
val fileNamePrefix: String,
)
private val defaultGifItems = listOf(
RemotePickerItem("Happy", "https://media.giphy.com/media/ICOgUNjpvO0PC/giphy.gif", "gif_happy"),
RemotePickerItem("Cat", "https://media.giphy.com/media/mlvseq9yvZhba/giphy.gif", "gif_cat"),
RemotePickerItem("Wow", "https://media.giphy.com/media/3oEjI6SIIHBdRxXI40/giphy.gif", "gif_wow"),
RemotePickerItem("Party", "https://media.giphy.com/media/l0MYt5jPR6QX5pnqM/giphy.gif", "gif_party"),
RemotePickerItem("Love", "https://media.giphy.com/media/MDJ9IbxxvDUQM/giphy.gif", "gif_love"),
RemotePickerItem("Dance", "https://media.giphy.com/media/111ebonMs90YLu/giphy.gif", "gif_dance"),
)
private val defaultStickerItems = listOf(
RemotePickerItem("Sticker 1", "https://raw.githubusercontent.com/twitter/twemoji/master/assets/72x72/1f63a.png", "sticker_cat"),
RemotePickerItem("Sticker 2", "https://raw.githubusercontent.com/twitter/twemoji/master/assets/72x72/1f973.png", "sticker_party"),
RemotePickerItem("Sticker 3", "https://raw.githubusercontent.com/twitter/twemoji/master/assets/72x72/1f525.png", "sticker_fire"),
RemotePickerItem("Sticker 4", "https://raw.githubusercontent.com/twitter/twemoji/master/assets/72x72/1f970.png", "sticker_love"),
RemotePickerItem("Sticker 5", "https://raw.githubusercontent.com/twitter/twemoji/master/assets/72x72/1f44d.png", "sticker_like"),
RemotePickerItem("Sticker 6", "https://raw.githubusercontent.com/twitter/twemoji/master/assets/72x72/1f602.png", "sticker_lol"),
)
private fun buildChatTimelineItems(messages: List<MessageItem>): List<ChatTimelineItem> {
if (messages.isEmpty()) return emptyList()
val items = mutableListOf<ChatTimelineItem>()
var previousDate: LocalDate? = null
messages.forEach { message ->
val date = parseMessageLocalDate(message.createdAt)
if (date != null && date != previousDate) {
items += ChatTimelineItem.DayHeader(
dateKey = date.toString(),
label = formatDateSeparatorLabel(date),
)
previousDate = date
}
items += ChatTimelineItem.MessageEntry(message)
}
return items
}
@Composable
private fun DaySeparatorChip(label: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.58f),
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
)
}
}
}
private fun parseMessageLocalDate(createdAt: String): LocalDate? {
return runCatching {
Instant.parse(createdAt).atZone(ZoneId.systemDefault()).toLocalDate()
}.getOrNull()
}
private val daySeparatorFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMMM", Locale("ru"))
private fun formatDateSeparatorLabel(date: LocalDate): String {
val today = LocalDate.now()
return when {
date == today -> "Сегодня"
date == today.minusDays(1) -> "Вчера"
else -> date.format(daySeparatorFormatter)
}
}
private fun TextFieldValue.insertAtCursor(value: String): TextFieldValue {
val start = selection.min
val end = selection.max
val nextText = text.replaceRange(start, end, value)
val cursor = start + value.length
return copy(text = nextText, selection = TextRange(cursor))
}
private suspend fun downloadRemoteMedia(url: String, fileNamePrefix: String): PickedMediaPayload? {
return withContext(Dispatchers.IO) {
runCatching {
val connection = java.net.URL(url).openConnection()
connection.connectTimeout = 10_000
connection.readTimeout = 15_000
val mime = connection.contentType?.substringBefore(";")?.ifBlank { null } ?: when {
url.endsWith(".gif", ignoreCase = true) -> "image/gif"
url.endsWith(".webp", ignoreCase = true) -> "image/webp"
url.endsWith(".png", ignoreCase = true) -> "image/png"
else -> "application/octet-stream"
}
val ext = when {
mime.contains("gif", ignoreCase = true) -> "gif"
mime.contains("webp", ignoreCase = true) -> "webp"
mime.contains("png", ignoreCase = true) -> "png"
else -> "bin"
}
val bytes = connection.getInputStream().use { it.readBytes() }
if (bytes.isEmpty()) return@runCatching null
PickedMediaPayload(
fileName = "${fileNamePrefix}_${System.currentTimeMillis()}.$ext",
mimeType = mime,
bytes = bytes,
)
}.getOrNull()
}
}
private val messageTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
private fun formatMessageTime(createdAt: String): String {

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.AppCompat.DayNight.NoActionBar" parent="@android:style/Theme.DeviceDefault.NoActionBar" />
<integer name="google_play_services_version">12451000</integer>
</resources>