Android chat UX: video viewer, emoji/gif/sticker picker, day separators
This commit is contained in:
@@ -943,3 +943,17 @@
|
|||||||
- Notification delivery polish (foundation):
|
- Notification delivery polish (foundation):
|
||||||
- foreground notification body now respects preview setting and falls back to media-type labels when previews are disabled.
|
- 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`.
|
- 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.
|
||||||
|
|||||||
@@ -490,11 +490,15 @@ class NetworkMessageRepository @Inject constructor(
|
|||||||
mimeType: String,
|
mimeType: String,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
): String {
|
): String {
|
||||||
|
val normalizedMime = mimeType.lowercase()
|
||||||
|
val normalizedName = fileName.lowercase()
|
||||||
return when {
|
return when {
|
||||||
mimeType.startsWith("image/") -> "image"
|
normalizedMime == "image/gif" || normalizedName.endsWith(".gif") || normalizedName.startsWith("gif_") -> "gif"
|
||||||
mimeType.startsWith("video/") -> "video"
|
normalizedName.startsWith("sticker_") || normalizedMime == "image/webp" -> "sticker"
|
||||||
mimeType.startsWith("audio/") && fileName.startsWith("voice_", ignoreCase = true) -> "voice"
|
normalizedMime.startsWith("image/") -> "image"
|
||||||
mimeType.startsWith("audio/") -> "audio"
|
normalizedMime.startsWith("video/") -> "video"
|
||||||
|
normalizedMime.startsWith("audio/") && normalizedName.startsWith("voice_") -> "voice"
|
||||||
|
normalizedMime.startsWith("audio/") -> "audio"
|
||||||
else -> "file"
|
else -> "file"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -127,12 +128,17 @@ 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
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import ru.daemonlord.messenger.core.audio.AppAudioFocusCoordinator
|
import ru.daemonlord.messenger.core.audio.AppAudioFocusCoordinator
|
||||||
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||||
import ru.daemonlord.messenger.ui.chat.voice.VoiceRecorder
|
import ru.daemonlord.messenger.ui.chat.voice.VoiceRecorder
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -208,6 +214,13 @@ fun ChatRoute(
|
|||||||
onCancelComposeAction = viewModel::onCancelComposeAction,
|
onCancelComposeAction = viewModel::onCancelComposeAction,
|
||||||
onLoadMore = viewModel::loadMore,
|
onLoadMore = viewModel::loadMore,
|
||||||
onPickMedia = { pickMediaLauncher.launch("*/*") },
|
onPickMedia = { pickMediaLauncher.launch("*/*") },
|
||||||
|
onSendRemoteMedia = { fileName, mimeType, bytes ->
|
||||||
|
viewModel.onMediaPicked(
|
||||||
|
fileName = fileName,
|
||||||
|
mimeType = mimeType,
|
||||||
|
bytes = bytes,
|
||||||
|
)
|
||||||
|
},
|
||||||
onVoiceRecordStart = {
|
onVoiceRecordStart = {
|
||||||
if (!hasAudioPermission) {
|
if (!hasAudioPermission) {
|
||||||
startRecordingAfterPermissionGrant = true
|
startRecordingAfterPermissionGrant = true
|
||||||
@@ -267,6 +280,7 @@ fun ChatScreen(
|
|||||||
onCancelComposeAction: () -> Unit,
|
onCancelComposeAction: () -> Unit,
|
||||||
onLoadMore: () -> Unit,
|
onLoadMore: () -> Unit,
|
||||||
onPickMedia: () -> Unit,
|
onPickMedia: () -> Unit,
|
||||||
|
onSendRemoteMedia: (String, String, ByteArray) -> Unit,
|
||||||
onVoiceRecordStart: () -> Unit,
|
onVoiceRecordStart: () -> Unit,
|
||||||
onVoiceRecordTick: () -> Unit,
|
onVoiceRecordTick: () -> Unit,
|
||||||
onVoiceRecordLock: () -> Unit,
|
onVoiceRecordLock: () -> Unit,
|
||||||
@@ -277,6 +291,7 @@ fun ChatScreen(
|
|||||||
onVisibleIncomingMessageId: (Long?) -> Unit,
|
onVisibleIncomingMessageId: (Long?) -> Unit,
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val allImageUrls = remember(state.messages) {
|
val allImageUrls = remember(state.messages) {
|
||||||
state.messages
|
state.messages
|
||||||
.flatMap { message -> message.attachments }
|
.flatMap { message -> message.attachments }
|
||||||
@@ -285,7 +300,9 @@ fun ChatScreen(
|
|||||||
.map { (url, _) -> url }
|
.map { (url, _) -> url }
|
||||||
.distinct()
|
.distinct()
|
||||||
}
|
}
|
||||||
|
val timelineItems = remember(state.messages) { buildChatTimelineItems(state.messages) }
|
||||||
var viewerImageIndex by remember { mutableStateOf<Int?>(null) }
|
var viewerImageIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var viewerVideoUrl by remember { mutableStateOf<String?>(null) }
|
||||||
var dismissedPinnedMessageId by remember { mutableStateOf<Long?>(null) }
|
var dismissedPinnedMessageId by remember { mutableStateOf<Long?>(null) }
|
||||||
var topAudioStrip by remember { mutableStateOf<TopAudioStrip?>(null) }
|
var topAudioStrip by remember { mutableStateOf<TopAudioStrip?>(null) }
|
||||||
var forceStopAudioSourceId by remember { mutableStateOf<String?>(null) }
|
var forceStopAudioSourceId by remember { mutableStateOf<String?>(null) }
|
||||||
@@ -293,6 +310,9 @@ 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 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 showChatMenu by remember { mutableStateOf(false) }
|
||||||
var showChatInfoSheet by remember { mutableStateOf(false) }
|
var showChatInfoSheet by remember { mutableStateOf(false) }
|
||||||
var chatInfoTab by remember { mutableStateOf(ChatInfoTab.Media) }
|
var chatInfoTab by remember { mutableStateOf(ChatInfoTab.Media) }
|
||||||
@@ -311,9 +331,11 @@ fun ChatScreen(
|
|||||||
delay(120)
|
delay(120)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(state.highlightedMessageId, state.messages) {
|
LaunchedEffect(state.highlightedMessageId, timelineItems) {
|
||||||
val messageId = state.highlightedMessageId ?: return@LaunchedEffect
|
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) {
|
if (index >= 0) {
|
||||||
listState.animateScrollToItem(index = index)
|
listState.animateScrollToItem(index = index)
|
||||||
}
|
}
|
||||||
@@ -333,10 +355,13 @@ fun ChatScreen(
|
|||||||
onInlineSearchChanged("")
|
onInlineSearchChanged("")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(listState, state.messages) {
|
LaunchedEffect(listState, timelineItems) {
|
||||||
snapshotFlow {
|
snapshotFlow {
|
||||||
listState.layoutInfo.visibleItemsInfo
|
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 }
|
.filter { !it.isOutgoing }
|
||||||
.maxOfOrNull { it.id }
|
.maxOfOrNull { it.id }
|
||||||
}
|
}
|
||||||
@@ -647,7 +672,20 @@ fun ChatScreen(
|
|||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.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)
|
val isSelected = state.actionState.selectedMessageIds.contains(message.id)
|
||||||
MessageBubble(
|
MessageBubble(
|
||||||
message = message,
|
message = message,
|
||||||
@@ -659,6 +697,9 @@ fun ChatScreen(
|
|||||||
val idx = allImageUrls.indexOf(imageUrl)
|
val idx = allImageUrls.indexOf(imageUrl)
|
||||||
viewerImageIndex = if (idx >= 0) idx else null
|
viewerImageIndex = if (idx >= 0) idx else null
|
||||||
},
|
},
|
||||||
|
onAttachmentVideoClick = { videoUrl ->
|
||||||
|
viewerVideoUrl = videoUrl
|
||||||
|
},
|
||||||
onAudioPlaybackChanged = { playback ->
|
onAudioPlaybackChanged = { playback ->
|
||||||
if (playback.isPlaying) {
|
if (playback.isPlaying) {
|
||||||
topAudioStrip = playback
|
topAudioStrip = playback
|
||||||
@@ -1145,7 +1186,7 @@ fun ChatScreen(
|
|||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f),
|
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f),
|
||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { /* emoji picker step */ },
|
onClick = { showEmojiPicker = true },
|
||||||
enabled = state.canSendMessages,
|
enabled = state.canSendMessages,
|
||||||
) {
|
) {
|
||||||
Icon(
|
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,
|
isInlineHighlighted: Boolean,
|
||||||
reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
|
reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
|
||||||
onAttachmentImageClick: (String) -> Unit,
|
onAttachmentImageClick: (String) -> Unit,
|
||||||
|
onAttachmentVideoClick: (String) -> Unit,
|
||||||
onAudioPlaybackChanged: (TopAudioStrip) -> Unit,
|
onAudioPlaybackChanged: (TopAudioStrip) -> Unit,
|
||||||
forceStopAudioSourceId: String?,
|
forceStopAudioSourceId: String?,
|
||||||
onForceStopAudioSourceHandled: (String) -> Unit,
|
onForceStopAudioSourceHandled: (String) -> Unit,
|
||||||
@@ -1597,6 +1822,7 @@ private fun MessageBubble(
|
|||||||
VideoAttachmentCard(
|
VideoAttachmentCard(
|
||||||
url = attachment.fileUrl,
|
url = attachment.fileUrl,
|
||||||
fileType = attachment.fileType,
|
fileType = attachment.fileType,
|
||||||
|
onOpenViewer = { onAttachmentVideoClick(attachment.fileUrl) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1664,11 +1890,13 @@ private fun MessageBubble(
|
|||||||
private fun VideoAttachmentCard(
|
private fun VideoAttachmentCard(
|
||||||
url: String,
|
url: String,
|
||||||
fileType: String,
|
fileType: String,
|
||||||
|
onOpenViewer: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f), RoundedCornerShape(10.dp))
|
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f), RoundedCornerShape(10.dp))
|
||||||
|
.clickable(onClick = onOpenViewer)
|
||||||
.padding(8.dp),
|
.padding(8.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
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 val messageTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
|
||||||
private fun formatMessageTime(createdAt: String): String {
|
private fun formatMessageTime(createdAt: String): String {
|
||||||
|
|||||||
5
android/app/src/main/res/values/themes.xml
Normal file
5
android/app/src/main/res/values/themes.xml
Normal 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>
|
||||||
@@ -63,8 +63,10 @@
|
|||||||
|
|
||||||
## 8. Медиа и вложения
|
## 8. Медиа и вложения
|
||||||
- [x] Upload image/video/file/audio
|
- [x] Upload image/video/file/audio
|
||||||
|
- [x] Upload/send GIF and sticker attachments
|
||||||
- [x] Галерея в сообщении (multi media)
|
- [x] Галерея в сообщении (multi media)
|
||||||
- [x] Media viewer (zoom/swipe/download)
|
- [x] Media viewer (zoom/swipe/download)
|
||||||
|
- [x] Fullscreen video viewer from chat bubbles
|
||||||
- [x] Единое контекстное меню для медиа
|
- [x] Единое контекстное меню для медиа
|
||||||
- [x] Voice playback waveform + speed
|
- [x] Voice playback waveform + speed
|
||||||
- [x] Audio player UI (не как voice)
|
- [x] Audio player UI (не как voice)
|
||||||
@@ -107,6 +109,7 @@
|
|||||||
- [x] Контекстные меню без конфликтов жестов
|
- [x] Контекстные меню без конфликтов жестов
|
||||||
- [x] Bottom sheets/dialog behavior consistency
|
- [x] Bottom sheets/dialog behavior consistency
|
||||||
- [x] Accessibility (TalkBack, dynamic type)
|
- [x] Accessibility (TalkBack, dynamic type)
|
||||||
|
- [x] Day separators in chat timeline (Сегодня/Вчера/дата)
|
||||||
|
|
||||||
## 14. Безопасность
|
## 14. Безопасность
|
||||||
- [x] Secure token storage (EncryptedSharedPrefs/Keystore)
|
- [x] Secure token storage (EncryptedSharedPrefs/Keystore)
|
||||||
|
|||||||
Reference in New Issue
Block a user