diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 6a50cb4..2fe7fa1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -709,7 +709,7 @@ fun ChatDetailScreen( contentDescription = "Back", tint = - Color.White, + if (isDarkTheme) Color.White else Color(0xFF007AFF), modifier = Modifier.size( 32.dp diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index e49dd0e..c5bf3b8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -1,16 +1,13 @@ package com.rosetta.messenger.ui.chats.components -import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.util.Base64 import android.util.Log import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -25,38 +22,31 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.geometry.CornerRadius -import compose.icons.TablerIcons -import compose.icons.tablericons.* -import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.MessageAttachment import com.rosetta.messenger.network.TransportManager -import com.rosetta.messenger.R import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.chats.models.MessageStatus import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.utils.AttachmentFileManager import com.rosetta.messenger.utils.AvatarFileManager import com.vanniktech.blurhash.BlurHash +import compose.icons.TablerIcons +import compose.icons.tablericons.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private const val TAG = "AttachmentComponents" -/** - * Статус скачивания attachment (как в desktop) - */ +/** Статус скачивания attachment (как в desktop) */ enum class DownloadStatus { DOWNLOADED, NOT_DOWNLOADED, @@ -67,88 +57,89 @@ enum class DownloadStatus { } /** - * Composable для отображения всех attachments в сообщении - * 🖼️ IMAGE attachments группируются в коллаж (как в Telegram) + * Composable для отображения всех attachments в сообщении 🖼️ IMAGE attachments группируются в + * коллаж (как в Telegram) */ @Composable fun MessageAttachments( - attachments: List, - chachaKey: String, - privateKey: String, - isOutgoing: Boolean, - isDarkTheme: Boolean, - senderPublicKey: String, - timestamp: java.util.Date, - messageStatus: MessageStatus = MessageStatus.READ, - avatarRepository: AvatarRepository? = null, - currentUserPublicKey: String = "", - hasCaption: Boolean = false, // Если есть caption - время показывается под фото, не на фото - onImageClick: (attachmentId: String) -> Unit = {}, - modifier: Modifier = Modifier + attachments: List, + chachaKey: String, + privateKey: String, + isOutgoing: Boolean, + isDarkTheme: Boolean, + senderPublicKey: String, + timestamp: java.util.Date, + messageStatus: MessageStatus = MessageStatus.READ, + avatarRepository: AvatarRepository? = null, + currentUserPublicKey: String = "", + hasCaption: Boolean = false, // Если есть caption - время показывается под фото, не на фото + onImageClick: (attachmentId: String) -> Unit = {}, + modifier: Modifier = Modifier ) { 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) - ) { + val otherAttachments = + attachments.filter { + it.type != AttachmentType.IMAGE && it.type != AttachmentType.MESSAGES + } + + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { // 🖼️ Коллаж для изображений (если больше 1) if (imageAttachments.isNotEmpty()) { ImageCollage( - attachments = imageAttachments, - chachaKey = chachaKey, - privateKey = privateKey, - senderPublicKey = senderPublicKey, - isOutgoing = isOutgoing, - isDarkTheme = isDarkTheme, - timestamp = timestamp, - messageStatus = messageStatus, - hasCaption = hasCaption, - onImageClick = onImageClick + attachments = imageAttachments, + chachaKey = chachaKey, + privateKey = privateKey, + senderPublicKey = senderPublicKey, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + timestamp = timestamp, + messageStatus = messageStatus, + hasCaption = hasCaption, + onImageClick = onImageClick ) } - + // Остальные attachments по отдельности otherAttachments.forEach { attachment -> when (attachment.type) { AttachmentType.FILE -> { FileAttachment( - attachment = attachment, - chachaKey = chachaKey, - privateKey = privateKey, - isOutgoing = isOutgoing, - isDarkTheme = isDarkTheme, - timestamp = timestamp, - messageStatus = messageStatus + attachment = attachment, + chachaKey = chachaKey, + privateKey = privateKey, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + timestamp = timestamp, + messageStatus = messageStatus ) } AttachmentType.AVATAR -> { AvatarAttachment( - attachment = attachment, - chachaKey = chachaKey, - privateKey = privateKey, - senderPublicKey = senderPublicKey, - avatarRepository = avatarRepository, - currentUserPublicKey = currentUserPublicKey, - isOutgoing = isOutgoing, - isDarkTheme = isDarkTheme, - timestamp = timestamp, - messageStatus = messageStatus + attachment = attachment, + chachaKey = chachaKey, + privateKey = privateKey, + senderPublicKey = senderPublicKey, + avatarRepository = avatarRepository, + currentUserPublicKey = currentUserPublicKey, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + timestamp = timestamp, + messageStatus = messageStatus ) } - else -> { /* MESSAGES обрабатываются отдельно */ } + else -> { + /* MESSAGES обрабатываются отдельно */ + } } } } } /** - * 🖼️ Коллаж изображений в стиле Telegram - * Разные layout'ы в зависимости от количества фото: + * 🖼️ Коллаж изображений в стиле Telegram Разные layout'ы в зависимости от количества фото: * - 1 фото: полная ширина * - 2 фото: 2 колонки * - 3 фото: 1 большое слева + 2 маленьких справа @@ -157,71 +148,74 @@ fun MessageAttachments( */ @Composable fun ImageCollage( - attachments: List, - chachaKey: String, - privateKey: String, - senderPublicKey: String, - isOutgoing: Boolean, - isDarkTheme: Boolean, - timestamp: java.util.Date, - messageStatus: MessageStatus = MessageStatus.READ, - hasCaption: Boolean = false, // Если есть caption - время показывается под фото - onImageClick: (attachmentId: String) -> Unit = {}, - modifier: Modifier = Modifier + attachments: List, + chachaKey: String, + privateKey: String, + senderPublicKey: String, + isOutgoing: Boolean, + isDarkTheme: Boolean, + timestamp: java.util.Date, + messageStatus: MessageStatus = MessageStatus.READ, + hasCaption: Boolean = false, // Если есть caption - время показывается под фото + onImageClick: (attachmentId: String) -> Unit = {}, + modifier: Modifier = Modifier ) { val count = attachments.size val spacing = 2.dp - + // Показываем время и статус только если нет caption val showOverlayOnLast = !hasCaption - + // Закругление: если есть caption - только сверху, снизу прямые углы - val collageShape = if (hasCaption) { - RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 0.dp, bottomEnd = 0.dp) - } else { - RoundedCornerShape(16.dp) - } - - Box( - modifier = modifier - .clip(collageShape) - ) { + val collageShape = + if (hasCaption) { + RoundedCornerShape( + topStart = 16.dp, + topEnd = 16.dp, + bottomStart = 0.dp, + bottomEnd = 0.dp + ) + } else { + RoundedCornerShape(16.dp) + } + + Box(modifier = modifier.clip(collageShape)) { when (count) { 1 -> { // Одно фото - полная ширина ImageAttachment( - attachment = attachments[0], - chachaKey = chachaKey, - privateKey = privateKey, - senderPublicKey = senderPublicKey, - isOutgoing = isOutgoing, - isDarkTheme = isDarkTheme, - timestamp = timestamp, - messageStatus = messageStatus, - showTimeOverlay = true, - onImageClick = onImageClick + attachment = attachments[0], + chachaKey = chachaKey, + privateKey = privateKey, + senderPublicKey = senderPublicKey, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + timestamp = timestamp, + messageStatus = messageStatus, + showTimeOverlay = showOverlayOnLast, + onImageClick = onImageClick ) } 2 -> { // Два фото - горизонтально Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing) + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing) ) { attachments.forEachIndexed { index, attachment -> Box(modifier = Modifier.weight(1f)) { ImageAttachment( - attachment = attachment, - chachaKey = chachaKey, - privateKey = privateKey, - senderPublicKey = senderPublicKey, - isOutgoing = isOutgoing, - isDarkTheme = isDarkTheme, - timestamp = timestamp, - messageStatus = messageStatus, - showTimeOverlay = index == count - 1, - aspectRatio = 1f, - onImageClick = onImageClick + attachment = attachment, + chachaKey = chachaKey, + privateKey = privateKey, + senderPublicKey = senderPublicKey, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + timestamp = timestamp, + messageStatus = messageStatus, + showTimeOverlay = showOverlayOnLast && index == count - 1, + aspectRatio = 1f, + onImageClick = onImageClick ) } } @@ -230,35 +224,13 @@ fun ImageCollage( 3 -> { // Три фото: 1 большое слева + 2 маленьких справа Row( - modifier = Modifier - .fillMaxWidth() - .height(200.dp), - horizontalArrangement = Arrangement.spacedBy(spacing) + 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, - senderPublicKey = senderPublicKey, - isOutgoing = isOutgoing, - isDarkTheme = isDarkTheme, - timestamp = timestamp, - messageStatus = messageStatus, - showTimeOverlay = false, - fillMaxSize = true, - onImageClick = onImageClick - ) - } - // Два маленьких справа - Column( - modifier = Modifier.weight(1f).fillMaxHeight(), - verticalArrangement = Arrangement.spacedBy(spacing) - ) { - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - ImageAttachment( - attachment = attachments[1], + attachment = attachments[0], chachaKey = chachaKey, privateKey = privateKey, senderPublicKey = senderPublicKey, @@ -269,21 +241,41 @@ fun ImageCollage( showTimeOverlay = false, fillMaxSize = true, onImageClick = onImageClick + ) + } + // Два маленьких справа + Column( + modifier = Modifier.weight(1f).fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(spacing) + ) { + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + ImageAttachment( + attachment = attachments[1], + chachaKey = chachaKey, + privateKey = privateKey, + senderPublicKey = senderPublicKey, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + timestamp = timestamp, + messageStatus = messageStatus, + showTimeOverlay = false, + fillMaxSize = true, + onImageClick = onImageClick ) } Box(modifier = Modifier.weight(1f).fillMaxWidth()) { ImageAttachment( - attachment = attachments[2], - chachaKey = chachaKey, - privateKey = privateKey, - senderPublicKey = senderPublicKey, - isOutgoing = isOutgoing, - isDarkTheme = isDarkTheme, - timestamp = timestamp, - messageStatus = messageStatus, - showTimeOverlay = true, - fillMaxSize = true, - onImageClick = onImageClick + attachment = attachments[2], + chachaKey = chachaKey, + privateKey = privateKey, + senderPublicKey = senderPublicKey, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + timestamp = timestamp, + messageStatus = messageStatus, + showTimeOverlay = showOverlayOnLast, + fillMaxSize = true, + onImageClick = onImageClick ) } } @@ -292,76 +284,76 @@ fun ImageCollage( 4 -> { // Четыре фото: 2x2 сетка Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(spacing) + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(spacing) ) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing) + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing) ) { Box(modifier = Modifier.weight(1f).aspectRatio(1f)) { ImageAttachment( - attachment = attachments[0], - chachaKey = chachaKey, - privateKey = privateKey, - senderPublicKey = senderPublicKey, - isOutgoing = isOutgoing, - isDarkTheme = isDarkTheme, - timestamp = timestamp, - messageStatus = messageStatus, - showTimeOverlay = false, - fillMaxSize = true, - onImageClick = onImageClick + attachment = attachments[0], + chachaKey = chachaKey, + privateKey = privateKey, + senderPublicKey = senderPublicKey, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + timestamp = timestamp, + messageStatus = messageStatus, + showTimeOverlay = false, + fillMaxSize = true, + onImageClick = onImageClick ) } Box(modifier = Modifier.weight(1f).aspectRatio(1f)) { ImageAttachment( - attachment = attachments[1], - chachaKey = chachaKey, - privateKey = privateKey, - senderPublicKey = senderPublicKey, - isOutgoing = isOutgoing, - isDarkTheme = isDarkTheme, - timestamp = timestamp, - messageStatus = messageStatus, - showTimeOverlay = false, - fillMaxSize = true, - onImageClick = onImageClick + attachment = attachments[1], + chachaKey = chachaKey, + privateKey = privateKey, + senderPublicKey = senderPublicKey, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + timestamp = timestamp, + messageStatus = messageStatus, + showTimeOverlay = false, + fillMaxSize = true, + onImageClick = onImageClick ) } } Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing) + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing) ) { Box(modifier = Modifier.weight(1f).aspectRatio(1f)) { ImageAttachment( - attachment = attachments[2], - chachaKey = chachaKey, - privateKey = privateKey, - senderPublicKey = senderPublicKey, - isOutgoing = isOutgoing, - isDarkTheme = isDarkTheme, - timestamp = timestamp, - messageStatus = messageStatus, - showTimeOverlay = false, - fillMaxSize = true, - onImageClick = onImageClick + attachment = attachments[2], + chachaKey = chachaKey, + privateKey = privateKey, + senderPublicKey = senderPublicKey, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + timestamp = timestamp, + messageStatus = messageStatus, + showTimeOverlay = false, + fillMaxSize = true, + onImageClick = onImageClick ) } Box(modifier = Modifier.weight(1f).aspectRatio(1f)) { ImageAttachment( - attachment = attachments[3], - chachaKey = chachaKey, - privateKey = privateKey, - senderPublicKey = senderPublicKey, - isOutgoing = isOutgoing, - isDarkTheme = isDarkTheme, - timestamp = timestamp, - messageStatus = messageStatus, - showTimeOverlay = true, - fillMaxSize = true, - onImageClick = onImageClick + attachment = attachments[3], + chachaKey = chachaKey, + privateKey = privateKey, + senderPublicKey = senderPublicKey, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + timestamp = timestamp, + messageStatus = messageStatus, + showTimeOverlay = showOverlayOnLast, + fillMaxSize = true, + onImageClick = onImageClick ) } } @@ -370,42 +362,42 @@ fun ImageCollage( else -> { // 5+ фото: 2 сверху + остальные снизу по 3 в ряд Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(spacing) + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(spacing) ) { // Первые 2 фото сверху Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing) + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing) ) { Box(modifier = Modifier.weight(1f).aspectRatio(1f)) { ImageAttachment( - attachment = attachments[0], - chachaKey = chachaKey, - privateKey = privateKey, - senderPublicKey = senderPublicKey, - isOutgoing = isOutgoing, - isDarkTheme = isDarkTheme, - timestamp = timestamp, - messageStatus = messageStatus, - showTimeOverlay = false, - fillMaxSize = true, - onImageClick = onImageClick + attachment = attachments[0], + chachaKey = chachaKey, + privateKey = privateKey, + senderPublicKey = senderPublicKey, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + timestamp = timestamp, + messageStatus = messageStatus, + showTimeOverlay = false, + fillMaxSize = true, + onImageClick = onImageClick ) } Box(modifier = Modifier.weight(1f).aspectRatio(1f)) { ImageAttachment( - attachment = attachments[1], - chachaKey = chachaKey, - privateKey = privateKey, - senderPublicKey = senderPublicKey, - isOutgoing = isOutgoing, - isDarkTheme = isDarkTheme, - timestamp = timestamp, - messageStatus = messageStatus, - showTimeOverlay = false, - fillMaxSize = true, - onImageClick = onImageClick + attachment = attachments[1], + chachaKey = chachaKey, + privateKey = privateKey, + senderPublicKey = senderPublicKey, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + timestamp = timestamp, + messageStatus = messageStatus, + showTimeOverlay = false, + fillMaxSize = true, + onImageClick = onImageClick ) } } @@ -414,31 +406,29 @@ fun ImageCollage( remaining.chunked(3).forEachIndexed { rowIndex, rowItems -> val isLastRow = rowIndex == remaining.chunked(3).size - 1 Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing) + 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, - senderPublicKey = senderPublicKey, - isOutgoing = isOutgoing, - isDarkTheme = isDarkTheme, - timestamp = timestamp, - messageStatus = messageStatus, - showTimeOverlay = isLastItem, - fillMaxSize = true, - onImageClick = onImageClick + attachment = attachment, + chachaKey = chachaKey, + privateKey = privateKey, + senderPublicKey = senderPublicKey, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + timestamp = timestamp, + messageStatus = messageStatus, + showTimeOverlay = showOverlayOnLast && isLastItem, + fillMaxSize = true, + onImageClick = onImageClick ) } } // Заполняем пустые места если в ряду меньше 3 фото - repeat(3 - rowItems.size) { - Spacer(modifier = Modifier.weight(1f)) - } + repeat(3 - rowItems.size) { Spacer(modifier = Modifier.weight(1f)) } } } } @@ -449,7 +439,7 @@ fun ImageCollage( /** * Image attachment - Telegram style с blurhash placeholder - * + * * Логика определения статуса (как в Desktop): * 1. Если preview НЕ содержит UUID → это локальный файл → DOWNLOADED * 2. Если есть UUID (download tag) → проверяем файловую систему через AttachmentFileManager @@ -458,74 +448,83 @@ fun ImageCollage( */ @Composable fun ImageAttachment( - attachment: MessageAttachment, - chachaKey: String, - privateKey: String, - senderPublicKey: String, - isOutgoing: Boolean, - isDarkTheme: Boolean, - timestamp: java.util.Date, - messageStatus: MessageStatus = MessageStatus.READ, - showTimeOverlay: Boolean = true, - aspectRatio: Float? = null, - fillMaxSize: Boolean = false, - onImageClick: (attachmentId: String) -> Unit = {} + attachment: MessageAttachment, + chachaKey: String, + privateKey: String, + senderPublicKey: String, + isOutgoing: Boolean, + isDarkTheme: Boolean, + timestamp: java.util.Date, + messageStatus: MessageStatus = MessageStatus.READ, + showTimeOverlay: Boolean = true, + aspectRatio: Float? = null, + fillMaxSize: Boolean = false, + onImageClick: (attachmentId: String) -> Unit = {} ) { val context = LocalContext.current val scope = rememberCoroutineScope() - + var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) } var imageBitmap by remember { mutableStateOf(null) } var blurhashBitmap by remember { mutableStateOf(null) } var downloadProgress by remember { mutableStateOf(0f) } - + val preview = getPreview(attachment.preview) val downloadTag = getDownloadTag(attachment.preview) - + // Анимация прогресса - val animatedProgress by animateFloatAsState( - targetValue = downloadProgress, - animationSpec = tween(durationMillis = 300), - label = "progress" - ) - + val animatedProgress by + animateFloatAsState( + targetValue = downloadProgress, + animationSpec = tween(durationMillis = 300), + label = "progress" + ) + // Определяем начальный статус и декодируем blurhash (как в Desktop calcDownloadStatus) LaunchedEffect(attachment.id) { // Определяем статус (логика из Desktop useAttachment.ts) withContext(Dispatchers.IO) { - downloadStatus = when { - // 1. Если blob уже есть в памяти → DOWNLOADED - attachment.blob.isNotEmpty() -> { - Log.d(TAG, "📦 Blob already in memory for ${attachment.id}") - DownloadStatus.DOWNLOADED - } - // 2. Если preview НЕ содержит UUID → это наш локальный файл → DOWNLOADED - !isDownloadTag(attachment.preview) -> { - Log.d(TAG, "📦 No download tag, local file for ${attachment.id}") - DownloadStatus.DOWNLOADED - } - // 3. Есть UUID (download tag) → проверяем файловую систему - else -> { - // Проверяем есть ли файл локально (как в Desktop: readFile(`m/${md5hash}`)) - val hasLocal = AttachmentFileManager.hasAttachment( - context, attachment.id, senderPublicKey - ) - if (hasLocal) { - Log.d(TAG, "📦 Found local file for ${attachment.id}") - DownloadStatus.DOWNLOADED - } else { - Log.d(TAG, "📥 Need to download ${attachment.id}") - DownloadStatus.NOT_DOWNLOADED + downloadStatus = + when { + // 1. Если blob уже есть в памяти → DOWNLOADED + attachment.blob.isNotEmpty() -> { + Log.d(TAG, "📦 Blob already in memory for ${attachment.id}") + DownloadStatus.DOWNLOADED + } + // 2. Если preview НЕ содержит UUID → это наш локальный файл → DOWNLOADED + !isDownloadTag(attachment.preview) -> { + Log.d(TAG, "📦 No download tag, local file for ${attachment.id}") + DownloadStatus.DOWNLOADED + } + // 3. Есть UUID (download tag) → проверяем файловую систему + else -> { + // Проверяем есть ли файл локально (как в Desktop: + // readFile(`m/${md5hash}`)) + val hasLocal = + AttachmentFileManager.hasAttachment( + context, + attachment.id, + senderPublicKey + ) + if (hasLocal) { + Log.d(TAG, "📦 Found local file for ${attachment.id}") + DownloadStatus.DOWNLOADED + } else { + Log.d(TAG, "📥 Need to download ${attachment.id}") + DownloadStatus.NOT_DOWNLOADED + } + } } - } - } } - + // Декодируем blurhash для placeholder (если есть) if (preview.isNotEmpty() && !isDownloadTag(preview)) { withContext(Dispatchers.IO) { try { - Log.d(TAG, "🎨 Decoding blurhash: ${preview.take(30)}... (length: ${preview.length})") + Log.d( + TAG, + "🎨 Decoding blurhash: ${preview.take(30)}... (length: ${preview.length})" + ) blurhashBitmap = BlurHash.decode(preview, 200, 200) if (blurhashBitmap != null) { Log.d(TAG, "✅ Blurhash decoded successfully") @@ -537,9 +536,12 @@ fun ImageAttachment( } } } else { - Log.d(TAG, "⚠️ No valid blurhash preview (preview='${preview.take(20)}...', isDownloadTag=${isDownloadTag(preview)})") + Log.d( + TAG, + "⚠️ No valid blurhash preview (preview='${preview.take(20)}...', isDownloadTag=${isDownloadTag(preview)})" + ) } - + // Загружаем изображение если статус DOWNLOADED if (downloadStatus == DownloadStatus.DOWNLOADED) { withContext(Dispatchers.IO) { @@ -550,9 +552,13 @@ fun ImageAttachment( } else { // 2. Читаем из файловой системы (как в Desktop getBlob) Log.d(TAG, "🖼️ Loading image from local file") - val localBlob = AttachmentFileManager.readAttachment( - context, attachment.id, senderPublicKey, privateKey - ) + val localBlob = + AttachmentFileManager.readAttachment( + context, + attachment.id, + senderPublicKey, + privateKey + ) if (localBlob != null) { imageBitmap = base64ToBitmap(localBlob) Log.d(TAG, "✅ Image loaded from local file") @@ -564,7 +570,7 @@ fun ImageAttachment( } } } - + val download: () -> Unit = { if (downloadTag.isNotEmpty()) { scope.launch { @@ -576,7 +582,7 @@ fun ImageAttachment( Log.d(TAG, "🏷️ Download tag: $downloadTag") Log.d(TAG, "👤 Sender public key: ${senderPublicKey.take(16)}...") Log.d(TAG, "=====================================") - + // Скачиваем зашифрованный контент Log.d(TAG, "⬇️ Downloading encrypted content from CDN...") val startTime = System.currentTimeMillis() @@ -584,43 +590,50 @@ fun ImageAttachment( val downloadTime = System.currentTimeMillis() - startTime Log.d(TAG, "✅ Downloaded ${encryptedContent.length} chars in ${downloadTime}ms") downloadProgress = 0.5f - + downloadStatus = DownloadStatus.DECRYPTING Log.d(TAG, "🔓 Starting decryption...") - + // КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop) // Сначала расшифровываем его, получаем raw bytes Log.d(TAG, "🔑 Decrypting ChaCha key from sender...") - val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) + val decryptedKeyAndNonce = + MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) Log.d(TAG, "🔑 ChaCha key decrypted: ${decryptedKeyAndNonce.size} bytes") - - // Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует bytes в password + + // Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует + // bytes в password Log.d(TAG, "🔓 Decrypting image blob with PBKDF2...") val decryptStartTime = System.currentTimeMillis() - val decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey( - encryptedContent, - decryptedKeyAndNonce - ) + val decrypted = + MessageCrypto.decryptAttachmentBlobWithPlainKey( + encryptedContent, + decryptedKeyAndNonce + ) val decryptTime = System.currentTimeMillis() - decryptStartTime Log.d(TAG, "🔓 Decryption completed in ${decryptTime}ms") downloadProgress = 0.8f - + if (decrypted != null) { Log.d(TAG, "✅ Decrypted blob: ${decrypted.length} chars") withContext(Dispatchers.IO) { Log.d(TAG, "🖼️ Converting to bitmap...") imageBitmap = base64ToBitmap(decrypted) - Log.d(TAG, "✅ Bitmap created: ${imageBitmap?.width}x${imageBitmap?.height}") - + Log.d( + TAG, + "✅ Bitmap created: ${imageBitmap?.width}x${imageBitmap?.height}" + ) + // 💾 Сохраняем в файловую систему (как в Desktop) Log.d(TAG, "💾 Saving to local storage...") - val saved = AttachmentFileManager.saveAttachment( - context = context, - blob = decrypted, - attachmentId = attachment.id, - publicKey = senderPublicKey, - privateKey = privateKey - ) + val saved = + AttachmentFileManager.saveAttachment( + context = context, + blob = decrypted, + attachmentId = attachment.id, + publicKey = senderPublicKey, + privateKey = privateKey + ) Log.d(TAG, "💾 Image saved to local storage: $saved") } downloadProgress = 1f @@ -649,189 +662,201 @@ fun ImageAttachment( Log.w(TAG, "⚠️ Cannot download image: empty download tag for ${attachment.id}") } } - + // Telegram-style image с blurhash placeholder и тонким бордером val timeFormat = remember { java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) } - val borderColor = if (isOutgoing) { - Color.White.copy(alpha = 0.15f) - } else { - if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) - } - + val borderColor = + if (isOutgoing) { + Color.White.copy(alpha = 0.15f) + } else { + if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) + } + // 📐 Вычисляем размер пузырька на основе соотношения сторон изображения (как в Telegram) // Используем размеры из attachment или из загруженного bitmap val actualWidth = if (attachment.width > 0) attachment.width else imageBitmap?.width ?: 0 val actualHeight = if (attachment.height > 0) attachment.height else imageBitmap?.height ?: 0 - - val (imageWidth, imageHeight) = remember(actualWidth, actualHeight, fillMaxSize, aspectRatio) { - // Если fillMaxSize - используем 100% контейнера - if (fillMaxSize) { - null to null - } else { - // Telegram-style размеры - больше и адаптивнее - val maxWidth = 280.dp - val maxHeight = 400.dp // Увеличено для вертикальных фото - val minWidth = 160.dp - val minHeight = 120.dp - - if (actualWidth > 0 && actualHeight > 0) { - val ar = actualWidth.toFloat() / actualHeight.toFloat() - - when { - // Очень широкое изображение (panorama) - ar > 2.0f -> { - val width = maxWidth - val height = (maxWidth.value / ar).dp.coerceIn(minHeight, maxHeight) - width to height - } - // Широкое изображение (landscape) - ar > 1.2f -> { - val width = maxWidth - val height = (maxWidth.value / ar).dp.coerceIn(minHeight, maxHeight) - width to height - } - // Очень высокое изображение (story-like) - ar < 0.5f -> { - val height = maxHeight - val width = (maxHeight.value * ar).dp.coerceIn(minWidth, maxWidth) - width to height - } - // Высокое изображение (portrait) - ar < 0.85f -> { - val height = maxHeight - val width = (maxHeight.value * ar).dp.coerceIn(minWidth, maxWidth) - width to height - } - // Квадратное или близкое к квадрату - else -> { - val size = 240.dp - size to size + + val (imageWidth, imageHeight) = + remember(actualWidth, actualHeight, fillMaxSize, aspectRatio) { + // Если fillMaxSize - используем 100% контейнера + if (fillMaxSize) { + null to null + } else { + // Telegram-style размеры - больше и адаптивнее + val maxWidth = 280.dp + val maxHeight = 400.dp // Увеличено для вертикальных фото + val minWidth = 160.dp + val minHeight = 120.dp + + if (actualWidth > 0 && actualHeight > 0) { + val ar = actualWidth.toFloat() / actualHeight.toFloat() + + when { + // Очень широкое изображение (panorama) + ar > 2.0f -> { + val width = maxWidth + val height = (maxWidth.value / ar).dp.coerceIn(minHeight, maxHeight) + width to height + } + // Широкое изображение (landscape) + ar > 1.2f -> { + val width = maxWidth + val height = (maxWidth.value / ar).dp.coerceIn(minHeight, maxHeight) + width to height + } + // Очень высокое изображение (story-like) + ar < 0.5f -> { + val height = maxHeight + val width = (maxHeight.value * ar).dp.coerceIn(minWidth, maxWidth) + width to height + } + // Высокое изображение (portrait) + ar < 0.85f -> { + val height = maxHeight + val width = (maxHeight.value * ar).dp.coerceIn(minWidth, maxWidth) + width to height + } + // Квадратное или близкое к квадрату + else -> { + val size = 240.dp + size to size + } + } + } else { + // Fallback если размеры не указаны - квадрат средний + 240.dp to 240.dp } } - } else { - // Fallback если размеры не указаны - квадрат средний - 240.dp to 240.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) - } - + 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 = sizeModifier - .clip(RoundedCornerShape(if (fillMaxSize) 0.dp else 12.dp)) - .background(Color.Transparent) - .clickable { - when (downloadStatus) { - DownloadStatus.NOT_DOWNLOADED -> download() - DownloadStatus.DOWNLOADED -> { - // 📸 Open image viewer - onImageClick(attachment.id) - } - DownloadStatus.ERROR -> download() - else -> {} - } - }, - contentAlignment = Alignment.Center + modifier = + sizeModifier + .clip(RoundedCornerShape(if (fillMaxSize) 0.dp else 12.dp)) + .background(Color.Transparent) + .clickable { + when (downloadStatus) { + DownloadStatus.NOT_DOWNLOADED -> download() + DownloadStatus.DOWNLOADED -> { + // 📸 Open image viewer + onImageClick(attachment.id) + } + DownloadStatus.ERROR -> download() + else -> {} + } + }, + contentAlignment = Alignment.Center ) { // Фоновый слой - blurhash или placeholder when { imageBitmap != null -> { // Показываем полное изображение Image( - bitmap = imageBitmap!!.asImageBitmap(), - contentDescription = "Image", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + bitmap = imageBitmap!!.asImageBitmap(), + contentDescription = "Image", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) } blurhashBitmap != null -> { // Показываем blurhash placeholder Image( - bitmap = blurhashBitmap!!.asImageBitmap(), - contentDescription = "Preview", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + bitmap = blurhashBitmap!!.asImageBitmap(), + contentDescription = "Preview", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) } else -> { // Простой placeholder с приятным gradient фоном Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - colors = if (isDarkTheme) - listOf(Color(0xFF2A2A2A), Color(0xFF1A1A1A)) - else - listOf(Color(0xFFE8E8E8), Color(0xFFD0D0D0)) - ) - ) + modifier = + Modifier.fillMaxSize() + .background( + Brush.verticalGradient( + colors = + if (isDarkTheme) + listOf( + Color(0xFF2A2A2A), + Color(0xFF1A1A1A) + ) + else + listOf( + Color(0xFFE8E8E8), + Color(0xFFD0D0D0) + ) + ) + ) ) } } - + // Время в правом нижнем углу (показываем только если 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) + 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) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) ) { Text( - text = timeFormat.format(timestamp), - color = Color.White, - fontSize = 11.sp, - fontWeight = FontWeight.Medium + 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) + 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) + 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) + 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) + compose.icons.TablerIcons.Checks, + contentDescription = null, + tint = Color(0xFF4FC3F7), + modifier = Modifier.size(14.dp) ) } else -> {} @@ -840,74 +865,68 @@ fun ImageAttachment( } } } - + // Оверлей для статуса скачивания if (downloadStatus != DownloadStatus.DOWNLOADED) { Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.3f)), - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.3f)), + contentAlignment = Alignment.Center ) { when (downloadStatus) { DownloadStatus.NOT_DOWNLOADED -> { // Кнопка скачивания Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.5f)), - contentAlignment = Alignment.Center + modifier = + Modifier.size(48.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center ) { Icon( - Icons.Default.ArrowDownward, - contentDescription = "Download", - tint = Color.White, - modifier = Modifier.size(24.dp) + Icons.Default.ArrowDownward, + contentDescription = "Download", + tint = Color.White, + modifier = Modifier.size(24.dp) ) } } DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { // Индикатор загрузки с прогрессом Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.5f)), - contentAlignment = Alignment.Center + modifier = + Modifier.size(48.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center ) { CircularProgressIndicator( - modifier = Modifier.size(36.dp), - color = Color.White, - strokeWidth = 3.dp + modifier = Modifier.size(36.dp), + color = Color.White, + strokeWidth = 3.dp ) } } DownloadStatus.ERROR -> { // Ошибка с кнопкой повтора - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(Color(0xFFE53935).copy(alpha = 0.8f)), - contentAlignment = Alignment.Center + modifier = + Modifier.size(48.dp) + .clip(CircleShape) + .background( + Color(0xFFE53935).copy(alpha = 0.8f) + ), + contentAlignment = Alignment.Center ) { Icon( - Icons.Default.Refresh, - contentDescription = "Retry", - tint = Color.White, - modifier = Modifier.size(24.dp) + Icons.Default.Refresh, + contentDescription = "Retry", + tint = Color.White, + modifier = Modifier.size(24.dp) ) } Spacer(modifier = Modifier.height(8.dp)) - Text( - "Expired", - fontSize = 12.sp, - color = Color.White - ) + Text("Expired", fontSize = 12.sp, color = Color.White) } } else -> {} @@ -917,65 +936,67 @@ fun ImageAttachment( } } -/** - * File attachment - Telegram style - */ +/** File attachment - Telegram style */ @Composable fun FileAttachment( - attachment: MessageAttachment, - chachaKey: String, - privateKey: String, - isOutgoing: Boolean, - isDarkTheme: Boolean, - timestamp: java.util.Date, - messageStatus: MessageStatus = MessageStatus.READ + attachment: MessageAttachment, + chachaKey: String, + privateKey: String, + isOutgoing: Boolean, + isDarkTheme: Boolean, + timestamp: java.util.Date, + messageStatus: MessageStatus = MessageStatus.READ ) { val context = LocalContext.current val scope = rememberCoroutineScope() var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) } var downloadProgress by remember { mutableStateOf(0f) } - + val preview = attachment.preview val downloadTag = getDownloadTag(preview) val (fileSize, fileName) = parseFilePreview(preview) - + // Анимация прогресса - val animatedProgress by animateFloatAsState( - targetValue = downloadProgress, - animationSpec = tween(durationMillis = 300), - label = "progress" - ) - + val animatedProgress by + animateFloatAsState( + targetValue = downloadProgress, + animationSpec = tween(durationMillis = 300), + label = "progress" + ) + LaunchedEffect(attachment.id) { - downloadStatus = if (isDownloadTag(preview)) { - DownloadStatus.NOT_DOWNLOADED - } else { - DownloadStatus.DOWNLOADED - } + downloadStatus = + if (isDownloadTag(preview)) { + DownloadStatus.NOT_DOWNLOADED + } else { + DownloadStatus.DOWNLOADED + } } - + val download: () -> Unit = { if (downloadTag.isNotEmpty()) { scope.launch { try { downloadStatus = DownloadStatus.DOWNLOADING Log.d(TAG, "📥 Downloading file: $fileName") - + val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag) downloadProgress = 0.6f - + downloadStatus = DownloadStatus.DECRYPTING - + // КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop) // Сначала расшифровываем его, получаем raw bytes - val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) - - val decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey( - encryptedContent, - decryptedKeyAndNonce - ) + val decryptedKeyAndNonce = + MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) + + val decrypted = + MessageCrypto.decryptAttachmentBlobWithPlainKey( + encryptedContent, + decryptedKeyAndNonce + ) downloadProgress = 0.9f - + if (decrypted != null) { // TODO: Save to Downloads folder downloadProgress = 1f @@ -991,169 +1012,171 @@ fun FileAttachment( } } } - + // Telegram-style файл - как в desktop: без внутреннего фона, просто иконка + текст - Box( - modifier = Modifier.fillMaxWidth() - ) { + Box(modifier = Modifier.fillMaxWidth()) { Row( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = downloadStatus == DownloadStatus.NOT_DOWNLOADED || downloadStatus == DownloadStatus.ERROR) { - download() - } - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .clickable( + enabled = + downloadStatus == DownloadStatus.NOT_DOWNLOADED || + downloadStatus == DownloadStatus.ERROR + ) { download() } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically ) { // File icon с индикатором прогресса - круглая иконка как в desktop - Box( - modifier = Modifier.size(40.dp), - contentAlignment = Alignment.Center - ) { + Box(modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center) { // Круглый фон иконки Box( - modifier = Modifier - .fillMaxSize() - .clip(CircleShape) - .background( - if (downloadStatus == DownloadStatus.ERROR) Color(0xFFE53935) - else PrimaryBlue - ), - contentAlignment = Alignment.Center + modifier = + Modifier.fillMaxSize() + .clip(CircleShape) + .background( + if (downloadStatus == DownloadStatus.ERROR) + Color(0xFFE53935) + else PrimaryBlue + ), + contentAlignment = Alignment.Center ) { when (downloadStatus) { DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = Color.White, - strokeWidth = 2.dp + modifier = Modifier.size(24.dp), + color = Color.White, + strokeWidth = 2.dp ) } DownloadStatus.NOT_DOWNLOADED -> { Icon( - Icons.Default.ArrowDownward, - contentDescription = "Download", - tint = Color.White, - modifier = Modifier.size(20.dp) + Icons.Default.ArrowDownward, + contentDescription = "Download", + tint = Color.White, + modifier = Modifier.size(20.dp) ) } DownloadStatus.DOWNLOADED -> { Icon( - Icons.Default.InsertDriveFile, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(20.dp) + Icons.Default.InsertDriveFile, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp) ) } DownloadStatus.ERROR -> { Icon( - Icons.Default.Close, - contentDescription = "Error", - tint = Color.White, - modifier = Modifier.size(20.dp) + Icons.Default.Close, + contentDescription = "Error", + tint = Color.White, + modifier = Modifier.size(20.dp) ) } else -> { Icon( - Icons.Default.InsertDriveFile, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(20.dp) + Icons.Default.InsertDriveFile, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp) ) } } } } - + Spacer(modifier = Modifier.width(10.dp)) - + // File info Column(modifier = Modifier.weight(1f)) { Text( - text = fileName, - fontSize = 14.sp, - fontWeight = FontWeight.Normal, - color = if (isOutgoing) Color.White else (if (isDarkTheme) Color.White else Color.Black), - maxLines = 1, - overflow = TextOverflow.Ellipsis + text = fileName, + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + color = + if (isOutgoing) Color.White + else (if (isDarkTheme) Color.White else Color.Black), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) Spacer(modifier = Modifier.height(2.dp)) - + // Размер файла и тип val fileExtension = fileName.substringAfterLast('.', "").uppercase() Text( - text = when (downloadStatus) { - DownloadStatus.DOWNLOADING -> "Downloading..." - DownloadStatus.DECRYPTING -> "Decrypting..." - DownloadStatus.ERROR -> "File expired" - else -> "${formatFileSize(fileSize)} $fileExtension" - }, - fontSize = 12.sp, - color = if (downloadStatus == DownloadStatus.ERROR) { - Color(0xFFE53935) - } else if (isOutgoing) { - Color.White.copy(alpha = 0.7f) - } else { - PrimaryBlue - } + text = + when (downloadStatus) { + DownloadStatus.DOWNLOADING -> "Downloading..." + DownloadStatus.DECRYPTING -> "Decrypting..." + DownloadStatus.ERROR -> "File expired" + else -> "${formatFileSize(fileSize)} $fileExtension" + }, + fontSize = 12.sp, + color = + if (downloadStatus == DownloadStatus.ERROR) { + Color(0xFFE53935) + } else if (isOutgoing) { + Color.White.copy(alpha = 0.7f) + } else { + PrimaryBlue + } ) } } - + // Time and checkmarks (bottom-right overlay) for outgoing files if (isOutgoing) { Row( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(bottom = 6.dp, end = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End + modifier = + Modifier.align(Alignment.BottomEnd).padding(bottom = 6.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End ) { // Time Text( - text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(), - fontSize = 11.sp, - color = Color.White.copy(alpha = 0.7f) + text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(), + fontSize = 11.sp, + color = Color.White.copy(alpha = 0.7f) ) - + Spacer(modifier = Modifier.width(4.dp)) - + // Checkmarks when (messageStatus) { MessageStatus.SENDING -> { Icon( - compose.icons.TablerIcons.Clock, - contentDescription = null, - tint = Color.White.copy(alpha = 0.7f), - modifier = Modifier.size(14.dp) + 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) + compose.icons.TablerIcons.Check, + contentDescription = null, + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(14.dp) ) } MessageStatus.DELIVERED, MessageStatus.READ -> { Icon( - compose.icons.TablerIcons.Checks, - contentDescription = null, - tint = if (messageStatus == MessageStatus.READ) { - Color(0xFF4FC3F7) - } else { - Color.White.copy(alpha = 0.7f) - }, - modifier = Modifier.size(14.dp) + compose.icons.TablerIcons.Checks, + contentDescription = null, + tint = + if (messageStatus == MessageStatus.READ) { + Color(0xFF4FC3F7) + } else { + Color.White.copy(alpha = 0.7f) + }, + modifier = Modifier.size(14.dp) ) } MessageStatus.ERROR -> { Icon( - Icons.Default.Error, - contentDescription = null, - tint = Color(0xFFE53935), - modifier = Modifier.size(14.dp) + Icons.Default.Error, + contentDescription = null, + tint = Color(0xFFE53935), + modifier = Modifier.size(14.dp) ) } } @@ -1164,60 +1187,64 @@ fun FileAttachment( @Composable fun AvatarAttachment( - attachment: MessageAttachment, - chachaKey: String, - privateKey: String, - senderPublicKey: String, - avatarRepository: AvatarRepository?, - currentUserPublicKey: String = "", - isOutgoing: Boolean, - isDarkTheme: Boolean, - timestamp: java.util.Date = java.util.Date(), - messageStatus: MessageStatus = MessageStatus.READ + attachment: MessageAttachment, + chachaKey: String, + privateKey: String, + senderPublicKey: String, + avatarRepository: AvatarRepository?, + currentUserPublicKey: String = "", + isOutgoing: Boolean, + isDarkTheme: Boolean, + timestamp: java.util.Date = java.util.Date(), + messageStatus: MessageStatus = MessageStatus.READ ) { val context = LocalContext.current val scope = rememberCoroutineScope() - + var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) } var avatarBitmap by remember { mutableStateOf(null) } var blurhashBitmap by remember { mutableStateOf(null) } - + val preview = getPreview(attachment.preview) val downloadTag = getDownloadTag(attachment.preview) - + val timeFormat = remember { java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) } - + // Определяем начальный статус (как в Desktop calcDownloadStatus для AVATAR) LaunchedEffect(attachment.id) { withContext(Dispatchers.IO) { - downloadStatus = when { - // 1. Если blob уже есть в памяти → DOWNLOADED - attachment.blob.isNotEmpty() -> { - Log.d(TAG, "📦 Avatar blob in memory for ${attachment.id}") - DownloadStatus.DOWNLOADED - } - // 2. Если preview НЕ содержит UUID → локальный файл → DOWNLOADED - !isDownloadTag(attachment.preview) -> { - Log.d(TAG, "📦 No download tag for avatar ${attachment.id}") - DownloadStatus.DOWNLOADED - } - // 3. Есть UUID (download tag) → проверяем файловую систему - // Desktop: readFile(`a/${md5(attachment.id + publicKey)}`) - else -> { - val hasLocal = AvatarFileManager.hasAvatarByAttachmentId( - context, attachment.id, senderPublicKey - ) - if (hasLocal) { - Log.d(TAG, "📦 Found local avatar file for ${attachment.id}") - DownloadStatus.DOWNLOADED - } else { - Log.d(TAG, "📥 Need to download avatar ${attachment.id}") - DownloadStatus.NOT_DOWNLOADED + downloadStatus = + when { + // 1. Если blob уже есть в памяти → DOWNLOADED + attachment.blob.isNotEmpty() -> { + Log.d(TAG, "📦 Avatar blob in memory for ${attachment.id}") + DownloadStatus.DOWNLOADED + } + // 2. Если preview НЕ содержит UUID → локальный файл → DOWNLOADED + !isDownloadTag(attachment.preview) -> { + Log.d(TAG, "📦 No download tag for avatar ${attachment.id}") + DownloadStatus.DOWNLOADED + } + // 3. Есть UUID (download tag) → проверяем файловую систему + // Desktop: readFile(`a/${md5(attachment.id + publicKey)}`) + else -> { + val hasLocal = + AvatarFileManager.hasAvatarByAttachmentId( + context, + attachment.id, + senderPublicKey + ) + if (hasLocal) { + Log.d(TAG, "📦 Found local avatar file for ${attachment.id}") + DownloadStatus.DOWNLOADED + } else { + Log.d(TAG, "📥 Need to download avatar ${attachment.id}") + DownloadStatus.NOT_DOWNLOADED + } + } } - } - } } - + // Decode blurhash if (preview.length >= 20) { withContext(Dispatchers.IO) { @@ -1228,7 +1255,7 @@ fun AvatarAttachment( } } } - + // Загружаем аватар если статус DOWNLOADED if (downloadStatus == DownloadStatus.DOWNLOADED) { withContext(Dispatchers.IO) { @@ -1239,9 +1266,12 @@ fun AvatarAttachment( } else { // 2. Читаем из файловой системы (как в Desktop getBlob) Log.d(TAG, "🖼️ Loading avatar from local file") - val localBlob = AvatarFileManager.readAvatarByAttachmentId( - context, attachment.id, senderPublicKey - ) + val localBlob = + AvatarFileManager.readAvatarByAttachmentId( + context, + attachment.id, + senderPublicKey + ) if (localBlob != null) { avatarBitmap = base64ToBitmap(localBlob) Log.d(TAG, "✅ Avatar loaded from local file") @@ -1253,7 +1283,7 @@ fun AvatarAttachment( } } } - + val download: () -> Unit = { if (downloadTag.isNotEmpty()) { scope.launch { @@ -1265,73 +1295,94 @@ fun AvatarAttachment( Log.d(TAG, "🏷️ Download tag: $downloadTag") Log.d(TAG, "👤 Sender public key: ${senderPublicKey.take(16)}...") Log.d(TAG, "=====================================") - + Log.d(TAG, "⬇️ Downloading encrypted avatar from CDN...") val startTime = System.currentTimeMillis() val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag) val downloadTime = System.currentTimeMillis() - startTime Log.d(TAG, "✅ Downloaded ${encryptedContent.length} chars in ${downloadTime}ms") - + downloadStatus = DownloadStatus.DECRYPTING Log.d(TAG, "🔓 Starting decryption...") - + // КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop) // Сначала расшифровываем его, получаем raw bytes Log.d(TAG, "🔑 Decrypting ChaCha key from sender...") - val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) + val decryptedKeyAndNonce = + MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) Log.d(TAG, "🔑 ChaCha key decrypted: ${decryptedKeyAndNonce.size} bytes") - - // Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует bytes в password + + // Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует + // bytes в password Log.d(TAG, "🔓 Decrypting avatar blob with PBKDF2...") val decryptStartTime = System.currentTimeMillis() - val decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey( - encryptedContent, - decryptedKeyAndNonce - ) + val decrypted = + MessageCrypto.decryptAttachmentBlobWithPlainKey( + encryptedContent, + decryptedKeyAndNonce + ) val decryptTime = System.currentTimeMillis() - decryptStartTime Log.d(TAG, "🔓 Decryption completed in ${decryptTime}ms") - + if (decrypted != null) { Log.d(TAG, "✅ Decrypted blob: ${decrypted.length} chars") withContext(Dispatchers.IO) { Log.d(TAG, "🖼️ Converting to bitmap...") avatarBitmap = base64ToBitmap(decrypted) - Log.d(TAG, "✅ Bitmap created: ${avatarBitmap?.width}x${avatarBitmap?.height}") - + Log.d( + TAG, + "✅ Bitmap created: ${avatarBitmap?.width}x${avatarBitmap?.height}" + ) + // 💾 Сохраняем в файловую систему по attachment.id (как в Desktop) // Desktop: writeFile(`a/${md5(attachment.id + publicKey)}`, encrypted) Log.d(TAG, "💾 Saving avatar to file system by attachment ID...") - val path = AvatarFileManager.saveAvatarByAttachmentId( - context = context, - base64Image = decrypted, - attachmentId = attachment.id, - publicKey = senderPublicKey - ) + val path = + AvatarFileManager.saveAvatarByAttachmentId( + context = context, + base64Image = decrypted, + attachmentId = attachment.id, + publicKey = senderPublicKey + ) Log.d(TAG, "💾 Avatar saved to: $path") } // Сохраняем аватар в репозиторий (для UI обновления) - // Если это исходящее сообщение с аватаром, сохраняем для текущего пользователя - val targetPublicKey = if (isOutgoing && currentUserPublicKey.isNotEmpty()) { - Log.d(TAG, "💾 Saving avatar to repository for CURRENT user ${currentUserPublicKey.take(16)}...") - currentUserPublicKey - } else { - Log.d(TAG, "💾 Saving avatar to repository for SENDER ${senderPublicKey.take(16)}...") - senderPublicKey - } - + // Если это исходящее сообщение с аватаром, сохраняем для текущего + // пользователя + val targetPublicKey = + if (isOutgoing && currentUserPublicKey.isNotEmpty()) { + Log.d( + TAG, + "💾 Saving avatar to repository for CURRENT user ${currentUserPublicKey.take(16)}..." + ) + currentUserPublicKey + } else { + Log.d( + TAG, + "💾 Saving avatar to repository for SENDER ${senderPublicKey.take(16)}..." + ) + senderPublicKey + } + // ВАЖНО: ждем завершения сохранения в репозиторий if (avatarRepository != null) { try { Log.d(TAG, "📤 Calling avatarRepository.saveAvatar()...") avatarRepository.saveAvatar(targetPublicKey, decrypted) - Log.d(TAG, "✅ Avatar saved to repository for ${targetPublicKey.take(16)}") + Log.d( + TAG, + "✅ Avatar saved to repository for ${targetPublicKey.take(16)}" + ) } catch (e: Exception) { Log.e(TAG, "❌ Failed to save avatar to repository: ${e.message}", e) } } else { - Log.e(TAG, "❌ avatarRepository is NULL! Cannot save avatar for ${targetPublicKey.take(16)}") + Log.e( + TAG, + "❌ avatarRepository is NULL! Cannot save avatar for ${targetPublicKey.take(16)}" + ) } - + downloadStatus = DownloadStatus.DOWNLOADED Log.d(TAG, "=====================================") Log.d(TAG, "✅ AVATAR DOWNLOAD COMPLETE") @@ -1358,196 +1409,209 @@ fun AvatarAttachment( Log.w(TAG, "⚠️ Cannot download avatar: empty download tag for ${attachment.id}") } } - + // Telegram-style avatar attachment с временем и статусом Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background( - if (isOutgoing) { - Color.White.copy(alpha = 0.12f) - } else { - if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5) - } - ) - .clickable(enabled = downloadStatus == DownloadStatus.NOT_DOWNLOADED || downloadStatus == DownloadStatus.ERROR) { - download() - } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background( + if (isOutgoing) { + Color.White.copy(alpha = 0.12f) + } else { + if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5) + } + ) + .clickable( + enabled = + downloadStatus == DownloadStatus.NOT_DOWNLOADED || + downloadStatus == DownloadStatus.ERROR + ) { download() } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically ) { // Avatar preview - круглое изображение Box( - modifier = Modifier - .size(56.dp) - .clip(CircleShape) - .background( - if (isOutgoing) Color.White.copy(0.15f) - else (if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)) - ), - contentAlignment = Alignment.Center + modifier = + Modifier.size(56.dp) + .clip(CircleShape) + .background( + if (isOutgoing) Color.White.copy(0.15f) + else + (if (isDarkTheme) Color(0xFF3A3A3A) + else Color(0xFFE8E8E8)) + ), + contentAlignment = Alignment.Center ) { when { avatarBitmap != null -> { Image( - bitmap = avatarBitmap!!.asImageBitmap(), - contentDescription = "Avatar", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + bitmap = avatarBitmap!!.asImageBitmap(), + contentDescription = "Avatar", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) } blurhashBitmap != null -> { Image( - bitmap = blurhashBitmap!!.asImageBitmap(), - contentDescription = "Avatar preview", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + bitmap = blurhashBitmap!!.asImageBitmap(), + contentDescription = "Avatar preview", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) } - downloadStatus == DownloadStatus.DOWNLOADING || downloadStatus == DownloadStatus.DECRYPTING -> { + downloadStatus == DownloadStatus.DOWNLOADING || + downloadStatus == DownloadStatus.DECRYPTING -> { CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = if (isOutgoing) Color.White else PrimaryBlue, - strokeWidth = 2.dp + modifier = Modifier.size(24.dp), + color = if (isOutgoing) Color.White else PrimaryBlue, + strokeWidth = 2.dp ) } else -> { Icon( - Icons.Default.Person, - contentDescription = null, - tint = if (isOutgoing) Color.White.copy(0.6f) - else (if (isDarkTheme) Color.White.copy(0.5f) else Color.Gray), - modifier = Modifier.size(28.dp) + Icons.Default.Person, + contentDescription = null, + tint = + if (isOutgoing) Color.White.copy(0.6f) + else (if (isDarkTheme) Color.White.copy(0.5f) else Color.Gray), + modifier = Modifier.size(28.dp) ) } } - + // Иконка скачивания поверх аватара если нужно скачать if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) { Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.4f)), - contentAlignment = Alignment.Center + modifier = + Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)), + contentAlignment = Alignment.Center ) { Icon( - Icons.Default.ArrowDownward, - contentDescription = "Download", - tint = Color.White, - modifier = Modifier.size(24.dp) + Icons.Default.ArrowDownward, + contentDescription = "Download", + tint = Color.White, + modifier = Modifier.size(24.dp) ) } } } - + Spacer(modifier = Modifier.width(12.dp)) - + // Info и время/статус Column(modifier = Modifier.weight(1f)) { // Заголовок с иконкой замка Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = "Profile Photo", - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - color = if (isOutgoing) Color.White else (if (isDarkTheme) Color.White else Color.Black) + text = "Profile Photo", + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = + if (isOutgoing) Color.White + else (if (isDarkTheme) Color.White else Color.Black) ) Spacer(modifier = Modifier.width(6.dp)) Icon( - Icons.Default.Lock, - contentDescription = "End-to-end encrypted", - tint = if (isOutgoing) Color.White.copy(0.6f) - else (if (isDarkTheme) Color.White.copy(0.4f) else Color.Gray), - modifier = Modifier.size(12.dp) + Icons.Default.Lock, + contentDescription = "End-to-end encrypted", + tint = + if (isOutgoing) Color.White.copy(0.6f) + else (if (isDarkTheme) Color.White.copy(0.4f) else Color.Gray), + modifier = Modifier.size(12.dp) ) } - + Spacer(modifier = Modifier.height(2.dp)) - + // Описание статуса Text( - text = when (downloadStatus) { - DownloadStatus.DOWNLOADING -> "Downloading..." - DownloadStatus.DECRYPTING -> "Decrypting..." - DownloadStatus.ERROR -> "Tap to retry" - DownloadStatus.DOWNLOADED -> "Shared profile photo" - else -> "Tap to download" - }, - fontSize = 13.sp, - color = if (isOutgoing) Color.White.copy(alpha = 0.7f) - else (if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray) + text = + when (downloadStatus) { + DownloadStatus.DOWNLOADING -> "Downloading..." + DownloadStatus.DECRYPTING -> "Decrypting..." + DownloadStatus.ERROR -> "Tap to retry" + DownloadStatus.DOWNLOADED -> "Shared profile photo" + else -> "Tap to download" + }, + fontSize = 13.sp, + color = + if (isOutgoing) Color.White.copy(alpha = 0.7f) + else (if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray) ) - + Spacer(modifier = Modifier.height(4.dp)) - + // Время и статус доставки Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - text = timeFormat.format(timestamp), - fontSize = 11.sp, - color = if (isOutgoing) Color.White.copy(alpha = 0.6f) - else (if (isDarkTheme) Color.White.copy(alpha = 0.4f) else Color.Gray) + text = timeFormat.format(timestamp), + fontSize = 11.sp, + color = + if (isOutgoing) Color.White.copy(alpha = 0.6f) + else + (if (isDarkTheme) Color.White.copy(alpha = 0.4f) + else Color.Gray) ) - + // Галочки статуса для исходящих сообщений if (isOutgoing) { when (messageStatus) { MessageStatus.SENDING -> { Icon( - compose.icons.TablerIcons.Clock, - contentDescription = "Sending", - tint = Color.White.copy(alpha = 0.6f), - modifier = Modifier.size(14.dp) + compose.icons.TablerIcons.Clock, + contentDescription = "Sending", + tint = Color.White.copy(alpha = 0.6f), + modifier = Modifier.size(14.dp) ) } MessageStatus.SENT -> { Icon( - compose.icons.TablerIcons.Check, - contentDescription = "Sent", - tint = Color.White.copy(alpha = 0.6f), - modifier = Modifier.size(14.dp) + compose.icons.TablerIcons.Check, + contentDescription = "Sent", + tint = Color.White.copy(alpha = 0.6f), + modifier = Modifier.size(14.dp) ) } MessageStatus.DELIVERED -> { Icon( - compose.icons.TablerIcons.Checks, - contentDescription = "Delivered", - tint = Color.White.copy(alpha = 0.6f), - modifier = Modifier.size(14.dp) + compose.icons.TablerIcons.Checks, + contentDescription = "Delivered", + tint = Color.White.copy(alpha = 0.6f), + modifier = Modifier.size(14.dp) ) } MessageStatus.READ -> { Icon( - compose.icons.TablerIcons.Checks, - contentDescription = "Read", - tint = Color(0xFF4FC3F7), - modifier = Modifier.size(14.dp) + compose.icons.TablerIcons.Checks, + contentDescription = "Read", + tint = Color(0xFF4FC3F7), + modifier = Modifier.size(14.dp) ) } MessageStatus.ERROR -> { Icon( - Icons.Default.Error, - contentDescription = "Error", - tint = Color(0xFFE53935), - modifier = Modifier.size(14.dp) + Icons.Default.Error, + contentDescription = "Error", + tint = Color(0xFFE53935), + modifier = Modifier.size(14.dp) ) } } } } } - + // Иконка ошибки справа if (downloadStatus == DownloadStatus.ERROR) { Icon( - Icons.Default.Refresh, - contentDescription = "Retry", - tint = Color(0xFFE53935), - modifier = Modifier.size(24.dp) + Icons.Default.Refresh, + contentDescription = "Retry", + tint = Color(0xFFE53935), + modifier = Modifier.size(24.dp) ) } } @@ -1557,20 +1621,19 @@ fun AvatarAttachment( // Helper functions // ================================ -private val uuidRegex = Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") +private val uuidRegex = + Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") /** - * Проверка является ли preview UUID тегом для скачивания - * Как в desktop: attachment.preview.split("::")[0].match(uuidRegex) + * Проверка является ли preview UUID тегом для скачивания Как в desktop: + * attachment.preview.split("::")[0].match(uuidRegex) */ private fun isDownloadTag(preview: String): Boolean { val firstPart = preview.split("::").firstOrNull() ?: return false return uuidRegex.matches(firstPart) } -/** - * Получить download tag из preview - */ +/** Получить download tag из preview */ private fun getDownloadTag(preview: String): String { val parts = preview.split("::") if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) { @@ -1579,10 +1642,7 @@ private fun getDownloadTag(preview: String): String { return "" } -/** - * Получить preview без download tag - * Как в desktop: preview.split("::").splice(1).join("::") - */ +/** Получить preview без download tag Как в desktop: preview.split("::").splice(1).join("::") */ private fun getPreview(preview: String): String { val parts = preview.split("::") if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) { @@ -1591,10 +1651,7 @@ private fun getPreview(preview: String): String { return preview } -/** - * Парсинг preview для файлов - * Формат: "UUID::filesize::filename" или "filesize::filename" - */ +/** Парсинг preview для файлов Формат: "UUID::filesize::filename" или "filesize::filename" */ private fun parseFilePreview(preview: String): Pair { val parts = preview.split("::") return when { @@ -1617,16 +1674,15 @@ private fun parseFilePreview(preview: String): Pair { } } -/** - * Декодирование base64 в Bitmap - */ +/** Декодирование base64 в Bitmap */ private fun base64ToBitmap(base64: String): Bitmap? { return try { - val cleanBase64 = if (base64.contains(",")) { - base64.substringAfter(",") - } else { - base64 - } + val cleanBase64 = + if (base64.contains(",")) { + base64.substringAfter(",") + } else { + base64 + } val bytes = Base64.decode(cleanBase64, Base64.DEFAULT) BitmapFactory.decodeByteArray(bytes, 0, bytes.size) } catch (e: Exception) { @@ -1635,9 +1691,7 @@ private fun base64ToBitmap(base64: String): Bitmap? { } } -/** - * Форматирование размера файла - */ +/** Форматирование размера файла */ private fun formatFileSize(bytes: Long): String { return when { bytes < 1024 -> "$bytes B" diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt index 38f0b73..888862c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt @@ -5,15 +5,12 @@ import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory -import android.graphics.Matrix import android.net.Uri import android.util.Log import android.widget.ImageView import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.FileProvider -import com.yalantis.ucrop.UCrop import androidx.compose.animation.* import androidx.compose.foundation.* import androidx.compose.foundation.ExperimentalFoundationApi @@ -43,24 +40,23 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import com.yalantis.ucrop.UCrop import compose.icons.TablerIcons import compose.icons.tablericons.* -import com.rosetta.messenger.ui.onboarding.PrimaryBlue import ja.burhanrashid52.photoeditor.PhotoEditor import ja.burhanrashid52.photoeditor.PhotoEditorView import ja.burhanrashid52.photoeditor.SaveSettings +import java.io.File +import kotlin.coroutines.resume import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import java.io.File -import kotlin.coroutines.resume private const val TAG = "ImageEditorScreen" -/** - * Available editing tools - */ +/** Available editing tools */ enum class EditorTool { NONE, DRAW, @@ -68,37 +64,34 @@ enum class EditorTool { ROTATE } -/** - * Drawing colors - */ -val drawingColors = listOf( - Color.White, - Color.Black, - Color.Red, - Color(0xFFFF9500), // Orange - Color.Yellow, - Color(0xFF34C759), // Green - Color(0xFF007AFF), // Blue - Color(0xFF5856D6), // Purple - Color(0xFFFF2D55), // Pink -) +/** Drawing colors */ +val drawingColors = + listOf( + Color.White, + Color.Black, + Color.Red, + Color(0xFFFF9500), // Orange + Color.Yellow, + Color(0xFF34C759), // Green + Color(0xFF007AFF), // Blue + Color(0xFF5856D6), // Purple + Color(0xFFFF2D55), // Pink + ) -/** - * Telegram-style image editor screen with caption input - */ +/** Telegram-style image editor screen with caption input */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ImageEditorScreen( - imageUri: Uri, - onDismiss: () -> Unit, - onSave: (Uri) -> Unit, - onSaveWithCaption: ((Uri, String) -> Unit)? = null, // New callback with caption - isDarkTheme: Boolean = true, - showCaptionInput: Boolean = false // Show caption input for camera flow + imageUri: Uri, + onDismiss: () -> Unit, + onSave: (Uri) -> Unit, + onSaveWithCaption: ((Uri, String) -> Unit)? = null, // New callback with caption + isDarkTheme: Boolean = true, + showCaptionInput: Boolean = false // Show caption input for camera flow ) { val context = LocalContext.current val scope = rememberCoroutineScope() - + // Editor state var currentTool by remember { mutableStateOf(EditorTool.NONE) } var selectedColor by remember { mutableStateOf(Color.White) } @@ -106,392 +99,376 @@ fun ImageEditorScreen( var showColorPicker by remember { mutableStateOf(false) } var showBrushSizeSlider by remember { mutableStateOf(false) } var isSaving by remember { mutableStateOf(false) } - + // Caption state var caption by remember { mutableStateOf("") } - + // Current image URI (can change after crop) var currentImageUri by remember { mutableStateOf(imageUri) } - + // Rotation state var rotationAngle by remember { mutableStateOf(0f) } var isFlippedHorizontally by remember { mutableStateOf(false) } var isFlippedVertically by remember { mutableStateOf(false) } - + // PhotoEditor reference var photoEditor by remember { mutableStateOf(null) } var photoEditorView by remember { mutableStateOf(null) } - + // UCrop launcher - val cropLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode == Activity.RESULT_OK) { - result.data?.let { data -> - UCrop.getOutput(data)?.let { croppedUri -> - currentImageUri = croppedUri - // Reload image in PhotoEditorView - photoEditorView?.source?.setImageURI(croppedUri) + val cropLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.let { data -> + UCrop.getOutput(data)?.let { croppedUri -> + currentImageUri = croppedUri + // Reload image in PhotoEditorView + photoEditorView?.source?.setImageURI(croppedUri) + } + } } } - } - } - - BackHandler { - onDismiss() - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black) - ) { + + BackHandler { onDismiss() } + + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { // Photo Editor View - FULLSCREEN edge-to-edge AndroidView( - factory = { ctx -> - PhotoEditorView(ctx).apply { - photoEditorView = this - // Load image - fullscreen, CENTER_CROP чтобы заполнить экран - source.setImageURI(currentImageUri) - source.scaleType = ImageView.ScaleType.CENTER_CROP - - // Build PhotoEditor - photoEditor = PhotoEditor.Builder(ctx, this) - .setPinchTextScalable(true) - .setClipSourceImage(true) - .build() - } - }, - update = { view -> - // Apply rotation and flip transformations - view.source.rotation = rotationAngle - view.source.scaleX = if (isFlippedHorizontally) -1f else 1f - view.source.scaleY = if (isFlippedVertically) -1f else 1f - }, - modifier = Modifier.fillMaxSize() + factory = { ctx -> + PhotoEditorView(ctx).apply { + photoEditorView = this + // Load image - fullscreen, CENTER_CROP чтобы заполнить экран + source.setImageURI(currentImageUri) + source.scaleType = ImageView.ScaleType.CENTER_CROP + + // Build PhotoEditor + photoEditor = + PhotoEditor.Builder(ctx, this) + .setPinchTextScalable(true) + .setClipSourceImage(true) + .build() + } + }, + update = { view -> + // Apply rotation and flip transformations + view.source.rotation = rotationAngle + view.source.scaleX = if (isFlippedHorizontally) -1f else 1f + view.source.scaleY = if (isFlippedVertically) -1f else 1f + }, + modifier = Modifier.fillMaxSize() ) - + // Top toolbar - OVERLAY (поверх фото) Row( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { // Close button IconButton(onClick = onDismiss) { Icon( - TablerIcons.X, - contentDescription = "Close", - tint = Color.White, - modifier = Modifier.size(28.dp) + TablerIcons.X, + contentDescription = "Close", + tint = Color.White, + modifier = Modifier.size(28.dp) ) } - + // Undo / Redo buttons Row { IconButton(onClick = { photoEditor?.undo() }) { Icon( - TablerIcons.ArrowBackUp, - contentDescription = "Undo", - tint = Color.White, - modifier = Modifier.size(26.dp) + TablerIcons.ArrowBackUp, + contentDescription = "Undo", + tint = Color.White, + modifier = Modifier.size(26.dp) ) } IconButton(onClick = { photoEditor?.redo() }) { Icon( - TablerIcons.ArrowForwardUp, - contentDescription = "Redo", - tint = Color.White, - modifier = Modifier.size(26.dp) + TablerIcons.ArrowForwardUp, + contentDescription = "Redo", + tint = Color.White, + modifier = Modifier.size(26.dp) ) } } } - + // Bottom section - OVERLAY (Caption + Tools) Column( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .imePadding() - .navigationBarsPadding() + modifier = + Modifier.fillMaxWidth() + .align(Alignment.BottomCenter) + .imePadding() + .navigationBarsPadding() ) { // Color picker bar (when drawing) AnimatedVisibility( - visible = currentTool == EditorTool.DRAW && showColorPicker, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + visible = currentTool == EditorTool.DRAW && showColorPicker, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() ) { ColorPickerBar( - selectedColor = selectedColor, - onColorSelected = { color -> - selectedColor = color - photoEditor?.brushColor = color.toArgb() - } + selectedColor = selectedColor, + onColorSelected = { color -> + selectedColor = color + photoEditor?.brushColor = color.toArgb() + } ) } - + // Brush size slider AnimatedVisibility( - visible = currentTool == EditorTool.DRAW && showBrushSizeSlider, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + visible = currentTool == EditorTool.DRAW && showBrushSizeSlider, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() ) { BrushSizeBar( - brushSize = brushSize, - onBrushSizeChanged = { size -> - brushSize = size - photoEditor?.brushSize = size - }, - selectedColor = selectedColor + brushSize = brushSize, + onBrushSizeChanged = { size -> + brushSize = size + photoEditor?.brushSize = size + }, + selectedColor = selectedColor ) } - + // Rotate/Flip options bar AnimatedVisibility( - visible = currentTool == EditorTool.ROTATE, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + visible = currentTool == EditorTool.ROTATE, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() ) { RotateOptionsBar( - onRotateLeft = { rotationAngle = (rotationAngle - 90f) % 360f }, - onRotateRight = { rotationAngle = (rotationAngle + 90f) % 360f }, - onFlipHorizontal = { isFlippedHorizontally = !isFlippedHorizontally }, - onFlipVertical = { isFlippedVertically = !isFlippedVertically } + onRotateLeft = { rotationAngle = (rotationAngle - 90f) % 360f }, + onRotateRight = { rotationAngle = (rotationAngle + 90f) % 360f }, + onFlipHorizontal = { isFlippedHorizontally = !isFlippedHorizontally }, + onFlipVertical = { isFlippedVertically = !isFlippedVertically } ) } - + // Caption input bar (Telegram-style) - Beautiful overlay if (showCaptionInput) { TelegramCaptionInputBar( - caption = caption, - onCaptionChange = { caption = it }, - isSaving = isSaving, - onSend = { - scope.launch { - isSaving = true - saveEditedImage(context, photoEditor) { savedUri -> - isSaving = false - if (savedUri != null) { - if (onSaveWithCaption != null) { - onSaveWithCaption(savedUri, caption) - } else { - onSave(savedUri) + caption = caption, + onCaptionChange = { caption = it }, + isSaving = isSaving, + onSend = { + scope.launch { + isSaving = true + saveEditedImage(context, photoEditor) { savedUri -> + isSaving = false + if (savedUri != null) { + if (onSaveWithCaption != null) { + onSaveWithCaption(savedUri, caption) + } else { + onSave(savedUri) + } } } } } - } ) } - + // Bottom toolbar with tools BottomToolbar( - currentTool = currentTool, - onToolSelected = { tool -> - when (tool) { - EditorTool.DRAW -> { - if (currentTool == EditorTool.DRAW) { - showColorPicker = !showColorPicker + currentTool = currentTool, + onToolSelected = { tool -> + when (tool) { + EditorTool.DRAW -> { + if (currentTool == EditorTool.DRAW) { + showColorPicker = !showColorPicker + showBrushSizeSlider = false + } else { + currentTool = tool + photoEditor?.setBrushDrawingMode(true) + photoEditor?.brushColor = selectedColor.toArgb() + photoEditor?.brushSize = brushSize + showColorPicker = true + } + } + EditorTool.CROP -> { + currentTool = EditorTool.NONE + showColorPicker = false showBrushSizeSlider = false - } else { - currentTool = tool - photoEditor?.setBrushDrawingMode(true) - photoEditor?.brushColor = selectedColor.toArgb() - photoEditor?.brushSize = brushSize - showColorPicker = true + photoEditor?.setBrushDrawingMode(false) + // Launch UCrop + launchCrop(context, currentImageUri, cropLauncher) + } + EditorTool.ROTATE -> { + currentTool = + if (currentTool == EditorTool.ROTATE) EditorTool.NONE + else tool + showColorPicker = false + showBrushSizeSlider = false + photoEditor?.setBrushDrawingMode(false) + } + else -> { + currentTool = EditorTool.NONE + showColorPicker = false + showBrushSizeSlider = false + photoEditor?.setBrushDrawingMode(false) } } - EditorTool.CROP -> { - currentTool = EditorTool.NONE - showColorPicker = false - showBrushSizeSlider = false - photoEditor?.setBrushDrawingMode(false) - // Launch UCrop - launchCrop(context, currentImageUri, cropLauncher) - } - EditorTool.ROTATE -> { - currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else tool - showColorPicker = false - showBrushSizeSlider = false - photoEditor?.setBrushDrawingMode(false) - } - else -> { - currentTool = EditorTool.NONE - showColorPicker = false - showBrushSizeSlider = false - photoEditor?.setBrushDrawingMode(false) - } + }, + onBrushSizeClick = { + showBrushSizeSlider = !showBrushSizeSlider + showColorPicker = false + }, + onEraserClick = { photoEditor?.brushEraser() }, + onCropClick = { launchCrop(context, currentImageUri, cropLauncher) }, + onRotateClick = { + currentTool = + if (currentTool == EditorTool.ROTATE) EditorTool.NONE + else EditorTool.ROTATE + showColorPicker = false + showBrushSizeSlider = false + photoEditor?.setBrushDrawingMode(false) } - }, - onBrushSizeClick = { - showBrushSizeSlider = !showBrushSizeSlider - showColorPicker = false - }, - onEraserClick = { - photoEditor?.brushEraser() - }, - onCropClick = { - launchCrop(context, currentImageUri, cropLauncher) - }, - onRotateClick = { - currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else EditorTool.ROTATE - showColorPicker = false - showBrushSizeSlider = false - photoEditor?.setBrushDrawingMode(false) - } ) } } } -/** - * Telegram-style Caption input bar with send button - Beautiful transparent overlay - */ +/** Telegram-style Caption input bar with send button - Beautiful transparent overlay */ @OptIn(ExperimentalMaterial3Api::class) @Composable private fun TelegramCaptionInputBar( - caption: String, - onCaptionChange: (String) -> Unit, - isSaving: Boolean, - onSend: () -> Unit + caption: String, + onCaptionChange: (String) -> Unit, + isSaving: Boolean, + onSend: () -> Unit ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(10.dp) + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { // Caption text field - Beautiful transparent style Box( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(22.dp)) - .background(Color.White.copy(alpha = 0.15f)) + modifier = + Modifier.weight(1f) + .clip(RoundedCornerShape(22.dp)) + .background(Color.White.copy(alpha = 0.15f)) ) { BasicTextField( - value = caption, - onValueChange = onCaptionChange, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - textStyle = androidx.compose.ui.text.TextStyle( - color = Color.White, - fontSize = 16.sp - ), - maxLines = 4, - decorationBox = { innerTextField -> - Box { - if (caption.isEmpty()) { - Text( - "Add a caption...", - color = Color.White.copy(alpha = 0.6f), - fontSize = 16.sp - ) + value = caption, + onValueChange = onCaptionChange, + modifier = + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + textStyle = + androidx.compose.ui.text.TextStyle( + color = Color.White, + fontSize = 16.sp + ), + maxLines = 4, + decorationBox = { innerTextField -> + Box { + if (caption.isEmpty()) { + Text( + "Add a caption...", + color = Color.White.copy(alpha = 0.6f), + fontSize = 16.sp + ) + } + innerTextField() } - innerTextField() } - } ) } - + // Send button - Blue circle like Telegram Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(PrimaryBlue) - .clickable(enabled = !isSaving) { onSend() }, - contentAlignment = Alignment.Center + modifier = + Modifier.size(48.dp).clip(CircleShape).background(PrimaryBlue).clickable( + enabled = !isSaving + ) { onSend() }, + contentAlignment = Alignment.Center ) { if (isSaving) { CircularProgressIndicator( - modifier = Modifier.size(22.dp), - color = Color.White, - strokeWidth = 2.dp + modifier = Modifier.size(22.dp), + color = Color.White, + strokeWidth = 2.dp ) } else { Icon( - imageVector = TablerIcons.Send, - contentDescription = "Send", - tint = Color.White, - modifier = Modifier - .size(22.dp) - .offset(x = 1.dp) // Slight offset for better centering + imageVector = TablerIcons.Send, + contentDescription = "Send", + tint = Color.White, + modifier = + Modifier.size(22.dp) + .offset(x = 1.dp) // Slight offset for better centering ) } } } } -/** - * Caption input bar with send button (like Telegram) - OLD VERSION - */ +/** Caption input bar with send button (like Telegram) - OLD VERSION */ @OptIn(ExperimentalMaterial3Api::class) @Composable private fun CaptionInputBar( - caption: String, - onCaptionChange: (String) -> Unit, - isSaving: Boolean, - onSend: () -> Unit + caption: String, + onCaptionChange: (String) -> Unit, + isSaving: Boolean, + onSend: () -> Unit ) { - Surface( - color = Color(0xFF1C1C1E), - modifier = Modifier.fillMaxWidth() - ) { + Surface(color = Color(0xFF1C1C1E), modifier = Modifier.fillMaxWidth()) { Row( - modifier = Modifier - .fillMaxWidth() - .imePadding() - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + modifier = + Modifier.fillMaxWidth() + .imePadding() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { // Caption text field OutlinedTextField( - value = caption, - onValueChange = onCaptionChange, - modifier = Modifier.weight(1f), - placeholder = { - Text( - "Add a caption...", - color = Color.White.copy(alpha = 0.5f) - ) - }, - maxLines = 3, - colors = TextFieldDefaults.outlinedTextFieldColors( - focusedTextColor = Color.White, - unfocusedTextColor = Color.White, - cursorColor = PrimaryBlue, - focusedBorderColor = PrimaryBlue, - unfocusedBorderColor = Color.White.copy(alpha = 0.3f) - ), - shape = RoundedCornerShape(20.dp) + value = caption, + onValueChange = onCaptionChange, + modifier = Modifier.weight(1f), + placeholder = { + Text("Add a caption...", color = Color.White.copy(alpha = 0.5f)) + }, + maxLines = 3, + colors = + TextFieldDefaults.outlinedTextFieldColors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + cursorColor = PrimaryBlue, + focusedBorderColor = PrimaryBlue, + unfocusedBorderColor = Color.White.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(20.dp) ) - + // Send button FloatingActionButton( - onClick = onSend, - containerColor = PrimaryBlue, - modifier = Modifier.size(48.dp), - elevation = FloatingActionButtonDefaults.elevation(0.dp) + onClick = onSend, + containerColor = PrimaryBlue, + modifier = Modifier.size(48.dp), + elevation = FloatingActionButtonDefaults.elevation(0.dp) ) { if (isSaving) { CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = Color.White, - strokeWidth = 2.dp + modifier = Modifier.size(20.dp), + color = Color.White, + strokeWidth = 2.dp ) } else { Icon( - imageVector = TablerIcons.Send, - contentDescription = "Send", - tint = Color.White, - modifier = Modifier.size(22.dp) + imageVector = TablerIcons.Send, + contentDescription = "Send", + tint = Color.White, + modifier = Modifier.size(22.dp) ) } } @@ -501,74 +478,71 @@ private fun CaptionInputBar( @Composable private fun BottomToolbar( - currentTool: EditorTool, - onToolSelected: (EditorTool) -> Unit, - onBrushSizeClick: () -> Unit, - onEraserClick: () -> Unit, - onCropClick: () -> Unit, - onRotateClick: () -> Unit + currentTool: EditorTool, + onToolSelected: (EditorTool) -> Unit, + onBrushSizeClick: () -> Unit, + onEraserClick: () -> Unit, + onCropClick: () -> Unit, + onRotateClick: () -> Unit ) { - Surface( - color = Color(0xFF1C1C1E), - modifier = Modifier.fillMaxWidth() - ) { + Surface(color = Color(0xFF1C1C1E), modifier = Modifier.fillMaxWidth()) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp) - .navigationBarsPadding(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .navigationBarsPadding(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically ) { // Crop tool ToolButton( - icon = TablerIcons.Crop, - label = "Crop", - isSelected = currentTool == EditorTool.CROP, - onClick = onCropClick + icon = TablerIcons.Crop, + label = "Crop", + isSelected = currentTool == EditorTool.CROP, + onClick = onCropClick ) - + // Rotate tool ToolButton( - icon = TablerIcons.Rotate, - label = "Rotate", - isSelected = currentTool == EditorTool.ROTATE, - onClick = onRotateClick + icon = TablerIcons.Rotate, + label = "Rotate", + isSelected = currentTool == EditorTool.ROTATE, + onClick = onRotateClick ) - + // Draw tool ToolButton( - icon = TablerIcons.Pencil, - label = "Draw", - isSelected = currentTool == EditorTool.DRAW, - onClick = { onToolSelected(EditorTool.DRAW) } + icon = TablerIcons.Pencil, + label = "Draw", + isSelected = currentTool == EditorTool.DRAW, + onClick = { onToolSelected(EditorTool.DRAW) } ) - + // Eraser (when drawing) AnimatedVisibility( - visible = currentTool == EditorTool.DRAW, - enter = fadeIn() + scaleIn(), - exit = fadeOut() + scaleOut() + visible = currentTool == EditorTool.DRAW, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut() ) { ToolButton( - icon = TablerIcons.Eraser, - label = "Eraser", - isSelected = false, - onClick = onEraserClick + icon = TablerIcons.Eraser, + label = "Eraser", + isSelected = false, + onClick = onEraserClick ) } - + // Brush size (when drawing) AnimatedVisibility( - visible = currentTool == EditorTool.DRAW, - enter = fadeIn() + scaleIn(), - exit = fadeOut() + scaleOut() + visible = currentTool == EditorTool.DRAW, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut() ) { ToolButton( - icon = TablerIcons.Circle, - label = "Size", - isSelected = false, - onClick = onBrushSizeClick + icon = TablerIcons.Circle, + label = "Size", + isSelected = false, + onClick = onBrushSizeClick ) } } @@ -577,58 +551,50 @@ private fun BottomToolbar( @Composable private fun ToolButton( - icon: androidx.compose.ui.graphics.vector.ImageVector, - label: String, - isSelected: Boolean, - onClick: () -> Unit + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + isSelected: Boolean, + onClick: () -> Unit ) { Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onClick - ) - .padding(8.dp) + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier.clip(RoundedCornerShape(8.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick + ) + .padding(8.dp) ) { Icon( - imageVector = icon, - contentDescription = label, - tint = if (isSelected) PrimaryBlue else Color.White, - modifier = Modifier.size(24.dp) + imageVector = icon, + contentDescription = label, + tint = if (isSelected) PrimaryBlue else Color.White, + modifier = Modifier.size(24.dp) ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = label, - color = if (isSelected) PrimaryBlue else Color.White.copy(alpha = 0.7f), - fontSize = 10.sp + text = label, + color = if (isSelected) PrimaryBlue else Color.White.copy(alpha = 0.7f), + fontSize = 10.sp ) } } @Composable -private fun ColorPickerBar( - selectedColor: Color, - onColorSelected: (Color) -> Unit -) { - Surface( - color = Color(0xFF2C2C2E), - modifier = Modifier.fillMaxWidth() - ) { +private fun ColorPickerBar(selectedColor: Color, onColorSelected: (Color) -> Unit) { + Surface(color = Color(0xFF2C2C2E), modifier = Modifier.fillMaxWidth()) { LazyRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 8.dp) + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 8.dp) ) { items(drawingColors) { color -> ColorButton( - color = color, - isSelected = color == selectedColor, - onClick = { onColorSelected(color) } + color = color, + isSelected = color == selectedColor, + onClick = { onColorSelected(color) } ) } } @@ -636,32 +602,32 @@ private fun ColorPickerBar( } @Composable -private fun ColorButton( - color: Color, - isSelected: Boolean, - onClick: () -> Unit -) { +private fun ColorButton(color: Color, isSelected: Boolean, onClick: () -> Unit) { Box( - modifier = Modifier - .size(32.dp) - .clip(CircleShape) - .background(color) - .then( - if (isSelected) { - Modifier.border(3.dp, Color.White, CircleShape) - } else { - Modifier.border(1.dp, Color.White.copy(alpha = 0.3f), CircleShape) - } - ) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center + modifier = + Modifier.size(32.dp) + .clip(CircleShape) + .background(color) + .then( + if (isSelected) { + Modifier.border(3.dp, Color.White, CircleShape) + } else { + Modifier.border( + 1.dp, + Color.White.copy(alpha = 0.3f), + CircleShape + ) + } + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center ) { if (isSelected) { Icon( - Icons.Default.Check, - contentDescription = null, - tint = if (color == Color.White) Color.Black else Color.White, - modifier = Modifier.size(16.dp) + Icons.Default.Check, + contentDescription = null, + tint = if (color == Color.White) Color.Black else Color.White, + modifier = Modifier.size(16.dp) ) } } @@ -669,132 +635,113 @@ private fun ColorButton( @Composable private fun BrushSizeBar( - brushSize: Float, - onBrushSizeChanged: (Float) -> Unit, - selectedColor: Color + brushSize: Float, + onBrushSizeChanged: (Float) -> Unit, + selectedColor: Color ) { - Surface( - color = Color(0xFF2C2C2E), - modifier = Modifier.fillMaxWidth() - ) { + Surface(color = Color(0xFF2C2C2E), modifier = Modifier.fillMaxWidth()) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically ) { // Min indicator - Box( - modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(selectedColor) - ) - + Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(selectedColor)) + // Slider Slider( - value = brushSize, - onValueChange = onBrushSizeChanged, - valueRange = 5f..50f, - modifier = Modifier - .weight(1f) - .padding(horizontal = 16.dp), - colors = SliderDefaults.colors( - thumbColor = selectedColor, - activeTrackColor = selectedColor - ) + value = brushSize, + onValueChange = onBrushSizeChanged, + valueRange = 5f..50f, + modifier = Modifier.weight(1f).padding(horizontal = 16.dp), + colors = + SliderDefaults.colors( + thumbColor = selectedColor, + activeTrackColor = selectedColor + ) ) - + // Max indicator - Box( - modifier = Modifier - .size(24.dp) - .clip(CircleShape) - .background(selectedColor) - ) + Box(modifier = Modifier.size(24.dp).clip(CircleShape).background(selectedColor)) } } } -/** - * Save edited image and return the URI - */ +/** Save edited image and return the URI */ private suspend fun saveEditedImage( - context: Context, - photoEditor: PhotoEditor?, - onResult: (Uri?) -> Unit + context: Context, + photoEditor: PhotoEditor?, + onResult: (Uri?) -> Unit ) { if (photoEditor == null) { onResult(null) return } - + withContext(Dispatchers.IO) { try { val file = File(context.cacheDir, "edited_${System.currentTimeMillis()}.png") - - val saveSettings = SaveSettings.Builder() - .setClearViewsEnabled(false) - .setTransparencyEnabled(true) - .build() - - photoEditor.saveAsFile( - file.absolutePath, - saveSettings, - object : PhotoEditor.OnSaveListener { - override fun onSuccess(imagePath: String) { - Log.d(TAG, "Image saved to: $imagePath") - onResult(Uri.fromFile(File(imagePath))) - } - - override fun onFailure(exception: Exception) { - Log.e(TAG, "Failed to save image", exception) - onResult(null) - } - } - ) - } catch (e: Exception) { - Log.e(TAG, "Error saving image", e) - withContext(Dispatchers.Main) { - onResult(null) - } - } - } -} -/** - * Save edited image synchronously using suspendCoroutine - */ -private suspend fun saveEditedImageSync( - context: Context, - photoEditor: PhotoEditor? -): Uri? { - if (photoEditor == null) return null - - return withContext(Dispatchers.IO) { - try { - val file = File(context.cacheDir, "edited_${System.currentTimeMillis()}_${(0..9999).random()}.png") - - val saveSettings = SaveSettings.Builder() - .setClearViewsEnabled(false) - .setTransparencyEnabled(true) - .build() - - suspendCancellableCoroutine { continuation -> - photoEditor.saveAsFile( + val saveSettings = + SaveSettings.Builder() + .setClearViewsEnabled(false) + .setTransparencyEnabled(true) + .build() + + photoEditor.saveAsFile( file.absolutePath, saveSettings, object : PhotoEditor.OnSaveListener { override fun onSuccess(imagePath: String) { - Log.d(TAG, "Image saved sync to: $imagePath") - continuation.resume(Uri.fromFile(File(imagePath))) + Log.d(TAG, "Image saved to: $imagePath") + onResult(Uri.fromFile(File(imagePath))) } - + override fun onFailure(exception: Exception) { - Log.e(TAG, "Failed to save image sync", exception) - continuation.resume(null) + Log.e(TAG, "Failed to save image", exception) + onResult(null) } } + ) + } catch (e: Exception) { + Log.e(TAG, "Error saving image", e) + withContext(Dispatchers.Main) { onResult(null) } + } + } +} + +/** Save edited image synchronously using suspendCoroutine */ +private suspend fun saveEditedImageSync(context: Context, photoEditor: PhotoEditor?): Uri? { + if (photoEditor == null) return null + + return withContext(Dispatchers.IO) { + try { + val file = + File( + context.cacheDir, + "edited_${System.currentTimeMillis()}_${(0..9999).random()}.png" + ) + + val saveSettings = + SaveSettings.Builder() + .setClearViewsEnabled(false) + .setTransparencyEnabled(true) + .build() + + suspendCancellableCoroutine { continuation -> + photoEditor.saveAsFile( + file.absolutePath, + saveSettings, + object : PhotoEditor.OnSaveListener { + override fun onSuccess(imagePath: String) { + Log.d(TAG, "Image saved sync to: $imagePath") + continuation.resume(Uri.fromFile(File(imagePath))) + } + + override fun onFailure(exception: Exception) { + Log.e(TAG, "Failed to save image sync", exception) + continuation.resume(null) + } + } ) } } catch (e: Exception) { @@ -804,166 +751,150 @@ private suspend fun saveEditedImageSync( } } -/** - * Rotate/Flip options bar - */ +/** Rotate/Flip options bar */ @Composable private fun RotateOptionsBar( - onRotateLeft: () -> Unit, - onRotateRight: () -> Unit, - onFlipHorizontal: () -> Unit, - onFlipVertical: () -> Unit + onRotateLeft: () -> Unit, + onRotateRight: () -> Unit, + onFlipHorizontal: () -> Unit, + onFlipVertical: () -> Unit ) { - Surface( - color = Color(0xFF2C2C2E), - modifier = Modifier.fillMaxWidth() - ) { + Surface(color = Color(0xFF2C2C2E), modifier = Modifier.fillMaxWidth()) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically ) { // Rotate left ToolButton( - icon = TablerIcons.RotateClockwise2, - label = "Left", - isSelected = false, - onClick = onRotateLeft + icon = TablerIcons.RotateClockwise2, + label = "Left", + isSelected = false, + onClick = onRotateLeft ) - + // Rotate right ToolButton( - icon = TablerIcons.Rotate2, - label = "Right", - isSelected = false, - onClick = onRotateRight + icon = TablerIcons.Rotate2, + label = "Right", + isSelected = false, + onClick = onRotateRight ) - + // Flip horizontal ToolButton( - icon = TablerIcons.FlipHorizontal, - label = "Flip H", - isSelected = false, - onClick = onFlipHorizontal + icon = TablerIcons.FlipHorizontal, + label = "Flip H", + isSelected = false, + onClick = onFlipHorizontal ) - + // Flip vertical ToolButton( - icon = TablerIcons.FlipVertical, - label = "Flip V", - isSelected = false, - onClick = onFlipVertical + icon = TablerIcons.FlipVertical, + label = "Flip V", + isSelected = false, + onClick = onFlipVertical ) } } } -/** - * Launch UCrop activity for image cropping - */ +/** Launch UCrop activity for image cropping */ private fun launchCrop( - context: Context, - sourceUri: Uri, - launcher: androidx.activity.result.ActivityResultLauncher + context: Context, + sourceUri: Uri, + launcher: androidx.activity.result.ActivityResultLauncher ) { try { val destinationFile = File(context.cacheDir, "cropped_${System.currentTimeMillis()}.png") val destinationUri = Uri.fromFile(destinationFile) - - val options = UCrop.Options().apply { - setCompressionFormat(Bitmap.CompressFormat.PNG) - setCompressionQuality(100) - setToolbarColor(android.graphics.Color.parseColor("#1C1C1E")) - setStatusBarColor(android.graphics.Color.parseColor("#1C1C1E")) - setActiveControlsWidgetColor(android.graphics.Color.parseColor("#007AFF")) - setToolbarWidgetColor(android.graphics.Color.WHITE) - setRootViewBackgroundColor(android.graphics.Color.BLACK) - setFreeStyleCropEnabled(true) - setShowCropGrid(true) - setShowCropFrame(true) - setHideBottomControls(false) - } - - val intent = UCrop.of(sourceUri, destinationUri) - .withOptions(options) - .getIntent(context) - + + val options = + UCrop.Options().apply { + setCompressionFormat(Bitmap.CompressFormat.PNG) + setCompressionQuality(100) + setToolbarColor(android.graphics.Color.parseColor("#1C1C1E")) + setStatusBarColor(android.graphics.Color.parseColor("#1C1C1E")) + setActiveControlsWidgetColor(android.graphics.Color.parseColor("#007AFF")) + setToolbarWidgetColor(android.graphics.Color.WHITE) + setRootViewBackgroundColor(android.graphics.Color.BLACK) + setFreeStyleCropEnabled(true) + setShowCropGrid(true) + setShowCropFrame(true) + setHideBottomControls(false) + } + + val intent = UCrop.of(sourceUri, destinationUri).withOptions(options).getIntent(context) + launcher.launch(intent) } catch (e: Exception) { Log.e(TAG, "Error launching crop", e) } } -/** - * Data class for image with caption - */ -data class ImageWithCaption( - val uri: Uri, - var caption: String = "" -) +/** Data class for image with caption */ +data class ImageWithCaption(val uri: Uri, var caption: String = "") /** - * Multi-image editor screen with swipe (like Telegram) - * Позволяет свайпать между фотками и добавлять caption к каждой + * Multi-image editor screen with swipe (like Telegram) Позволяет свайпать между фотками и добавлять + * caption к каждой */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun MultiImageEditorScreen( - imageUris: List, - onDismiss: () -> Unit, - onSendAll: (List) -> Unit, - isDarkTheme: Boolean = true + imageUris: List, + onDismiss: () -> Unit, + onSendAll: (List) -> Unit, + isDarkTheme: Boolean = true ) { val context = LocalContext.current val scope = rememberCoroutineScope() - + // State for each image - val imagesWithCaptions = remember { + val imagesWithCaptions = remember { mutableStateListOf().apply { addAll(imageUris.map { ImageWithCaption(it, "") }) } } - + // Pager state - val pagerState = rememberPagerState( - initialPage = 0, - pageCount = { imagesWithCaptions.size } - ) - + val pagerState = rememberPagerState(initialPage = 0, pageCount = { imagesWithCaptions.size }) + var isSaving by remember { mutableStateOf(false) } - + // Current caption (для текущей страницы) var currentCaption by remember { mutableStateOf("") } - + // === EDITING STATE === var currentTool by remember { mutableStateOf(EditorTool.NONE) } var selectedColor by remember { mutableStateOf(Color.White) } var brushSize by remember { mutableFloatStateOf(15f) } var showColorPicker by remember { mutableStateOf(false) } var showBrushSizeSlider by remember { mutableStateOf(false) } - + // PhotoEditor references for each page val photoEditors = remember { mutableStateMapOf() } val photoEditorViews = remember { mutableStateMapOf() } - + // Crop launcher - val cropLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode == Activity.RESULT_OK) { - result.data?.let { data -> - UCrop.getOutput(data)?.let { croppedUri -> - val currentPage = pagerState.currentPage - if (currentPage < imagesWithCaptions.size) { - imagesWithCaptions[currentPage] = imagesWithCaptions[currentPage].copy(uri = croppedUri) + val cropLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.let { data -> + UCrop.getOutput(data)?.let { croppedUri -> + val currentPage = pagerState.currentPage + if (currentPage < imagesWithCaptions.size) { + imagesWithCaptions[currentPage] = + imagesWithCaptions[currentPage].copy(uri = croppedUri) + } + } } } } - } - } - + // Sync caption when page changes LaunchedEffect(pagerState.currentPage) { currentCaption = imagesWithCaptions.getOrNull(pagerState.currentPage)?.caption ?: "" @@ -972,7 +903,7 @@ fun MultiImageEditorScreen( showColorPicker = false showBrushSizeSlider = false } - + // Update brush settings when they change LaunchedEffect(selectedColor, brushSize) { val currentPage = pagerState.currentPage @@ -983,124 +914,118 @@ fun MultiImageEditorScreen( } } } - - BackHandler { - onDismiss() - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black) - ) { + + BackHandler { onDismiss() } + + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { // Horizontal Pager для свайпа между фото HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - userScrollEnabled = currentTool == EditorTool.NONE // Disable swipe when editing - ) { page -> - Box( + state = pagerState, modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { + userScrollEnabled = currentTool == EditorTool.NONE // Disable swipe when editing + ) { page -> + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { // PhotoEditorView for editing AndroidView( - factory = { ctx -> - PhotoEditorView(ctx).apply { - photoEditorViews[page] = this - - // Load bitmap - scope.launch(Dispatchers.IO) { - try { - val inputStream = ctx.contentResolver.openInputStream(imagesWithCaptions[page].uri) - val bitmap = BitmapFactory.decodeStream(inputStream) - inputStream?.close() - - withContext(Dispatchers.Main) { - source.setImageBitmap(bitmap) - - // Create PhotoEditor - val editor = PhotoEditor.Builder(ctx, this@apply) - .setPinchTextScalable(true) - .build() - photoEditors[page] = editor + factory = { ctx -> + PhotoEditorView(ctx).apply { + photoEditorViews[page] = this + + // Load bitmap + scope.launch(Dispatchers.IO) { + try { + val inputStream = + ctx.contentResolver.openInputStream( + imagesWithCaptions[page].uri + ) + val bitmap = BitmapFactory.decodeStream(inputStream) + inputStream?.close() + + withContext(Dispatchers.Main) { + source.setImageBitmap(bitmap) + + // Create PhotoEditor + val editor = + PhotoEditor.Builder(ctx, this@apply) + .setPinchTextScalable(true) + .build() + photoEditors[page] = editor + } + } catch (e: Exception) { + Log.e(TAG, "Error loading image for page $page", e) + } + } + } + }, + modifier = Modifier.fillMaxSize(), + update = { view -> + // Reload if URI changed (after crop) + val currentUri = imagesWithCaptions.getOrNull(page)?.uri + if (currentUri != null) { + scope.launch(Dispatchers.IO) { + try { + val inputStream = + context.contentResolver.openInputStream(currentUri) + val bitmap = BitmapFactory.decodeStream(inputStream) + inputStream?.close() + + withContext(Dispatchers.Main) { + view.source.setImageBitmap(bitmap) + } + } catch (e: Exception) { + Log.e(TAG, "Error reloading image", e) } - } catch (e: Exception) { - Log.e(TAG, "Error loading image for page $page", e) } } } - }, - modifier = Modifier.fillMaxSize(), - update = { view -> - // Reload if URI changed (after crop) - val currentUri = imagesWithCaptions.getOrNull(page)?.uri - if (currentUri != null) { - scope.launch(Dispatchers.IO) { - try { - val inputStream = context.contentResolver.openInputStream(currentUri) - val bitmap = BitmapFactory.decodeStream(inputStream) - inputStream?.close() - - withContext(Dispatchers.Main) { - view.source.setImageBitmap(bitmap) - } - } catch (e: Exception) { - Log.e(TAG, "Error reloading image", e) - } - } - } - } ) } } - + // Top toolbar - OVERLAY Row( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { // Close button IconButton(onClick = onDismiss) { Icon( - TablerIcons.X, - contentDescription = "Close", - tint = Color.White, - modifier = Modifier.size(28.dp) + TablerIcons.X, + contentDescription = "Close", + tint = Color.White, + modifier = Modifier.size(28.dp) ) } - + // Page indicator if (imagesWithCaptions.size > 1) { Box( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .background(Color.Black.copy(alpha = 0.5f)) - .padding(horizontal = 12.dp, vertical = 6.dp) + modifier = + Modifier.clip(RoundedCornerShape(12.dp)) + .background(Color.Black.copy(alpha = 0.5f)) + .padding(horizontal = 12.dp, vertical = 6.dp) ) { Text( - text = "${pagerState.currentPage + 1} / ${imagesWithCaptions.size}", - color = Color.White, - fontSize = 14.sp, - fontWeight = FontWeight.Medium + text = "${pagerState.currentPage + 1} / ${imagesWithCaptions.size}", + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium ) } } - + // Undo button (when drawing) if (currentTool == EditorTool.DRAW) { - IconButton(onClick = { - photoEditors[pagerState.currentPage]?.undo() - }) { + IconButton(onClick = { photoEditors[pagerState.currentPage]?.undo() }) { Icon( - TablerIcons.ArrowBackUp, - contentDescription = "Undo", - tint = Color.White, - modifier = Modifier.size(28.dp) + TablerIcons.ArrowBackUp, + contentDescription = "Undo", + tint = Color.White, + modifier = Modifier.size(28.dp) ) } } else { @@ -1108,289 +1033,263 @@ fun MultiImageEditorScreen( Spacer(modifier = Modifier.size(48.dp)) } } - + // Bottom section - Tools + Caption + Send - Column( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .imePadding() - ) { + Column(modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter).imePadding()) { // Color picker bar (when drawing) AnimatedVisibility( - visible = showColorPicker && currentTool == EditorTool.DRAW, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + visible = showColorPicker && currentTool == EditorTool.DRAW, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() ) { ColorPickerBar( - selectedColor = selectedColor, - onColorSelected = { color -> - selectedColor = color - photoEditors[pagerState.currentPage]?.brushColor = color.toArgb() - } + selectedColor = selectedColor, + onColorSelected = { color -> + selectedColor = color + photoEditors[pagerState.currentPage]?.brushColor = color.toArgb() + } ) } - + // Brush size slider (when drawing) AnimatedVisibility( - visible = showBrushSizeSlider && currentTool == EditorTool.DRAW, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + visible = showBrushSizeSlider && currentTool == EditorTool.DRAW, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() ) { BrushSizeBar( - brushSize = brushSize, - onBrushSizeChanged = { size -> - brushSize = size - photoEditors[pagerState.currentPage]?.brushSize = size - }, - selectedColor = selectedColor + brushSize = brushSize, + onBrushSizeChanged = { size -> + brushSize = size + photoEditors[pagerState.currentPage]?.brushSize = size + }, + selectedColor = selectedColor ) } - + // Rotate options bar AnimatedVisibility( - visible = currentTool == EditorTool.ROTATE, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + visible = currentTool == EditorTool.ROTATE, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() ) { RotateOptionsBar( - onRotateLeft = { - photoEditorViews[pagerState.currentPage]?.source?.rotation = - (photoEditorViews[pagerState.currentPage]?.source?.rotation ?: 0f) - 90f - }, - onRotateRight = { - photoEditorViews[pagerState.currentPage]?.source?.rotation = - (photoEditorViews[pagerState.currentPage]?.source?.rotation ?: 0f) + 90f - }, - onFlipHorizontal = { - photoEditorViews[pagerState.currentPage]?.source?.let { imageView -> - imageView.scaleX = -imageView.scaleX + onRotateLeft = { + photoEditorViews[pagerState.currentPage]?.source?.rotation = + (photoEditorViews[pagerState.currentPage]?.source?.rotation + ?: 0f) - 90f + }, + onRotateRight = { + photoEditorViews[pagerState.currentPage]?.source?.rotation = + (photoEditorViews[pagerState.currentPage]?.source?.rotation + ?: 0f) + 90f + }, + onFlipHorizontal = { + photoEditorViews[pagerState.currentPage]?.source?.let { imageView -> + imageView.scaleX = -imageView.scaleX + } + }, + onFlipVertical = { + photoEditorViews[pagerState.currentPage]?.source?.let { imageView -> + imageView.scaleY = -imageView.scaleY + } } - }, - onFlipVertical = { - photoEditorViews[pagerState.currentPage]?.source?.let { imageView -> - imageView.scaleY = -imageView.scaleY - } - } ) } - + // Thumbnails strip (если больше 1 фото) if (imagesWithCaptions.size > 1 && currentTool == EditorTool.NONE) { LazyRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { items(imagesWithCaptions.size) { index -> val isSelected = pagerState.currentPage == index Box( - modifier = Modifier - .size(56.dp) - .clip(RoundedCornerShape(8.dp)) - .border( - width = if (isSelected) 2.dp else 0.dp, - color = if (isSelected) PrimaryBlue else Color.Transparent, - shape = RoundedCornerShape(8.dp) - ) - .clickable { - scope.launch { - pagerState.animateScrollToPage(index) - } - } + modifier = + Modifier.size(56.dp) + .clip(RoundedCornerShape(8.dp)) + .border( + width = if (isSelected) 2.dp else 0.dp, + color = + if (isSelected) PrimaryBlue + else Color.Transparent, + shape = RoundedCornerShape(8.dp) + ) + .clickable { + scope.launch { + pagerState.animateScrollToPage(index) + } + } ) { AsyncImageLoader( - uri = imagesWithCaptions[index].uri, - modifier = Modifier.fillMaxSize() + uri = imagesWithCaptions[index].uri, + modifier = Modifier.fillMaxSize() ) } } } } - - // Caption input bar (hidden during editing) + + // Send button (without caption input for multi-image) AnimatedVisibility( - visible = currentTool == EditorTool.NONE, - enter = fadeIn(), - exit = fadeOut() + visible = currentTool == EditorTool.NONE, + enter = fadeIn(), + exit = fadeOut() ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(10.dp) + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End ) { - // Caption text field + // Send button Box( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(22.dp)) - .background(Color.White.copy(alpha = 0.15f)) + modifier = + Modifier.size(48.dp) + .clip(CircleShape) + .background(PrimaryBlue) + .clickable(enabled = !isSaving) { + isSaving = true + // Save all edited images before sending + scope.launch { + val savedImages = + mutableListOf() + + for (i in imagesWithCaptions.indices) { + val editor = photoEditors[i] + val originalImage = imagesWithCaptions[i] + + if (editor != null) { + // Save edited image + val savedUri = + saveEditedImageSync( + context, + editor + ) + if (savedUri != null) { + savedImages.add( + originalImage.copy( + uri = savedUri + ) + ) + } else { + // Fallback to original if save + // fails + savedImages.add(originalImage) + } + } else { + // No editor for this page, use original + savedImages.add(originalImage) + } + } + + onSendAll(savedImages) + } + }, + contentAlignment = Alignment.Center ) { - BasicTextField( - value = currentCaption, - onValueChange = { newCaption -> - currentCaption = newCaption - // Update caption for current image - if (pagerState.currentPage < imagesWithCaptions.size) { - imagesWithCaptions[pagerState.currentPage] = - imagesWithCaptions[pagerState.currentPage].copy(caption = newCaption) - } - }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - textStyle = androidx.compose.ui.text.TextStyle( - color = Color.White, - fontSize = 16.sp - ), - maxLines = 4, - decorationBox = { innerTextField -> - Box { - if (currentCaption.isEmpty()) { - Text( - "Add a caption...", - color = Color.White.copy(alpha = 0.6f), - fontSize = 16.sp - ) - } - innerTextField() - } + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(22.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = TablerIcons.Send, + contentDescription = "Send", + tint = Color.White, + modifier = Modifier.size(22.dp).offset(x = 1.dp) + ) } - ) - } - - // Send button - Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(PrimaryBlue) - .clickable(enabled = !isSaving) { - isSaving = true - // Save all edited images before sending - scope.launch { - val savedImages = mutableListOf() - - for (i in imagesWithCaptions.indices) { - val editor = photoEditors[i] - val originalImage = imagesWithCaptions[i] - - if (editor != null) { - // Save edited image - val savedUri = saveEditedImageSync(context, editor) - if (savedUri != null) { - savedImages.add(originalImage.copy(uri = savedUri)) - } else { - // Fallback to original if save fails - savedImages.add(originalImage) - } - } else { - // No editor for this page, use original - savedImages.add(originalImage) - } - } - - onSendAll(savedImages) - } - }, - contentAlignment = Alignment.Center - ) { - if (isSaving) { - CircularProgressIndicator( - modifier = Modifier.size(22.dp), - color = Color.White, - strokeWidth = 2.dp - ) - } else { - Icon( - imageVector = TablerIcons.Send, - contentDescription = "Send", - tint = Color.White, - modifier = Modifier - .size(22.dp) - .offset(x = 1.dp) - ) } } } - } - + // Bottom toolbar with editing tools BottomToolbar( - currentTool = currentTool, - onToolSelected = { tool -> - val currentEditor = photoEditors[pagerState.currentPage] - when (tool) { - EditorTool.DRAW -> { - currentTool = if (currentTool == EditorTool.DRAW) EditorTool.NONE else tool - if (currentTool == EditorTool.DRAW) { - currentEditor?.setBrushDrawingMode(true) - currentEditor?.brushColor = selectedColor.toArgb() - currentEditor?.brushSize = brushSize - showColorPicker = true - } else { - currentEditor?.setBrushDrawingMode(false) - showColorPicker = false + currentTool = currentTool, + onToolSelected = { tool -> + val currentEditor = photoEditors[pagerState.currentPage] + when (tool) { + EditorTool.DRAW -> { + currentTool = + if (currentTool == EditorTool.DRAW) EditorTool.NONE + else tool + if (currentTool == EditorTool.DRAW) { + currentEditor?.setBrushDrawingMode(true) + currentEditor?.brushColor = selectedColor.toArgb() + currentEditor?.brushSize = brushSize + showColorPicker = true + } else { + currentEditor?.setBrushDrawingMode(false) + showColorPicker = false + } + showBrushSizeSlider = false + } + EditorTool.CROP -> { + currentTool = EditorTool.NONE + showColorPicker = false + showBrushSizeSlider = false + currentEditor?.setBrushDrawingMode(false) + // Launch UCrop + launchCrop( + context, + imagesWithCaptions[pagerState.currentPage].uri, + cropLauncher + ) + } + EditorTool.ROTATE -> { + currentTool = + if (currentTool == EditorTool.ROTATE) EditorTool.NONE + else tool + showColorPicker = false + showBrushSizeSlider = false + currentEditor?.setBrushDrawingMode(false) + } + else -> { + currentTool = EditorTool.NONE + showColorPicker = false + showBrushSizeSlider = false + currentEditor?.setBrushDrawingMode(false) } - showBrushSizeSlider = false - } - EditorTool.CROP -> { - currentTool = EditorTool.NONE - showColorPicker = false - showBrushSizeSlider = false - currentEditor?.setBrushDrawingMode(false) - // Launch UCrop - launchCrop(context, imagesWithCaptions[pagerState.currentPage].uri, cropLauncher) - } - EditorTool.ROTATE -> { - currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else tool - showColorPicker = false - showBrushSizeSlider = false - currentEditor?.setBrushDrawingMode(false) - } - else -> { - currentTool = EditorTool.NONE - showColorPicker = false - showBrushSizeSlider = false - currentEditor?.setBrushDrawingMode(false) } + }, + onBrushSizeClick = { + showBrushSizeSlider = !showBrushSizeSlider + showColorPicker = false + }, + onEraserClick = { photoEditors[pagerState.currentPage]?.brushEraser() }, + onCropClick = { + launchCrop( + context, + imagesWithCaptions[pagerState.currentPage].uri, + cropLauncher + ) + }, + onRotateClick = { + currentTool = + if (currentTool == EditorTool.ROTATE) EditorTool.NONE + else EditorTool.ROTATE + showColorPicker = false + showBrushSizeSlider = false + photoEditors[pagerState.currentPage]?.setBrushDrawingMode(false) } - }, - onBrushSizeClick = { - showBrushSizeSlider = !showBrushSizeSlider - showColorPicker = false - }, - onEraserClick = { - photoEditors[pagerState.currentPage]?.brushEraser() - }, - onCropClick = { - launchCrop(context, imagesWithCaptions[pagerState.currentPage].uri, cropLauncher) - }, - onRotateClick = { - currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else EditorTool.ROTATE - showColorPicker = false - showBrushSizeSlider = false - photoEditors[pagerState.currentPage]?.setBrushDrawingMode(false) - } ) } } } -/** - * Simple async image loader composable - */ +/** Simple async image loader composable */ @Composable -private fun AsyncImageLoader( - uri: Uri, - modifier: Modifier = Modifier -) { +private fun AsyncImageLoader(uri: Uri, modifier: Modifier = Modifier) { val context = LocalContext.current var bitmap by remember(uri) { mutableStateOf(null) } - + LaunchedEffect(uri) { withContext(Dispatchers.IO) { try { @@ -1402,21 +1301,17 @@ private fun AsyncImageLoader( } } } - + bitmap?.let { bmp -> Image( - bitmap = bmp.asImageBitmap(), - contentDescription = null, - modifier = modifier, - contentScale = androidx.compose.ui.layout.ContentScale.Fit - ) - } ?: Box( - modifier = modifier.background(Color.DarkGray), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - modifier = Modifier.size(32.dp), - color = Color.White + bitmap = bmp.asImageBitmap(), + contentDescription = null, + modifier = modifier, + contentScale = androidx.compose.ui.layout.ContentScale.Fit ) } + ?: Box( + modifier = modifier.background(Color.DarkGray), + contentAlignment = Alignment.Center + ) { CircularProgressIndicator(modifier = Modifier.size(32.dp), color = Color.White) } }