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 =
|
contentDescription =
|
||||||
"Back",
|
"Back",
|
||||||
tint =
|
tint =
|
||||||
Color.White,
|
if (isDarkTheme) Color.White else Color(0xFF007AFF),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.size(
|
Modifier.size(
|
||||||
32.dp
|
32.dp
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
package com.rosetta.messenger.ui.chats.components
|
package com.rosetta.messenger.ui.chats.components
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
@@ -25,38 +22,31 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
|
||||||
import compose.icons.TablerIcons
|
|
||||||
import compose.icons.tablericons.*
|
|
||||||
import com.rosetta.messenger.crypto.CryptoManager
|
|
||||||
import com.rosetta.messenger.crypto.MessageCrypto
|
import com.rosetta.messenger.crypto.MessageCrypto
|
||||||
import com.rosetta.messenger.network.AttachmentType
|
import com.rosetta.messenger.network.AttachmentType
|
||||||
import com.rosetta.messenger.network.MessageAttachment
|
import com.rosetta.messenger.network.MessageAttachment
|
||||||
import com.rosetta.messenger.network.TransportManager
|
import com.rosetta.messenger.network.TransportManager
|
||||||
import com.rosetta.messenger.R
|
|
||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.chats.models.MessageStatus
|
import com.rosetta.messenger.ui.chats.models.MessageStatus
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||||
import com.rosetta.messenger.utils.AvatarFileManager
|
import com.rosetta.messenger.utils.AvatarFileManager
|
||||||
import com.vanniktech.blurhash.BlurHash
|
import com.vanniktech.blurhash.BlurHash
|
||||||
|
import compose.icons.TablerIcons
|
||||||
|
import compose.icons.tablericons.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
private const val TAG = "AttachmentComponents"
|
private const val TAG = "AttachmentComponents"
|
||||||
|
|
||||||
/**
|
/** Статус скачивания attachment (как в desktop) */
|
||||||
* Статус скачивания attachment (как в desktop)
|
|
||||||
*/
|
|
||||||
enum class DownloadStatus {
|
enum class DownloadStatus {
|
||||||
DOWNLOADED,
|
DOWNLOADED,
|
||||||
NOT_DOWNLOADED,
|
NOT_DOWNLOADED,
|
||||||
@@ -67,8 +57,8 @@ enum class DownloadStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable для отображения всех attachments в сообщении
|
* Composable для отображения всех attachments в сообщении 🖼️ IMAGE attachments группируются в
|
||||||
* 🖼️ IMAGE attachments группируются в коллаж (как в Telegram)
|
* коллаж (как в Telegram)
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageAttachments(
|
fun MessageAttachments(
|
||||||
@@ -90,12 +80,12 @@ fun MessageAttachments(
|
|||||||
|
|
||||||
// Разделяем attachments по типам
|
// Разделяем attachments по типам
|
||||||
val imageAttachments = attachments.filter { it.type == AttachmentType.IMAGE }
|
val imageAttachments = attachments.filter { it.type == AttachmentType.IMAGE }
|
||||||
val otherAttachments = attachments.filter { it.type != AttachmentType.IMAGE && it.type != AttachmentType.MESSAGES }
|
val otherAttachments =
|
||||||
|
attachments.filter {
|
||||||
|
it.type != AttachmentType.IMAGE && it.type != AttachmentType.MESSAGES
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
modifier = modifier,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
// 🖼️ Коллаж для изображений (если больше 1)
|
// 🖼️ Коллаж для изображений (если больше 1)
|
||||||
if (imageAttachments.isNotEmpty()) {
|
if (imageAttachments.isNotEmpty()) {
|
||||||
ImageCollage(
|
ImageCollage(
|
||||||
@@ -140,15 +130,16 @@ fun MessageAttachments(
|
|||||||
messageStatus = messageStatus
|
messageStatus = messageStatus
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> { /* MESSAGES обрабатываются отдельно */ }
|
else -> {
|
||||||
|
/* MESSAGES обрабатываются отдельно */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🖼️ Коллаж изображений в стиле Telegram
|
* 🖼️ Коллаж изображений в стиле Telegram Разные layout'ы в зависимости от количества фото:
|
||||||
* Разные layout'ы в зависимости от количества фото:
|
|
||||||
* - 1 фото: полная ширина
|
* - 1 фото: полная ширина
|
||||||
* - 2 фото: 2 колонки
|
* - 2 фото: 2 колонки
|
||||||
* - 3 фото: 1 большое слева + 2 маленьких справа
|
* - 3 фото: 1 большое слева + 2 маленьких справа
|
||||||
@@ -176,16 +167,19 @@ fun ImageCollage(
|
|||||||
val showOverlayOnLast = !hasCaption
|
val showOverlayOnLast = !hasCaption
|
||||||
|
|
||||||
// Закругление: если есть caption - только сверху, снизу прямые углы
|
// Закругление: если есть caption - только сверху, снизу прямые углы
|
||||||
val collageShape = if (hasCaption) {
|
val collageShape =
|
||||||
RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 0.dp, bottomEnd = 0.dp)
|
if (hasCaption) {
|
||||||
|
RoundedCornerShape(
|
||||||
|
topStart = 16.dp,
|
||||||
|
topEnd = 16.dp,
|
||||||
|
bottomStart = 0.dp,
|
||||||
|
bottomEnd = 0.dp
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
RoundedCornerShape(16.dp)
|
RoundedCornerShape(16.dp)
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(modifier = modifier.clip(collageShape)) {
|
||||||
modifier = modifier
|
|
||||||
.clip(collageShape)
|
|
||||||
) {
|
|
||||||
when (count) {
|
when (count) {
|
||||||
1 -> {
|
1 -> {
|
||||||
// Одно фото - полная ширина
|
// Одно фото - полная ширина
|
||||||
@@ -198,7 +192,7 @@ fun ImageCollage(
|
|||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
messageStatus = messageStatus,
|
messageStatus = messageStatus,
|
||||||
showTimeOverlay = true,
|
showTimeOverlay = showOverlayOnLast,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -219,7 +213,7 @@ fun ImageCollage(
|
|||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
messageStatus = messageStatus,
|
messageStatus = messageStatus,
|
||||||
showTimeOverlay = index == count - 1,
|
showTimeOverlay = showOverlayOnLast && index == count - 1,
|
||||||
aspectRatio = 1f,
|
aspectRatio = 1f,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
@@ -230,9 +224,7 @@ fun ImageCollage(
|
|||||||
3 -> {
|
3 -> {
|
||||||
// Три фото: 1 большое слева + 2 маленьких справа
|
// Три фото: 1 большое слева + 2 маленьких справа
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().height(200.dp),
|
||||||
.fillMaxWidth()
|
|
||||||
.height(200.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(spacing)
|
horizontalArrangement = Arrangement.spacedBy(spacing)
|
||||||
) {
|
) {
|
||||||
// Большое фото слева
|
// Большое фото слева
|
||||||
@@ -281,7 +273,7 @@ fun ImageCollage(
|
|||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
messageStatus = messageStatus,
|
messageStatus = messageStatus,
|
||||||
showTimeOverlay = true,
|
showTimeOverlay = showOverlayOnLast,
|
||||||
fillMaxSize = true,
|
fillMaxSize = true,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
@@ -359,7 +351,7 @@ fun ImageCollage(
|
|||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
messageStatus = messageStatus,
|
messageStatus = messageStatus,
|
||||||
showTimeOverlay = true,
|
showTimeOverlay = showOverlayOnLast,
|
||||||
fillMaxSize = true,
|
fillMaxSize = true,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
@@ -429,16 +421,14 @@ fun ImageCollage(
|
|||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
messageStatus = messageStatus,
|
messageStatus = messageStatus,
|
||||||
showTimeOverlay = isLastItem,
|
showTimeOverlay = showOverlayOnLast && isLastItem,
|
||||||
fillMaxSize = true,
|
fillMaxSize = true,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Заполняем пустые места если в ряду меньше 3 фото
|
// Заполняем пустые места если в ряду меньше 3 фото
|
||||||
repeat(3 - rowItems.size) {
|
repeat(3 - rowItems.size) { Spacer(modifier = Modifier.weight(1f)) }
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -483,7 +473,8 @@ fun ImageAttachment(
|
|||||||
val downloadTag = getDownloadTag(attachment.preview)
|
val downloadTag = getDownloadTag(attachment.preview)
|
||||||
|
|
||||||
// Анимация прогресса
|
// Анимация прогресса
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by
|
||||||
|
animateFloatAsState(
|
||||||
targetValue = downloadProgress,
|
targetValue = downloadProgress,
|
||||||
animationSpec = tween(durationMillis = 300),
|
animationSpec = tween(durationMillis = 300),
|
||||||
label = "progress"
|
label = "progress"
|
||||||
@@ -493,7 +484,8 @@ fun ImageAttachment(
|
|||||||
LaunchedEffect(attachment.id) {
|
LaunchedEffect(attachment.id) {
|
||||||
// Определяем статус (логика из Desktop useAttachment.ts)
|
// Определяем статус (логика из Desktop useAttachment.ts)
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
downloadStatus = when {
|
downloadStatus =
|
||||||
|
when {
|
||||||
// 1. Если blob уже есть в памяти → DOWNLOADED
|
// 1. Если blob уже есть в памяти → DOWNLOADED
|
||||||
attachment.blob.isNotEmpty() -> {
|
attachment.blob.isNotEmpty() -> {
|
||||||
Log.d(TAG, "📦 Blob already in memory for ${attachment.id}")
|
Log.d(TAG, "📦 Blob already in memory for ${attachment.id}")
|
||||||
@@ -506,9 +498,13 @@ fun ImageAttachment(
|
|||||||
}
|
}
|
||||||
// 3. Есть UUID (download tag) → проверяем файловую систему
|
// 3. Есть UUID (download tag) → проверяем файловую систему
|
||||||
else -> {
|
else -> {
|
||||||
// Проверяем есть ли файл локально (как в Desktop: readFile(`m/${md5hash}`))
|
// Проверяем есть ли файл локально (как в Desktop:
|
||||||
val hasLocal = AttachmentFileManager.hasAttachment(
|
// readFile(`m/${md5hash}`))
|
||||||
context, attachment.id, senderPublicKey
|
val hasLocal =
|
||||||
|
AttachmentFileManager.hasAttachment(
|
||||||
|
context,
|
||||||
|
attachment.id,
|
||||||
|
senderPublicKey
|
||||||
)
|
)
|
||||||
if (hasLocal) {
|
if (hasLocal) {
|
||||||
Log.d(TAG, "📦 Found local file for ${attachment.id}")
|
Log.d(TAG, "📦 Found local file for ${attachment.id}")
|
||||||
@@ -525,7 +521,10 @@ fun ImageAttachment(
|
|||||||
if (preview.isNotEmpty() && !isDownloadTag(preview)) {
|
if (preview.isNotEmpty() && !isDownloadTag(preview)) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Log.d(TAG, "🎨 Decoding blurhash: ${preview.take(30)}... (length: ${preview.length})")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"🎨 Decoding blurhash: ${preview.take(30)}... (length: ${preview.length})"
|
||||||
|
)
|
||||||
blurhashBitmap = BlurHash.decode(preview, 200, 200)
|
blurhashBitmap = BlurHash.decode(preview, 200, 200)
|
||||||
if (blurhashBitmap != null) {
|
if (blurhashBitmap != null) {
|
||||||
Log.d(TAG, "✅ Blurhash decoded successfully")
|
Log.d(TAG, "✅ Blurhash decoded successfully")
|
||||||
@@ -537,7 +536,10 @@ fun ImageAttachment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "⚠️ No valid blurhash preview (preview='${preview.take(20)}...', isDownloadTag=${isDownloadTag(preview)})")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"⚠️ No valid blurhash preview (preview='${preview.take(20)}...', isDownloadTag=${isDownloadTag(preview)})"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загружаем изображение если статус DOWNLOADED
|
// Загружаем изображение если статус DOWNLOADED
|
||||||
@@ -550,8 +552,12 @@ fun ImageAttachment(
|
|||||||
} else {
|
} else {
|
||||||
// 2. Читаем из файловой системы (как в Desktop getBlob)
|
// 2. Читаем из файловой системы (как в Desktop getBlob)
|
||||||
Log.d(TAG, "🖼️ Loading image from local file")
|
Log.d(TAG, "🖼️ Loading image from local file")
|
||||||
val localBlob = AttachmentFileManager.readAttachment(
|
val localBlob =
|
||||||
context, attachment.id, senderPublicKey, privateKey
|
AttachmentFileManager.readAttachment(
|
||||||
|
context,
|
||||||
|
attachment.id,
|
||||||
|
senderPublicKey,
|
||||||
|
privateKey
|
||||||
)
|
)
|
||||||
if (localBlob != null) {
|
if (localBlob != null) {
|
||||||
imageBitmap = base64ToBitmap(localBlob)
|
imageBitmap = base64ToBitmap(localBlob)
|
||||||
@@ -591,13 +597,16 @@ fun ImageAttachment(
|
|||||||
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
|
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
|
||||||
// Сначала расшифровываем его, получаем raw bytes
|
// Сначала расшифровываем его, получаем raw bytes
|
||||||
Log.d(TAG, "🔑 Decrypting ChaCha key from sender...")
|
Log.d(TAG, "🔑 Decrypting ChaCha key from sender...")
|
||||||
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
val decryptedKeyAndNonce =
|
||||||
|
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||||
Log.d(TAG, "🔑 ChaCha key decrypted: ${decryptedKeyAndNonce.size} bytes")
|
Log.d(TAG, "🔑 ChaCha key decrypted: ${decryptedKeyAndNonce.size} bytes")
|
||||||
|
|
||||||
// Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует bytes в password
|
// Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует
|
||||||
|
// bytes в password
|
||||||
Log.d(TAG, "🔓 Decrypting image blob with PBKDF2...")
|
Log.d(TAG, "🔓 Decrypting image blob with PBKDF2...")
|
||||||
val decryptStartTime = System.currentTimeMillis()
|
val decryptStartTime = System.currentTimeMillis()
|
||||||
val decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
val decrypted =
|
||||||
|
MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
||||||
encryptedContent,
|
encryptedContent,
|
||||||
decryptedKeyAndNonce
|
decryptedKeyAndNonce
|
||||||
)
|
)
|
||||||
@@ -610,11 +619,15 @@ fun ImageAttachment(
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
Log.d(TAG, "🖼️ Converting to bitmap...")
|
Log.d(TAG, "🖼️ Converting to bitmap...")
|
||||||
imageBitmap = base64ToBitmap(decrypted)
|
imageBitmap = base64ToBitmap(decrypted)
|
||||||
Log.d(TAG, "✅ Bitmap created: ${imageBitmap?.width}x${imageBitmap?.height}")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"✅ Bitmap created: ${imageBitmap?.width}x${imageBitmap?.height}"
|
||||||
|
)
|
||||||
|
|
||||||
// 💾 Сохраняем в файловую систему (как в Desktop)
|
// 💾 Сохраняем в файловую систему (как в Desktop)
|
||||||
Log.d(TAG, "💾 Saving to local storage...")
|
Log.d(TAG, "💾 Saving to local storage...")
|
||||||
val saved = AttachmentFileManager.saveAttachment(
|
val saved =
|
||||||
|
AttachmentFileManager.saveAttachment(
|
||||||
context = context,
|
context = context,
|
||||||
blob = decrypted,
|
blob = decrypted,
|
||||||
attachmentId = attachment.id,
|
attachmentId = attachment.id,
|
||||||
@@ -652,7 +665,8 @@ fun ImageAttachment(
|
|||||||
|
|
||||||
// Telegram-style image с blurhash placeholder и тонким бордером
|
// Telegram-style image с blurhash placeholder и тонким бордером
|
||||||
val timeFormat = remember { java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) }
|
val timeFormat = remember { java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) }
|
||||||
val borderColor = if (isOutgoing) {
|
val borderColor =
|
||||||
|
if (isOutgoing) {
|
||||||
Color.White.copy(alpha = 0.15f)
|
Color.White.copy(alpha = 0.15f)
|
||||||
} else {
|
} else {
|
||||||
if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
|
if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
|
||||||
@@ -663,7 +677,8 @@ fun ImageAttachment(
|
|||||||
val actualWidth = if (attachment.width > 0) attachment.width else imageBitmap?.width ?: 0
|
val actualWidth = if (attachment.width > 0) attachment.width else imageBitmap?.width ?: 0
|
||||||
val actualHeight = if (attachment.height > 0) attachment.height else imageBitmap?.height ?: 0
|
val actualHeight = if (attachment.height > 0) attachment.height else imageBitmap?.height ?: 0
|
||||||
|
|
||||||
val (imageWidth, imageHeight) = remember(actualWidth, actualHeight, fillMaxSize, aspectRatio) {
|
val (imageWidth, imageHeight) =
|
||||||
|
remember(actualWidth, actualHeight, fillMaxSize, aspectRatio) {
|
||||||
// Если fillMaxSize - используем 100% контейнера
|
// Если fillMaxSize - используем 100% контейнера
|
||||||
if (fillMaxSize) {
|
if (fillMaxSize) {
|
||||||
null to null
|
null to null
|
||||||
@@ -716,15 +731,18 @@ fun ImageAttachment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Модификатор размера
|
// Модификатор размера
|
||||||
val sizeModifier = when {
|
val sizeModifier =
|
||||||
|
when {
|
||||||
fillMaxSize -> Modifier.fillMaxSize()
|
fillMaxSize -> Modifier.fillMaxSize()
|
||||||
aspectRatio != null -> Modifier.fillMaxWidth().aspectRatio(aspectRatio)
|
aspectRatio != null -> Modifier.fillMaxWidth().aspectRatio(aspectRatio)
|
||||||
imageWidth != null && imageHeight != null -> Modifier.width(imageWidth).height(imageHeight)
|
imageWidth != null && imageHeight != null ->
|
||||||
|
Modifier.width(imageWidth).height(imageHeight)
|
||||||
else -> Modifier.size(220.dp)
|
else -> Modifier.size(220.dp)
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = sizeModifier
|
modifier =
|
||||||
|
sizeModifier
|
||||||
.clip(RoundedCornerShape(if (fillMaxSize) 0.dp else 12.dp))
|
.clip(RoundedCornerShape(if (fillMaxSize) 0.dp else 12.dp))
|
||||||
.background(Color.Transparent)
|
.background(Color.Transparent)
|
||||||
.clickable {
|
.clickable {
|
||||||
@@ -763,14 +781,21 @@ fun ImageAttachment(
|
|||||||
else -> {
|
else -> {
|
||||||
// Простой placeholder с приятным gradient фоном
|
// Простой placeholder с приятным gradient фоном
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
.background(
|
.background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
colors = if (isDarkTheme)
|
colors =
|
||||||
listOf(Color(0xFF2A2A2A), Color(0xFF1A1A1A))
|
if (isDarkTheme)
|
||||||
|
listOf(
|
||||||
|
Color(0xFF2A2A2A),
|
||||||
|
Color(0xFF1A1A1A)
|
||||||
|
)
|
||||||
else
|
else
|
||||||
listOf(Color(0xFFE8E8E8), Color(0xFFD0D0D0))
|
listOf(
|
||||||
|
Color(0xFFE8E8E8),
|
||||||
|
Color(0xFFD0D0D0)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -780,8 +805,8 @@ fun ImageAttachment(
|
|||||||
// Время в правом нижнем углу (показываем только если showTimeOverlay = true)
|
// Время в правом нижнем углу (показываем только если showTimeOverlay = true)
|
||||||
if (showTimeOverlay) {
|
if (showTimeOverlay) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.align(Alignment.BottomEnd)
|
Modifier.align(Alignment.BottomEnd)
|
||||||
.padding(8.dp)
|
.padding(8.dp)
|
||||||
.background(
|
.background(
|
||||||
Color.Black.copy(alpha = 0.5f),
|
Color.Black.copy(alpha = 0.5f),
|
||||||
@@ -844,17 +869,15 @@ fun ImageAttachment(
|
|||||||
// Оверлей для статуса скачивания
|
// Оверлей для статуса скачивания
|
||||||
if (downloadStatus != DownloadStatus.DOWNLOADED) {
|
if (downloadStatus != DownloadStatus.DOWNLOADED) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.3f)),
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black.copy(alpha = 0.3f)),
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
when (downloadStatus) {
|
when (downloadStatus) {
|
||||||
DownloadStatus.NOT_DOWNLOADED -> {
|
DownloadStatus.NOT_DOWNLOADED -> {
|
||||||
// Кнопка скачивания
|
// Кнопка скачивания
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.size(48.dp)
|
Modifier.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(Color.Black.copy(alpha = 0.5f)),
|
.background(Color.Black.copy(alpha = 0.5f)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
@@ -870,8 +893,8 @@ fun ImageAttachment(
|
|||||||
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
|
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
|
||||||
// Индикатор загрузки с прогрессом
|
// Индикатор загрузки с прогрессом
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.size(48.dp)
|
Modifier.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(Color.Black.copy(alpha = 0.5f)),
|
.background(Color.Black.copy(alpha = 0.5f)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
@@ -885,14 +908,14 @@ fun ImageAttachment(
|
|||||||
}
|
}
|
||||||
DownloadStatus.ERROR -> {
|
DownloadStatus.ERROR -> {
|
||||||
// Ошибка с кнопкой повтора
|
// Ошибка с кнопкой повтора
|
||||||
Column(
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.size(48.dp)
|
Modifier.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(Color(0xFFE53935).copy(alpha = 0.8f)),
|
.background(
|
||||||
|
Color(0xFFE53935).copy(alpha = 0.8f)
|
||||||
|
),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -903,11 +926,7 @@ fun ImageAttachment(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text("Expired", fontSize = 12.sp, color = Color.White)
|
||||||
"Expired",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
@@ -917,9 +936,7 @@ fun ImageAttachment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** File attachment - Telegram style */
|
||||||
* File attachment - Telegram style
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FileAttachment(
|
fun FileAttachment(
|
||||||
attachment: MessageAttachment,
|
attachment: MessageAttachment,
|
||||||
@@ -940,14 +957,16 @@ fun FileAttachment(
|
|||||||
val (fileSize, fileName) = parseFilePreview(preview)
|
val (fileSize, fileName) = parseFilePreview(preview)
|
||||||
|
|
||||||
// Анимация прогресса
|
// Анимация прогресса
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by
|
||||||
|
animateFloatAsState(
|
||||||
targetValue = downloadProgress,
|
targetValue = downloadProgress,
|
||||||
animationSpec = tween(durationMillis = 300),
|
animationSpec = tween(durationMillis = 300),
|
||||||
label = "progress"
|
label = "progress"
|
||||||
)
|
)
|
||||||
|
|
||||||
LaunchedEffect(attachment.id) {
|
LaunchedEffect(attachment.id) {
|
||||||
downloadStatus = if (isDownloadTag(preview)) {
|
downloadStatus =
|
||||||
|
if (isDownloadTag(preview)) {
|
||||||
DownloadStatus.NOT_DOWNLOADED
|
DownloadStatus.NOT_DOWNLOADED
|
||||||
} else {
|
} else {
|
||||||
DownloadStatus.DOWNLOADED
|
DownloadStatus.DOWNLOADED
|
||||||
@@ -968,9 +987,11 @@ fun FileAttachment(
|
|||||||
|
|
||||||
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
|
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
|
||||||
// Сначала расшифровываем его, получаем raw bytes
|
// Сначала расшифровываем его, получаем raw bytes
|
||||||
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
val decryptedKeyAndNonce =
|
||||||
|
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||||
|
|
||||||
val decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
val decrypted =
|
||||||
|
MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
||||||
encryptedContent,
|
encryptedContent,
|
||||||
decryptedKeyAndNonce
|
decryptedKeyAndNonce
|
||||||
)
|
)
|
||||||
@@ -993,30 +1014,28 @@ fun FileAttachment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Telegram-style файл - как в desktop: без внутреннего фона, просто иконка + текст
|
// Telegram-style файл - как в desktop: без внутреннего фона, просто иконка + текст
|
||||||
Box(
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.clickable(enabled = downloadStatus == DownloadStatus.NOT_DOWNLOADED || downloadStatus == DownloadStatus.ERROR) {
|
.clickable(
|
||||||
download()
|
enabled =
|
||||||
}
|
downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
|
||||||
|
downloadStatus == DownloadStatus.ERROR
|
||||||
|
) { download() }
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// File icon с индикатором прогресса - круглая иконка как в desktop
|
// File icon с индикатором прогресса - круглая иконка как в desktop
|
||||||
Box(
|
Box(modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center) {
|
||||||
modifier = Modifier.size(40.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
// Круглый фон иконки
|
// Круглый фон иконки
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(
|
||||||
if (downloadStatus == DownloadStatus.ERROR) Color(0xFFE53935)
|
if (downloadStatus == DownloadStatus.ERROR)
|
||||||
|
Color(0xFFE53935)
|
||||||
else PrimaryBlue
|
else PrimaryBlue
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
@@ -1073,7 +1092,9 @@ fun FileAttachment(
|
|||||||
text = fileName,
|
text = fileName,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
color = if (isOutgoing) Color.White else (if (isDarkTheme) Color.White else Color.Black),
|
color =
|
||||||
|
if (isOutgoing) Color.White
|
||||||
|
else (if (isDarkTheme) Color.White else Color.Black),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
@@ -1082,14 +1103,16 @@ fun FileAttachment(
|
|||||||
// Размер файла и тип
|
// Размер файла и тип
|
||||||
val fileExtension = fileName.substringAfterLast('.', "").uppercase()
|
val fileExtension = fileName.substringAfterLast('.', "").uppercase()
|
||||||
Text(
|
Text(
|
||||||
text = when (downloadStatus) {
|
text =
|
||||||
|
when (downloadStatus) {
|
||||||
DownloadStatus.DOWNLOADING -> "Downloading..."
|
DownloadStatus.DOWNLOADING -> "Downloading..."
|
||||||
DownloadStatus.DECRYPTING -> "Decrypting..."
|
DownloadStatus.DECRYPTING -> "Decrypting..."
|
||||||
DownloadStatus.ERROR -> "File expired"
|
DownloadStatus.ERROR -> "File expired"
|
||||||
else -> "${formatFileSize(fileSize)} $fileExtension"
|
else -> "${formatFileSize(fileSize)} $fileExtension"
|
||||||
},
|
},
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = if (downloadStatus == DownloadStatus.ERROR) {
|
color =
|
||||||
|
if (downloadStatus == DownloadStatus.ERROR) {
|
||||||
Color(0xFFE53935)
|
Color(0xFFE53935)
|
||||||
} else if (isOutgoing) {
|
} else if (isOutgoing) {
|
||||||
Color.White.copy(alpha = 0.7f)
|
Color.White.copy(alpha = 0.7f)
|
||||||
@@ -1103,9 +1126,8 @@ fun FileAttachment(
|
|||||||
// Time and checkmarks (bottom-right overlay) for outgoing files
|
// Time and checkmarks (bottom-right overlay) for outgoing files
|
||||||
if (isOutgoing) {
|
if (isOutgoing) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.align(Alignment.BottomEnd)
|
Modifier.align(Alignment.BottomEnd).padding(bottom = 6.dp, end = 4.dp),
|
||||||
.padding(bottom = 6.dp, end = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.End
|
horizontalArrangement = Arrangement.End
|
||||||
) {
|
) {
|
||||||
@@ -1140,7 +1162,8 @@ fun FileAttachment(
|
|||||||
Icon(
|
Icon(
|
||||||
compose.icons.TablerIcons.Checks,
|
compose.icons.TablerIcons.Checks,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = if (messageStatus == MessageStatus.READ) {
|
tint =
|
||||||
|
if (messageStatus == MessageStatus.READ) {
|
||||||
Color(0xFF4FC3F7)
|
Color(0xFF4FC3F7)
|
||||||
} else {
|
} else {
|
||||||
Color.White.copy(alpha = 0.7f)
|
Color.White.copy(alpha = 0.7f)
|
||||||
@@ -1190,7 +1213,8 @@ fun AvatarAttachment(
|
|||||||
// Определяем начальный статус (как в Desktop calcDownloadStatus для AVATAR)
|
// Определяем начальный статус (как в Desktop calcDownloadStatus для AVATAR)
|
||||||
LaunchedEffect(attachment.id) {
|
LaunchedEffect(attachment.id) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
downloadStatus = when {
|
downloadStatus =
|
||||||
|
when {
|
||||||
// 1. Если blob уже есть в памяти → DOWNLOADED
|
// 1. Если blob уже есть в памяти → DOWNLOADED
|
||||||
attachment.blob.isNotEmpty() -> {
|
attachment.blob.isNotEmpty() -> {
|
||||||
Log.d(TAG, "📦 Avatar blob in memory for ${attachment.id}")
|
Log.d(TAG, "📦 Avatar blob in memory for ${attachment.id}")
|
||||||
@@ -1204,8 +1228,11 @@ fun AvatarAttachment(
|
|||||||
// 3. Есть UUID (download tag) → проверяем файловую систему
|
// 3. Есть UUID (download tag) → проверяем файловую систему
|
||||||
// Desktop: readFile(`a/${md5(attachment.id + publicKey)}`)
|
// Desktop: readFile(`a/${md5(attachment.id + publicKey)}`)
|
||||||
else -> {
|
else -> {
|
||||||
val hasLocal = AvatarFileManager.hasAvatarByAttachmentId(
|
val hasLocal =
|
||||||
context, attachment.id, senderPublicKey
|
AvatarFileManager.hasAvatarByAttachmentId(
|
||||||
|
context,
|
||||||
|
attachment.id,
|
||||||
|
senderPublicKey
|
||||||
)
|
)
|
||||||
if (hasLocal) {
|
if (hasLocal) {
|
||||||
Log.d(TAG, "📦 Found local avatar file for ${attachment.id}")
|
Log.d(TAG, "📦 Found local avatar file for ${attachment.id}")
|
||||||
@@ -1239,8 +1266,11 @@ fun AvatarAttachment(
|
|||||||
} else {
|
} else {
|
||||||
// 2. Читаем из файловой системы (как в Desktop getBlob)
|
// 2. Читаем из файловой системы (как в Desktop getBlob)
|
||||||
Log.d(TAG, "🖼️ Loading avatar from local file")
|
Log.d(TAG, "🖼️ Loading avatar from local file")
|
||||||
val localBlob = AvatarFileManager.readAvatarByAttachmentId(
|
val localBlob =
|
||||||
context, attachment.id, senderPublicKey
|
AvatarFileManager.readAvatarByAttachmentId(
|
||||||
|
context,
|
||||||
|
attachment.id,
|
||||||
|
senderPublicKey
|
||||||
)
|
)
|
||||||
if (localBlob != null) {
|
if (localBlob != null) {
|
||||||
avatarBitmap = base64ToBitmap(localBlob)
|
avatarBitmap = base64ToBitmap(localBlob)
|
||||||
@@ -1278,13 +1308,16 @@ fun AvatarAttachment(
|
|||||||
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
|
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
|
||||||
// Сначала расшифровываем его, получаем raw bytes
|
// Сначала расшифровываем его, получаем raw bytes
|
||||||
Log.d(TAG, "🔑 Decrypting ChaCha key from sender...")
|
Log.d(TAG, "🔑 Decrypting ChaCha key from sender...")
|
||||||
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
val decryptedKeyAndNonce =
|
||||||
|
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||||
Log.d(TAG, "🔑 ChaCha key decrypted: ${decryptedKeyAndNonce.size} bytes")
|
Log.d(TAG, "🔑 ChaCha key decrypted: ${decryptedKeyAndNonce.size} bytes")
|
||||||
|
|
||||||
// Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует bytes в password
|
// Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует
|
||||||
|
// bytes в password
|
||||||
Log.d(TAG, "🔓 Decrypting avatar blob with PBKDF2...")
|
Log.d(TAG, "🔓 Decrypting avatar blob with PBKDF2...")
|
||||||
val decryptStartTime = System.currentTimeMillis()
|
val decryptStartTime = System.currentTimeMillis()
|
||||||
val decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
val decrypted =
|
||||||
|
MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
||||||
encryptedContent,
|
encryptedContent,
|
||||||
decryptedKeyAndNonce
|
decryptedKeyAndNonce
|
||||||
)
|
)
|
||||||
@@ -1296,12 +1329,16 @@ fun AvatarAttachment(
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
Log.d(TAG, "🖼️ Converting to bitmap...")
|
Log.d(TAG, "🖼️ Converting to bitmap...")
|
||||||
avatarBitmap = base64ToBitmap(decrypted)
|
avatarBitmap = base64ToBitmap(decrypted)
|
||||||
Log.d(TAG, "✅ Bitmap created: ${avatarBitmap?.width}x${avatarBitmap?.height}")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"✅ Bitmap created: ${avatarBitmap?.width}x${avatarBitmap?.height}"
|
||||||
|
)
|
||||||
|
|
||||||
// 💾 Сохраняем в файловую систему по attachment.id (как в Desktop)
|
// 💾 Сохраняем в файловую систему по attachment.id (как в Desktop)
|
||||||
// Desktop: writeFile(`a/${md5(attachment.id + publicKey)}`, encrypted)
|
// Desktop: writeFile(`a/${md5(attachment.id + publicKey)}`, encrypted)
|
||||||
Log.d(TAG, "💾 Saving avatar to file system by attachment ID...")
|
Log.d(TAG, "💾 Saving avatar to file system by attachment ID...")
|
||||||
val path = AvatarFileManager.saveAvatarByAttachmentId(
|
val path =
|
||||||
|
AvatarFileManager.saveAvatarByAttachmentId(
|
||||||
context = context,
|
context = context,
|
||||||
base64Image = decrypted,
|
base64Image = decrypted,
|
||||||
attachmentId = attachment.id,
|
attachmentId = attachment.id,
|
||||||
@@ -1310,12 +1347,20 @@ fun AvatarAttachment(
|
|||||||
Log.d(TAG, "💾 Avatar saved to: $path")
|
Log.d(TAG, "💾 Avatar saved to: $path")
|
||||||
}
|
}
|
||||||
// Сохраняем аватар в репозиторий (для UI обновления)
|
// Сохраняем аватар в репозиторий (для UI обновления)
|
||||||
// Если это исходящее сообщение с аватаром, сохраняем для текущего пользователя
|
// Если это исходящее сообщение с аватаром, сохраняем для текущего
|
||||||
val targetPublicKey = if (isOutgoing && currentUserPublicKey.isNotEmpty()) {
|
// пользователя
|
||||||
Log.d(TAG, "💾 Saving avatar to repository for CURRENT user ${currentUserPublicKey.take(16)}...")
|
val targetPublicKey =
|
||||||
|
if (isOutgoing && currentUserPublicKey.isNotEmpty()) {
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"💾 Saving avatar to repository for CURRENT user ${currentUserPublicKey.take(16)}..."
|
||||||
|
)
|
||||||
currentUserPublicKey
|
currentUserPublicKey
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "💾 Saving avatar to repository for SENDER ${senderPublicKey.take(16)}...")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"💾 Saving avatar to repository for SENDER ${senderPublicKey.take(16)}..."
|
||||||
|
)
|
||||||
senderPublicKey
|
senderPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1324,12 +1369,18 @@ fun AvatarAttachment(
|
|||||||
try {
|
try {
|
||||||
Log.d(TAG, "📤 Calling avatarRepository.saveAvatar()...")
|
Log.d(TAG, "📤 Calling avatarRepository.saveAvatar()...")
|
||||||
avatarRepository.saveAvatar(targetPublicKey, decrypted)
|
avatarRepository.saveAvatar(targetPublicKey, decrypted)
|
||||||
Log.d(TAG, "✅ Avatar saved to repository for ${targetPublicKey.take(16)}")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"✅ Avatar saved to repository for ${targetPublicKey.take(16)}"
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "❌ Failed to save avatar to repository: ${e.message}", e)
|
Log.e(TAG, "❌ Failed to save avatar to repository: ${e.message}", e)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Log.e(TAG, "❌ avatarRepository is NULL! Cannot save avatar for ${targetPublicKey.take(16)}")
|
Log.e(
|
||||||
|
TAG,
|
||||||
|
"❌ avatarRepository is NULL! Cannot save avatar for ${targetPublicKey.take(16)}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadStatus = DownloadStatus.DOWNLOADED
|
downloadStatus = DownloadStatus.DOWNLOADED
|
||||||
@@ -1361,8 +1412,8 @@ fun AvatarAttachment(
|
|||||||
|
|
||||||
// Telegram-style avatar attachment с временем и статусом
|
// Telegram-style avatar attachment с временем и статусом
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(
|
.background(
|
||||||
if (isOutgoing) {
|
if (isOutgoing) {
|
||||||
@@ -1371,20 +1422,24 @@ fun AvatarAttachment(
|
|||||||
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)
|
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.clickable(enabled = downloadStatus == DownloadStatus.NOT_DOWNLOADED || downloadStatus == DownloadStatus.ERROR) {
|
.clickable(
|
||||||
download()
|
enabled =
|
||||||
}
|
downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
|
||||||
|
downloadStatus == DownloadStatus.ERROR
|
||||||
|
) { download() }
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Avatar preview - круглое изображение
|
// Avatar preview - круглое изображение
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.size(56.dp)
|
Modifier.size(56.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(
|
||||||
if (isOutgoing) Color.White.copy(0.15f)
|
if (isOutgoing) Color.White.copy(0.15f)
|
||||||
else (if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8))
|
else
|
||||||
|
(if (isDarkTheme) Color(0xFF3A3A3A)
|
||||||
|
else Color(0xFFE8E8E8))
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
@@ -1405,7 +1460,8 @@ fun AvatarAttachment(
|
|||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
downloadStatus == DownloadStatus.DOWNLOADING || downloadStatus == DownloadStatus.DECRYPTING -> {
|
downloadStatus == DownloadStatus.DOWNLOADING ||
|
||||||
|
downloadStatus == DownloadStatus.DECRYPTING -> {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
color = if (isOutgoing) Color.White else PrimaryBlue,
|
color = if (isOutgoing) Color.White else PrimaryBlue,
|
||||||
@@ -1416,7 +1472,8 @@ fun AvatarAttachment(
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Person,
|
Icons.Default.Person,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = if (isOutgoing) Color.White.copy(0.6f)
|
tint =
|
||||||
|
if (isOutgoing) Color.White.copy(0.6f)
|
||||||
else (if (isDarkTheme) Color.White.copy(0.5f) else Color.Gray),
|
else (if (isDarkTheme) Color.White.copy(0.5f) else Color.Gray),
|
||||||
modifier = Modifier.size(28.dp)
|
modifier = Modifier.size(28.dp)
|
||||||
)
|
)
|
||||||
@@ -1426,9 +1483,8 @@ fun AvatarAttachment(
|
|||||||
// Иконка скачивания поверх аватара если нужно скачать
|
// Иконка скачивания поверх аватара если нужно скачать
|
||||||
if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) {
|
if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.fillMaxSize()
|
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)),
|
||||||
.background(Color.Black.copy(alpha = 0.4f)),
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -1451,13 +1507,16 @@ fun AvatarAttachment(
|
|||||||
text = "Profile Photo",
|
text = "Profile Photo",
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = if (isOutgoing) Color.White else (if (isDarkTheme) Color.White else Color.Black)
|
color =
|
||||||
|
if (isOutgoing) Color.White
|
||||||
|
else (if (isDarkTheme) Color.White else Color.Black)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Lock,
|
Icons.Default.Lock,
|
||||||
contentDescription = "End-to-end encrypted",
|
contentDescription = "End-to-end encrypted",
|
||||||
tint = if (isOutgoing) Color.White.copy(0.6f)
|
tint =
|
||||||
|
if (isOutgoing) Color.White.copy(0.6f)
|
||||||
else (if (isDarkTheme) Color.White.copy(0.4f) else Color.Gray),
|
else (if (isDarkTheme) Color.White.copy(0.4f) else Color.Gray),
|
||||||
modifier = Modifier.size(12.dp)
|
modifier = Modifier.size(12.dp)
|
||||||
)
|
)
|
||||||
@@ -1467,7 +1526,8 @@ fun AvatarAttachment(
|
|||||||
|
|
||||||
// Описание статуса
|
// Описание статуса
|
||||||
Text(
|
Text(
|
||||||
text = when (downloadStatus) {
|
text =
|
||||||
|
when (downloadStatus) {
|
||||||
DownloadStatus.DOWNLOADING -> "Downloading..."
|
DownloadStatus.DOWNLOADING -> "Downloading..."
|
||||||
DownloadStatus.DECRYPTING -> "Decrypting..."
|
DownloadStatus.DECRYPTING -> "Decrypting..."
|
||||||
DownloadStatus.ERROR -> "Tap to retry"
|
DownloadStatus.ERROR -> "Tap to retry"
|
||||||
@@ -1475,7 +1535,8 @@ fun AvatarAttachment(
|
|||||||
else -> "Tap to download"
|
else -> "Tap to download"
|
||||||
},
|
},
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
color = if (isOutgoing) Color.White.copy(alpha = 0.7f)
|
color =
|
||||||
|
if (isOutgoing) Color.White.copy(alpha = 0.7f)
|
||||||
else (if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray)
|
else (if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1489,8 +1550,11 @@ fun AvatarAttachment(
|
|||||||
Text(
|
Text(
|
||||||
text = timeFormat.format(timestamp),
|
text = timeFormat.format(timestamp),
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
color = if (isOutgoing) Color.White.copy(alpha = 0.6f)
|
color =
|
||||||
else (if (isDarkTheme) Color.White.copy(alpha = 0.4f) else Color.Gray)
|
if (isOutgoing) Color.White.copy(alpha = 0.6f)
|
||||||
|
else
|
||||||
|
(if (isDarkTheme) Color.White.copy(alpha = 0.4f)
|
||||||
|
else Color.Gray)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Галочки статуса для исходящих сообщений
|
// Галочки статуса для исходящих сообщений
|
||||||
@@ -1557,20 +1621,19 @@ fun AvatarAttachment(
|
|||||||
// Helper functions
|
// Helper functions
|
||||||
// ================================
|
// ================================
|
||||||
|
|
||||||
private val uuidRegex = Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
private val uuidRegex =
|
||||||
|
Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверка является ли preview UUID тегом для скачивания
|
* Проверка является ли preview UUID тегом для скачивания Как в desktop:
|
||||||
* Как в desktop: attachment.preview.split("::")[0].match(uuidRegex)
|
* attachment.preview.split("::")[0].match(uuidRegex)
|
||||||
*/
|
*/
|
||||||
private fun isDownloadTag(preview: String): Boolean {
|
private fun isDownloadTag(preview: String): Boolean {
|
||||||
val firstPart = preview.split("::").firstOrNull() ?: return false
|
val firstPart = preview.split("::").firstOrNull() ?: return false
|
||||||
return uuidRegex.matches(firstPart)
|
return uuidRegex.matches(firstPart)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Получить download tag из preview */
|
||||||
* Получить download tag из preview
|
|
||||||
*/
|
|
||||||
private fun getDownloadTag(preview: String): String {
|
private fun getDownloadTag(preview: String): String {
|
||||||
val parts = preview.split("::")
|
val parts = preview.split("::")
|
||||||
if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) {
|
if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) {
|
||||||
@@ -1579,10 +1642,7 @@ private fun getDownloadTag(preview: String): String {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Получить preview без download tag Как в desktop: preview.split("::").splice(1).join("::") */
|
||||||
* Получить preview без download tag
|
|
||||||
* Как в desktop: preview.split("::").splice(1).join("::")
|
|
||||||
*/
|
|
||||||
private fun getPreview(preview: String): String {
|
private fun getPreview(preview: String): String {
|
||||||
val parts = preview.split("::")
|
val parts = preview.split("::")
|
||||||
if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) {
|
if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) {
|
||||||
@@ -1591,10 +1651,7 @@ private fun getPreview(preview: String): String {
|
|||||||
return preview
|
return preview
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Парсинг preview для файлов Формат: "UUID::filesize::filename" или "filesize::filename" */
|
||||||
* Парсинг preview для файлов
|
|
||||||
* Формат: "UUID::filesize::filename" или "filesize::filename"
|
|
||||||
*/
|
|
||||||
private fun parseFilePreview(preview: String): Pair<Long, String> {
|
private fun parseFilePreview(preview: String): Pair<Long, String> {
|
||||||
val parts = preview.split("::")
|
val parts = preview.split("::")
|
||||||
return when {
|
return when {
|
||||||
@@ -1617,12 +1674,11 @@ private fun parseFilePreview(preview: String): Pair<Long, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Декодирование base64 в Bitmap */
|
||||||
* Декодирование base64 в Bitmap
|
|
||||||
*/
|
|
||||||
private fun base64ToBitmap(base64: String): Bitmap? {
|
private fun base64ToBitmap(base64: String): Bitmap? {
|
||||||
return try {
|
return try {
|
||||||
val cleanBase64 = if (base64.contains(",")) {
|
val cleanBase64 =
|
||||||
|
if (base64.contains(",")) {
|
||||||
base64.substringAfter(",")
|
base64.substringAfter(",")
|
||||||
} else {
|
} else {
|
||||||
base64
|
base64
|
||||||
@@ -1635,9 +1691,7 @@ private fun base64ToBitmap(base64: String): Bitmap? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Форматирование размера файла */
|
||||||
* Форматирование размера файла
|
|
||||||
*/
|
|
||||||
private fun formatFileSize(bytes: Long): String {
|
private fun formatFileSize(bytes: Long): String {
|
||||||
return when {
|
return when {
|
||||||
bytes < 1024 -> "$bytes B"
|
bytes < 1024 -> "$bytes B"
|
||||||
|
|||||||
@@ -5,15 +5,12 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.Matrix
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import com.yalantis.ucrop.UCrop
|
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
@@ -43,24 +40,23 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
|
import com.yalantis.ucrop.UCrop
|
||||||
import compose.icons.TablerIcons
|
import compose.icons.TablerIcons
|
||||||
import compose.icons.tablericons.*
|
import compose.icons.tablericons.*
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
|
||||||
import ja.burhanrashid52.photoeditor.PhotoEditor
|
import ja.burhanrashid52.photoeditor.PhotoEditor
|
||||||
import ja.burhanrashid52.photoeditor.PhotoEditorView
|
import ja.burhanrashid52.photoeditor.PhotoEditorView
|
||||||
import ja.burhanrashid52.photoeditor.SaveSettings
|
import ja.burhanrashid52.photoeditor.SaveSettings
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.coroutines.resume
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
|
|
||||||
private const val TAG = "ImageEditorScreen"
|
private const val TAG = "ImageEditorScreen"
|
||||||
|
|
||||||
/**
|
/** Available editing tools */
|
||||||
* Available editing tools
|
|
||||||
*/
|
|
||||||
enum class EditorTool {
|
enum class EditorTool {
|
||||||
NONE,
|
NONE,
|
||||||
DRAW,
|
DRAW,
|
||||||
@@ -68,10 +64,9 @@ enum class EditorTool {
|
|||||||
ROTATE
|
ROTATE
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Drawing colors */
|
||||||
* Drawing colors
|
val drawingColors =
|
||||||
*/
|
listOf(
|
||||||
val drawingColors = listOf(
|
|
||||||
Color.White,
|
Color.White,
|
||||||
Color.Black,
|
Color.Black,
|
||||||
Color.Red,
|
Color.Red,
|
||||||
@@ -83,9 +78,7 @@ val drawingColors = listOf(
|
|||||||
Color(0xFFFF2D55), // Pink
|
Color(0xFFFF2D55), // Pink
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/** Telegram-style image editor screen with caption input */
|
||||||
* Telegram-style image editor screen with caption input
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ImageEditorScreen(
|
fun ImageEditorScreen(
|
||||||
@@ -123,7 +116,8 @@ fun ImageEditorScreen(
|
|||||||
var photoEditorView by remember { mutableStateOf<PhotoEditorView?>(null) }
|
var photoEditorView by remember { mutableStateOf<PhotoEditorView?>(null) }
|
||||||
|
|
||||||
// UCrop launcher
|
// UCrop launcher
|
||||||
val cropLauncher = rememberLauncherForActivityResult(
|
val cropLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.StartActivityForResult()
|
contract = ActivityResultContracts.StartActivityForResult()
|
||||||
) { result ->
|
) { result ->
|
||||||
if (result.resultCode == Activity.RESULT_OK) {
|
if (result.resultCode == Activity.RESULT_OK) {
|
||||||
@@ -137,15 +131,9 @@ fun ImageEditorScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler {
|
BackHandler { onDismiss() }
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black)
|
|
||||||
) {
|
|
||||||
// Photo Editor View - FULLSCREEN edge-to-edge
|
// Photo Editor View - FULLSCREEN edge-to-edge
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
@@ -156,7 +144,8 @@ fun ImageEditorScreen(
|
|||||||
source.scaleType = ImageView.ScaleType.CENTER_CROP
|
source.scaleType = ImageView.ScaleType.CENTER_CROP
|
||||||
|
|
||||||
// Build PhotoEditor
|
// Build PhotoEditor
|
||||||
photoEditor = PhotoEditor.Builder(ctx, this)
|
photoEditor =
|
||||||
|
PhotoEditor.Builder(ctx, this)
|
||||||
.setPinchTextScalable(true)
|
.setPinchTextScalable(true)
|
||||||
.setClipSourceImage(true)
|
.setClipSourceImage(true)
|
||||||
.build()
|
.build()
|
||||||
@@ -173,8 +162,8 @@ fun ImageEditorScreen(
|
|||||||
|
|
||||||
// Top toolbar - OVERLAY (поверх фото)
|
// Top toolbar - OVERLAY (поверх фото)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -213,8 +202,8 @@ fun ImageEditorScreen(
|
|||||||
|
|
||||||
// Bottom section - OVERLAY (Caption + Tools)
|
// Bottom section - OVERLAY (Caption + Tools)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.imePadding()
|
.imePadding()
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
@@ -314,7 +303,9 @@ fun ImageEditorScreen(
|
|||||||
launchCrop(context, currentImageUri, cropLauncher)
|
launchCrop(context, currentImageUri, cropLauncher)
|
||||||
}
|
}
|
||||||
EditorTool.ROTATE -> {
|
EditorTool.ROTATE -> {
|
||||||
currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else tool
|
currentTool =
|
||||||
|
if (currentTool == EditorTool.ROTATE) EditorTool.NONE
|
||||||
|
else tool
|
||||||
showColorPicker = false
|
showColorPicker = false
|
||||||
showBrushSizeSlider = false
|
showBrushSizeSlider = false
|
||||||
photoEditor?.setBrushDrawingMode(false)
|
photoEditor?.setBrushDrawingMode(false)
|
||||||
@@ -331,14 +322,12 @@ fun ImageEditorScreen(
|
|||||||
showBrushSizeSlider = !showBrushSizeSlider
|
showBrushSizeSlider = !showBrushSizeSlider
|
||||||
showColorPicker = false
|
showColorPicker = false
|
||||||
},
|
},
|
||||||
onEraserClick = {
|
onEraserClick = { photoEditor?.brushEraser() },
|
||||||
photoEditor?.brushEraser()
|
onCropClick = { launchCrop(context, currentImageUri, cropLauncher) },
|
||||||
},
|
|
||||||
onCropClick = {
|
|
||||||
launchCrop(context, currentImageUri, cropLauncher)
|
|
||||||
},
|
|
||||||
onRotateClick = {
|
onRotateClick = {
|
||||||
currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else EditorTool.ROTATE
|
currentTool =
|
||||||
|
if (currentTool == EditorTool.ROTATE) EditorTool.NONE
|
||||||
|
else EditorTool.ROTATE
|
||||||
showColorPicker = false
|
showColorPicker = false
|
||||||
showBrushSizeSlider = false
|
showBrushSizeSlider = false
|
||||||
photoEditor?.setBrushDrawingMode(false)
|
photoEditor?.setBrushDrawingMode(false)
|
||||||
@@ -348,9 +337,7 @@ fun ImageEditorScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Telegram-style Caption input bar with send button - Beautiful transparent overlay */
|
||||||
* Telegram-style Caption input bar with send button - Beautiful transparent overlay
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun TelegramCaptionInputBar(
|
private fun TelegramCaptionInputBar(
|
||||||
@@ -360,26 +347,24 @@ private fun TelegramCaptionInputBar(
|
|||||||
onSend: () -> Unit
|
onSend: () -> Unit
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.Bottom,
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
// Caption text field - Beautiful transparent style
|
// Caption text field - Beautiful transparent style
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.weight(1f)
|
Modifier.weight(1f)
|
||||||
.clip(RoundedCornerShape(22.dp))
|
.clip(RoundedCornerShape(22.dp))
|
||||||
.background(Color.White.copy(alpha = 0.15f))
|
.background(Color.White.copy(alpha = 0.15f))
|
||||||
) {
|
) {
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = caption,
|
value = caption,
|
||||||
onValueChange = onCaptionChange,
|
onValueChange = onCaptionChange,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.fillMaxWidth()
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
textStyle =
|
||||||
textStyle = androidx.compose.ui.text.TextStyle(
|
androidx.compose.ui.text.TextStyle(
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 16.sp
|
fontSize = 16.sp
|
||||||
),
|
),
|
||||||
@@ -401,11 +386,10 @@ private fun TelegramCaptionInputBar(
|
|||||||
|
|
||||||
// Send button - Blue circle like Telegram
|
// Send button - Blue circle like Telegram
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.size(48.dp)
|
Modifier.size(48.dp).clip(CircleShape).background(PrimaryBlue).clickable(
|
||||||
.clip(CircleShape)
|
enabled = !isSaving
|
||||||
.background(PrimaryBlue)
|
) { onSend() },
|
||||||
.clickable(enabled = !isSaving) { onSend() },
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (isSaving) {
|
if (isSaving) {
|
||||||
@@ -419,8 +403,8 @@ private fun TelegramCaptionInputBar(
|
|||||||
imageVector = TablerIcons.Send,
|
imageVector = TablerIcons.Send,
|
||||||
contentDescription = "Send",
|
contentDescription = "Send",
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.size(22.dp)
|
Modifier.size(22.dp)
|
||||||
.offset(x = 1.dp) // Slight offset for better centering
|
.offset(x = 1.dp) // Slight offset for better centering
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -428,9 +412,7 @@ private fun TelegramCaptionInputBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Caption input bar with send button (like Telegram) - OLD VERSION */
|
||||||
* Caption input bar with send button (like Telegram) - OLD VERSION
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun CaptionInputBar(
|
private fun CaptionInputBar(
|
||||||
@@ -439,13 +421,10 @@ private fun CaptionInputBar(
|
|||||||
isSaving: Boolean,
|
isSaving: Boolean,
|
||||||
onSend: () -> Unit
|
onSend: () -> Unit
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(color = Color(0xFF1C1C1E), modifier = Modifier.fillMaxWidth()) {
|
||||||
color = Color(0xFF1C1C1E),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.imePadding()
|
.imePadding()
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -457,13 +436,11 @@ private fun CaptionInputBar(
|
|||||||
onValueChange = onCaptionChange,
|
onValueChange = onCaptionChange,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text("Add a caption...", color = Color.White.copy(alpha = 0.5f))
|
||||||
"Add a caption...",
|
|
||||||
color = Color.White.copy(alpha = 0.5f)
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
maxLines = 3,
|
maxLines = 3,
|
||||||
colors = TextFieldDefaults.outlinedTextFieldColors(
|
colors =
|
||||||
|
TextFieldDefaults.outlinedTextFieldColors(
|
||||||
focusedTextColor = Color.White,
|
focusedTextColor = Color.White,
|
||||||
unfocusedTextColor = Color.White,
|
unfocusedTextColor = Color.White,
|
||||||
cursorColor = PrimaryBlue,
|
cursorColor = PrimaryBlue,
|
||||||
@@ -508,13 +485,10 @@ private fun BottomToolbar(
|
|||||||
onCropClick: () -> Unit,
|
onCropClick: () -> Unit,
|
||||||
onRotateClick: () -> Unit
|
onRotateClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(color = Color(0xFF1C1C1E), modifier = Modifier.fillMaxWidth()) {
|
||||||
color = Color(0xFF1C1C1E),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
.navigationBarsPadding(),
|
.navigationBarsPadding(),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
@@ -584,8 +558,8 @@ private fun ToolButton(
|
|||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.clip(RoundedCornerShape(8.dp))
|
Modifier.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable(
|
.clickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null,
|
indication = null,
|
||||||
@@ -609,18 +583,10 @@ private fun ToolButton(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColorPickerBar(
|
private fun ColorPickerBar(selectedColor: Color, onColorSelected: (Color) -> Unit) {
|
||||||
selectedColor: Color,
|
Surface(color = Color(0xFF2C2C2E), modifier = Modifier.fillMaxWidth()) {
|
||||||
onColorSelected: (Color) -> Unit
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
color = Color(0xFF2C2C2E),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
LazyRow(
|
LazyRow(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
contentPadding = PaddingValues(horizontal = 8.dp)
|
contentPadding = PaddingValues(horizontal = 8.dp)
|
||||||
) {
|
) {
|
||||||
@@ -636,21 +602,21 @@ private fun ColorPickerBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColorButton(
|
private fun ColorButton(color: Color, isSelected: Boolean, onClick: () -> Unit) {
|
||||||
color: Color,
|
|
||||||
isSelected: Boolean,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.size(32.dp)
|
Modifier.size(32.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(color)
|
.background(color)
|
||||||
.then(
|
.then(
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Modifier.border(3.dp, Color.White, CircleShape)
|
Modifier.border(3.dp, Color.White, CircleShape)
|
||||||
} else {
|
} else {
|
||||||
Modifier.border(1.dp, Color.White.copy(alpha = 0.3f), CircleShape)
|
Modifier.border(
|
||||||
|
1.dp,
|
||||||
|
Color.White.copy(alpha = 0.3f),
|
||||||
|
CircleShape
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
@@ -673,52 +639,34 @@ private fun BrushSizeBar(
|
|||||||
onBrushSizeChanged: (Float) -> Unit,
|
onBrushSizeChanged: (Float) -> Unit,
|
||||||
selectedColor: Color
|
selectedColor: Color
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(color = Color(0xFF2C2C2E), modifier = Modifier.fillMaxWidth()) {
|
||||||
color = Color(0xFF2C2C2E),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Min indicator
|
// Min indicator
|
||||||
Box(
|
Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(selectedColor))
|
||||||
modifier = Modifier
|
|
||||||
.size(8.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(selectedColor)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Slider
|
// Slider
|
||||||
Slider(
|
Slider(
|
||||||
value = brushSize,
|
value = brushSize,
|
||||||
onValueChange = onBrushSizeChanged,
|
onValueChange = onBrushSizeChanged,
|
||||||
valueRange = 5f..50f,
|
valueRange = 5f..50f,
|
||||||
modifier = Modifier
|
modifier = Modifier.weight(1f).padding(horizontal = 16.dp),
|
||||||
.weight(1f)
|
colors =
|
||||||
.padding(horizontal = 16.dp),
|
SliderDefaults.colors(
|
||||||
colors = SliderDefaults.colors(
|
|
||||||
thumbColor = selectedColor,
|
thumbColor = selectedColor,
|
||||||
activeTrackColor = selectedColor
|
activeTrackColor = selectedColor
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Max indicator
|
// Max indicator
|
||||||
Box(
|
Box(modifier = Modifier.size(24.dp).clip(CircleShape).background(selectedColor))
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(selectedColor)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Save edited image and return the URI */
|
||||||
* Save edited image and return the URI
|
|
||||||
*/
|
|
||||||
private suspend fun saveEditedImage(
|
private suspend fun saveEditedImage(
|
||||||
context: Context,
|
context: Context,
|
||||||
photoEditor: PhotoEditor?,
|
photoEditor: PhotoEditor?,
|
||||||
@@ -733,7 +681,8 @@ private suspend fun saveEditedImage(
|
|||||||
try {
|
try {
|
||||||
val file = File(context.cacheDir, "edited_${System.currentTimeMillis()}.png")
|
val file = File(context.cacheDir, "edited_${System.currentTimeMillis()}.png")
|
||||||
|
|
||||||
val saveSettings = SaveSettings.Builder()
|
val saveSettings =
|
||||||
|
SaveSettings.Builder()
|
||||||
.setClearViewsEnabled(false)
|
.setClearViewsEnabled(false)
|
||||||
.setTransparencyEnabled(true)
|
.setTransparencyEnabled(true)
|
||||||
.build()
|
.build()
|
||||||
@@ -755,27 +704,25 @@ private suspend fun saveEditedImage(
|
|||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error saving image", e)
|
Log.e(TAG, "Error saving image", e)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) { onResult(null) }
|
||||||
onResult(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Save edited image synchronously using suspendCoroutine */
|
||||||
* Save edited image synchronously using suspendCoroutine
|
private suspend fun saveEditedImageSync(context: Context, photoEditor: PhotoEditor?): Uri? {
|
||||||
*/
|
|
||||||
private suspend fun saveEditedImageSync(
|
|
||||||
context: Context,
|
|
||||||
photoEditor: PhotoEditor?
|
|
||||||
): Uri? {
|
|
||||||
if (photoEditor == null) return null
|
if (photoEditor == null) return null
|
||||||
|
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val file = File(context.cacheDir, "edited_${System.currentTimeMillis()}_${(0..9999).random()}.png")
|
val file =
|
||||||
|
File(
|
||||||
|
context.cacheDir,
|
||||||
|
"edited_${System.currentTimeMillis()}_${(0..9999).random()}.png"
|
||||||
|
)
|
||||||
|
|
||||||
val saveSettings = SaveSettings.Builder()
|
val saveSettings =
|
||||||
|
SaveSettings.Builder()
|
||||||
.setClearViewsEnabled(false)
|
.setClearViewsEnabled(false)
|
||||||
.setTransparencyEnabled(true)
|
.setTransparencyEnabled(true)
|
||||||
.build()
|
.build()
|
||||||
@@ -804,9 +751,7 @@ private suspend fun saveEditedImageSync(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Rotate/Flip options bar */
|
||||||
* Rotate/Flip options bar
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RotateOptionsBar(
|
private fun RotateOptionsBar(
|
||||||
onRotateLeft: () -> Unit,
|
onRotateLeft: () -> Unit,
|
||||||
@@ -814,14 +759,9 @@ private fun RotateOptionsBar(
|
|||||||
onFlipHorizontal: () -> Unit,
|
onFlipHorizontal: () -> Unit,
|
||||||
onFlipVertical: () -> Unit
|
onFlipVertical: () -> Unit
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(color = Color(0xFF2C2C2E), modifier = Modifier.fillMaxWidth()) {
|
||||||
color = Color(0xFF2C2C2E),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 12.dp),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
@@ -860,9 +800,7 @@ private fun RotateOptionsBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Launch UCrop activity for image cropping */
|
||||||
* Launch UCrop activity for image cropping
|
|
||||||
*/
|
|
||||||
private fun launchCrop(
|
private fun launchCrop(
|
||||||
context: Context,
|
context: Context,
|
||||||
sourceUri: Uri,
|
sourceUri: Uri,
|
||||||
@@ -872,7 +810,8 @@ private fun launchCrop(
|
|||||||
val destinationFile = File(context.cacheDir, "cropped_${System.currentTimeMillis()}.png")
|
val destinationFile = File(context.cacheDir, "cropped_${System.currentTimeMillis()}.png")
|
||||||
val destinationUri = Uri.fromFile(destinationFile)
|
val destinationUri = Uri.fromFile(destinationFile)
|
||||||
|
|
||||||
val options = UCrop.Options().apply {
|
val options =
|
||||||
|
UCrop.Options().apply {
|
||||||
setCompressionFormat(Bitmap.CompressFormat.PNG)
|
setCompressionFormat(Bitmap.CompressFormat.PNG)
|
||||||
setCompressionQuality(100)
|
setCompressionQuality(100)
|
||||||
setToolbarColor(android.graphics.Color.parseColor("#1C1C1E"))
|
setToolbarColor(android.graphics.Color.parseColor("#1C1C1E"))
|
||||||
@@ -886,9 +825,7 @@ private fun launchCrop(
|
|||||||
setHideBottomControls(false)
|
setHideBottomControls(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
val intent = UCrop.of(sourceUri, destinationUri)
|
val intent = UCrop.of(sourceUri, destinationUri).withOptions(options).getIntent(context)
|
||||||
.withOptions(options)
|
|
||||||
.getIntent(context)
|
|
||||||
|
|
||||||
launcher.launch(intent)
|
launcher.launch(intent)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -896,17 +833,12 @@ private fun launchCrop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Data class for image with caption */
|
||||||
* Data class for image with caption
|
data class ImageWithCaption(val uri: Uri, var caption: String = "")
|
||||||
*/
|
|
||||||
data class ImageWithCaption(
|
|
||||||
val uri: Uri,
|
|
||||||
var caption: String = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multi-image editor screen with swipe (like Telegram)
|
* Multi-image editor screen with swipe (like Telegram) Позволяет свайпать между фотками и добавлять
|
||||||
* Позволяет свайпать между фотками и добавлять caption к каждой
|
* caption к каждой
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -927,10 +859,7 @@ fun MultiImageEditorScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pager state
|
// Pager state
|
||||||
val pagerState = rememberPagerState(
|
val pagerState = rememberPagerState(initialPage = 0, pageCount = { imagesWithCaptions.size })
|
||||||
initialPage = 0,
|
|
||||||
pageCount = { imagesWithCaptions.size }
|
|
||||||
)
|
|
||||||
|
|
||||||
var isSaving by remember { mutableStateOf(false) }
|
var isSaving by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -949,7 +878,8 @@ fun MultiImageEditorScreen(
|
|||||||
val photoEditorViews = remember { mutableStateMapOf<Int, PhotoEditorView?>() }
|
val photoEditorViews = remember { mutableStateMapOf<Int, PhotoEditorView?>() }
|
||||||
|
|
||||||
// Crop launcher
|
// Crop launcher
|
||||||
val cropLauncher = rememberLauncherForActivityResult(
|
val cropLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.StartActivityForResult()
|
contract = ActivityResultContracts.StartActivityForResult()
|
||||||
) { result ->
|
) { result ->
|
||||||
if (result.resultCode == Activity.RESULT_OK) {
|
if (result.resultCode == Activity.RESULT_OK) {
|
||||||
@@ -957,7 +887,8 @@ fun MultiImageEditorScreen(
|
|||||||
UCrop.getOutput(data)?.let { croppedUri ->
|
UCrop.getOutput(data)?.let { croppedUri ->
|
||||||
val currentPage = pagerState.currentPage
|
val currentPage = pagerState.currentPage
|
||||||
if (currentPage < imagesWithCaptions.size) {
|
if (currentPage < imagesWithCaptions.size) {
|
||||||
imagesWithCaptions[currentPage] = imagesWithCaptions[currentPage].copy(uri = croppedUri)
|
imagesWithCaptions[currentPage] =
|
||||||
|
imagesWithCaptions[currentPage].copy(uri = croppedUri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -984,25 +915,16 @@ fun MultiImageEditorScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler {
|
BackHandler { onDismiss() }
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black)
|
|
||||||
) {
|
|
||||||
// Horizontal Pager для свайпа между фото
|
// Horizontal Pager для свайпа между фото
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
userScrollEnabled = currentTool == EditorTool.NONE // Disable swipe when editing
|
userScrollEnabled = currentTool == EditorTool.NONE // Disable swipe when editing
|
||||||
) { page ->
|
) { page ->
|
||||||
Box(
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
// PhotoEditorView for editing
|
// PhotoEditorView for editing
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
@@ -1012,7 +934,10 @@ fun MultiImageEditorScreen(
|
|||||||
// Load bitmap
|
// Load bitmap
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val inputStream = ctx.contentResolver.openInputStream(imagesWithCaptions[page].uri)
|
val inputStream =
|
||||||
|
ctx.contentResolver.openInputStream(
|
||||||
|
imagesWithCaptions[page].uri
|
||||||
|
)
|
||||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
val bitmap = BitmapFactory.decodeStream(inputStream)
|
||||||
inputStream?.close()
|
inputStream?.close()
|
||||||
|
|
||||||
@@ -1020,7 +945,8 @@ fun MultiImageEditorScreen(
|
|||||||
source.setImageBitmap(bitmap)
|
source.setImageBitmap(bitmap)
|
||||||
|
|
||||||
// Create PhotoEditor
|
// Create PhotoEditor
|
||||||
val editor = PhotoEditor.Builder(ctx, this@apply)
|
val editor =
|
||||||
|
PhotoEditor.Builder(ctx, this@apply)
|
||||||
.setPinchTextScalable(true)
|
.setPinchTextScalable(true)
|
||||||
.build()
|
.build()
|
||||||
photoEditors[page] = editor
|
photoEditors[page] = editor
|
||||||
@@ -1038,7 +964,8 @@ fun MultiImageEditorScreen(
|
|||||||
if (currentUri != null) {
|
if (currentUri != null) {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val inputStream = context.contentResolver.openInputStream(currentUri)
|
val inputStream =
|
||||||
|
context.contentResolver.openInputStream(currentUri)
|
||||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
val bitmap = BitmapFactory.decodeStream(inputStream)
|
||||||
inputStream?.close()
|
inputStream?.close()
|
||||||
|
|
||||||
@@ -1057,8 +984,8 @@ fun MultiImageEditorScreen(
|
|||||||
|
|
||||||
// Top toolbar - OVERLAY
|
// Top toolbar - OVERLAY
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -1077,8 +1004,8 @@ fun MultiImageEditorScreen(
|
|||||||
// Page indicator
|
// Page indicator
|
||||||
if (imagesWithCaptions.size > 1) {
|
if (imagesWithCaptions.size > 1) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.clip(RoundedCornerShape(12.dp))
|
Modifier.clip(RoundedCornerShape(12.dp))
|
||||||
.background(Color.Black.copy(alpha = 0.5f))
|
.background(Color.Black.copy(alpha = 0.5f))
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
) {
|
) {
|
||||||
@@ -1093,9 +1020,7 @@ fun MultiImageEditorScreen(
|
|||||||
|
|
||||||
// Undo button (when drawing)
|
// Undo button (when drawing)
|
||||||
if (currentTool == EditorTool.DRAW) {
|
if (currentTool == EditorTool.DRAW) {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = { photoEditors[pagerState.currentPage]?.undo() }) {
|
||||||
photoEditors[pagerState.currentPage]?.undo()
|
|
||||||
}) {
|
|
||||||
Icon(
|
Icon(
|
||||||
TablerIcons.ArrowBackUp,
|
TablerIcons.ArrowBackUp,
|
||||||
contentDescription = "Undo",
|
contentDescription = "Undo",
|
||||||
@@ -1110,12 +1035,7 @@ fun MultiImageEditorScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bottom section - Tools + Caption + Send
|
// Bottom section - Tools + Caption + Send
|
||||||
Column(
|
Column(modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter).imePadding()) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.imePadding()
|
|
||||||
) {
|
|
||||||
// Color picker bar (when drawing)
|
// Color picker bar (when drawing)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showColorPicker && currentTool == EditorTool.DRAW,
|
visible = showColorPicker && currentTool == EditorTool.DRAW,
|
||||||
@@ -1156,11 +1076,13 @@ fun MultiImageEditorScreen(
|
|||||||
RotateOptionsBar(
|
RotateOptionsBar(
|
||||||
onRotateLeft = {
|
onRotateLeft = {
|
||||||
photoEditorViews[pagerState.currentPage]?.source?.rotation =
|
photoEditorViews[pagerState.currentPage]?.source?.rotation =
|
||||||
(photoEditorViews[pagerState.currentPage]?.source?.rotation ?: 0f) - 90f
|
(photoEditorViews[pagerState.currentPage]?.source?.rotation
|
||||||
|
?: 0f) - 90f
|
||||||
},
|
},
|
||||||
onRotateRight = {
|
onRotateRight = {
|
||||||
photoEditorViews[pagerState.currentPage]?.source?.rotation =
|
photoEditorViews[pagerState.currentPage]?.source?.rotation =
|
||||||
(photoEditorViews[pagerState.currentPage]?.source?.rotation ?: 0f) + 90f
|
(photoEditorViews[pagerState.currentPage]?.source?.rotation
|
||||||
|
?: 0f) + 90f
|
||||||
},
|
},
|
||||||
onFlipHorizontal = {
|
onFlipHorizontal = {
|
||||||
photoEditorViews[pagerState.currentPage]?.source?.let { imageView ->
|
photoEditorViews[pagerState.currentPage]?.source?.let { imageView ->
|
||||||
@@ -1178,20 +1100,22 @@ fun MultiImageEditorScreen(
|
|||||||
// Thumbnails strip (если больше 1 фото)
|
// Thumbnails strip (если больше 1 фото)
|
||||||
if (imagesWithCaptions.size > 1 && currentTool == EditorTool.NONE) {
|
if (imagesWithCaptions.size > 1 && currentTool == EditorTool.NONE) {
|
||||||
LazyRow(
|
LazyRow(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
items(imagesWithCaptions.size) { index ->
|
items(imagesWithCaptions.size) { index ->
|
||||||
val isSelected = pagerState.currentPage == index
|
val isSelected = pagerState.currentPage == index
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.size(56.dp)
|
Modifier.size(56.dp)
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.border(
|
.border(
|
||||||
width = if (isSelected) 2.dp else 0.dp,
|
width = if (isSelected) 2.dp else 0.dp,
|
||||||
color = if (isSelected) PrimaryBlue else Color.Transparent,
|
color =
|
||||||
|
if (isSelected) PrimaryBlue
|
||||||
|
else Color.Transparent,
|
||||||
shape = RoundedCornerShape(8.dp)
|
shape = RoundedCornerShape(8.dp)
|
||||||
)
|
)
|
||||||
.clickable {
|
.clickable {
|
||||||
@@ -1209,70 +1133,31 @@ fun MultiImageEditorScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caption input bar (hidden during editing)
|
// Send button (without caption input for multi-image)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = currentTool == EditorTool.NONE,
|
visible = currentTool == EditorTool.NONE,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut()
|
exit = fadeOut()
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
horizontalArrangement = Arrangement.End
|
||||||
) {
|
) {
|
||||||
// Caption text field
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.clip(RoundedCornerShape(22.dp))
|
|
||||||
.background(Color.White.copy(alpha = 0.15f))
|
|
||||||
) {
|
|
||||||
BasicTextField(
|
|
||||||
value = currentCaption,
|
|
||||||
onValueChange = { newCaption ->
|
|
||||||
currentCaption = newCaption
|
|
||||||
// Update caption for current image
|
|
||||||
if (pagerState.currentPage < imagesWithCaptions.size) {
|
|
||||||
imagesWithCaptions[pagerState.currentPage] =
|
|
||||||
imagesWithCaptions[pagerState.currentPage].copy(caption = newCaption)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
textStyle = androidx.compose.ui.text.TextStyle(
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 16.sp
|
|
||||||
),
|
|
||||||
maxLines = 4,
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
Box {
|
|
||||||
if (currentCaption.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
"Add a caption...",
|
|
||||||
color = Color.White.copy(alpha = 0.6f),
|
|
||||||
fontSize = 16.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
innerTextField()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send button
|
// Send button
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.size(48.dp)
|
Modifier.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(PrimaryBlue)
|
.background(PrimaryBlue)
|
||||||
.clickable(enabled = !isSaving) {
|
.clickable(enabled = !isSaving) {
|
||||||
isSaving = true
|
isSaving = true
|
||||||
// Save all edited images before sending
|
// Save all edited images before sending
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val savedImages = mutableListOf<ImageWithCaption>()
|
val savedImages =
|
||||||
|
mutableListOf<ImageWithCaption>()
|
||||||
|
|
||||||
for (i in imagesWithCaptions.indices) {
|
for (i in imagesWithCaptions.indices) {
|
||||||
val editor = photoEditors[i]
|
val editor = photoEditors[i]
|
||||||
@@ -1280,11 +1165,20 @@ fun MultiImageEditorScreen(
|
|||||||
|
|
||||||
if (editor != null) {
|
if (editor != null) {
|
||||||
// Save edited image
|
// Save edited image
|
||||||
val savedUri = saveEditedImageSync(context, editor)
|
val savedUri =
|
||||||
|
saveEditedImageSync(
|
||||||
|
context,
|
||||||
|
editor
|
||||||
|
)
|
||||||
if (savedUri != null) {
|
if (savedUri != null) {
|
||||||
savedImages.add(originalImage.copy(uri = savedUri))
|
savedImages.add(
|
||||||
|
originalImage.copy(
|
||||||
|
uri = savedUri
|
||||||
|
)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
// Fallback to original if save fails
|
// Fallback to original if save
|
||||||
|
// fails
|
||||||
savedImages.add(originalImage)
|
savedImages.add(originalImage)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1309,9 +1203,7 @@ fun MultiImageEditorScreen(
|
|||||||
imageVector = TablerIcons.Send,
|
imageVector = TablerIcons.Send,
|
||||||
contentDescription = "Send",
|
contentDescription = "Send",
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
modifier = Modifier
|
modifier = Modifier.size(22.dp).offset(x = 1.dp)
|
||||||
.size(22.dp)
|
|
||||||
.offset(x = 1.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1325,7 +1217,9 @@ fun MultiImageEditorScreen(
|
|||||||
val currentEditor = photoEditors[pagerState.currentPage]
|
val currentEditor = photoEditors[pagerState.currentPage]
|
||||||
when (tool) {
|
when (tool) {
|
||||||
EditorTool.DRAW -> {
|
EditorTool.DRAW -> {
|
||||||
currentTool = if (currentTool == EditorTool.DRAW) EditorTool.NONE else tool
|
currentTool =
|
||||||
|
if (currentTool == EditorTool.DRAW) EditorTool.NONE
|
||||||
|
else tool
|
||||||
if (currentTool == EditorTool.DRAW) {
|
if (currentTool == EditorTool.DRAW) {
|
||||||
currentEditor?.setBrushDrawingMode(true)
|
currentEditor?.setBrushDrawingMode(true)
|
||||||
currentEditor?.brushColor = selectedColor.toArgb()
|
currentEditor?.brushColor = selectedColor.toArgb()
|
||||||
@@ -1343,10 +1237,16 @@ fun MultiImageEditorScreen(
|
|||||||
showBrushSizeSlider = false
|
showBrushSizeSlider = false
|
||||||
currentEditor?.setBrushDrawingMode(false)
|
currentEditor?.setBrushDrawingMode(false)
|
||||||
// Launch UCrop
|
// Launch UCrop
|
||||||
launchCrop(context, imagesWithCaptions[pagerState.currentPage].uri, cropLauncher)
|
launchCrop(
|
||||||
|
context,
|
||||||
|
imagesWithCaptions[pagerState.currentPage].uri,
|
||||||
|
cropLauncher
|
||||||
|
)
|
||||||
}
|
}
|
||||||
EditorTool.ROTATE -> {
|
EditorTool.ROTATE -> {
|
||||||
currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else tool
|
currentTool =
|
||||||
|
if (currentTool == EditorTool.ROTATE) EditorTool.NONE
|
||||||
|
else tool
|
||||||
showColorPicker = false
|
showColorPicker = false
|
||||||
showBrushSizeSlider = false
|
showBrushSizeSlider = false
|
||||||
currentEditor?.setBrushDrawingMode(false)
|
currentEditor?.setBrushDrawingMode(false)
|
||||||
@@ -1363,14 +1263,18 @@ fun MultiImageEditorScreen(
|
|||||||
showBrushSizeSlider = !showBrushSizeSlider
|
showBrushSizeSlider = !showBrushSizeSlider
|
||||||
showColorPicker = false
|
showColorPicker = false
|
||||||
},
|
},
|
||||||
onEraserClick = {
|
onEraserClick = { photoEditors[pagerState.currentPage]?.brushEraser() },
|
||||||
photoEditors[pagerState.currentPage]?.brushEraser()
|
|
||||||
},
|
|
||||||
onCropClick = {
|
onCropClick = {
|
||||||
launchCrop(context, imagesWithCaptions[pagerState.currentPage].uri, cropLauncher)
|
launchCrop(
|
||||||
|
context,
|
||||||
|
imagesWithCaptions[pagerState.currentPage].uri,
|
||||||
|
cropLauncher
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onRotateClick = {
|
onRotateClick = {
|
||||||
currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else EditorTool.ROTATE
|
currentTool =
|
||||||
|
if (currentTool == EditorTool.ROTATE) EditorTool.NONE
|
||||||
|
else EditorTool.ROTATE
|
||||||
showColorPicker = false
|
showColorPicker = false
|
||||||
showBrushSizeSlider = false
|
showBrushSizeSlider = false
|
||||||
photoEditors[pagerState.currentPage]?.setBrushDrawingMode(false)
|
photoEditors[pagerState.currentPage]?.setBrushDrawingMode(false)
|
||||||
@@ -1380,14 +1284,9 @@ fun MultiImageEditorScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Simple async image loader composable */
|
||||||
* Simple async image loader composable
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AsyncImageLoader(
|
private fun AsyncImageLoader(uri: Uri, modifier: Modifier = Modifier) {
|
||||||
uri: Uri,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var bitmap by remember(uri) { mutableStateOf<android.graphics.Bitmap?>(null) }
|
var bitmap by remember(uri) { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||||
|
|
||||||
@@ -1410,13 +1309,9 @@ private fun AsyncImageLoader(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
contentScale = androidx.compose.ui.layout.ContentScale.Fit
|
contentScale = androidx.compose.ui.layout.ContentScale.Fit
|
||||||
)
|
)
|
||||||
} ?: Box(
|
}
|
||||||
|
?: Box(
|
||||||
modifier = modifier.background(Color.DarkGray),
|
modifier = modifier.background(Color.DarkGray),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) { CircularProgressIndicator(modifier = Modifier.size(32.dp), color = Color.White) }
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(32.dp),
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user