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 =
"Back",
tint =
Color.White,
if (isDarkTheme) Color.White else Color(0xFF007AFF),
modifier =
Modifier.size(
32.dp

View File

@@ -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,8 +57,8 @@ enum class DownloadStatus {
}
/**
* Composable для отображения всех attachments в сообщении
* 🖼️ IMAGE attachments группируются в коллаж (как в Telegram)
* Composable для отображения всех attachments в сообщении 🖼️ IMAGE attachments группируются в
* коллаж (как в Telegram)
*/
@Composable
fun MessageAttachments(
@@ -90,12 +80,12 @@ fun MessageAttachments(
// Разделяем attachments по типам
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(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
// 🖼️ Коллаж для изображений (если больше 1)
if (imageAttachments.isNotEmpty()) {
ImageCollage(
@@ -140,15 +130,16 @@ fun MessageAttachments(
messageStatus = messageStatus
)
}
else -> { /* MESSAGES обрабатываются отдельно */ }
else -> {
/* MESSAGES обрабатываются отдельно */
}
}
}
}
}
/**
* 🖼️ Коллаж изображений в стиле Telegram
* Разные layout'ы в зависимости от количества фото:
* 🖼️ Коллаж изображений в стиле Telegram Разные layout'ы в зависимости от количества фото:
* - 1 фото: полная ширина
* - 2 фото: 2 колонки
* - 3 фото: 1 большое слева + 2 маленьких справа
@@ -176,16 +167,19 @@ fun ImageCollage(
val showOverlayOnLast = !hasCaption
// Закругление: если есть caption - только сверху, снизу прямые углы
val collageShape = if (hasCaption) {
RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 0.dp, bottomEnd = 0.dp)
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)
) {
Box(modifier = modifier.clip(collageShape)) {
when (count) {
1 -> {
// Одно фото - полная ширина
@@ -198,7 +192,7 @@ fun ImageCollage(
isDarkTheme = isDarkTheme,
timestamp = timestamp,
messageStatus = messageStatus,
showTimeOverlay = true,
showTimeOverlay = showOverlayOnLast,
onImageClick = onImageClick
)
}
@@ -219,7 +213,7 @@ fun ImageCollage(
isDarkTheme = isDarkTheme,
timestamp = timestamp,
messageStatus = messageStatus,
showTimeOverlay = index == count - 1,
showTimeOverlay = showOverlayOnLast && index == count - 1,
aspectRatio = 1f,
onImageClick = onImageClick
)
@@ -230,9 +224,7 @@ fun ImageCollage(
3 -> {
// Три фото: 1 большое слева + 2 маленьких справа
Row(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
modifier = Modifier.fillMaxWidth().height(200.dp),
horizontalArrangement = Arrangement.spacedBy(spacing)
) {
// Большое фото слева
@@ -281,7 +273,7 @@ fun ImageCollage(
isDarkTheme = isDarkTheme,
timestamp = timestamp,
messageStatus = messageStatus,
showTimeOverlay = true,
showTimeOverlay = showOverlayOnLast,
fillMaxSize = true,
onImageClick = onImageClick
)
@@ -359,7 +351,7 @@ fun ImageCollage(
isDarkTheme = isDarkTheme,
timestamp = timestamp,
messageStatus = messageStatus,
showTimeOverlay = true,
showTimeOverlay = showOverlayOnLast,
fillMaxSize = true,
onImageClick = onImageClick
)
@@ -429,16 +421,14 @@ fun ImageCollage(
isDarkTheme = isDarkTheme,
timestamp = timestamp,
messageStatus = messageStatus,
showTimeOverlay = isLastItem,
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)) }
}
}
}
@@ -483,7 +473,8 @@ fun ImageAttachment(
val downloadTag = getDownloadTag(attachment.preview)
// Анимация прогресса
val animatedProgress by animateFloatAsState(
val animatedProgress by
animateFloatAsState(
targetValue = downloadProgress,
animationSpec = tween(durationMillis = 300),
label = "progress"
@@ -493,7 +484,8 @@ fun ImageAttachment(
LaunchedEffect(attachment.id) {
// Определяем статус (логика из Desktop useAttachment.ts)
withContext(Dispatchers.IO) {
downloadStatus = when {
downloadStatus =
when {
// 1. Если blob уже есть в памяти → DOWNLOADED
attachment.blob.isNotEmpty() -> {
Log.d(TAG, "📦 Blob already in memory for ${attachment.id}")
@@ -506,9 +498,13 @@ fun ImageAttachment(
}
// 3. Есть UUID (download tag) → проверяем файловую систему
else -> {
// Проверяем есть ли файл локально (как в Desktop: readFile(`m/${md5hash}`))
val hasLocal = AttachmentFileManager.hasAttachment(
context, attachment.id, senderPublicKey
// Проверяем есть ли файл локально (как в Desktop:
// readFile(`m/${md5hash}`))
val hasLocal =
AttachmentFileManager.hasAttachment(
context,
attachment.id,
senderPublicKey
)
if (hasLocal) {
Log.d(TAG, "📦 Found local file for ${attachment.id}")
@@ -525,7 +521,10 @@ fun ImageAttachment(
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,7 +536,10 @@ 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
@@ -550,8 +552,12 @@ 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)
@@ -591,13 +597,16 @@ fun ImageAttachment(
// КРИТИЧНО: 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(
val decrypted =
MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent,
decryptedKeyAndNonce
)
@@ -610,11 +619,15 @@ fun ImageAttachment(
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(
val saved =
AttachmentFileManager.saveAttachment(
context = context,
blob = decrypted,
attachmentId = attachment.id,
@@ -652,7 +665,8 @@ fun ImageAttachment(
// Telegram-style image с blurhash placeholder и тонким бордером
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)
} else {
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 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% контейнера
if (fillMaxSize) {
null to null
@@ -716,15 +731,18 @@ fun ImageAttachment(
}
// Модификатор размера
val sizeModifier = when {
val sizeModifier =
when {
fillMaxSize -> Modifier.fillMaxSize()
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)
}
Box(
modifier = sizeModifier
modifier =
sizeModifier
.clip(RoundedCornerShape(if (fillMaxSize) 0.dp else 12.dp))
.background(Color.Transparent)
.clickable {
@@ -763,14 +781,21 @@ fun ImageAttachment(
else -> {
// Простой placeholder с приятным gradient фоном
Box(
modifier = Modifier
.fillMaxSize()
modifier =
Modifier.fillMaxSize()
.background(
Brush.verticalGradient(
colors = if (isDarkTheme)
listOf(Color(0xFF2A2A2A), Color(0xFF1A1A1A))
colors =
if (isDarkTheme)
listOf(
Color(0xFF2A2A2A),
Color(0xFF1A1A1A)
)
else
listOf(Color(0xFFE8E8E8), Color(0xFFD0D0D0))
listOf(
Color(0xFFE8E8E8),
Color(0xFFD0D0D0)
)
)
)
)
@@ -780,8 +805,8 @@ fun ImageAttachment(
// Время в правом нижнем углу (показываем только если showTimeOverlay = true)
if (showTimeOverlay) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
modifier =
Modifier.align(Alignment.BottomEnd)
.padding(8.dp)
.background(
Color.Black.copy(alpha = 0.5f),
@@ -844,17 +869,15 @@ fun ImageAttachment(
// Оверлей для статуса скачивания
if (downloadStatus != DownloadStatus.DOWNLOADED) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f)),
modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.3f)),
contentAlignment = Alignment.Center
) {
when (downloadStatus) {
DownloadStatus.NOT_DOWNLOADED -> {
// Кнопка скачивания
Box(
modifier = Modifier
.size(48.dp)
modifier =
Modifier.size(48.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center
@@ -870,8 +893,8 @@ fun ImageAttachment(
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
// Индикатор загрузки с прогрессом
Box(
modifier = Modifier
.size(48.dp)
modifier =
Modifier.size(48.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center
@@ -885,14 +908,14 @@ fun ImageAttachment(
}
DownloadStatus.ERROR -> {
// Ошибка с кнопкой повтора
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(48.dp)
modifier =
Modifier.size(48.dp)
.clip(CircleShape)
.background(Color(0xFFE53935).copy(alpha = 0.8f)),
.background(
Color(0xFFE53935).copy(alpha = 0.8f)
),
contentAlignment = Alignment.Center
) {
Icon(
@@ -903,11 +926,7 @@ fun ImageAttachment(
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
"Expired",
fontSize = 12.sp,
color = Color.White
)
Text("Expired", fontSize = 12.sp, color = Color.White)
}
}
else -> {}
@@ -917,9 +936,7 @@ fun ImageAttachment(
}
}
/**
* File attachment - Telegram style
*/
/** File attachment - Telegram style */
@Composable
fun FileAttachment(
attachment: MessageAttachment,
@@ -940,14 +957,16 @@ fun FileAttachment(
val (fileSize, fileName) = parseFilePreview(preview)
// Анимация прогресса
val animatedProgress by animateFloatAsState(
val animatedProgress by
animateFloatAsState(
targetValue = downloadProgress,
animationSpec = tween(durationMillis = 300),
label = "progress"
)
LaunchedEffect(attachment.id) {
downloadStatus = if (isDownloadTag(preview)) {
downloadStatus =
if (isDownloadTag(preview)) {
DownloadStatus.NOT_DOWNLOADED
} else {
DownloadStatus.DOWNLOADED
@@ -968,9 +987,11 @@ fun FileAttachment(
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
// Сначала расшифровываем его, получаем raw bytes
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
val decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
val decrypted =
MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent,
decryptedKeyAndNonce
)
@@ -993,30 +1014,28 @@ 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()
}
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()
modifier =
Modifier.fillMaxSize()
.clip(CircleShape)
.background(
if (downloadStatus == DownloadStatus.ERROR) Color(0xFFE53935)
if (downloadStatus == DownloadStatus.ERROR)
Color(0xFFE53935)
else PrimaryBlue
),
contentAlignment = Alignment.Center
@@ -1073,7 +1092,9 @@ fun FileAttachment(
text = fileName,
fontSize = 14.sp,
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,
overflow = TextOverflow.Ellipsis
)
@@ -1082,14 +1103,16 @@ fun FileAttachment(
// Размер файла и тип
val fileExtension = fileName.substringAfterLast('.', "").uppercase()
Text(
text = when (downloadStatus) {
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 =
if (downloadStatus == DownloadStatus.ERROR) {
Color(0xFFE53935)
} else if (isOutgoing) {
Color.White.copy(alpha = 0.7f)
@@ -1103,9 +1126,8 @@ fun FileAttachment(
// Time and checkmarks (bottom-right overlay) for outgoing files
if (isOutgoing) {
Row(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = 6.dp, end = 4.dp),
modifier =
Modifier.align(Alignment.BottomEnd).padding(bottom = 6.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
@@ -1140,7 +1162,8 @@ fun FileAttachment(
Icon(
compose.icons.TablerIcons.Checks,
contentDescription = null,
tint = if (messageStatus == MessageStatus.READ) {
tint =
if (messageStatus == MessageStatus.READ) {
Color(0xFF4FC3F7)
} else {
Color.White.copy(alpha = 0.7f)
@@ -1190,7 +1213,8 @@ fun AvatarAttachment(
// Определяем начальный статус (как в Desktop calcDownloadStatus для AVATAR)
LaunchedEffect(attachment.id) {
withContext(Dispatchers.IO) {
downloadStatus = when {
downloadStatus =
when {
// 1. Если blob уже есть в памяти → DOWNLOADED
attachment.blob.isNotEmpty() -> {
Log.d(TAG, "📦 Avatar blob in memory for ${attachment.id}")
@@ -1204,8 +1228,11 @@ fun AvatarAttachment(
// 3. Есть UUID (download tag) → проверяем файловую систему
// Desktop: readFile(`a/${md5(attachment.id + publicKey)}`)
else -> {
val hasLocal = AvatarFileManager.hasAvatarByAttachmentId(
context, attachment.id, senderPublicKey
val hasLocal =
AvatarFileManager.hasAvatarByAttachmentId(
context,
attachment.id,
senderPublicKey
)
if (hasLocal) {
Log.d(TAG, "📦 Found local avatar file for ${attachment.id}")
@@ -1239,8 +1266,11 @@ 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)
@@ -1278,13 +1308,16 @@ fun AvatarAttachment(
// КРИТИЧНО: 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(
val decrypted =
MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent,
decryptedKeyAndNonce
)
@@ -1296,12 +1329,16 @@ fun AvatarAttachment(
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(
val path =
AvatarFileManager.saveAvatarByAttachmentId(
context = context,
base64Image = decrypted,
attachmentId = attachment.id,
@@ -1310,12 +1347,20 @@ fun AvatarAttachment(
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)}...")
// Если это исходящее сообщение с аватаром, сохраняем для текущего
// пользователя
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)}...")
Log.d(
TAG,
"💾 Saving avatar to repository for SENDER ${senderPublicKey.take(16)}..."
)
senderPublicKey
}
@@ -1324,12 +1369,18 @@ fun AvatarAttachment(
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
@@ -1361,8 +1412,8 @@ fun AvatarAttachment(
// Telegram-style avatar attachment с временем и статусом
Row(
modifier = Modifier
.fillMaxWidth()
modifier =
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(
if (isOutgoing) {
@@ -1371,20 +1422,24 @@ fun AvatarAttachment(
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)
}
)
.clickable(enabled = downloadStatus == DownloadStatus.NOT_DOWNLOADED || downloadStatus == DownloadStatus.ERROR) {
download()
}
.clickable(
enabled =
downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
downloadStatus == DownloadStatus.ERROR
) { download() }
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Avatar preview - круглое изображение
Box(
modifier = Modifier
.size(56.dp)
modifier =
Modifier.size(56.dp)
.clip(CircleShape)
.background(
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
) {
@@ -1405,7 +1460,8 @@ fun AvatarAttachment(
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,
@@ -1416,7 +1472,8 @@ fun AvatarAttachment(
Icon(
Icons.Default.Person,
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),
modifier = Modifier.size(28.dp)
)
@@ -1426,9 +1483,8 @@ fun AvatarAttachment(
// Иконка скачивания поверх аватара если нужно скачать
if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f)),
modifier =
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center
) {
Icon(
@@ -1451,13 +1507,16 @@ fun AvatarAttachment(
text = "Profile Photo",
fontSize = 15.sp,
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))
Icon(
Icons.Default.Lock,
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),
modifier = Modifier.size(12.dp)
)
@@ -1467,7 +1526,8 @@ fun AvatarAttachment(
// Описание статуса
Text(
text = when (downloadStatus) {
text =
when (downloadStatus) {
DownloadStatus.DOWNLOADING -> "Downloading..."
DownloadStatus.DECRYPTING -> "Decrypting..."
DownloadStatus.ERROR -> "Tap to retry"
@@ -1475,7 +1535,8 @@ fun AvatarAttachment(
else -> "Tap to download"
},
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)
)
@@ -1489,8 +1550,11 @@ fun AvatarAttachment(
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)
color =
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
// ================================
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<Long, String> {
val parts = preview.split("::")
return when {
@@ -1617,12 +1674,11 @@ private fun parseFilePreview(preview: String): Pair<Long, String> {
}
}
/**
* Декодирование base64 в Bitmap
*/
/** Декодирование base64 в Bitmap */
private fun base64ToBitmap(base64: String): Bitmap? {
return try {
val cleanBase64 = if (base64.contains(",")) {
val cleanBase64 =
if (base64.contains(",")) {
base64.substringAfter(",")
} else {
base64
@@ -1635,9 +1691,7 @@ private fun base64ToBitmap(base64: String): Bitmap? {
}
}
/**
* Форматирование размера файла
*/
/** Форматирование размера файла */
private fun formatFileSize(bytes: Long): String {
return when {
bytes < 1024 -> "$bytes B"

View File

@@ -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,10 +64,9 @@ enum class EditorTool {
ROTATE
}
/**
* Drawing colors
*/
val drawingColors = listOf(
/** Drawing colors */
val drawingColors =
listOf(
Color.White,
Color.Black,
Color.Red,
@@ -81,11 +76,9 @@ val drawingColors = listOf(
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(
@@ -123,7 +116,8 @@ fun ImageEditorScreen(
var photoEditorView by remember { mutableStateOf<PhotoEditorView?>(null) }
// UCrop launcher
val cropLauncher = rememberLauncherForActivityResult(
val cropLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
@@ -137,15 +131,9 @@ fun ImageEditorScreen(
}
}
BackHandler {
onDismiss()
}
BackHandler { onDismiss() }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
// Photo Editor View - FULLSCREEN edge-to-edge
AndroidView(
factory = { ctx ->
@@ -156,7 +144,8 @@ fun ImageEditorScreen(
source.scaleType = ImageView.ScaleType.CENTER_CROP
// Build PhotoEditor
photoEditor = PhotoEditor.Builder(ctx, this)
photoEditor =
PhotoEditor.Builder(ctx, this)
.setPinchTextScalable(true)
.setClipSourceImage(true)
.build()
@@ -173,8 +162,8 @@ fun ImageEditorScreen(
// Top toolbar - OVERLAY (поверх фото)
Row(
modifier = Modifier
.fillMaxWidth()
modifier =
Modifier.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -213,8 +202,8 @@ fun ImageEditorScreen(
// Bottom section - OVERLAY (Caption + Tools)
Column(
modifier = Modifier
.fillMaxWidth()
modifier =
Modifier.fillMaxWidth()
.align(Alignment.BottomCenter)
.imePadding()
.navigationBarsPadding()
@@ -314,7 +303,9 @@ fun ImageEditorScreen(
launchCrop(context, currentImageUri, cropLauncher)
}
EditorTool.ROTATE -> {
currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else tool
currentTool =
if (currentTool == EditorTool.ROTATE) EditorTool.NONE
else tool
showColorPicker = false
showBrushSizeSlider = false
photoEditor?.setBrushDrawingMode(false)
@@ -331,14 +322,12 @@ fun ImageEditorScreen(
showBrushSizeSlider = !showBrushSizeSlider
showColorPicker = false
},
onEraserClick = {
photoEditor?.brushEraser()
},
onCropClick = {
launchCrop(context, currentImageUri, cropLauncher)
},
onEraserClick = { photoEditor?.brushEraser() },
onCropClick = { launchCrop(context, currentImageUri, cropLauncher) },
onRotateClick = {
currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else EditorTool.ROTATE
currentTool =
if (currentTool == EditorTool.ROTATE) EditorTool.NONE
else EditorTool.ROTATE
showColorPicker = false
showBrushSizeSlider = 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)
@Composable
private fun TelegramCaptionInputBar(
@@ -360,26 +347,24 @@ private fun TelegramCaptionInputBar(
onSend: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.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)
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(
modifier =
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
textStyle =
androidx.compose.ui.text.TextStyle(
color = Color.White,
fontSize = 16.sp
),
@@ -401,11 +386,10 @@ private fun TelegramCaptionInputBar(
// Send button - Blue circle like Telegram
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(PrimaryBlue)
.clickable(enabled = !isSaving) { onSend() },
modifier =
Modifier.size(48.dp).clip(CircleShape).background(PrimaryBlue).clickable(
enabled = !isSaving
) { onSend() },
contentAlignment = Alignment.Center
) {
if (isSaving) {
@@ -419,8 +403,8 @@ private fun TelegramCaptionInputBar(
imageVector = TablerIcons.Send,
contentDescription = "Send",
tint = Color.White,
modifier = Modifier
.size(22.dp)
modifier =
Modifier.size(22.dp)
.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)
@Composable
private fun CaptionInputBar(
@@ -439,13 +421,10 @@ private fun CaptionInputBar(
isSaving: Boolean,
onSend: () -> Unit
) {
Surface(
color = Color(0xFF1C1C1E),
modifier = Modifier.fillMaxWidth()
) {
Surface(color = Color(0xFF1C1C1E), modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
modifier =
Modifier.fillMaxWidth()
.imePadding()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -457,13 +436,11 @@ private fun CaptionInputBar(
onValueChange = onCaptionChange,
modifier = Modifier.weight(1f),
placeholder = {
Text(
"Add a caption...",
color = Color.White.copy(alpha = 0.5f)
)
Text("Add a caption...", color = Color.White.copy(alpha = 0.5f))
},
maxLines = 3,
colors = TextFieldDefaults.outlinedTextFieldColors(
colors =
TextFieldDefaults.outlinedTextFieldColors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
cursorColor = PrimaryBlue,
@@ -508,13 +485,10 @@ private fun BottomToolbar(
onCropClick: () -> Unit,
onRotateClick: () -> Unit
) {
Surface(
color = Color(0xFF1C1C1E),
modifier = Modifier.fillMaxWidth()
) {
Surface(color = Color(0xFF1C1C1E), modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
modifier =
Modifier.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.navigationBarsPadding(),
horizontalArrangement = Arrangement.SpaceEvenly,
@@ -584,8 +558,8 @@ private fun ToolButton(
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
modifier =
Modifier.clip(RoundedCornerShape(8.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
@@ -609,18 +583,10 @@ private fun ToolButton(
}
@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),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(horizontal = 8.dp)
) {
@@ -636,21 +602,21 @@ 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)
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)
Modifier.border(
1.dp,
Color.White.copy(alpha = 0.3f),
CircleShape
)
}
)
.clickable(onClick = onClick),
@@ -673,52 +639,34 @@ private fun BrushSizeBar(
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),
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(
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?,
@@ -733,7 +681,8 @@ private suspend fun saveEditedImage(
try {
val file = File(context.cacheDir, "edited_${System.currentTimeMillis()}.png")
val saveSettings = SaveSettings.Builder()
val saveSettings =
SaveSettings.Builder()
.setClearViewsEnabled(false)
.setTransparencyEnabled(true)
.build()
@@ -755,27 +704,25 @@ private suspend fun saveEditedImage(
)
} catch (e: Exception) {
Log.e(TAG, "Error saving image", e)
withContext(Dispatchers.Main) {
onResult(null)
}
withContext(Dispatchers.Main) { onResult(null) }
}
}
}
/**
* Save edited image synchronously using suspendCoroutine
*/
private suspend fun saveEditedImageSync(
context: Context,
photoEditor: PhotoEditor?
): Uri? {
/** 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 file =
File(
context.cacheDir,
"edited_${System.currentTimeMillis()}_${(0..9999).random()}.png"
)
val saveSettings = SaveSettings.Builder()
val saveSettings =
SaveSettings.Builder()
.setClearViewsEnabled(false)
.setTransparencyEnabled(true)
.build()
@@ -804,9 +751,7 @@ private suspend fun saveEditedImageSync(
}
}
/**
* Rotate/Flip options bar
*/
/** Rotate/Flip options bar */
@Composable
private fun RotateOptionsBar(
onRotateLeft: () -> Unit,
@@ -814,14 +759,9 @@ private fun RotateOptionsBar(
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),
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
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(
context: Context,
sourceUri: Uri,
@@ -872,7 +810,8 @@ private fun launchCrop(
val destinationFile = File(context.cacheDir, "cropped_${System.currentTimeMillis()}.png")
val destinationUri = Uri.fromFile(destinationFile)
val options = UCrop.Options().apply {
val options =
UCrop.Options().apply {
setCompressionFormat(Bitmap.CompressFormat.PNG)
setCompressionQuality(100)
setToolbarColor(android.graphics.Color.parseColor("#1C1C1E"))
@@ -886,9 +825,7 @@ private fun launchCrop(
setHideBottomControls(false)
}
val intent = UCrop.of(sourceUri, destinationUri)
.withOptions(options)
.getIntent(context)
val intent = UCrop.of(sourceUri, destinationUri).withOptions(options).getIntent(context)
launcher.launch(intent)
} catch (e: Exception) {
@@ -896,17 +833,12 @@ private fun launchCrop(
}
}
/**
* 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
@@ -927,10 +859,7 @@ fun MultiImageEditorScreen(
}
// Pager state
val pagerState = rememberPagerState(
initialPage = 0,
pageCount = { imagesWithCaptions.size }
)
val pagerState = rememberPagerState(initialPage = 0, pageCount = { imagesWithCaptions.size })
var isSaving by remember { mutableStateOf(false) }
@@ -949,7 +878,8 @@ fun MultiImageEditorScreen(
val photoEditorViews = remember { mutableStateMapOf<Int, PhotoEditorView?>() }
// Crop launcher
val cropLauncher = rememberLauncherForActivityResult(
val cropLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
@@ -957,7 +887,8 @@ fun MultiImageEditorScreen(
UCrop.getOutput(data)?.let { croppedUri ->
val currentPage = pagerState.currentPage
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 {
onDismiss()
}
BackHandler { onDismiss() }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
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(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// PhotoEditorView for editing
AndroidView(
factory = { ctx ->
@@ -1012,7 +934,10 @@ fun MultiImageEditorScreen(
// Load bitmap
scope.launch(Dispatchers.IO) {
try {
val inputStream = ctx.contentResolver.openInputStream(imagesWithCaptions[page].uri)
val inputStream =
ctx.contentResolver.openInputStream(
imagesWithCaptions[page].uri
)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
@@ -1020,7 +945,8 @@ fun MultiImageEditorScreen(
source.setImageBitmap(bitmap)
// Create PhotoEditor
val editor = PhotoEditor.Builder(ctx, this@apply)
val editor =
PhotoEditor.Builder(ctx, this@apply)
.setPinchTextScalable(true)
.build()
photoEditors[page] = editor
@@ -1038,7 +964,8 @@ fun MultiImageEditorScreen(
if (currentUri != null) {
scope.launch(Dispatchers.IO) {
try {
val inputStream = context.contentResolver.openInputStream(currentUri)
val inputStream =
context.contentResolver.openInputStream(currentUri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
@@ -1057,8 +984,8 @@ fun MultiImageEditorScreen(
// Top toolbar - OVERLAY
Row(
modifier = Modifier
.fillMaxWidth()
modifier =
Modifier.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -1077,8 +1004,8 @@ fun MultiImageEditorScreen(
// Page indicator
if (imagesWithCaptions.size > 1) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
modifier =
Modifier.clip(RoundedCornerShape(12.dp))
.background(Color.Black.copy(alpha = 0.5f))
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
@@ -1093,9 +1020,7 @@ fun MultiImageEditorScreen(
// 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",
@@ -1110,12 +1035,7 @@ fun MultiImageEditorScreen(
}
// 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,
@@ -1156,11 +1076,13 @@ fun MultiImageEditorScreen(
RotateOptionsBar(
onRotateLeft = {
photoEditorViews[pagerState.currentPage]?.source?.rotation =
(photoEditorViews[pagerState.currentPage]?.source?.rotation ?: 0f) - 90f
(photoEditorViews[pagerState.currentPage]?.source?.rotation
?: 0f) - 90f
},
onRotateRight = {
photoEditorViews[pagerState.currentPage]?.source?.rotation =
(photoEditorViews[pagerState.currentPage]?.source?.rotation ?: 0f) + 90f
(photoEditorViews[pagerState.currentPage]?.source?.rotation
?: 0f) + 90f
},
onFlipHorizontal = {
photoEditorViews[pagerState.currentPage]?.source?.let { imageView ->
@@ -1178,20 +1100,22 @@ fun MultiImageEditorScreen(
// Thumbnails strip (если больше 1 фото)
if (imagesWithCaptions.size > 1 && currentTool == EditorTool.NONE) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
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)
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,
color =
if (isSelected) PrimaryBlue
else Color.Transparent,
shape = RoundedCornerShape(8.dp)
)
.clickable {
@@ -1209,70 +1133,31 @@ fun MultiImageEditorScreen(
}
}
// Caption input bar (hidden during editing)
// Send button (without caption input for multi-image)
AnimatedVisibility(
visible = currentTool == EditorTool.NONE,
enter = fadeIn(),
exit = fadeOut()
) {
Row(
modifier = Modifier
.fillMaxWidth()
modifier =
Modifier.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(10.dp)
verticalAlignment = Alignment.CenterVertically,
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
Box(
modifier = Modifier
.size(48.dp)
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<ImageWithCaption>()
val savedImages =
mutableListOf<ImageWithCaption>()
for (i in imagesWithCaptions.indices) {
val editor = photoEditors[i]
@@ -1280,11 +1165,20 @@ fun MultiImageEditorScreen(
if (editor != null) {
// Save edited image
val savedUri = saveEditedImageSync(context, editor)
val savedUri =
saveEditedImageSync(
context,
editor
)
if (savedUri != null) {
savedImages.add(originalImage.copy(uri = savedUri))
savedImages.add(
originalImage.copy(
uri = savedUri
)
)
} else {
// Fallback to original if save fails
// Fallback to original if save
// fails
savedImages.add(originalImage)
}
} else {
@@ -1309,9 +1203,7 @@ fun MultiImageEditorScreen(
imageVector = TablerIcons.Send,
contentDescription = "Send",
tint = Color.White,
modifier = Modifier
.size(22.dp)
.offset(x = 1.dp)
modifier = Modifier.size(22.dp).offset(x = 1.dp)
)
}
}
@@ -1325,7 +1217,9 @@ fun MultiImageEditorScreen(
val currentEditor = photoEditors[pagerState.currentPage]
when (tool) {
EditorTool.DRAW -> {
currentTool = if (currentTool == EditorTool.DRAW) EditorTool.NONE else tool
currentTool =
if (currentTool == EditorTool.DRAW) EditorTool.NONE
else tool
if (currentTool == EditorTool.DRAW) {
currentEditor?.setBrushDrawingMode(true)
currentEditor?.brushColor = selectedColor.toArgb()
@@ -1343,10 +1237,16 @@ fun MultiImageEditorScreen(
showBrushSizeSlider = false
currentEditor?.setBrushDrawingMode(false)
// Launch UCrop
launchCrop(context, imagesWithCaptions[pagerState.currentPage].uri, cropLauncher)
launchCrop(
context,
imagesWithCaptions[pagerState.currentPage].uri,
cropLauncher
)
}
EditorTool.ROTATE -> {
currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else tool
currentTool =
if (currentTool == EditorTool.ROTATE) EditorTool.NONE
else tool
showColorPicker = false
showBrushSizeSlider = false
currentEditor?.setBrushDrawingMode(false)
@@ -1363,14 +1263,18 @@ fun MultiImageEditorScreen(
showBrushSizeSlider = !showBrushSizeSlider
showColorPicker = false
},
onEraserClick = {
photoEditors[pagerState.currentPage]?.brushEraser()
},
onEraserClick = { photoEditors[pagerState.currentPage]?.brushEraser() },
onCropClick = {
launchCrop(context, imagesWithCaptions[pagerState.currentPage].uri, cropLauncher)
launchCrop(
context,
imagesWithCaptions[pagerState.currentPage].uri,
cropLauncher
)
},
onRotateClick = {
currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else EditorTool.ROTATE
currentTool =
if (currentTool == EditorTool.ROTATE) EditorTool.NONE
else EditorTool.ROTATE
showColorPicker = false
showBrushSizeSlider = false
photoEditors[pagerState.currentPage]?.setBrushDrawingMode(false)
@@ -1380,14 +1284,9 @@ fun MultiImageEditorScreen(
}
}
/**
* 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<android.graphics.Bitmap?>(null) }
@@ -1410,13 +1309,9 @@ private fun AsyncImageLoader(
modifier = modifier,
contentScale = androidx.compose.ui.layout.ContentScale.Fit
)
} ?: Box(
}
?: Box(
modifier = modifier.background(Color.DarkGray),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
color = Color.White
)
}
) { CircularProgressIndicator(modifier = Modifier.size(32.dp), color = Color.White) }
}