feat: Enhance image attachment handling with collage display; support multiple layouts for images in messages
This commit is contained in:
@@ -1967,9 +1967,12 @@ fun ChatDetailScreen(
|
||||
onDismiss = { showMediaPicker = false },
|
||||
isDarkTheme = isDarkTheme,
|
||||
onMediaSelected = { selectedMedia ->
|
||||
// 📸 Отправляем выбранные изображения
|
||||
android.util.Log.d("ChatDetailScreen", "📸 Sending ${selectedMedia.size} media items")
|
||||
// 📸 Отправляем выбранные изображения как коллаж (группу)
|
||||
android.util.Log.d("ChatDetailScreen", "📸 Sending ${selectedMedia.size} media items as group")
|
||||
scope.launch {
|
||||
// Собираем все изображения
|
||||
val imageDataList = mutableListOf<ChatViewModel.ImageData>()
|
||||
|
||||
for (item in selectedMedia) {
|
||||
if (item.isVideo) {
|
||||
// TODO: Поддержка видео
|
||||
@@ -1979,12 +1982,15 @@ fun ChatDetailScreen(
|
||||
val base64 = MediaUtils.uriToBase64Image(context, item.uri)
|
||||
val blurhash = MediaUtils.generateBlurhash(context, item.uri)
|
||||
if (base64 != null) {
|
||||
viewModel.sendImageMessage(base64, blurhash)
|
||||
// Небольшая задержка между отправками для правильного порядка
|
||||
delay(100)
|
||||
imageDataList.add(ChatViewModel.ImageData(base64, blurhash))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Отправляем группой (коллаж)
|
||||
if (imageDataList.isNotEmpty()) {
|
||||
viewModel.sendImageGroup(imageDataList)
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenCamera = {
|
||||
|
||||
@@ -1391,17 +1391,30 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
|
||||
// Шифруем изображение с ChaCha ключом
|
||||
// 🚀 Шифруем изображение с ChaCha ключом для Transport Server
|
||||
val encryptedImageBlob = MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce)
|
||||
|
||||
// Сохраняем оригинал для БД (зашифрованный приватным ключом)
|
||||
val imageBlobForDatabase = CryptoManager.encryptWithPassword(imageBase64, privateKey)
|
||||
val attachmentId = "img_$timestamp"
|
||||
|
||||
// 📤 Загружаем на Transport Server (как в desktop)
|
||||
// НЕ для Saved Messages - там не нужно загружать
|
||||
val isSavedMessages = (sender == recipient)
|
||||
var uploadTag = ""
|
||||
|
||||
if (!isSavedMessages) {
|
||||
Log.d(TAG, "📤 Uploading image to Transport Server...")
|
||||
uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
|
||||
Log.d(TAG, "📤 Upload complete, tag: $uploadTag")
|
||||
}
|
||||
|
||||
// Preview содержит tag::blurhash (как в desktop)
|
||||
val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash
|
||||
|
||||
val imageAttachment = MessageAttachment(
|
||||
id = "img_$timestamp",
|
||||
blob = encryptedImageBlob,
|
||||
id = attachmentId,
|
||||
blob = "", // 🔥 Пустой blob - файл на Transport Server!
|
||||
type = AttachmentType.IMAGE,
|
||||
preview = blurhash
|
||||
preview = previewWithTag
|
||||
)
|
||||
|
||||
val packet = PacketMessage().apply {
|
||||
@@ -1415,8 +1428,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
attachments = listOf(imageAttachment)
|
||||
}
|
||||
|
||||
// Для Saved Messages не отправляем на сервер
|
||||
val isSavedMessages = (sender == recipient)
|
||||
// Отправляем пакет (без blob!)
|
||||
if (!isSavedMessages) {
|
||||
ProtocolManager.send(packet)
|
||||
}
|
||||
@@ -1425,12 +1437,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
}
|
||||
|
||||
// 💾 Сохраняем изображение в файл (как в desktop)
|
||||
// Файлы НЕ сохраняем - они слишком большие, загружаются с CDN
|
||||
// 💾 Сохраняем изображение в файл локально (как в desktop)
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = getApplication(),
|
||||
blob = imageBase64,
|
||||
attachmentId = imageAttachment.id,
|
||||
attachmentId = attachmentId,
|
||||
publicKey = sender,
|
||||
privateKey = privateKey
|
||||
)
|
||||
@@ -1439,9 +1450,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Изображение хранится в файловой системе
|
||||
val attachmentsJson = JSONArray().apply {
|
||||
put(JSONObject().apply {
|
||||
put("id", imageAttachment.id)
|
||||
put("id", attachmentId)
|
||||
put("type", AttachmentType.IMAGE.value)
|
||||
put("preview", blurhash)
|
||||
put("preview", previewWithTag)
|
||||
put("blob", "") // Пустой blob - не сохраняем в БД!
|
||||
})
|
||||
}.toString()
|
||||
@@ -1471,6 +1482,169 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🖼️ Отправка группы изображений как коллаж (как в Telegram)
|
||||
* @param images Список пар (base64, blurhash) для каждого изображения
|
||||
* @param caption Подпись к группе изображений (опционально)
|
||||
*/
|
||||
data class ImageData(val base64: String, val blurhash: String)
|
||||
|
||||
fun sendImageGroup(images: List<ImageData>, caption: String = "") {
|
||||
if (images.isEmpty()) return
|
||||
|
||||
// Если одно изображение - отправляем обычным способом
|
||||
if (images.size == 1) {
|
||||
sendImageMessage(images[0].base64, images[0].blurhash, caption)
|
||||
return
|
||||
}
|
||||
|
||||
val recipient = opponentKey
|
||||
val sender = myPublicKey
|
||||
val privateKey = myPrivateKey
|
||||
|
||||
if (recipient == null || sender == null || privateKey == null) {
|
||||
Log.e(TAG, "🖼️ Cannot send image group: missing keys")
|
||||
return
|
||||
}
|
||||
if (isSending) {
|
||||
Log.w(TAG, "🖼️ Already sending message")
|
||||
return
|
||||
}
|
||||
|
||||
isSending = true
|
||||
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val text = caption.trim()
|
||||
|
||||
// Создаём attachments для всех изображений
|
||||
val attachmentsList = images.mapIndexed { index, imageData ->
|
||||
MessageAttachment(
|
||||
id = "img_${timestamp}_$index",
|
||||
type = AttachmentType.IMAGE,
|
||||
preview = imageData.blurhash,
|
||||
blob = imageData.base64 // Для локального отображения
|
||||
)
|
||||
}
|
||||
|
||||
// 1. 🚀 Optimistic UI
|
||||
val optimisticMessage = ChatMessage(
|
||||
id = messageId,
|
||||
text = text,
|
||||
isOutgoing = true,
|
||||
timestamp = Date(timestamp),
|
||||
status = MessageStatus.SENDING,
|
||||
attachments = attachmentsList
|
||||
)
|
||||
_messages.value = _messages.value + optimisticMessage
|
||||
_inputText.value = ""
|
||||
|
||||
Log.d(TAG, "🖼️ Sending image group: ${images.size} images, id=$messageId")
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
// Шифрование текста
|
||||
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
||||
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
|
||||
// Загружаем каждое изображение на Transport Server и создаём attachments
|
||||
val networkAttachments = mutableListOf<MessageAttachment>()
|
||||
val attachmentsJsonArray = JSONArray()
|
||||
|
||||
for ((index, imageData) in images.withIndex()) {
|
||||
val attachmentId = "img_${timestamp}_$index"
|
||||
|
||||
// Шифруем изображение с ChaCha ключом
|
||||
val encryptedImageBlob = MessageCrypto.encryptReplyBlob(imageData.base64, plainKeyAndNonce)
|
||||
|
||||
// Загружаем на Transport Server
|
||||
val uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
|
||||
val previewWithTag = if (uploadTag != null) {
|
||||
"$uploadTag::${imageData.blurhash}"
|
||||
} else {
|
||||
imageData.blurhash
|
||||
}
|
||||
|
||||
// Сохраняем в файл локально
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = getApplication(),
|
||||
blob = imageData.base64,
|
||||
attachmentId = attachmentId,
|
||||
publicKey = sender,
|
||||
privateKey = privateKey
|
||||
)
|
||||
|
||||
// Для сети
|
||||
networkAttachments.add(MessageAttachment(
|
||||
id = attachmentId,
|
||||
blob = if (uploadTag != null) "" else encryptedImageBlob,
|
||||
type = AttachmentType.IMAGE,
|
||||
preview = previewWithTag
|
||||
))
|
||||
|
||||
// Для БД
|
||||
attachmentsJsonArray.put(JSONObject().apply {
|
||||
put("id", attachmentId)
|
||||
put("type", AttachmentType.IMAGE.value)
|
||||
put("preview", previewWithTag)
|
||||
put("blob", "") // Пустой blob - изображения в файловой системе
|
||||
})
|
||||
|
||||
Log.d(TAG, "🖼️ Image $index uploaded: tag=${uploadTag?.take(20) ?: "null"}")
|
||||
}
|
||||
|
||||
// Создаём пакет
|
||||
val packet = PacketMessage().apply {
|
||||
fromPublicKey = sender
|
||||
toPublicKey = recipient
|
||||
content = encryptedContent
|
||||
chachaKey = encryptedKey
|
||||
this.timestamp = timestamp
|
||||
this.privateKey = privateKeyHash
|
||||
this.messageId = messageId
|
||||
attachments = networkAttachments
|
||||
}
|
||||
|
||||
// Для Saved Messages не отправляем на сервер
|
||||
val isSavedMessages = (sender == recipient)
|
||||
if (!isSavedMessages) {
|
||||
ProtocolManager.send(packet)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
}
|
||||
|
||||
// Сохраняем в БД
|
||||
saveMessageToDatabase(
|
||||
messageId = messageId,
|
||||
text = text,
|
||||
encryptedContent = encryptedContent,
|
||||
encryptedKey = encryptedKey,
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered = if (isSavedMessages) 2 else 0,
|
||||
attachmentsJson = attachmentsJsonArray.toString()
|
||||
)
|
||||
|
||||
saveDialog(if (text.isNotEmpty()) text else "📷 ${images.size} photos", timestamp)
|
||||
Log.d(TAG, "🖼️ ✅ Image group sent successfully")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "🖼️ ❌ Failed to send image group", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
}
|
||||
} finally {
|
||||
isSending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 📄 Отправка сообщения с файлом
|
||||
* @param fileBase64 Base64 содержимого файла
|
||||
@@ -1529,15 +1703,30 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
|
||||
// Шифруем файл
|
||||
// 🚀 Шифруем файл с ChaCha ключом для Transport Server
|
||||
val encryptedFileBlob = MessageCrypto.encryptReplyBlob(fileBase64, plainKeyAndNonce)
|
||||
val fileBlobForDatabase = CryptoManager.encryptWithPassword(fileBase64, privateKey)
|
||||
|
||||
val attachmentId = "file_$timestamp"
|
||||
|
||||
// 📤 Загружаем на Transport Server (как в desktop)
|
||||
// НЕ для Saved Messages - там не нужно загружать
|
||||
val isSavedMessages = (sender == recipient)
|
||||
var uploadTag = ""
|
||||
|
||||
if (!isSavedMessages) {
|
||||
Log.d(TAG, "📤 Uploading file to Transport Server...")
|
||||
uploadTag = TransportManager.uploadFile(attachmentId, encryptedFileBlob)
|
||||
Log.d(TAG, "📤 Upload complete, tag: $uploadTag")
|
||||
}
|
||||
|
||||
// Preview содержит tag::size::name (как в desktop)
|
||||
val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$preview" else preview
|
||||
|
||||
val fileAttachment = MessageAttachment(
|
||||
id = "file_$timestamp",
|
||||
blob = encryptedFileBlob,
|
||||
id = attachmentId,
|
||||
blob = "", // 🔥 Пустой blob - файл на Transport Server!
|
||||
type = AttachmentType.FILE,
|
||||
preview = preview
|
||||
preview = previewWithTag
|
||||
)
|
||||
|
||||
val packet = PacketMessage().apply {
|
||||
@@ -1551,7 +1740,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
attachments = listOf(fileAttachment)
|
||||
}
|
||||
|
||||
val isSavedMessages = (sender == recipient)
|
||||
// Отправляем пакет (без blob!)
|
||||
if (!isSavedMessages) {
|
||||
ProtocolManager.send(packet)
|
||||
}
|
||||
@@ -1560,13 +1749,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
}
|
||||
|
||||
// ⚠️ НЕ сохраняем blob в БД - он слишком большой (SQLite CursorWindow 2MB limit)
|
||||
// Файл должен храниться в файловой системе или загружаться с сервера при необходимости
|
||||
// ⚠️ НЕ сохраняем файл локально - они слишком большие
|
||||
// Файлы загружаются с Transport Server при необходимости
|
||||
val attachmentsJson = JSONArray().apply {
|
||||
put(JSONObject().apply {
|
||||
put("id", fileAttachment.id)
|
||||
put("id", attachmentId)
|
||||
put("type", AttachmentType.FILE.value)
|
||||
put("preview", preview)
|
||||
put("preview", previewWithTag)
|
||||
put("blob", "") // Пустой blob - не сохраняем в БД!
|
||||
})
|
||||
}.toString()
|
||||
|
||||
@@ -67,6 +67,7 @@ enum class DownloadStatus {
|
||||
|
||||
/**
|
||||
* Composable для отображения всех attachments в сообщении
|
||||
* 🖼️ IMAGE attachments группируются в коллаж (как в Telegram)
|
||||
*/
|
||||
@Composable
|
||||
fun MessageAttachments(
|
||||
@@ -83,23 +84,30 @@ fun MessageAttachments(
|
||||
) {
|
||||
if (attachments.isEmpty()) return
|
||||
|
||||
// Разделяем attachments по типам
|
||||
val imageAttachments = attachments.filter { it.type == AttachmentType.IMAGE }
|
||||
val otherAttachments = attachments.filter { it.type != AttachmentType.IMAGE && it.type != AttachmentType.MESSAGES }
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
attachments.forEach { attachment ->
|
||||
// 🖼️ Коллаж для изображений (если больше 1)
|
||||
if (imageAttachments.isNotEmpty()) {
|
||||
ImageCollage(
|
||||
attachments = imageAttachments,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus
|
||||
)
|
||||
}
|
||||
|
||||
// Остальные attachments по отдельности
|
||||
otherAttachments.forEach { attachment ->
|
||||
when (attachment.type) {
|
||||
AttachmentType.IMAGE -> {
|
||||
ImageAttachment(
|
||||
attachment = attachment,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus
|
||||
)
|
||||
}
|
||||
AttachmentType.FILE -> {
|
||||
FileAttachment(
|
||||
attachment = attachment,
|
||||
@@ -122,8 +130,273 @@ fun MessageAttachments(
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
AttachmentType.MESSAGES -> {
|
||||
// MESSAGES обрабатываются отдельно как reply
|
||||
else -> { /* MESSAGES обрабатываются отдельно */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🖼️ Коллаж изображений в стиле Telegram
|
||||
* Разные layout'ы в зависимости от количества фото:
|
||||
* - 1 фото: полная ширина
|
||||
* - 2 фото: 2 колонки
|
||||
* - 3 фото: 1 большое слева + 2 маленьких справа
|
||||
* - 4 фото: 2x2 сетка
|
||||
* - 5+ фото: 2 сверху + 3 снизу (или больше рядов)
|
||||
*/
|
||||
@Composable
|
||||
fun ImageCollage(
|
||||
attachments: List<MessageAttachment>,
|
||||
chachaKey: String,
|
||||
privateKey: String,
|
||||
isOutgoing: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
timestamp: java.util.Date,
|
||||
messageStatus: MessageStatus = MessageStatus.READ,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val count = attachments.size
|
||||
val spacing = 2.dp
|
||||
|
||||
// Показываем время и статус только на последнем изображении коллажа
|
||||
val showOverlayOnLast = true
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
) {
|
||||
when (count) {
|
||||
1 -> {
|
||||
// Одно фото - полная ширина
|
||||
ImageAttachment(
|
||||
attachment = attachments[0],
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = true
|
||||
)
|
||||
}
|
||||
2 -> {
|
||||
// Два фото - горизонтально
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing)
|
||||
) {
|
||||
attachments.forEachIndexed { index, attachment ->
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
ImageAttachment(
|
||||
attachment = attachment,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = index == count - 1,
|
||||
aspectRatio = 1f
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3 -> {
|
||||
// Три фото: 1 большое слева + 2 маленьких справа
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing)
|
||||
) {
|
||||
// Большое фото слева
|
||||
Box(modifier = Modifier.weight(1.5f).fillMaxHeight()) {
|
||||
ImageAttachment(
|
||||
attachment = attachments[0],
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = false,
|
||||
fillMaxSize = true
|
||||
)
|
||||
}
|
||||
// Два маленьких справа
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing)
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
ImageAttachment(
|
||||
attachment = attachments[1],
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = false,
|
||||
fillMaxSize = true
|
||||
)
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
ImageAttachment(
|
||||
attachment = attachments[2],
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = true,
|
||||
fillMaxSize = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
4 -> {
|
||||
// Четыре фото: 2x2 сетка
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing)
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
|
||||
ImageAttachment(
|
||||
attachment = attachments[0],
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = false,
|
||||
fillMaxSize = true
|
||||
)
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
|
||||
ImageAttachment(
|
||||
attachment = attachments[1],
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = false,
|
||||
fillMaxSize = true
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing)
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
|
||||
ImageAttachment(
|
||||
attachment = attachments[2],
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = false,
|
||||
fillMaxSize = true
|
||||
)
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
|
||||
ImageAttachment(
|
||||
attachment = attachments[3],
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = true,
|
||||
fillMaxSize = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// 5+ фото: 2 сверху + остальные снизу по 3 в ряд
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing)
|
||||
) {
|
||||
// Первые 2 фото сверху
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing)
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
|
||||
ImageAttachment(
|
||||
attachment = attachments[0],
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = false,
|
||||
fillMaxSize = true
|
||||
)
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
|
||||
ImageAttachment(
|
||||
attachment = attachments[1],
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = false,
|
||||
fillMaxSize = true
|
||||
)
|
||||
}
|
||||
}
|
||||
// Остальные по 3 в ряд
|
||||
val remaining = attachments.drop(2)
|
||||
remaining.chunked(3).forEachIndexed { rowIndex, rowItems ->
|
||||
val isLastRow = rowIndex == remaining.chunked(3).size - 1
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing)
|
||||
) {
|
||||
rowItems.forEachIndexed { index, attachment ->
|
||||
val isLastItem = isLastRow && index == rowItems.size - 1
|
||||
Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
|
||||
ImageAttachment(
|
||||
attachment = attachment,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = isLastItem,
|
||||
fillMaxSize = true
|
||||
)
|
||||
}
|
||||
}
|
||||
// Заполняем пустые места если в ряду меньше 3 фото
|
||||
repeat(3 - rowItems.size) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,7 +414,10 @@ fun ImageAttachment(
|
||||
isOutgoing: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
timestamp: java.util.Date,
|
||||
messageStatus: MessageStatus = MessageStatus.READ
|
||||
messageStatus: MessageStatus = MessageStatus.READ,
|
||||
showTimeOverlay: Boolean = true,
|
||||
aspectRatio: Float? = null,
|
||||
fillMaxSize: Boolean = false
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -258,45 +534,56 @@ fun ImageAttachment(
|
||||
}
|
||||
|
||||
// 📐 Вычисляем размер пузырька на основе соотношения сторон изображения (как в Telegram)
|
||||
val (imageWidth, imageHeight) = remember(attachment.width, attachment.height) {
|
||||
val maxWidth = 260.dp
|
||||
val maxHeight = 340.dp
|
||||
val minWidth = 180.dp
|
||||
val minHeight = 140.dp
|
||||
|
||||
if (attachment.width > 0 && attachment.height > 0) {
|
||||
val aspectRatio = attachment.width.toFloat() / attachment.height.toFloat()
|
||||
|
||||
when {
|
||||
// Широкое изображение (landscape)
|
||||
aspectRatio > 1.2f -> {
|
||||
val width = maxWidth
|
||||
val height = (maxWidth.value / aspectRatio).dp.coerceIn(minHeight, maxHeight)
|
||||
width to height
|
||||
}
|
||||
// Высокое изображение (portrait)
|
||||
aspectRatio < 0.8f -> {
|
||||
val height = maxHeight
|
||||
val width = (maxHeight.value * aspectRatio).dp.coerceIn(minWidth, maxWidth)
|
||||
width to height
|
||||
}
|
||||
// Квадратное или близкое к квадрату
|
||||
else -> {
|
||||
val size = 220.dp
|
||||
size to size
|
||||
}
|
||||
}
|
||||
val (imageWidth, imageHeight) = remember(attachment.width, attachment.height, fillMaxSize, aspectRatio) {
|
||||
// Если fillMaxSize - используем 100% контейнера
|
||||
if (fillMaxSize) {
|
||||
null to null
|
||||
} else {
|
||||
// Fallback если размеры не указаны
|
||||
220.dp to 220.dp
|
||||
val maxWidth = 260.dp
|
||||
val maxHeight = 340.dp
|
||||
val minWidth = 180.dp
|
||||
val minHeight = 140.dp
|
||||
|
||||
if (attachment.width > 0 && attachment.height > 0) {
|
||||
val ar = attachment.width.toFloat() / attachment.height.toFloat()
|
||||
|
||||
when {
|
||||
// Широкое изображение (landscape)
|
||||
ar > 1.2f -> {
|
||||
val width = maxWidth
|
||||
val height = (maxWidth.value / ar).dp.coerceIn(minHeight, maxHeight)
|
||||
width to height
|
||||
}
|
||||
// Высокое изображение (portrait)
|
||||
ar < 0.8f -> {
|
||||
val height = maxHeight
|
||||
val width = (maxHeight.value * ar).dp.coerceIn(minWidth, maxWidth)
|
||||
width to height
|
||||
}
|
||||
// Квадратное или близкое к квадрату
|
||||
else -> {
|
||||
val size = 220.dp
|
||||
size to size
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback если размеры не указаны
|
||||
220.dp to 220.dp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Модификатор размера
|
||||
val sizeModifier = when {
|
||||
fillMaxSize -> Modifier.fillMaxSize()
|
||||
aspectRatio != null -> Modifier.fillMaxWidth().aspectRatio(aspectRatio)
|
||||
imageWidth != null && imageHeight != null -> Modifier.width(imageWidth).height(imageHeight)
|
||||
else -> Modifier.size(220.dp)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(imageWidth)
|
||||
.height(imageHeight)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
modifier = sizeModifier
|
||||
.clip(RoundedCornerShape(if (fillMaxSize) 0.dp else 12.dp))
|
||||
.background(Color.Transparent)
|
||||
.clickable {
|
||||
when (downloadStatus) {
|
||||
@@ -347,63 +634,65 @@ fun ImageAttachment(
|
||||
}
|
||||
}
|
||||
|
||||
// Время в правом нижнем углу (всегда показываем, даже когда фото blurred)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(8.dp)
|
||||
.background(
|
||||
Color.Black.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
.padding(horizontal = 6.dp, vertical = 3.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
// Время в правом нижнем углу (показываем только если showTimeOverlay = true)
|
||||
if (showTimeOverlay) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(8.dp)
|
||||
.background(
|
||||
Color.Black.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
.padding(horizontal = 6.dp, vertical = 3.dp)
|
||||
) {
|
||||
Text(
|
||||
text = timeFormat.format(timestamp),
|
||||
color = Color.White,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
if (isOutgoing) {
|
||||
// Статус доставки для исходящих
|
||||
when (messageStatus) {
|
||||
MessageStatus.SENDING -> {
|
||||
Icon(
|
||||
compose.icons.TablerIcons.Clock,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = timeFormat.format(timestamp),
|
||||
color = Color.White,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
if (isOutgoing) {
|
||||
// Статус доставки для исходящих
|
||||
when (messageStatus) {
|
||||
MessageStatus.SENDING -> {
|
||||
Icon(
|
||||
compose.icons.TablerIcons.Clock,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
MessageStatus.SENT -> {
|
||||
Icon(
|
||||
compose.icons.TablerIcons.Check,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
MessageStatus.DELIVERED -> {
|
||||
Icon(
|
||||
compose.icons.TablerIcons.Checks,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
MessageStatus.READ -> {
|
||||
Icon(
|
||||
compose.icons.TablerIcons.Checks,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF4FC3F7),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
MessageStatus.SENT -> {
|
||||
Icon(
|
||||
compose.icons.TablerIcons.Check,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
MessageStatus.DELIVERED -> {
|
||||
Icon(
|
||||
compose.icons.TablerIcons.Checks,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
MessageStatus.READ -> {
|
||||
Icon(
|
||||
compose.icons.TablerIcons.Checks,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF4FC3F7),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user