From 78934a5f28d6111abd95dd380160c307809d64a2 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 10 Mar 2026 08:38:54 +0300 Subject: [PATCH] Android chat UX: video viewer, emoji/gif/sticker picker, day separators --- android/CHANGELOG.md | 14 + .../repository/NetworkMessageRepository.kt | 12 +- .../messenger/ui/chat/ChatScreen.kt | 372 +++++++++++++++++- android/app/src/main/res/values/themes.xml | 5 + docs/android-checklist.md | 3 + 5 files changed, 396 insertions(+), 10 deletions(-) create mode 100644 android/app/src/main/res/values/themes.xml diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 71ece77..1dabf17 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt index 42a3e3d..ba16778 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt @@ -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" } } 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 376d327..cb107ad 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 @@ -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(null) } + var viewerVideoUrl by remember { mutableStateOf(null) } var dismissedPinnedMessageId by remember { mutableStateOf(null) } var topAudioStrip by remember { mutableStateOf(null) } var forceStopAudioSourceId by remember { mutableStateOf(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, 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): List { + if (messages.isEmpty()) return emptyList() + val items = mutableListOf() + 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 { diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..ee8ce5f --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + +