fix: implement optimistic UI for image sending and enhance attachment handling
This commit is contained in:
@@ -265,7 +265,8 @@ data class MessageAttachment(
|
|||||||
val type: AttachmentType,
|
val type: AttachmentType,
|
||||||
val preview: String = "", // Метаданные: "UUID::metadata" или "filesize::filename"
|
val preview: String = "", // Метаданные: "UUID::metadata" или "filesize::filename"
|
||||||
val width: Int = 0,
|
val width: Int = 0,
|
||||||
val height: Int = 0
|
val height: Int = 0,
|
||||||
|
val localUri: String = "" // 🚀 Локальный URI для мгновенного отображения (optimistic UI)
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2151,27 +2151,12 @@ fun ChatDetailScreen(
|
|||||||
pendingCameraPhotoUri = null
|
pendingCameraPhotoUri = null
|
||||||
},
|
},
|
||||||
onSave = { editedUri ->
|
onSave = { editedUri ->
|
||||||
// Fallback если onSaveWithCaption не сработал
|
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
|
||||||
pendingCameraPhotoUri = null
|
viewModel.sendImageFromUri(editedUri, "")
|
||||||
scope.launch {
|
|
||||||
val base64 = MediaUtils.uriToBase64Image(context, editedUri)
|
|
||||||
val blurhash = MediaUtils.generateBlurhash(context, editedUri)
|
|
||||||
val (width, height) = MediaUtils.getImageDimensions(context, editedUri)
|
|
||||||
if (base64 != null) {
|
|
||||||
viewModel.sendImageMessage(base64, blurhash, "", width, height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onSaveWithCaption = { editedUri, caption ->
|
onSaveWithCaption = { editedUri, caption ->
|
||||||
pendingCameraPhotoUri = null
|
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
|
||||||
scope.launch {
|
viewModel.sendImageFromUri(editedUri, caption)
|
||||||
val base64 = MediaUtils.uriToBase64Image(context, editedUri)
|
|
||||||
val blurhash = MediaUtils.generateBlurhash(context, editedUri)
|
|
||||||
val (width, height) = MediaUtils.getImageDimensions(context, editedUri)
|
|
||||||
if (base64 != null) {
|
|
||||||
viewModel.sendImageMessage(base64, blurhash, caption, width, height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
showCaptionInput = true,
|
showCaptionInput = true,
|
||||||
@@ -2187,33 +2172,9 @@ fun ChatDetailScreen(
|
|||||||
pendingGalleryImages = emptyList()
|
pendingGalleryImages = emptyList()
|
||||||
},
|
},
|
||||||
onSendAll = { imagesWithCaptions ->
|
onSendAll = { imagesWithCaptions ->
|
||||||
pendingGalleryImages = emptyList()
|
// 🚀 Мгновенный optimistic UI для каждого фото
|
||||||
scope.launch {
|
for (imageWithCaption in imagesWithCaptions) {
|
||||||
// Собираем все изображения с их caption
|
viewModel.sendImageFromUri(imageWithCaption.uri, imageWithCaption.caption)
|
||||||
val imageDataList = mutableListOf<ChatViewModel.ImageData>()
|
|
||||||
|
|
||||||
for (imageWithCaption in imagesWithCaptions) {
|
|
||||||
val base64 = MediaUtils.uriToBase64Image(context, imageWithCaption.uri)
|
|
||||||
val blurhash = MediaUtils.generateBlurhash(context, imageWithCaption.uri)
|
|
||||||
val (width, height) = MediaUtils.getImageDimensions(context, imageWithCaption.uri)
|
|
||||||
if (base64 != null) {
|
|
||||||
imageDataList.add(ChatViewModel.ImageData(base64, blurhash, width, height))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если одно фото с caption - отправляем как обычное сообщение
|
|
||||||
if (imageDataList.size == 1 && imagesWithCaptions[0].caption.isNotBlank()) {
|
|
||||||
viewModel.sendImageMessage(
|
|
||||||
imageDataList[0].base64,
|
|
||||||
imageDataList[0].blurhash,
|
|
||||||
imagesWithCaptions[0].caption,
|
|
||||||
imageDataList[0].width,
|
|
||||||
imageDataList[0].height
|
|
||||||
)
|
|
||||||
} else if (imageDataList.isNotEmpty()) {
|
|
||||||
// Отправляем группой (коллаж)
|
|
||||||
viewModel.sendImageGroup(imageDataList)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
|||||||
@@ -1467,6 +1467,235 @@ val newList = messages + optimisticMessages
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 📸🚀 Отправка изображения по URI с МГНОВЕННЫМ optimistic UI
|
||||||
|
* Фото появляется в чате СРАЗУ, конвертация и отправка происходят в фоне
|
||||||
|
*
|
||||||
|
* @param imageUri URI изображения
|
||||||
|
* @param caption Подпись к изображению
|
||||||
|
*/
|
||||||
|
fun sendImageFromUri(imageUri: android.net.Uri, caption: String = "") {
|
||||||
|
val recipient = opponentKey
|
||||||
|
val sender = myPublicKey
|
||||||
|
val privateKey = myPrivateKey
|
||||||
|
val context = getApplication<Application>()
|
||||||
|
|
||||||
|
if (recipient == null || sender == null || privateKey == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||||
|
val timestamp = System.currentTimeMillis()
|
||||||
|
val text = caption.trim()
|
||||||
|
|
||||||
|
// 1. 🚀 МГНОВЕННО показываем optimistic сообщение с localUri
|
||||||
|
// Используем URI напрямую для отображения (без конвертации в base64)
|
||||||
|
val optimisticMessage = ChatMessage(
|
||||||
|
id = messageId,
|
||||||
|
text = text,
|
||||||
|
isOutgoing = true,
|
||||||
|
timestamp = Date(timestamp),
|
||||||
|
status = MessageStatus.SENDING,
|
||||||
|
attachments = listOf(
|
||||||
|
MessageAttachment(
|
||||||
|
id = "img_$timestamp",
|
||||||
|
blob = "", // Пока пустой, обновим после конвертации
|
||||||
|
type = AttachmentType.IMAGE,
|
||||||
|
preview = "", // Пока пустой, обновим после генерации
|
||||||
|
width = 0,
|
||||||
|
height = 0,
|
||||||
|
localUri = imageUri.toString() // 🔥 Используем localUri для мгновенного показа
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
addMessageSafely(optimisticMessage)
|
||||||
|
_inputText.value = ""
|
||||||
|
|
||||||
|
// 2. 🔄 В ФОНЕ: конвертируем, генерируем blurhash и отправляем
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
// Получаем размеры изображения
|
||||||
|
val (width, height) = com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri)
|
||||||
|
|
||||||
|
// Конвертируем в base64
|
||||||
|
val imageBase64 = com.rosetta.messenger.utils.MediaUtils.uriToBase64Image(context, imageUri)
|
||||||
|
if (imageBase64 == null) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.ERROR)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерируем blurhash
|
||||||
|
val blurhash = com.rosetta.messenger.utils.MediaUtils.generateBlurhash(context, imageUri)
|
||||||
|
|
||||||
|
// 3. 🔄 Обновляем optimistic сообщение с реальными данными
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
updateOptimisticImageMessage(messageId, imageBase64, blurhash, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 📤 Отправляем (шифрование + загрузка на сервер)
|
||||||
|
sendImageMessageInternal(
|
||||||
|
messageId = messageId,
|
||||||
|
imageBase64 = imageBase64,
|
||||||
|
blurhash = blurhash,
|
||||||
|
caption = text,
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
timestamp = timestamp,
|
||||||
|
recipient = recipient,
|
||||||
|
sender = sender,
|
||||||
|
privateKey = privateKey
|
||||||
|
)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔄 Обновляет optimistic сообщение с реальными данными изображения
|
||||||
|
*/
|
||||||
|
private fun updateOptimisticImageMessage(messageId: String, base64: String, blurhash: String, width: Int, height: Int) {
|
||||||
|
val currentMessages = _messages.value.toMutableList()
|
||||||
|
val index = currentMessages.indexOfFirst { it.id == messageId }
|
||||||
|
if (index != -1) {
|
||||||
|
val message = currentMessages[index]
|
||||||
|
val updatedAttachments = message.attachments.map { att ->
|
||||||
|
if (att.type == AttachmentType.IMAGE) {
|
||||||
|
att.copy(
|
||||||
|
preview = blurhash,
|
||||||
|
blob = base64,
|
||||||
|
width = width,
|
||||||
|
height = height
|
||||||
|
)
|
||||||
|
} else att
|
||||||
|
}
|
||||||
|
currentMessages[index] = message.copy(attachments = updatedAttachments)
|
||||||
|
_messages.value = currentMessages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 📤 Внутренняя функция отправки изображения (уже с готовым base64)
|
||||||
|
*/
|
||||||
|
private suspend fun sendImageMessageInternal(
|
||||||
|
messageId: String,
|
||||||
|
imageBase64: String,
|
||||||
|
blurhash: String,
|
||||||
|
caption: String,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
timestamp: Long,
|
||||||
|
recipient: String,
|
||||||
|
sender: String,
|
||||||
|
privateKey: String
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val context = getApplication<Application>()
|
||||||
|
|
||||||
|
// Шифрование текста
|
||||||
|
val encryptResult = MessageCrypto.encryptForSending(caption, recipient)
|
||||||
|
val encryptedContent = encryptResult.ciphertext
|
||||||
|
val encryptedKey = encryptResult.encryptedKey
|
||||||
|
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
||||||
|
|
||||||
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
|
|
||||||
|
// 🚀 Шифруем изображение с ChaCha ключом для Transport Server
|
||||||
|
val encryptedImageBlob = MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce)
|
||||||
|
|
||||||
|
val attachmentId = "img_$timestamp"
|
||||||
|
|
||||||
|
// 📤 Загружаем на Transport Server
|
||||||
|
val isSavedMessages = (sender == recipient)
|
||||||
|
var uploadTag = ""
|
||||||
|
|
||||||
|
if (!isSavedMessages) {
|
||||||
|
uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview содержит tag::blurhash
|
||||||
|
val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash
|
||||||
|
|
||||||
|
val imageAttachment = MessageAttachment(
|
||||||
|
id = attachmentId,
|
||||||
|
blob = "",
|
||||||
|
type = AttachmentType.IMAGE,
|
||||||
|
preview = previewWithTag,
|
||||||
|
width = width,
|
||||||
|
height = height
|
||||||
|
)
|
||||||
|
|
||||||
|
val packet = PacketMessage().apply {
|
||||||
|
fromPublicKey = sender
|
||||||
|
toPublicKey = recipient
|
||||||
|
content = encryptedContent
|
||||||
|
chachaKey = encryptedKey
|
||||||
|
this.timestamp = timestamp
|
||||||
|
this.privateKey = privateKeyHash
|
||||||
|
this.messageId = messageId
|
||||||
|
attachments = listOf(imageAttachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправляем пакет
|
||||||
|
if (!isSavedMessages) {
|
||||||
|
ProtocolManager.send(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 💾 Сохраняем изображение в файл локально
|
||||||
|
AttachmentFileManager.saveAttachment(
|
||||||
|
context = context,
|
||||||
|
blob = imageBase64,
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
publicKey = sender,
|
||||||
|
privateKey = privateKey
|
||||||
|
)
|
||||||
|
|
||||||
|
// Сохраняем в БД
|
||||||
|
val attachmentsJson = JSONArray().apply {
|
||||||
|
put(JSONObject().apply {
|
||||||
|
put("id", attachmentId)
|
||||||
|
put("type", AttachmentType.IMAGE.value)
|
||||||
|
put("preview", previewWithTag)
|
||||||
|
put("blob", "")
|
||||||
|
put("width", width)
|
||||||
|
put("height", height)
|
||||||
|
})
|
||||||
|
}.toString()
|
||||||
|
|
||||||
|
saveMessageToDatabase(
|
||||||
|
messageId = messageId,
|
||||||
|
text = caption,
|
||||||
|
encryptedContent = encryptedContent,
|
||||||
|
encryptedKey = encryptedKey,
|
||||||
|
timestamp = timestamp,
|
||||||
|
isFromMe = true,
|
||||||
|
delivered = if (isSavedMessages) 2 else 0,
|
||||||
|
attachmentsJson = attachmentsJson
|
||||||
|
)
|
||||||
|
|
||||||
|
// Обновляем статус на SENT
|
||||||
|
if (!isSavedMessages) {
|
||||||
|
updateMessageStatusInDb(messageId, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDialog(if (caption.isNotEmpty()) caption else "photo", timestamp)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 📸 Отправка сообщения с изображением
|
* 📸 Отправка сообщения с изображением
|
||||||
* @param imageBase64 Base64 изображения
|
* @param imageBase64 Base64 изображения
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.rosetta.messenger.ui.chats.components
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Matrix
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
@@ -33,6 +34,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import com.rosetta.messenger.crypto.MessageCrypto
|
import com.rosetta.messenger.crypto.MessageCrypto
|
||||||
import com.rosetta.messenger.network.AttachmentType
|
import com.rosetta.messenger.network.AttachmentType
|
||||||
import com.rosetta.messenger.network.MessageAttachment
|
import com.rosetta.messenger.network.MessageAttachment
|
||||||
@@ -538,11 +540,15 @@ fun ImageAttachment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Определяем начальный статус и декодируем blurhash (как в Desktop calcDownloadStatus)
|
// Определяем начальный статус и декодируем blurhash (как в Desktop calcDownloadStatus)
|
||||||
LaunchedEffect(attachment.id) {
|
LaunchedEffect(attachment.id, attachment.localUri) {
|
||||||
// Определяем статус (логика из Desktop useAttachment.ts)
|
// Определяем статус (логика из Desktop useAttachment.ts)
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
downloadStatus =
|
downloadStatus =
|
||||||
when {
|
when {
|
||||||
|
// 🚀 0. Если есть localUri - это optimistic UI, показываем сразу
|
||||||
|
attachment.localUri.isNotEmpty() -> {
|
||||||
|
DownloadStatus.DOWNLOADED
|
||||||
|
}
|
||||||
// 1. Если blob уже есть в памяти → DOWNLOADED
|
// 1. Если blob уже есть в памяти → DOWNLOADED
|
||||||
attachment.blob.isNotEmpty() -> {
|
attachment.blob.isNotEmpty() -> {
|
||||||
DownloadStatus.DOWNLOADED
|
DownloadStatus.DOWNLOADED
|
||||||
@@ -587,11 +593,67 @@ fun ImageAttachment(
|
|||||||
// Загружаем изображение если статус DOWNLOADED
|
// Загружаем изображение если статус DOWNLOADED
|
||||||
if (downloadStatus == DownloadStatus.DOWNLOADED) {
|
if (downloadStatus == DownloadStatus.DOWNLOADED) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
// 1. Сначала пробуем blob из памяти
|
// 🚀 0. Если есть localUri - загружаем напрямую из URI (optimistic UI)
|
||||||
if (attachment.blob.isNotEmpty()) {
|
if (attachment.localUri.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
val uri = android.net.Uri.parse(attachment.localUri)
|
||||||
|
|
||||||
|
// 📐 Читаем EXIF ориентацию
|
||||||
|
val orientation = try {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||||
|
val exif = ExifInterface(inputStream)
|
||||||
|
exif.getAttributeInt(
|
||||||
|
ExifInterface.TAG_ORIENTATION,
|
||||||
|
ExifInterface.ORIENTATION_NORMAL
|
||||||
|
)
|
||||||
|
} ?: ExifInterface.ORIENTATION_NORMAL
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ExifInterface.ORIENTATION_NORMAL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем bitmap
|
||||||
|
val inputStream = context.contentResolver.openInputStream(uri)
|
||||||
|
var bitmap = BitmapFactory.decodeStream(inputStream)
|
||||||
|
inputStream?.close()
|
||||||
|
|
||||||
|
// 🔄 Применяем EXIF ориентацию
|
||||||
|
if (bitmap != null && orientation != ExifInterface.ORIENTATION_NORMAL) {
|
||||||
|
val matrix = Matrix()
|
||||||
|
when (orientation) {
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
||||||
|
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
|
||||||
|
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
|
||||||
|
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||||
|
matrix.postRotate(90f)
|
||||||
|
matrix.preScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||||
|
matrix.postRotate(270f)
|
||||||
|
matrix.preScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||||
|
if (rotated != bitmap) {
|
||||||
|
bitmap.recycle()
|
||||||
|
bitmap = rotated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imageBitmap = bitmap
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Fallback to other methods
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Если нет bitmap, пробуем blob из памяти
|
||||||
|
if (imageBitmap == null && attachment.blob.isNotEmpty()) {
|
||||||
imageBitmap = base64ToBitmap(attachment.blob)
|
imageBitmap = base64ToBitmap(attachment.blob)
|
||||||
} else {
|
}
|
||||||
// 2. Читаем из файловой системы (как в Desktop getBlob)
|
|
||||||
|
// 2. Если всё ещё нет, читаем из файловой системы
|
||||||
|
if (imageBitmap == null) {
|
||||||
val localBlob =
|
val localBlob =
|
||||||
AttachmentFileManager.readAttachment(
|
AttachmentFileManager.readAttachment(
|
||||||
context,
|
context,
|
||||||
@@ -601,7 +663,8 @@ fun ImageAttachment(
|
|||||||
)
|
)
|
||||||
if (localBlob != null) {
|
if (localBlob != null) {
|
||||||
imageBitmap = base64ToBitmap(localBlob)
|
imageBitmap = base64ToBitmap(localBlob)
|
||||||
} else {
|
} else if (attachment.localUri.isEmpty()) {
|
||||||
|
// Только если нет localUri - помечаем как NOT_DOWNLOADED
|
||||||
downloadStatus = DownloadStatus.NOT_DOWNLOADED
|
downloadStatus = DownloadStatus.NOT_DOWNLOADED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -902,6 +965,30 @@ fun ImageAttachment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✈️ Telegram-style: Loader при отправке фото (самолётик/кружок)
|
||||||
|
// Показываем когда сообщение отправляется И это исходящее сообщение
|
||||||
|
if (isOutgoing && messageStatus == MessageStatus.SENDING && downloadStatus == DownloadStatus.DOWNLOADED) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
// Круглый индикатор отправки
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Black.copy(alpha = 0.5f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 2.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Оверлей для статуса скачивания
|
// Оверлей для статуса скачивания
|
||||||
if (downloadStatus != DownloadStatus.DOWNLOADED) {
|
if (downloadStatus != DownloadStatus.DOWNLOADED) {
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -603,19 +603,27 @@ fun ImageEditorScreen(
|
|||||||
onToggleEmojiPicker = { toggleEmojiPicker() },
|
onToggleEmojiPicker = { toggleEmojiPicker() },
|
||||||
onEditTextViewCreated = { editTextView = it },
|
onEditTextViewCreated = { editTextView = it },
|
||||||
onSend = {
|
onSend = {
|
||||||
|
// 🚀 Сохраняем caption для использования после анимации
|
||||||
|
val captionToSend = caption
|
||||||
|
val uriToSend = currentImageUri
|
||||||
|
|
||||||
|
// ✈️ Сразу запускаем fade-out анимацию (как в Telegram)
|
||||||
|
// Фото появится в чате через optimistic UI
|
||||||
scope.launch {
|
scope.launch {
|
||||||
isSaving = true
|
saveEditedImage(context, photoEditor, photoEditorView, uriToSend) { savedUri ->
|
||||||
saveEditedImage(context, photoEditor, photoEditorView, currentImageUri) { savedUri ->
|
|
||||||
isSaving = false
|
|
||||||
if (savedUri != null) {
|
if (savedUri != null) {
|
||||||
|
// Вызываем callback (он запустит sendImageMessage с optimistic UI)
|
||||||
if (onSaveWithCaption != null) {
|
if (onSaveWithCaption != null) {
|
||||||
onSaveWithCaption(savedUri, caption)
|
onSaveWithCaption(savedUri, captionToSend)
|
||||||
} else {
|
} else {
|
||||||
onSave(savedUri)
|
onSave(savedUri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🎬 Плавно закрываем экран (fade-out)
|
||||||
|
animatedDismiss()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1478,6 +1486,39 @@ fun MultiImageEditorScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 🎬 TELEGRAM-STYLE FADE ANIMATION
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
var isClosing by remember { mutableStateOf(false) }
|
||||||
|
val animationProgress = remember { Animatable(0f) }
|
||||||
|
|
||||||
|
// Запуск enter анимации
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
animationProgress.animateTo(
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 200,
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для плавного закрытия
|
||||||
|
fun animatedDismiss() {
|
||||||
|
if (isClosing) return
|
||||||
|
isClosing = true
|
||||||
|
scope.launch {
|
||||||
|
animationProgress.animateTo(
|
||||||
|
targetValue = 0f,
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 200,
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🎨 Черный статус бар и навигационный бар для редактора
|
// 🎨 Черный статус бар и навигационный бар для редактора
|
||||||
val activity = context as? Activity
|
val activity = context as? Activity
|
||||||
val window = activity?.window
|
val window = activity?.window
|
||||||
@@ -1554,12 +1595,20 @@ fun MultiImageEditorScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler { onDismiss() }
|
BackHandler { animatedDismiss() }
|
||||||
|
|
||||||
|
// 🎬 Анимированный контейнер с fade эффектом
|
||||||
|
val progress = animationProgress.value
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black)
|
.background(Color.Black.copy(alpha = progress))
|
||||||
|
.graphicsLayer {
|
||||||
|
alpha = progress
|
||||||
|
scaleX = 0.95f + 0.05f * progress
|
||||||
|
scaleY = 0.95f + 0.05f * progress
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
// Pager
|
// Pager
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
@@ -1646,7 +1695,7 @@ fun MultiImageEditorScreen(
|
|||||||
.padding(horizontal = 4.dp, vertical = 8.dp)
|
.padding(horizontal = 4.dp, vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onDismiss,
|
onClick = { animatedDismiss() },
|
||||||
modifier = Modifier.align(Alignment.CenterStart)
|
modifier = Modifier.align(Alignment.CenterStart)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -1829,15 +1878,18 @@ fun MultiImageEditorScreen(
|
|||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(PrimaryBlue)
|
.background(PrimaryBlue)
|
||||||
.clickable(enabled = !isSaving) {
|
.clickable(enabled = !isSaving && !isClosing) {
|
||||||
isSaving = true
|
// 🚀 Сохраняем копию данных перед анимацией
|
||||||
|
val imagesToSend = imagesWithCaptions.toList()
|
||||||
|
|
||||||
|
// 🎬 Запускаем fade-out анимацию сразу
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val savedImages = mutableListOf<ImageWithCaption>()
|
val savedImages = mutableListOf<ImageWithCaption>()
|
||||||
|
|
||||||
for (i in imagesWithCaptions.indices) {
|
for (i in imagesToSend.indices) {
|
||||||
val editor = photoEditors[i]
|
val editor = photoEditors[i]
|
||||||
val editorView = photoEditorViews[i]
|
val editorView = photoEditorViews[i]
|
||||||
val originalImage = imagesWithCaptions[i]
|
val originalImage = imagesToSend[i]
|
||||||
|
|
||||||
if (editor != null) {
|
if (editor != null) {
|
||||||
val savedUri = saveEditedImageSync(context, editor, editorView, originalImage.uri)
|
val savedUri = saveEditedImageSync(context, editor, editorView, originalImage.uri)
|
||||||
@@ -1851,27 +1903,23 @@ fun MultiImageEditorScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Вызываем callback (он запустит sendImageGroup с optimistic UI)
|
||||||
onSendAll(savedImages)
|
onSendAll(savedImages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✈️ Плавно закрываем экран (fade-out)
|
||||||
|
animatedDismiss()
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (isSaving) {
|
Icon(
|
||||||
CircularProgressIndicator(
|
TablerIcons.Send,
|
||||||
modifier = Modifier.size(22.dp),
|
contentDescription = "Send",
|
||||||
color = Color.White,
|
tint = Color.White,
|
||||||
strokeWidth = 2.dp
|
modifier = Modifier
|
||||||
)
|
.size(22.dp)
|
||||||
} else {
|
.offset(x = 1.dp)
|
||||||
Icon(
|
)
|
||||||
TablerIcons.Send,
|
|
||||||
contentDescription = "Send",
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(22.dp)
|
|
||||||
.offset(x = 1.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user