Implement feature X to enhance user experience and fix bug Y in module Z
This commit is contained in:
@@ -709,7 +709,7 @@ fun ChatDetailScreen(
|
||||
contentDescription =
|
||||
"Back",
|
||||
tint =
|
||||
Color.White,
|
||||
if (isDarkTheme) Color.White else Color(0xFF007AFF),
|
||||
modifier =
|
||||
Modifier.size(
|
||||
32.dp
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user