Implement feature X to enhance user experience and fix bug Y in module Z

This commit is contained in:
k1ngsterr1
2026-01-31 04:21:30 +05:00
parent c6f1998dc9
commit 56a9fc4c20
3 changed files with 1690 additions and 1741 deletions

View File

@@ -709,7 +709,7 @@ fun ChatDetailScreen(
contentDescription = contentDescription =
"Back", "Back",
tint = tint =
Color.White, if (isDarkTheme) Color.White else Color(0xFF007AFF),
modifier = modifier =
Modifier.size( Modifier.size(
32.dp 32.dp

View File

@@ -1,16 +1,13 @@
package com.rosetta.messenger.ui.chats.components package com.rosetta.messenger.ui.chats.components
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape 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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.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.crypto.MessageCrypto
import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.network.TransportManager import com.rosetta.messenger.network.TransportManager
import com.rosetta.messenger.R
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.models.MessageStatus import com.rosetta.messenger.ui.chats.models.MessageStatus
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.utils.AttachmentFileManager import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.AvatarFileManager
import com.vanniktech.blurhash.BlurHash import com.vanniktech.blurhash.BlurHash
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
private const val TAG = "AttachmentComponents" private const val TAG = "AttachmentComponents"
/** /** Статус скачивания attachment (как в desktop) */
* Статус скачивания attachment (как в desktop)
*/
enum class DownloadStatus { enum class DownloadStatus {
DOWNLOADED, DOWNLOADED,
NOT_DOWNLOADED, NOT_DOWNLOADED,
@@ -67,8 +57,8 @@ enum class DownloadStatus {
} }
/** /**
* Composable для отображения всех attachments в сообщении * Composable для отображения всех attachments в сообщении 🖼️ IMAGE attachments группируются в
* 🖼️ IMAGE attachments группируются в коллаж (как в Telegram) * коллаж (как в Telegram)
*/ */
@Composable @Composable
fun MessageAttachments( fun MessageAttachments(
@@ -90,12 +80,12 @@ fun MessageAttachments(
// Разделяем attachments по типам // Разделяем attachments по типам
val imageAttachments = attachments.filter { it.type == AttachmentType.IMAGE } val imageAttachments = attachments.filter { it.type == AttachmentType.IMAGE }
val otherAttachments = attachments.filter { it.type != AttachmentType.IMAGE && it.type != AttachmentType.MESSAGES } val otherAttachments =
attachments.filter {
it.type != AttachmentType.IMAGE && it.type != AttachmentType.MESSAGES
}
Column( Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// 🖼️ Коллаж для изображений (если больше 1) // 🖼️ Коллаж для изображений (если больше 1)
if (imageAttachments.isNotEmpty()) { if (imageAttachments.isNotEmpty()) {
ImageCollage( ImageCollage(
@@ -140,15 +130,16 @@ fun MessageAttachments(
messageStatus = messageStatus messageStatus = messageStatus
) )
} }
else -> { /* MESSAGES обрабатываются отдельно */ } else -> {
/* MESSAGES обрабатываются отдельно */
}
} }
} }
} }
} }
/** /**
* 🖼️ Коллаж изображений в стиле Telegram * 🖼️ Коллаж изображений в стиле Telegram Разные layout'ы в зависимости от количества фото:
* Разные layout'ы в зависимости от количества фото:
* - 1 фото: полная ширина * - 1 фото: полная ширина
* - 2 фото: 2 колонки * - 2 фото: 2 колонки
* - 3 фото: 1 большое слева + 2 маленьких справа * - 3 фото: 1 большое слева + 2 маленьких справа
@@ -176,16 +167,19 @@ fun ImageCollage(
val showOverlayOnLast = !hasCaption val showOverlayOnLast = !hasCaption
// Закругление: если есть caption - только сверху, снизу прямые углы // Закругление: если есть caption - только сверху, снизу прямые углы
val collageShape = if (hasCaption) { val collageShape =
RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 0.dp, bottomEnd = 0.dp) if (hasCaption) {
RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = 0.dp,
bottomEnd = 0.dp
)
} else { } else {
RoundedCornerShape(16.dp) RoundedCornerShape(16.dp)
} }
Box( Box(modifier = modifier.clip(collageShape)) {
modifier = modifier
.clip(collageShape)
) {
when (count) { when (count) {
1 -> { 1 -> {
// Одно фото - полная ширина // Одно фото - полная ширина
@@ -198,7 +192,7 @@ fun ImageCollage(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus, messageStatus = messageStatus,
showTimeOverlay = true, showTimeOverlay = showOverlayOnLast,
onImageClick = onImageClick onImageClick = onImageClick
) )
} }
@@ -219,7 +213,7 @@ fun ImageCollage(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus, messageStatus = messageStatus,
showTimeOverlay = index == count - 1, showTimeOverlay = showOverlayOnLast && index == count - 1,
aspectRatio = 1f, aspectRatio = 1f,
onImageClick = onImageClick onImageClick = onImageClick
) )
@@ -230,9 +224,7 @@ fun ImageCollage(
3 -> { 3 -> {
// Три фото: 1 большое слева + 2 маленьких справа // Три фото: 1 большое слева + 2 маленьких справа
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().height(200.dp),
.fillMaxWidth()
.height(200.dp),
horizontalArrangement = Arrangement.spacedBy(spacing) horizontalArrangement = Arrangement.spacedBy(spacing)
) { ) {
// Большое фото слева // Большое фото слева
@@ -281,7 +273,7 @@ fun ImageCollage(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus, messageStatus = messageStatus,
showTimeOverlay = true, showTimeOverlay = showOverlayOnLast,
fillMaxSize = true, fillMaxSize = true,
onImageClick = onImageClick onImageClick = onImageClick
) )
@@ -359,7 +351,7 @@ fun ImageCollage(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus, messageStatus = messageStatus,
showTimeOverlay = true, showTimeOverlay = showOverlayOnLast,
fillMaxSize = true, fillMaxSize = true,
onImageClick = onImageClick onImageClick = onImageClick
) )
@@ -429,16 +421,14 @@ fun ImageCollage(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus, messageStatus = messageStatus,
showTimeOverlay = isLastItem, showTimeOverlay = showOverlayOnLast && isLastItem,
fillMaxSize = true, fillMaxSize = true,
onImageClick = onImageClick onImageClick = onImageClick
) )
} }
} }
// Заполняем пустые места если в ряду меньше 3 фото // Заполняем пустые места если в ряду меньше 3 фото
repeat(3 - rowItems.size) { repeat(3 - rowItems.size) { Spacer(modifier = Modifier.weight(1f)) }
Spacer(modifier = Modifier.weight(1f))
}
} }
} }
} }
@@ -483,7 +473,8 @@ fun ImageAttachment(
val downloadTag = getDownloadTag(attachment.preview) val downloadTag = getDownloadTag(attachment.preview)
// Анимация прогресса // Анимация прогресса
val animatedProgress by animateFloatAsState( val animatedProgress by
animateFloatAsState(
targetValue = downloadProgress, targetValue = downloadProgress,
animationSpec = tween(durationMillis = 300), animationSpec = tween(durationMillis = 300),
label = "progress" label = "progress"
@@ -493,7 +484,8 @@ fun ImageAttachment(
LaunchedEffect(attachment.id) { LaunchedEffect(attachment.id) {
// Определяем статус (логика из Desktop useAttachment.ts) // Определяем статус (логика из Desktop useAttachment.ts)
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
downloadStatus = when { downloadStatus =
when {
// 1. Если blob уже есть в памяти → DOWNLOADED // 1. Если blob уже есть в памяти → DOWNLOADED
attachment.blob.isNotEmpty() -> { attachment.blob.isNotEmpty() -> {
Log.d(TAG, "📦 Blob already in memory for ${attachment.id}") Log.d(TAG, "📦 Blob already in memory for ${attachment.id}")
@@ -506,9 +498,13 @@ fun ImageAttachment(
} }
// 3. Есть UUID (download tag) → проверяем файловую систему // 3. Есть UUID (download tag) → проверяем файловую систему
else -> { else -> {
// Проверяем есть ли файл локально (как в Desktop: readFile(`m/${md5hash}`)) // Проверяем есть ли файл локально (как в Desktop:
val hasLocal = AttachmentFileManager.hasAttachment( // readFile(`m/${md5hash}`))
context, attachment.id, senderPublicKey val hasLocal =
AttachmentFileManager.hasAttachment(
context,
attachment.id,
senderPublicKey
) )
if (hasLocal) { if (hasLocal) {
Log.d(TAG, "📦 Found local file for ${attachment.id}") Log.d(TAG, "📦 Found local file for ${attachment.id}")
@@ -525,7 +521,10 @@ fun ImageAttachment(
if (preview.isNotEmpty() && !isDownloadTag(preview)) { if (preview.isNotEmpty() && !isDownloadTag(preview)) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { 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) blurhashBitmap = BlurHash.decode(preview, 200, 200)
if (blurhashBitmap != null) { if (blurhashBitmap != null) {
Log.d(TAG, "✅ Blurhash decoded successfully") Log.d(TAG, "✅ Blurhash decoded successfully")
@@ -537,7 +536,10 @@ fun ImageAttachment(
} }
} }
} else { } 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 // Загружаем изображение если статус DOWNLOADED
@@ -550,8 +552,12 @@ fun ImageAttachment(
} else { } else {
// 2. Читаем из файловой системы (как в Desktop getBlob) // 2. Читаем из файловой системы (как в Desktop getBlob)
Log.d(TAG, "🖼️ Loading image from local file") Log.d(TAG, "🖼️ Loading image from local file")
val localBlob = AttachmentFileManager.readAttachment( val localBlob =
context, attachment.id, senderPublicKey, privateKey AttachmentFileManager.readAttachment(
context,
attachment.id,
senderPublicKey,
privateKey
) )
if (localBlob != null) { if (localBlob != null) {
imageBitmap = base64ToBitmap(localBlob) imageBitmap = base64ToBitmap(localBlob)
@@ -591,13 +597,16 @@ fun ImageAttachment(
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop) // КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
// Сначала расшифровываем его, получаем raw bytes // Сначала расшифровываем его, получаем raw bytes
Log.d(TAG, "🔑 Decrypting ChaCha key from sender...") 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") Log.d(TAG, "🔑 ChaCha key decrypted: ${decryptedKeyAndNonce.size} bytes")
// Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует bytes в password // Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует
// bytes в password
Log.d(TAG, "🔓 Decrypting image blob with PBKDF2...") Log.d(TAG, "🔓 Decrypting image blob with PBKDF2...")
val decryptStartTime = System.currentTimeMillis() val decryptStartTime = System.currentTimeMillis()
val decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey( val decrypted =
MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent, encryptedContent,
decryptedKeyAndNonce decryptedKeyAndNonce
) )
@@ -610,11 +619,15 @@ fun ImageAttachment(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Log.d(TAG, "🖼️ Converting to bitmap...") Log.d(TAG, "🖼️ Converting to bitmap...")
imageBitmap = base64ToBitmap(decrypted) imageBitmap = base64ToBitmap(decrypted)
Log.d(TAG, "✅ Bitmap created: ${imageBitmap?.width}x${imageBitmap?.height}") Log.d(
TAG,
"✅ Bitmap created: ${imageBitmap?.width}x${imageBitmap?.height}"
)
// 💾 Сохраняем в файловую систему (как в Desktop) // 💾 Сохраняем в файловую систему (как в Desktop)
Log.d(TAG, "💾 Saving to local storage...") Log.d(TAG, "💾 Saving to local storage...")
val saved = AttachmentFileManager.saveAttachment( val saved =
AttachmentFileManager.saveAttachment(
context = context, context = context,
blob = decrypted, blob = decrypted,
attachmentId = attachment.id, attachmentId = attachment.id,
@@ -652,7 +665,8 @@ fun ImageAttachment(
// Telegram-style image с blurhash placeholder и тонким бордером // Telegram-style image с blurhash placeholder и тонким бордером
val timeFormat = remember { java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) } val timeFormat = remember { java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) }
val borderColor = if (isOutgoing) { val borderColor =
if (isOutgoing) {
Color.White.copy(alpha = 0.15f) Color.White.copy(alpha = 0.15f)
} else { } else {
if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
@@ -663,7 +677,8 @@ fun ImageAttachment(
val actualWidth = if (attachment.width > 0) attachment.width else imageBitmap?.width ?: 0 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 actualHeight = if (attachment.height > 0) attachment.height else imageBitmap?.height ?: 0
val (imageWidth, imageHeight) = remember(actualWidth, actualHeight, fillMaxSize, aspectRatio) { val (imageWidth, imageHeight) =
remember(actualWidth, actualHeight, fillMaxSize, aspectRatio) {
// Если fillMaxSize - используем 100% контейнера // Если fillMaxSize - используем 100% контейнера
if (fillMaxSize) { if (fillMaxSize) {
null to null null to null
@@ -716,15 +731,18 @@ fun ImageAttachment(
} }
// Модификатор размера // Модификатор размера
val sizeModifier = when { val sizeModifier =
when {
fillMaxSize -> Modifier.fillMaxSize() fillMaxSize -> Modifier.fillMaxSize()
aspectRatio != null -> Modifier.fillMaxWidth().aspectRatio(aspectRatio) aspectRatio != null -> Modifier.fillMaxWidth().aspectRatio(aspectRatio)
imageWidth != null && imageHeight != null -> Modifier.width(imageWidth).height(imageHeight) imageWidth != null && imageHeight != null ->
Modifier.width(imageWidth).height(imageHeight)
else -> Modifier.size(220.dp) else -> Modifier.size(220.dp)
} }
Box( Box(
modifier = sizeModifier modifier =
sizeModifier
.clip(RoundedCornerShape(if (fillMaxSize) 0.dp else 12.dp)) .clip(RoundedCornerShape(if (fillMaxSize) 0.dp else 12.dp))
.background(Color.Transparent) .background(Color.Transparent)
.clickable { .clickable {
@@ -763,14 +781,21 @@ fun ImageAttachment(
else -> { else -> {
// Простой placeholder с приятным gradient фоном // Простой placeholder с приятным gradient фоном
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier.fillMaxSize()
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
colors = if (isDarkTheme) colors =
listOf(Color(0xFF2A2A2A), Color(0xFF1A1A1A)) if (isDarkTheme)
listOf(
Color(0xFF2A2A2A),
Color(0xFF1A1A1A)
)
else else
listOf(Color(0xFFE8E8E8), Color(0xFFD0D0D0)) listOf(
Color(0xFFE8E8E8),
Color(0xFFD0D0D0)
)
) )
) )
) )
@@ -780,8 +805,8 @@ fun ImageAttachment(
// Время в правом нижнем углу (показываем только если showTimeOverlay = true) // Время в правом нижнем углу (показываем только если showTimeOverlay = true)
if (showTimeOverlay) { if (showTimeOverlay) {
Box( Box(
modifier = Modifier modifier =
.align(Alignment.BottomEnd) Modifier.align(Alignment.BottomEnd)
.padding(8.dp) .padding(8.dp)
.background( .background(
Color.Black.copy(alpha = 0.5f), Color.Black.copy(alpha = 0.5f),
@@ -844,17 +869,15 @@ fun ImageAttachment(
// Оверлей для статуса скачивания // Оверлей для статуса скачивания
if (downloadStatus != DownloadStatus.DOWNLOADED) { if (downloadStatus != DownloadStatus.DOWNLOADED) {
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.3f)),
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
when (downloadStatus) { when (downloadStatus) {
DownloadStatus.NOT_DOWNLOADED -> { DownloadStatus.NOT_DOWNLOADED -> {
// Кнопка скачивания // Кнопка скачивания
Box( Box(
modifier = Modifier modifier =
.size(48.dp) Modifier.size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f)), .background(Color.Black.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -870,8 +893,8 @@ fun ImageAttachment(
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
// Индикатор загрузки с прогрессом // Индикатор загрузки с прогрессом
Box( Box(
modifier = Modifier modifier =
.size(48.dp) Modifier.size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f)), .background(Color.Black.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -885,14 +908,14 @@ fun ImageAttachment(
} }
DownloadStatus.ERROR -> { DownloadStatus.ERROR -> {
// Ошибка с кнопкой повтора // Ошибка с кнопкой повтора
Column( Column(horizontalAlignment = Alignment.CenterHorizontally) {
horizontalAlignment = Alignment.CenterHorizontally
) {
Box( Box(
modifier = Modifier modifier =
.size(48.dp) Modifier.size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFFE53935).copy(alpha = 0.8f)), .background(
Color(0xFFE53935).copy(alpha = 0.8f)
),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
@@ -903,11 +926,7 @@ fun ImageAttachment(
) )
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text("Expired", fontSize = 12.sp, color = Color.White)
"Expired",
fontSize = 12.sp,
color = Color.White
)
} }
} }
else -> {} else -> {}
@@ -917,9 +936,7 @@ fun ImageAttachment(
} }
} }
/** /** File attachment - Telegram style */
* File attachment - Telegram style
*/
@Composable @Composable
fun FileAttachment( fun FileAttachment(
attachment: MessageAttachment, attachment: MessageAttachment,
@@ -940,14 +957,16 @@ fun FileAttachment(
val (fileSize, fileName) = parseFilePreview(preview) val (fileSize, fileName) = parseFilePreview(preview)
// Анимация прогресса // Анимация прогресса
val animatedProgress by animateFloatAsState( val animatedProgress by
animateFloatAsState(
targetValue = downloadProgress, targetValue = downloadProgress,
animationSpec = tween(durationMillis = 300), animationSpec = tween(durationMillis = 300),
label = "progress" label = "progress"
) )
LaunchedEffect(attachment.id) { LaunchedEffect(attachment.id) {
downloadStatus = if (isDownloadTag(preview)) { downloadStatus =
if (isDownloadTag(preview)) {
DownloadStatus.NOT_DOWNLOADED DownloadStatus.NOT_DOWNLOADED
} else { } else {
DownloadStatus.DOWNLOADED DownloadStatus.DOWNLOADED
@@ -968,9 +987,11 @@ fun FileAttachment(
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop) // КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
// Сначала расшифровываем его, получаем raw bytes // Сначала расшифровываем его, получаем raw bytes
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
val decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey( val decrypted =
MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent, encryptedContent,
decryptedKeyAndNonce decryptedKeyAndNonce
) )
@@ -993,30 +1014,28 @@ fun FileAttachment(
} }
// Telegram-style файл - как в desktop: без внутреннего фона, просто иконка + текст // Telegram-style файл - как в desktop: без внутреннего фона, просто иконка + текст
Box( Box(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth()
) {
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.clickable(enabled = downloadStatus == DownloadStatus.NOT_DOWNLOADED || downloadStatus == DownloadStatus.ERROR) { .clickable(
download() enabled =
} downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
downloadStatus == DownloadStatus.ERROR
) { download() }
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// File icon с индикатором прогресса - круглая иконка как в desktop // File icon с индикатором прогресса - круглая иконка как в desktop
Box( Box(modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center) {
modifier = Modifier.size(40.dp),
contentAlignment = Alignment.Center
) {
// Круглый фон иконки // Круглый фон иконки
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier.fillMaxSize()
.clip(CircleShape) .clip(CircleShape)
.background( .background(
if (downloadStatus == DownloadStatus.ERROR) Color(0xFFE53935) if (downloadStatus == DownloadStatus.ERROR)
Color(0xFFE53935)
else PrimaryBlue else PrimaryBlue
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -1073,7 +1092,9 @@ fun FileAttachment(
text = fileName, text = fileName,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
color = if (isOutgoing) Color.White else (if (isDarkTheme) Color.White else Color.Black), color =
if (isOutgoing) Color.White
else (if (isDarkTheme) Color.White else Color.Black),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -1082,14 +1103,16 @@ fun FileAttachment(
// Размер файла и тип // Размер файла и тип
val fileExtension = fileName.substringAfterLast('.', "").uppercase() val fileExtension = fileName.substringAfterLast('.', "").uppercase()
Text( Text(
text = when (downloadStatus) { text =
when (downloadStatus) {
DownloadStatus.DOWNLOADING -> "Downloading..." DownloadStatus.DOWNLOADING -> "Downloading..."
DownloadStatus.DECRYPTING -> "Decrypting..." DownloadStatus.DECRYPTING -> "Decrypting..."
DownloadStatus.ERROR -> "File expired" DownloadStatus.ERROR -> "File expired"
else -> "${formatFileSize(fileSize)} $fileExtension" else -> "${formatFileSize(fileSize)} $fileExtension"
}, },
fontSize = 12.sp, fontSize = 12.sp,
color = if (downloadStatus == DownloadStatus.ERROR) { color =
if (downloadStatus == DownloadStatus.ERROR) {
Color(0xFFE53935) Color(0xFFE53935)
} else if (isOutgoing) { } else if (isOutgoing) {
Color.White.copy(alpha = 0.7f) Color.White.copy(alpha = 0.7f)
@@ -1103,9 +1126,8 @@ fun FileAttachment(
// Time and checkmarks (bottom-right overlay) for outgoing files // Time and checkmarks (bottom-right overlay) for outgoing files
if (isOutgoing) { if (isOutgoing) {
Row( Row(
modifier = Modifier modifier =
.align(Alignment.BottomEnd) Modifier.align(Alignment.BottomEnd).padding(bottom = 6.dp, end = 4.dp),
.padding(bottom = 6.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End horizontalArrangement = Arrangement.End
) { ) {
@@ -1140,7 +1162,8 @@ fun FileAttachment(
Icon( Icon(
compose.icons.TablerIcons.Checks, compose.icons.TablerIcons.Checks,
contentDescription = null, contentDescription = null,
tint = if (messageStatus == MessageStatus.READ) { tint =
if (messageStatus == MessageStatus.READ) {
Color(0xFF4FC3F7) Color(0xFF4FC3F7)
} else { } else {
Color.White.copy(alpha = 0.7f) Color.White.copy(alpha = 0.7f)
@@ -1190,7 +1213,8 @@ fun AvatarAttachment(
// Определяем начальный статус (как в Desktop calcDownloadStatus для AVATAR) // Определяем начальный статус (как в Desktop calcDownloadStatus для AVATAR)
LaunchedEffect(attachment.id) { LaunchedEffect(attachment.id) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
downloadStatus = when { downloadStatus =
when {
// 1. Если blob уже есть в памяти → DOWNLOADED // 1. Если blob уже есть в памяти → DOWNLOADED
attachment.blob.isNotEmpty() -> { attachment.blob.isNotEmpty() -> {
Log.d(TAG, "📦 Avatar blob in memory for ${attachment.id}") Log.d(TAG, "📦 Avatar blob in memory for ${attachment.id}")
@@ -1204,8 +1228,11 @@ fun AvatarAttachment(
// 3. Есть UUID (download tag) → проверяем файловую систему // 3. Есть UUID (download tag) → проверяем файловую систему
// Desktop: readFile(`a/${md5(attachment.id + publicKey)}`) // Desktop: readFile(`a/${md5(attachment.id + publicKey)}`)
else -> { else -> {
val hasLocal = AvatarFileManager.hasAvatarByAttachmentId( val hasLocal =
context, attachment.id, senderPublicKey AvatarFileManager.hasAvatarByAttachmentId(
context,
attachment.id,
senderPublicKey
) )
if (hasLocal) { if (hasLocal) {
Log.d(TAG, "📦 Found local avatar file for ${attachment.id}") Log.d(TAG, "📦 Found local avatar file for ${attachment.id}")
@@ -1239,8 +1266,11 @@ fun AvatarAttachment(
} else { } else {
// 2. Читаем из файловой системы (как в Desktop getBlob) // 2. Читаем из файловой системы (как в Desktop getBlob)
Log.d(TAG, "🖼️ Loading avatar from local file") Log.d(TAG, "🖼️ Loading avatar from local file")
val localBlob = AvatarFileManager.readAvatarByAttachmentId( val localBlob =
context, attachment.id, senderPublicKey AvatarFileManager.readAvatarByAttachmentId(
context,
attachment.id,
senderPublicKey
) )
if (localBlob != null) { if (localBlob != null) {
avatarBitmap = base64ToBitmap(localBlob) avatarBitmap = base64ToBitmap(localBlob)
@@ -1278,13 +1308,16 @@ fun AvatarAttachment(
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop) // КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
// Сначала расшифровываем его, получаем raw bytes // Сначала расшифровываем его, получаем raw bytes
Log.d(TAG, "🔑 Decrypting ChaCha key from sender...") 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") Log.d(TAG, "🔑 ChaCha key decrypted: ${decryptedKeyAndNonce.size} bytes")
// Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует bytes в password // Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует
// bytes в password
Log.d(TAG, "🔓 Decrypting avatar blob with PBKDF2...") Log.d(TAG, "🔓 Decrypting avatar blob with PBKDF2...")
val decryptStartTime = System.currentTimeMillis() val decryptStartTime = System.currentTimeMillis()
val decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey( val decrypted =
MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent, encryptedContent,
decryptedKeyAndNonce decryptedKeyAndNonce
) )
@@ -1296,12 +1329,16 @@ fun AvatarAttachment(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Log.d(TAG, "🖼️ Converting to bitmap...") Log.d(TAG, "🖼️ Converting to bitmap...")
avatarBitmap = base64ToBitmap(decrypted) 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) // 💾 Сохраняем в файловую систему по attachment.id (как в Desktop)
// Desktop: writeFile(`a/${md5(attachment.id + publicKey)}`, encrypted) // Desktop: writeFile(`a/${md5(attachment.id + publicKey)}`, encrypted)
Log.d(TAG, "💾 Saving avatar to file system by attachment ID...") Log.d(TAG, "💾 Saving avatar to file system by attachment ID...")
val path = AvatarFileManager.saveAvatarByAttachmentId( val path =
AvatarFileManager.saveAvatarByAttachmentId(
context = context, context = context,
base64Image = decrypted, base64Image = decrypted,
attachmentId = attachment.id, attachmentId = attachment.id,
@@ -1310,12 +1347,20 @@ fun AvatarAttachment(
Log.d(TAG, "💾 Avatar saved to: $path") Log.d(TAG, "💾 Avatar saved to: $path")
} }
// Сохраняем аватар в репозиторий (для UI обновления) // Сохраняем аватар в репозиторий (для UI обновления)
// Если это исходящее сообщение с аватаром, сохраняем для текущего пользователя // Если это исходящее сообщение с аватаром, сохраняем для текущего
val targetPublicKey = if (isOutgoing && currentUserPublicKey.isNotEmpty()) { // пользователя
Log.d(TAG, "💾 Saving avatar to repository for CURRENT user ${currentUserPublicKey.take(16)}...") val targetPublicKey =
if (isOutgoing && currentUserPublicKey.isNotEmpty()) {
Log.d(
TAG,
"💾 Saving avatar to repository for CURRENT user ${currentUserPublicKey.take(16)}..."
)
currentUserPublicKey currentUserPublicKey
} else { } else {
Log.d(TAG, "💾 Saving avatar to repository for SENDER ${senderPublicKey.take(16)}...") Log.d(
TAG,
"💾 Saving avatar to repository for SENDER ${senderPublicKey.take(16)}..."
)
senderPublicKey senderPublicKey
} }
@@ -1324,12 +1369,18 @@ fun AvatarAttachment(
try { try {
Log.d(TAG, "📤 Calling avatarRepository.saveAvatar()...") Log.d(TAG, "📤 Calling avatarRepository.saveAvatar()...")
avatarRepository.saveAvatar(targetPublicKey, decrypted) 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) { } catch (e: Exception) {
Log.e(TAG, "❌ Failed to save avatar to repository: ${e.message}", e) Log.e(TAG, "❌ Failed to save avatar to repository: ${e.message}", e)
} }
} else { } 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 downloadStatus = DownloadStatus.DOWNLOADED
@@ -1361,8 +1412,8 @@ fun AvatarAttachment(
// Telegram-style avatar attachment с временем и статусом // Telegram-style avatar attachment с временем и статусом
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background( .background(
if (isOutgoing) { if (isOutgoing) {
@@ -1371,20 +1422,24 @@ fun AvatarAttachment(
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5) if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)
} }
) )
.clickable(enabled = downloadStatus == DownloadStatus.NOT_DOWNLOADED || downloadStatus == DownloadStatus.ERROR) { .clickable(
download() enabled =
} downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
downloadStatus == DownloadStatus.ERROR
) { download() }
.padding(12.dp), .padding(12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Avatar preview - круглое изображение // Avatar preview - круглое изображение
Box( Box(
modifier = Modifier modifier =
.size(56.dp) Modifier.size(56.dp)
.clip(CircleShape) .clip(CircleShape)
.background( .background(
if (isOutgoing) Color.White.copy(0.15f) if (isOutgoing) Color.White.copy(0.15f)
else (if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)) else
(if (isDarkTheme) Color(0xFF3A3A3A)
else Color(0xFFE8E8E8))
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@@ -1405,7 +1460,8 @@ fun AvatarAttachment(
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} }
downloadStatus == DownloadStatus.DOWNLOADING || downloadStatus == DownloadStatus.DECRYPTING -> { downloadStatus == DownloadStatus.DOWNLOADING ||
downloadStatus == DownloadStatus.DECRYPTING -> {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
color = if (isOutgoing) Color.White else PrimaryBlue, color = if (isOutgoing) Color.White else PrimaryBlue,
@@ -1416,7 +1472,8 @@ fun AvatarAttachment(
Icon( Icon(
Icons.Default.Person, Icons.Default.Person,
contentDescription = null, contentDescription = null,
tint = if (isOutgoing) Color.White.copy(0.6f) tint =
if (isOutgoing) Color.White.copy(0.6f)
else (if (isDarkTheme) Color.White.copy(0.5f) else Color.Gray), else (if (isDarkTheme) Color.White.copy(0.5f) else Color.Gray),
modifier = Modifier.size(28.dp) modifier = Modifier.size(28.dp)
) )
@@ -1426,9 +1483,8 @@ fun AvatarAttachment(
// Иконка скачивания поверх аватара если нужно скачать // Иконка скачивания поверх аватара если нужно скачать
if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) { if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) {
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)),
.background(Color.Black.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
@@ -1451,13 +1507,16 @@ fun AvatarAttachment(
text = "Profile Photo", text = "Profile Photo",
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = if (isOutgoing) Color.White else (if (isDarkTheme) Color.White else Color.Black) color =
if (isOutgoing) Color.White
else (if (isDarkTheme) Color.White else Color.Black)
) )
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Icon( Icon(
Icons.Default.Lock, Icons.Default.Lock,
contentDescription = "End-to-end encrypted", contentDescription = "End-to-end encrypted",
tint = if (isOutgoing) Color.White.copy(0.6f) tint =
if (isOutgoing) Color.White.copy(0.6f)
else (if (isDarkTheme) Color.White.copy(0.4f) else Color.Gray), else (if (isDarkTheme) Color.White.copy(0.4f) else Color.Gray),
modifier = Modifier.size(12.dp) modifier = Modifier.size(12.dp)
) )
@@ -1467,7 +1526,8 @@ fun AvatarAttachment(
// Описание статуса // Описание статуса
Text( Text(
text = when (downloadStatus) { text =
when (downloadStatus) {
DownloadStatus.DOWNLOADING -> "Downloading..." DownloadStatus.DOWNLOADING -> "Downloading..."
DownloadStatus.DECRYPTING -> "Decrypting..." DownloadStatus.DECRYPTING -> "Decrypting..."
DownloadStatus.ERROR -> "Tap to retry" DownloadStatus.ERROR -> "Tap to retry"
@@ -1475,7 +1535,8 @@ fun AvatarAttachment(
else -> "Tap to download" else -> "Tap to download"
}, },
fontSize = 13.sp, fontSize = 13.sp,
color = if (isOutgoing) Color.White.copy(alpha = 0.7f) color =
if (isOutgoing) Color.White.copy(alpha = 0.7f)
else (if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray) else (if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray)
) )
@@ -1489,8 +1550,11 @@ fun AvatarAttachment(
Text( Text(
text = timeFormat.format(timestamp), text = timeFormat.format(timestamp),
fontSize = 11.sp, fontSize = 11.sp,
color = if (isOutgoing) Color.White.copy(alpha = 0.6f) color =
else (if (isDarkTheme) Color.White.copy(alpha = 0.4f) else Color.Gray) if (isOutgoing) Color.White.copy(alpha = 0.6f)
else
(if (isDarkTheme) Color.White.copy(alpha = 0.4f)
else Color.Gray)
) )
// Галочки статуса для исходящих сообщений // Галочки статуса для исходящих сообщений
@@ -1557,20 +1621,19 @@ fun AvatarAttachment(
// Helper functions // 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 тегом для скачивания * Проверка является ли preview UUID тегом для скачивания Как в desktop:
* Как в desktop: attachment.preview.split("::")[0].match(uuidRegex) * attachment.preview.split("::")[0].match(uuidRegex)
*/ */
private fun isDownloadTag(preview: String): Boolean { private fun isDownloadTag(preview: String): Boolean {
val firstPart = preview.split("::").firstOrNull() ?: return false val firstPart = preview.split("::").firstOrNull() ?: return false
return uuidRegex.matches(firstPart) return uuidRegex.matches(firstPart)
} }
/** /** Получить download tag из preview */
* Получить download tag из preview
*/
private fun getDownloadTag(preview: String): String { private fun getDownloadTag(preview: String): String {
val parts = preview.split("::") val parts = preview.split("::")
if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) { if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) {
@@ -1579,10 +1642,7 @@ private fun getDownloadTag(preview: String): String {
return "" 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 { private fun getPreview(preview: String): String {
val parts = preview.split("::") val parts = preview.split("::")
if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) { if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) {
@@ -1591,10 +1651,7 @@ private fun getPreview(preview: String): String {
return preview return preview
} }
/** /** Парсинг preview для файлов Формат: "UUID::filesize::filename" или "filesize::filename" */
* Парсинг preview для файлов
* Формат: "UUID::filesize::filename" или "filesize::filename"
*/
private fun parseFilePreview(preview: String): Pair<Long, String> { private fun parseFilePreview(preview: String): Pair<Long, String> {
val parts = preview.split("::") val parts = preview.split("::")
return when { return when {
@@ -1617,12 +1674,11 @@ private fun parseFilePreview(preview: String): Pair<Long, String> {
} }
} }
/** /** Декодирование base64 в Bitmap */
* Декодирование base64 в Bitmap
*/
private fun base64ToBitmap(base64: String): Bitmap? { private fun base64ToBitmap(base64: String): Bitmap? {
return try { return try {
val cleanBase64 = if (base64.contains(",")) { val cleanBase64 =
if (base64.contains(",")) {
base64.substringAfter(",") base64.substringAfter(",")
} else { } else {
base64 base64
@@ -1635,9 +1691,7 @@ private fun base64ToBitmap(base64: String): Bitmap? {
} }
} }
/** /** Форматирование размера файла */
* Форматирование размера файла
*/
private fun formatFileSize(bytes: Long): String { private fun formatFileSize(bytes: Long): String {
return when { return when {
bytes < 1024 -> "$bytes B" bytes < 1024 -> "$bytes B"

View File

@@ -5,15 +5,12 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import android.widget.ImageView import android.widget.ImageView
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.FileProvider
import com.yalantis.ucrop.UCrop
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.ExperimentalFoundationApi 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView 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 compose.icons.tablericons.* import compose.icons.tablericons.*
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import ja.burhanrashid52.photoeditor.PhotoEditor import ja.burhanrashid52.photoeditor.PhotoEditor
import ja.burhanrashid52.photoeditor.PhotoEditorView import ja.burhanrashid52.photoeditor.PhotoEditorView
import ja.burhanrashid52.photoeditor.SaveSettings import ja.burhanrashid52.photoeditor.SaveSettings
import java.io.File
import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
import kotlin.coroutines.resume
private const val TAG = "ImageEditorScreen" private const val TAG = "ImageEditorScreen"
/** /** Available editing tools */
* Available editing tools
*/
enum class EditorTool { enum class EditorTool {
NONE, NONE,
DRAW, DRAW,
@@ -68,10 +64,9 @@ enum class EditorTool {
ROTATE ROTATE
} }
/** /** Drawing colors */
* Drawing colors val drawingColors =
*/ listOf(
val drawingColors = listOf(
Color.White, Color.White,
Color.Black, Color.Black,
Color.Red, Color.Red,
@@ -83,9 +78,7 @@ val drawingColors = listOf(
Color(0xFFFF2D55), // Pink Color(0xFFFF2D55), // Pink
) )
/** /** Telegram-style image editor screen with caption input */
* Telegram-style image editor screen with caption input
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ImageEditorScreen( fun ImageEditorScreen(
@@ -123,7 +116,8 @@ fun ImageEditorScreen(
var photoEditorView by remember { mutableStateOf<PhotoEditorView?>(null) } var photoEditorView by remember { mutableStateOf<PhotoEditorView?>(null) }
// UCrop launcher // UCrop launcher
val cropLauncher = rememberLauncherForActivityResult( val cropLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult() contract = ActivityResultContracts.StartActivityForResult()
) { result -> ) { result ->
if (result.resultCode == Activity.RESULT_OK) { if (result.resultCode == Activity.RESULT_OK) {
@@ -137,15 +131,9 @@ fun ImageEditorScreen(
} }
} }
BackHandler { BackHandler { onDismiss() }
onDismiss()
}
Box( Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
// Photo Editor View - FULLSCREEN edge-to-edge // Photo Editor View - FULLSCREEN edge-to-edge
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
@@ -156,7 +144,8 @@ fun ImageEditorScreen(
source.scaleType = ImageView.ScaleType.CENTER_CROP source.scaleType = ImageView.ScaleType.CENTER_CROP
// Build PhotoEditor // Build PhotoEditor
photoEditor = PhotoEditor.Builder(ctx, this) photoEditor =
PhotoEditor.Builder(ctx, this)
.setPinchTextScalable(true) .setPinchTextScalable(true)
.setClipSourceImage(true) .setClipSourceImage(true)
.build() .build()
@@ -173,8 +162,8 @@ fun ImageEditorScreen(
// Top toolbar - OVERLAY (поверх фото) // Top toolbar - OVERLAY (поверх фото)
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.statusBarsPadding() .statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -213,8 +202,8 @@ fun ImageEditorScreen(
// Bottom section - OVERLAY (Caption + Tools) // Bottom section - OVERLAY (Caption + Tools)
Column( Column(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.imePadding() .imePadding()
.navigationBarsPadding() .navigationBarsPadding()
@@ -314,7 +303,9 @@ fun ImageEditorScreen(
launchCrop(context, currentImageUri, cropLauncher) launchCrop(context, currentImageUri, cropLauncher)
} }
EditorTool.ROTATE -> { EditorTool.ROTATE -> {
currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else tool currentTool =
if (currentTool == EditorTool.ROTATE) EditorTool.NONE
else tool
showColorPicker = false showColorPicker = false
showBrushSizeSlider = false showBrushSizeSlider = false
photoEditor?.setBrushDrawingMode(false) photoEditor?.setBrushDrawingMode(false)
@@ -331,14 +322,12 @@ fun ImageEditorScreen(
showBrushSizeSlider = !showBrushSizeSlider showBrushSizeSlider = !showBrushSizeSlider
showColorPicker = false showColorPicker = false
}, },
onEraserClick = { onEraserClick = { photoEditor?.brushEraser() },
photoEditor?.brushEraser() onCropClick = { launchCrop(context, currentImageUri, cropLauncher) },
},
onCropClick = {
launchCrop(context, currentImageUri, cropLauncher)
},
onRotateClick = { onRotateClick = {
currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else EditorTool.ROTATE currentTool =
if (currentTool == EditorTool.ROTATE) EditorTool.NONE
else EditorTool.ROTATE
showColorPicker = false showColorPicker = false
showBrushSizeSlider = false showBrushSizeSlider = false
photoEditor?.setBrushDrawingMode(false) photoEditor?.setBrushDrawingMode(false)
@@ -348,9 +337,7 @@ fun ImageEditorScreen(
} }
} }
/** /** 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun TelegramCaptionInputBar( private fun TelegramCaptionInputBar(
@@ -360,26 +347,24 @@ private fun TelegramCaptionInputBar(
onSend: () -> Unit onSend: () -> Unit
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
// Caption text field - Beautiful transparent style // Caption text field - Beautiful transparent style
Box( Box(
modifier = Modifier modifier =
.weight(1f) Modifier.weight(1f)
.clip(RoundedCornerShape(22.dp)) .clip(RoundedCornerShape(22.dp))
.background(Color.White.copy(alpha = 0.15f)) .background(Color.White.copy(alpha = 0.15f))
) { ) {
BasicTextField( BasicTextField(
value = caption, value = caption,
onValueChange = onCaptionChange, onValueChange = onCaptionChange,
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
.padding(horizontal = 16.dp, vertical = 12.dp), textStyle =
textStyle = androidx.compose.ui.text.TextStyle( androidx.compose.ui.text.TextStyle(
color = Color.White, color = Color.White,
fontSize = 16.sp fontSize = 16.sp
), ),
@@ -401,11 +386,10 @@ private fun TelegramCaptionInputBar(
// Send button - Blue circle like Telegram // Send button - Blue circle like Telegram
Box( Box(
modifier = Modifier modifier =
.size(48.dp) Modifier.size(48.dp).clip(CircleShape).background(PrimaryBlue).clickable(
.clip(CircleShape) enabled = !isSaving
.background(PrimaryBlue) ) { onSend() },
.clickable(enabled = !isSaving) { onSend() },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (isSaving) { if (isSaving) {
@@ -419,8 +403,8 @@ private fun TelegramCaptionInputBar(
imageVector = TablerIcons.Send, imageVector = TablerIcons.Send,
contentDescription = "Send", contentDescription = "Send",
tint = Color.White, tint = Color.White,
modifier = Modifier modifier =
.size(22.dp) Modifier.size(22.dp)
.offset(x = 1.dp) // Slight offset for better centering .offset(x = 1.dp) // Slight offset for better centering
) )
} }
@@ -428,9 +412,7 @@ private fun TelegramCaptionInputBar(
} }
} }
/** /** Caption input bar with send button (like Telegram) - OLD VERSION */
* Caption input bar with send button (like Telegram) - OLD VERSION
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun CaptionInputBar( private fun CaptionInputBar(
@@ -439,13 +421,10 @@ private fun CaptionInputBar(
isSaving: Boolean, isSaving: Boolean,
onSend: () -> Unit onSend: () -> Unit
) { ) {
Surface( Surface(color = Color(0xFF1C1C1E), modifier = Modifier.fillMaxWidth()) {
color = Color(0xFF1C1C1E),
modifier = Modifier.fillMaxWidth()
) {
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.imePadding() .imePadding()
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -457,13 +436,11 @@ private fun CaptionInputBar(
onValueChange = onCaptionChange, onValueChange = onCaptionChange,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
placeholder = { placeholder = {
Text( Text("Add a caption...", color = Color.White.copy(alpha = 0.5f))
"Add a caption...",
color = Color.White.copy(alpha = 0.5f)
)
}, },
maxLines = 3, maxLines = 3,
colors = TextFieldDefaults.outlinedTextFieldColors( colors =
TextFieldDefaults.outlinedTextFieldColors(
focusedTextColor = Color.White, focusedTextColor = Color.White,
unfocusedTextColor = Color.White, unfocusedTextColor = Color.White,
cursorColor = PrimaryBlue, cursorColor = PrimaryBlue,
@@ -508,13 +485,10 @@ private fun BottomToolbar(
onCropClick: () -> Unit, onCropClick: () -> Unit,
onRotateClick: () -> Unit onRotateClick: () -> Unit
) { ) {
Surface( Surface(color = Color(0xFF1C1C1E), modifier = Modifier.fillMaxWidth()) {
color = Color(0xFF1C1C1E),
modifier = Modifier.fillMaxWidth()
) {
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 12.dp)
.navigationBarsPadding(), .navigationBarsPadding(),
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
@@ -584,8 +558,8 @@ private fun ToolButton(
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier modifier =
.clip(RoundedCornerShape(8.dp)) Modifier.clip(RoundedCornerShape(8.dp))
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, indication = null,
@@ -609,18 +583,10 @@ private fun ToolButton(
} }
@Composable @Composable
private fun ColorPickerBar( private fun ColorPickerBar(selectedColor: Color, onColorSelected: (Color) -> Unit) {
selectedColor: Color, Surface(color = Color(0xFF2C2C2E), modifier = Modifier.fillMaxWidth()) {
onColorSelected: (Color) -> Unit
) {
Surface(
color = Color(0xFF2C2C2E),
modifier = Modifier.fillMaxWidth()
) {
LazyRow( LazyRow(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(horizontal = 8.dp) contentPadding = PaddingValues(horizontal = 8.dp)
) { ) {
@@ -636,21 +602,21 @@ private fun ColorPickerBar(
} }
@Composable @Composable
private fun ColorButton( private fun ColorButton(color: Color, isSelected: Boolean, onClick: () -> Unit) {
color: Color,
isSelected: Boolean,
onClick: () -> Unit
) {
Box( Box(
modifier = Modifier modifier =
.size(32.dp) Modifier.size(32.dp)
.clip(CircleShape) .clip(CircleShape)
.background(color) .background(color)
.then( .then(
if (isSelected) { if (isSelected) {
Modifier.border(3.dp, Color.White, CircleShape) Modifier.border(3.dp, Color.White, CircleShape)
} else { } else {
Modifier.border(1.dp, Color.White.copy(alpha = 0.3f), CircleShape) Modifier.border(
1.dp,
Color.White.copy(alpha = 0.3f),
CircleShape
)
} }
) )
.clickable(onClick = onClick), .clickable(onClick = onClick),
@@ -673,52 +639,34 @@ private fun BrushSizeBar(
onBrushSizeChanged: (Float) -> Unit, onBrushSizeChanged: (Float) -> Unit,
selectedColor: Color selectedColor: Color
) { ) {
Surface( Surface(color = Color(0xFF2C2C2E), modifier = Modifier.fillMaxWidth()) {
color = Color(0xFF2C2C2E),
modifier = Modifier.fillMaxWidth()
) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Min indicator // Min indicator
Box( Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(selectedColor))
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(selectedColor)
)
// Slider // Slider
Slider( Slider(
value = brushSize, value = brushSize,
onValueChange = onBrushSizeChanged, onValueChange = onBrushSizeChanged,
valueRange = 5f..50f, valueRange = 5f..50f,
modifier = Modifier modifier = Modifier.weight(1f).padding(horizontal = 16.dp),
.weight(1f) colors =
.padding(horizontal = 16.dp), SliderDefaults.colors(
colors = SliderDefaults.colors(
thumbColor = selectedColor, thumbColor = selectedColor,
activeTrackColor = selectedColor activeTrackColor = selectedColor
) )
) )
// Max indicator // Max indicator
Box( Box(modifier = Modifier.size(24.dp).clip(CircleShape).background(selectedColor))
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( private suspend fun saveEditedImage(
context: Context, context: Context,
photoEditor: PhotoEditor?, photoEditor: PhotoEditor?,
@@ -733,7 +681,8 @@ private suspend fun saveEditedImage(
try { try {
val file = File(context.cacheDir, "edited_${System.currentTimeMillis()}.png") val file = File(context.cacheDir, "edited_${System.currentTimeMillis()}.png")
val saveSettings = SaveSettings.Builder() val saveSettings =
SaveSettings.Builder()
.setClearViewsEnabled(false) .setClearViewsEnabled(false)
.setTransparencyEnabled(true) .setTransparencyEnabled(true)
.build() .build()
@@ -755,27 +704,25 @@ private suspend fun saveEditedImage(
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error saving image", e) Log.e(TAG, "Error saving image", e)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) { onResult(null) }
onResult(null)
}
} }
} }
} }
/** /** Save edited image synchronously using suspendCoroutine */
* Save edited image synchronously using suspendCoroutine private suspend fun saveEditedImageSync(context: Context, photoEditor: PhotoEditor?): Uri? {
*/
private suspend fun saveEditedImageSync(
context: Context,
photoEditor: PhotoEditor?
): Uri? {
if (photoEditor == null) return null if (photoEditor == null) return null
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val file = File(context.cacheDir, "edited_${System.currentTimeMillis()}_${(0..9999).random()}.png") val file =
File(
context.cacheDir,
"edited_${System.currentTimeMillis()}_${(0..9999).random()}.png"
)
val saveSettings = SaveSettings.Builder() val saveSettings =
SaveSettings.Builder()
.setClearViewsEnabled(false) .setClearViewsEnabled(false)
.setTransparencyEnabled(true) .setTransparencyEnabled(true)
.build() .build()
@@ -804,9 +751,7 @@ private suspend fun saveEditedImageSync(
} }
} }
/** /** Rotate/Flip options bar */
* Rotate/Flip options bar
*/
@Composable @Composable
private fun RotateOptionsBar( private fun RotateOptionsBar(
onRotateLeft: () -> Unit, onRotateLeft: () -> Unit,
@@ -814,14 +759,9 @@ private fun RotateOptionsBar(
onFlipHorizontal: () -> Unit, onFlipHorizontal: () -> Unit,
onFlipVertical: () -> Unit onFlipVertical: () -> Unit
) { ) {
Surface( Surface(color = Color(0xFF2C2C2E), modifier = Modifier.fillMaxWidth()) {
color = Color(0xFF2C2C2E),
modifier = Modifier.fillMaxWidth()
) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 12.dp),
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -860,9 +800,7 @@ private fun RotateOptionsBar(
} }
} }
/** /** Launch UCrop activity for image cropping */
* Launch UCrop activity for image cropping
*/
private fun launchCrop( private fun launchCrop(
context: Context, context: Context,
sourceUri: Uri, sourceUri: Uri,
@@ -872,7 +810,8 @@ private fun launchCrop(
val destinationFile = File(context.cacheDir, "cropped_${System.currentTimeMillis()}.png") val destinationFile = File(context.cacheDir, "cropped_${System.currentTimeMillis()}.png")
val destinationUri = Uri.fromFile(destinationFile) val destinationUri = Uri.fromFile(destinationFile)
val options = UCrop.Options().apply { val options =
UCrop.Options().apply {
setCompressionFormat(Bitmap.CompressFormat.PNG) setCompressionFormat(Bitmap.CompressFormat.PNG)
setCompressionQuality(100) setCompressionQuality(100)
setToolbarColor(android.graphics.Color.parseColor("#1C1C1E")) setToolbarColor(android.graphics.Color.parseColor("#1C1C1E"))
@@ -886,9 +825,7 @@ private fun launchCrop(
setHideBottomControls(false) setHideBottomControls(false)
} }
val intent = UCrop.of(sourceUri, destinationUri) val intent = UCrop.of(sourceUri, destinationUri).withOptions(options).getIntent(context)
.withOptions(options)
.getIntent(context)
launcher.launch(intent) launcher.launch(intent)
} catch (e: Exception) { } catch (e: Exception) {
@@ -896,17 +833,12 @@ private fun launchCrop(
} }
} }
/** /** Data class for image with caption */
* Data class for image with caption data class ImageWithCaption(val uri: Uri, var caption: String = "")
*/
data class ImageWithCaption(
val uri: Uri,
var caption: String = ""
)
/** /**
* Multi-image editor screen with swipe (like Telegram) * Multi-image editor screen with swipe (like Telegram) Позволяет свайпать между фотками и добавлять
* Позволяет свайпать между фотками и добавлять caption к каждой * caption к каждой
*/ */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
@@ -927,10 +859,7 @@ fun MultiImageEditorScreen(
} }
// Pager state // Pager state
val pagerState = rememberPagerState( val pagerState = rememberPagerState(initialPage = 0, pageCount = { imagesWithCaptions.size })
initialPage = 0,
pageCount = { imagesWithCaptions.size }
)
var isSaving by remember { mutableStateOf(false) } var isSaving by remember { mutableStateOf(false) }
@@ -949,7 +878,8 @@ fun MultiImageEditorScreen(
val photoEditorViews = remember { mutableStateMapOf<Int, PhotoEditorView?>() } val photoEditorViews = remember { mutableStateMapOf<Int, PhotoEditorView?>() }
// Crop launcher // Crop launcher
val cropLauncher = rememberLauncherForActivityResult( val cropLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult() contract = ActivityResultContracts.StartActivityForResult()
) { result -> ) { result ->
if (result.resultCode == Activity.RESULT_OK) { if (result.resultCode == Activity.RESULT_OK) {
@@ -957,7 +887,8 @@ fun MultiImageEditorScreen(
UCrop.getOutput(data)?.let { croppedUri -> UCrop.getOutput(data)?.let { croppedUri ->
val currentPage = pagerState.currentPage val currentPage = pagerState.currentPage
if (currentPage < imagesWithCaptions.size) { if (currentPage < imagesWithCaptions.size) {
imagesWithCaptions[currentPage] = imagesWithCaptions[currentPage].copy(uri = croppedUri) imagesWithCaptions[currentPage] =
imagesWithCaptions[currentPage].copy(uri = croppedUri)
} }
} }
} }
@@ -984,25 +915,16 @@ fun MultiImageEditorScreen(
} }
} }
BackHandler { BackHandler { onDismiss() }
onDismiss()
}
Box( Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
// Horizontal Pager для свайпа между фото // Horizontal Pager для свайпа между фото
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
userScrollEnabled = currentTool == EditorTool.NONE // Disable swipe when editing userScrollEnabled = currentTool == EditorTool.NONE // Disable swipe when editing
) { page -> ) { page ->
Box( Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
// PhotoEditorView for editing // PhotoEditorView for editing
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
@@ -1012,7 +934,10 @@ fun MultiImageEditorScreen(
// Load bitmap // Load bitmap
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
val inputStream = ctx.contentResolver.openInputStream(imagesWithCaptions[page].uri) val inputStream =
ctx.contentResolver.openInputStream(
imagesWithCaptions[page].uri
)
val bitmap = BitmapFactory.decodeStream(inputStream) val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close() inputStream?.close()
@@ -1020,7 +945,8 @@ fun MultiImageEditorScreen(
source.setImageBitmap(bitmap) source.setImageBitmap(bitmap)
// Create PhotoEditor // Create PhotoEditor
val editor = PhotoEditor.Builder(ctx, this@apply) val editor =
PhotoEditor.Builder(ctx, this@apply)
.setPinchTextScalable(true) .setPinchTextScalable(true)
.build() .build()
photoEditors[page] = editor photoEditors[page] = editor
@@ -1038,7 +964,8 @@ fun MultiImageEditorScreen(
if (currentUri != null) { if (currentUri != null) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
val inputStream = context.contentResolver.openInputStream(currentUri) val inputStream =
context.contentResolver.openInputStream(currentUri)
val bitmap = BitmapFactory.decodeStream(inputStream) val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close() inputStream?.close()
@@ -1057,8 +984,8 @@ fun MultiImageEditorScreen(
// Top toolbar - OVERLAY // Top toolbar - OVERLAY
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.statusBarsPadding() .statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -1077,8 +1004,8 @@ fun MultiImageEditorScreen(
// Page indicator // Page indicator
if (imagesWithCaptions.size > 1) { if (imagesWithCaptions.size > 1) {
Box( Box(
modifier = Modifier modifier =
.clip(RoundedCornerShape(12.dp)) Modifier.clip(RoundedCornerShape(12.dp))
.background(Color.Black.copy(alpha = 0.5f)) .background(Color.Black.copy(alpha = 0.5f))
.padding(horizontal = 12.dp, vertical = 6.dp) .padding(horizontal = 12.dp, vertical = 6.dp)
) { ) {
@@ -1093,9 +1020,7 @@ fun MultiImageEditorScreen(
// Undo button (when drawing) // Undo button (when drawing)
if (currentTool == EditorTool.DRAW) { if (currentTool == EditorTool.DRAW) {
IconButton(onClick = { IconButton(onClick = { photoEditors[pagerState.currentPage]?.undo() }) {
photoEditors[pagerState.currentPage]?.undo()
}) {
Icon( Icon(
TablerIcons.ArrowBackUp, TablerIcons.ArrowBackUp,
contentDescription = "Undo", contentDescription = "Undo",
@@ -1110,12 +1035,7 @@ fun MultiImageEditorScreen(
} }
// Bottom section - Tools + Caption + Send // Bottom section - Tools + Caption + Send
Column( Column(modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter).imePadding()) {
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.imePadding()
) {
// Color picker bar (when drawing) // Color picker bar (when drawing)
AnimatedVisibility( AnimatedVisibility(
visible = showColorPicker && currentTool == EditorTool.DRAW, visible = showColorPicker && currentTool == EditorTool.DRAW,
@@ -1156,11 +1076,13 @@ fun MultiImageEditorScreen(
RotateOptionsBar( RotateOptionsBar(
onRotateLeft = { onRotateLeft = {
photoEditorViews[pagerState.currentPage]?.source?.rotation = photoEditorViews[pagerState.currentPage]?.source?.rotation =
(photoEditorViews[pagerState.currentPage]?.source?.rotation ?: 0f) - 90f (photoEditorViews[pagerState.currentPage]?.source?.rotation
?: 0f) - 90f
}, },
onRotateRight = { onRotateRight = {
photoEditorViews[pagerState.currentPage]?.source?.rotation = photoEditorViews[pagerState.currentPage]?.source?.rotation =
(photoEditorViews[pagerState.currentPage]?.source?.rotation ?: 0f) + 90f (photoEditorViews[pagerState.currentPage]?.source?.rotation
?: 0f) + 90f
}, },
onFlipHorizontal = { onFlipHorizontal = {
photoEditorViews[pagerState.currentPage]?.source?.let { imageView -> photoEditorViews[pagerState.currentPage]?.source?.let { imageView ->
@@ -1178,20 +1100,22 @@ fun MultiImageEditorScreen(
// Thumbnails strip (если больше 1 фото) // Thumbnails strip (если больше 1 фото)
if (imagesWithCaptions.size > 1 && currentTool == EditorTool.NONE) { if (imagesWithCaptions.size > 1 && currentTool == EditorTool.NONE) {
LazyRow( LazyRow(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items(imagesWithCaptions.size) { index -> items(imagesWithCaptions.size) { index ->
val isSelected = pagerState.currentPage == index val isSelected = pagerState.currentPage == index
Box( Box(
modifier = Modifier modifier =
.size(56.dp) Modifier.size(56.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.border( .border(
width = if (isSelected) 2.dp else 0.dp, width = if (isSelected) 2.dp else 0.dp,
color = if (isSelected) PrimaryBlue else Color.Transparent, color =
if (isSelected) PrimaryBlue
else Color.Transparent,
shape = RoundedCornerShape(8.dp) shape = RoundedCornerShape(8.dp)
) )
.clickable { .clickable {
@@ -1209,70 +1133,31 @@ fun MultiImageEditorScreen(
} }
} }
// Caption input bar (hidden during editing) // Send button (without caption input for multi-image)
AnimatedVisibility( AnimatedVisibility(
visible = currentTool == EditorTool.NONE, visible = currentTool == EditorTool.NONE,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut()
) { ) {
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.End
) { ) {
// Caption text field
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(22.dp))
.background(Color.White.copy(alpha = 0.15f))
) {
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()
}
}
)
}
// Send button // Send button
Box( Box(
modifier = Modifier modifier =
.size(48.dp) Modifier.size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background(PrimaryBlue) .background(PrimaryBlue)
.clickable(enabled = !isSaving) { .clickable(enabled = !isSaving) {
isSaving = true isSaving = true
// Save all edited images before sending // Save all edited images before sending
scope.launch { scope.launch {
val savedImages = mutableListOf<ImageWithCaption>() val savedImages =
mutableListOf<ImageWithCaption>()
for (i in imagesWithCaptions.indices) { for (i in imagesWithCaptions.indices) {
val editor = photoEditors[i] val editor = photoEditors[i]
@@ -1280,11 +1165,20 @@ fun MultiImageEditorScreen(
if (editor != null) { if (editor != null) {
// Save edited image // Save edited image
val savedUri = saveEditedImageSync(context, editor) val savedUri =
saveEditedImageSync(
context,
editor
)
if (savedUri != null) { if (savedUri != null) {
savedImages.add(originalImage.copy(uri = savedUri)) savedImages.add(
originalImage.copy(
uri = savedUri
)
)
} else { } else {
// Fallback to original if save fails // Fallback to original if save
// fails
savedImages.add(originalImage) savedImages.add(originalImage)
} }
} else { } else {
@@ -1309,9 +1203,7 @@ fun MultiImageEditorScreen(
imageVector = TablerIcons.Send, imageVector = TablerIcons.Send,
contentDescription = "Send", contentDescription = "Send",
tint = Color.White, tint = Color.White,
modifier = Modifier modifier = Modifier.size(22.dp).offset(x = 1.dp)
.size(22.dp)
.offset(x = 1.dp)
) )
} }
} }
@@ -1325,7 +1217,9 @@ fun MultiImageEditorScreen(
val currentEditor = photoEditors[pagerState.currentPage] val currentEditor = photoEditors[pagerState.currentPage]
when (tool) { when (tool) {
EditorTool.DRAW -> { EditorTool.DRAW -> {
currentTool = if (currentTool == EditorTool.DRAW) EditorTool.NONE else tool currentTool =
if (currentTool == EditorTool.DRAW) EditorTool.NONE
else tool
if (currentTool == EditorTool.DRAW) { if (currentTool == EditorTool.DRAW) {
currentEditor?.setBrushDrawingMode(true) currentEditor?.setBrushDrawingMode(true)
currentEditor?.brushColor = selectedColor.toArgb() currentEditor?.brushColor = selectedColor.toArgb()
@@ -1343,10 +1237,16 @@ fun MultiImageEditorScreen(
showBrushSizeSlider = false showBrushSizeSlider = false
currentEditor?.setBrushDrawingMode(false) currentEditor?.setBrushDrawingMode(false)
// Launch UCrop // Launch UCrop
launchCrop(context, imagesWithCaptions[pagerState.currentPage].uri, cropLauncher) launchCrop(
context,
imagesWithCaptions[pagerState.currentPage].uri,
cropLauncher
)
} }
EditorTool.ROTATE -> { EditorTool.ROTATE -> {
currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else tool currentTool =
if (currentTool == EditorTool.ROTATE) EditorTool.NONE
else tool
showColorPicker = false showColorPicker = false
showBrushSizeSlider = false showBrushSizeSlider = false
currentEditor?.setBrushDrawingMode(false) currentEditor?.setBrushDrawingMode(false)
@@ -1363,14 +1263,18 @@ fun MultiImageEditorScreen(
showBrushSizeSlider = !showBrushSizeSlider showBrushSizeSlider = !showBrushSizeSlider
showColorPicker = false showColorPicker = false
}, },
onEraserClick = { onEraserClick = { photoEditors[pagerState.currentPage]?.brushEraser() },
photoEditors[pagerState.currentPage]?.brushEraser()
},
onCropClick = { onCropClick = {
launchCrop(context, imagesWithCaptions[pagerState.currentPage].uri, cropLauncher) launchCrop(
context,
imagesWithCaptions[pagerState.currentPage].uri,
cropLauncher
)
}, },
onRotateClick = { onRotateClick = {
currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else EditorTool.ROTATE currentTool =
if (currentTool == EditorTool.ROTATE) EditorTool.NONE
else EditorTool.ROTATE
showColorPicker = false showColorPicker = false
showBrushSizeSlider = false showBrushSizeSlider = false
photoEditors[pagerState.currentPage]?.setBrushDrawingMode(false) photoEditors[pagerState.currentPage]?.setBrushDrawingMode(false)
@@ -1380,14 +1284,9 @@ fun MultiImageEditorScreen(
} }
} }
/** /** Simple async image loader composable */
* Simple async image loader composable
*/
@Composable @Composable
private fun AsyncImageLoader( private fun AsyncImageLoader(uri: Uri, modifier: Modifier = Modifier) {
uri: Uri,
modifier: Modifier = Modifier
) {
val context = LocalContext.current val context = LocalContext.current
var bitmap by remember(uri) { mutableStateOf<android.graphics.Bitmap?>(null) } var bitmap by remember(uri) { mutableStateOf<android.graphics.Bitmap?>(null) }
@@ -1410,13 +1309,9 @@ private fun AsyncImageLoader(
modifier = modifier, modifier = modifier,
contentScale = androidx.compose.ui.layout.ContentScale.Fit contentScale = androidx.compose.ui.layout.ContentScale.Fit
) )
} ?: Box( }
?: Box(
modifier = modifier.background(Color.DarkGray), modifier = modifier.background(Color.DarkGray),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) { CircularProgressIndicator(modifier = Modifier.size(32.dp), color = Color.White) }
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
color = Color.White
)
}
} }