Android chat UX: video viewer, emoji/gif/sticker picker, day separators
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user