feat: Add image viewer functionality with swipe and zoom capabilities
This commit is contained in:
@@ -59,6 +59,7 @@ import com.airbnb.lottie.compose.animateLottieCompositionAsState
|
||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.chats.models.*
|
||||
@@ -176,7 +177,11 @@ fun ChatDetailScreen(
|
||||
// 📨 Forward: показывать ли выбор чата
|
||||
var showForwardPicker by remember { mutableStateOf(false) }
|
||||
|
||||
// 📷 Camera: URI для сохранения фото
|
||||
// <EFBFBD> Image Viewer state
|
||||
var showImageViewer by remember { mutableStateOf(false) }
|
||||
var imageViewerInitialIndex by remember { mutableStateOf(0) }
|
||||
|
||||
// <20>📷 Camera: URI для сохранения фото
|
||||
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
// 📷 Camera launcher
|
||||
@@ -1706,12 +1711,16 @@ fun ChatDetailScreen(
|
||||
}
|
||||
},
|
||||
onSwipeToReply = {
|
||||
viewModel
|
||||
.setReplyMessages(
|
||||
listOf(
|
||||
message
|
||||
// Не разрешаем reply на сообщения с аватаркой
|
||||
val hasAvatar = message.attachments.any { it.type == AttachmentType.AVATAR }
|
||||
if (!hasAvatar) {
|
||||
viewModel
|
||||
.setReplyMessages(
|
||||
listOf(
|
||||
message
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
onReplyClick = {
|
||||
messageId
|
||||
@@ -1731,6 +1740,17 @@ fun ChatDetailScreen(
|
||||
.deleteMessage(
|
||||
message.id
|
||||
)
|
||||
},
|
||||
onImageClick = { attachmentId ->
|
||||
// 📸 Открыть просмотрщик фото
|
||||
val allImages = extractImagesFromMessages(
|
||||
messages,
|
||||
currentUserPublicKey,
|
||||
user.publicKey,
|
||||
user.title.ifEmpty { "User" }
|
||||
)
|
||||
imageViewerInitialIndex = findImageIndex(allImages, attachmentId)
|
||||
showImageViewer = true
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1742,6 +1762,23 @@ fun ChatDetailScreen(
|
||||
}
|
||||
} // Закрытие Box
|
||||
|
||||
// 📸 Image Viewer Overlay
|
||||
if (showImageViewer) {
|
||||
val allImages = extractImagesFromMessages(
|
||||
messages,
|
||||
currentUserPublicKey,
|
||||
user.publicKey,
|
||||
user.title.ifEmpty { "User" }
|
||||
)
|
||||
ImageViewerScreen(
|
||||
images = allImages,
|
||||
initialIndex = imageViewerInitialIndex,
|
||||
privateKey = currentUserPrivateKey,
|
||||
onDismiss = { showImageViewer = false },
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
|
||||
// Диалог подтверждения удаления чата
|
||||
if (showDeleteConfirm) {
|
||||
AlertDialog(
|
||||
|
||||
@@ -82,6 +82,7 @@ fun MessageAttachments(
|
||||
messageStatus: MessageStatus = MessageStatus.READ,
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
currentUserPublicKey: String = "",
|
||||
onImageClick: (attachmentId: String) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (attachments.isEmpty()) return
|
||||
@@ -104,7 +105,8 @@ fun MessageAttachments(
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus
|
||||
messageStatus = messageStatus,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
|
||||
@@ -161,6 +163,7 @@ fun ImageCollage(
|
||||
isDarkTheme: Boolean,
|
||||
timestamp: java.util.Date,
|
||||
messageStatus: MessageStatus = MessageStatus.READ,
|
||||
onImageClick: (attachmentId: String) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val count = attachments.size
|
||||
@@ -185,7 +188,8 @@ fun ImageCollage(
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = true
|
||||
showTimeOverlay = true,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
2 -> {
|
||||
@@ -206,7 +210,8 @@ fun ImageCollage(
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = index == count - 1,
|
||||
aspectRatio = 1f
|
||||
aspectRatio = 1f,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -232,7 +237,8 @@ fun ImageCollage(
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = false,
|
||||
fillMaxSize = true
|
||||
fillMaxSize = true,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
// Два маленьких справа
|
||||
@@ -251,7 +257,8 @@ fun ImageCollage(
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = false,
|
||||
fillMaxSize = true
|
||||
fillMaxSize = true,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
@@ -265,7 +272,8 @@ fun ImageCollage(
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = true,
|
||||
fillMaxSize = true
|
||||
fillMaxSize = true,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -292,7 +300,8 @@ fun ImageCollage(
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = false,
|
||||
fillMaxSize = true
|
||||
fillMaxSize = true,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
|
||||
@@ -306,7 +315,8 @@ fun ImageCollage(
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = false,
|
||||
fillMaxSize = true
|
||||
fillMaxSize = true,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -325,7 +335,8 @@ fun ImageCollage(
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = false,
|
||||
fillMaxSize = true
|
||||
fillMaxSize = true,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
|
||||
@@ -339,7 +350,8 @@ fun ImageCollage(
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = true,
|
||||
fillMaxSize = true
|
||||
fillMaxSize = true,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -367,7 +379,8 @@ fun ImageCollage(
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = false,
|
||||
fillMaxSize = true
|
||||
fillMaxSize = true,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
|
||||
@@ -381,7 +394,8 @@ fun ImageCollage(
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = false,
|
||||
fillMaxSize = true
|
||||
fillMaxSize = true,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -406,7 +420,8 @@ fun ImageCollage(
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
showTimeOverlay = isLastItem,
|
||||
fillMaxSize = true
|
||||
fillMaxSize = true,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -443,7 +458,8 @@ fun ImageAttachment(
|
||||
messageStatus: MessageStatus = MessageStatus.READ,
|
||||
showTimeOverlay: Boolean = true,
|
||||
aspectRatio: Float? = null,
|
||||
fillMaxSize: Boolean = false
|
||||
fillMaxSize: Boolean = false,
|
||||
onImageClick: (attachmentId: String) -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -688,7 +704,8 @@ fun ImageAttachment(
|
||||
when (downloadStatus) {
|
||||
DownloadStatus.NOT_DOWNLOADED -> download()
|
||||
DownloadStatus.DOWNLOADED -> {
|
||||
// TODO: Open image viewer
|
||||
// 📸 Open image viewer
|
||||
onImageClick(attachment.id)
|
||||
}
|
||||
DownloadStatus.ERROR -> download()
|
||||
else -> {}
|
||||
|
||||
@@ -148,7 +148,8 @@ fun MessageBubble(
|
||||
onSwipeToReply: () -> Unit = {},
|
||||
onReplyClick: (String) -> Unit = {},
|
||||
onRetry: () -> Unit = {},
|
||||
onDelete: () -> Unit = {}
|
||||
onDelete: () -> Unit = {},
|
||||
onImageClick: (attachmentId: String) -> Unit = {}
|
||||
) {
|
||||
// Swipe-to-reply state
|
||||
var swipeOffset by remember { mutableStateOf(0f) }
|
||||
@@ -385,7 +386,8 @@ fun MessageBubble(
|
||||
timestamp = message.timestamp,
|
||||
messageStatus = message.status,
|
||||
avatarRepository = avatarRepository,
|
||||
currentUserPublicKey = currentUserPublicKey
|
||||
currentUserPublicKey = currentUserPublicKey,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
if (message.text.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
package com.rosetta.messenger.ui.chats.components
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.gestures.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerDefaults
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import com.rosetta.messenger.crypto.MessageCrypto
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.TransportManager
|
||||
import com.rosetta.messenger.ui.chats.models.ChatMessage
|
||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||
import compose.icons.TablerIcons
|
||||
import compose.icons.tablericons.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val TAG = "ImageViewerScreen"
|
||||
|
||||
/**
|
||||
* Данные для просмотра изображения
|
||||
*/
|
||||
data class ViewableImage(
|
||||
val attachmentId: String,
|
||||
val preview: String,
|
||||
val blob: String,
|
||||
val chachaKey: String,
|
||||
val senderPublicKey: String,
|
||||
val senderName: String,
|
||||
val timestamp: Date,
|
||||
val width: Int = 0,
|
||||
val height: Int = 0
|
||||
)
|
||||
|
||||
/**
|
||||
* 📸 Полноэкранный просмотрщик фото в стиле Telegram
|
||||
*
|
||||
* Функции:
|
||||
* - Свайп влево/вправо для листания фото
|
||||
* - Pinch-to-zoom
|
||||
* - Свайп вниз для закрытия
|
||||
* - Плавные анимации
|
||||
* - Индикатор позиции
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ImageViewerScreen(
|
||||
images: List<ViewableImage>,
|
||||
initialIndex: Int,
|
||||
privateKey: String,
|
||||
onDismiss: () -> Unit,
|
||||
isDarkTheme: Boolean = true
|
||||
) {
|
||||
if (images.isEmpty()) {
|
||||
onDismiss()
|
||||
return
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
// Pager state
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = initialIndex.coerceIn(0, images.size - 1),
|
||||
pageCount = { images.size }
|
||||
)
|
||||
|
||||
// UI visibility state
|
||||
var showControls by remember { mutableStateOf(true) }
|
||||
|
||||
// Drag to dismiss
|
||||
var offsetY by remember { mutableStateOf(0f) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
val dismissThreshold = 200f
|
||||
|
||||
// Animated background alpha based on drag
|
||||
val backgroundAlpha by animateFloatAsState(
|
||||
targetValue = if (isDragging) {
|
||||
(1f - (offsetY.absoluteValue / 500f)).coerceIn(0.3f, 1f)
|
||||
} else 1f,
|
||||
animationSpec = tween(150),
|
||||
label = "backgroundAlpha"
|
||||
)
|
||||
|
||||
// Current image info
|
||||
val currentImage = images.getOrNull(pagerState.currentPage)
|
||||
val dateFormat = remember { SimpleDateFormat("d MMMM, HH:mm", Locale.getDefault()) }
|
||||
|
||||
BackHandler {
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = backgroundAlpha))
|
||||
) {
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 📸 HORIZONTAL PAGER
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.offset { IntOffset(0, offsetY.roundToInt()) },
|
||||
key = { images[it].attachmentId }
|
||||
) { page ->
|
||||
val image = images[page]
|
||||
|
||||
ZoomableImage(
|
||||
image = image,
|
||||
privateKey = privateKey,
|
||||
onTap = { showControls = !showControls },
|
||||
onVerticalDrag = { dragAmount ->
|
||||
offsetY += dragAmount
|
||||
isDragging = true
|
||||
},
|
||||
onDragEnd = {
|
||||
isDragging = false
|
||||
if (offsetY.absoluteValue > dismissThreshold) {
|
||||
onDismiss()
|
||||
} else {
|
||||
offsetY = 0f
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🎛️ TOP BAR
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
AnimatedVisibility(
|
||||
visible = showControls,
|
||||
enter = fadeIn() + slideInVertically { -it },
|
||||
exit = fadeOut() + slideOutVertically { -it },
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
Color.Black.copy(alpha = 0.6f)
|
||||
)
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 4.dp, vertical = 8.dp)
|
||||
) {
|
||||
// Back button
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.align(Alignment.CenterStart)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack,
|
||||
contentDescription = "Close",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
// Title and date
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(horizontal = 56.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = currentImage?.senderName ?: "",
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = currentImage?.timestamp?.let { dateFormat.format(it) } ?: "",
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
|
||||
// More options
|
||||
IconButton(
|
||||
onClick = { /* TODO: Share, save, etc */ },
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = "More",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 📍 PAGE INDICATOR (если больше 1 фото)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
if (images.size > 1) {
|
||||
AnimatedVisibility(
|
||||
visible = showControls,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 32.dp)
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
// Dots indicator
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
Color.Black.copy(alpha = 0.5f),
|
||||
RoundedCornerShape(16.dp)
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
repeat(images.size.coerceAtMost(10)) { index ->
|
||||
val isSelected = pagerState.currentPage == index
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(if (isSelected) 8.dp else 6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isSelected) Color.White
|
||||
else Color.White.copy(alpha = 0.4f)
|
||||
)
|
||||
)
|
||||
}
|
||||
// Показываем счетчик если больше 10 фото
|
||||
if (images.size > 10) {
|
||||
Text(
|
||||
text = "${pagerState.currentPage + 1}/${images.size}",
|
||||
color = Color.White,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔍 Zoomable Image - Telegram style
|
||||
* - Double tap to zoom 2.5x / reset
|
||||
* - Pinch to zoom 1x-5x
|
||||
* - Pan when zoomed
|
||||
* - Vertical drag to dismiss when not zoomed
|
||||
*/
|
||||
@Composable
|
||||
private fun ZoomableImage(
|
||||
image: ViewableImage,
|
||||
privateKey: String,
|
||||
onTap: () -> Unit,
|
||||
onVerticalDrag: (Float) -> Unit = {},
|
||||
onDragEnd: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var loadError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Zoom and pan state with animation
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
var offsetY by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
// Animated values for smooth transitions
|
||||
val animatedScale by animateFloatAsState(
|
||||
targetValue = scale,
|
||||
animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f),
|
||||
label = "scale"
|
||||
)
|
||||
val animatedOffsetX by animateFloatAsState(
|
||||
targetValue = offsetX,
|
||||
animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f),
|
||||
label = "offsetX"
|
||||
)
|
||||
val animatedOffsetY by animateFloatAsState(
|
||||
targetValue = offsetY,
|
||||
animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f),
|
||||
label = "offsetY"
|
||||
)
|
||||
|
||||
// Container size
|
||||
var containerSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
val minScale = 1f
|
||||
val maxScale = 5f
|
||||
|
||||
// Load image
|
||||
LaunchedEffect(image.attachmentId) {
|
||||
isLoading = true
|
||||
loadError = null
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// 1. Если blob уже есть
|
||||
if (image.blob.isNotEmpty()) {
|
||||
bitmap = base64ToBitmapSafe(image.blob)
|
||||
if (bitmap != null) {
|
||||
isLoading = false
|
||||
return@withContext
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Пробуем из локального файла
|
||||
val localBlob = AttachmentFileManager.readAttachment(
|
||||
context, image.attachmentId, image.senderPublicKey, privateKey
|
||||
)
|
||||
if (localBlob != null) {
|
||||
bitmap = base64ToBitmapSafe(localBlob)
|
||||
if (bitmap != null) {
|
||||
isLoading = false
|
||||
return@withContext
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Скачиваем с CDN
|
||||
val downloadTag = getDownloadTag(image.preview)
|
||||
if (downloadTag.isNotEmpty()) {
|
||||
Log.d(TAG, "📥 Downloading image from CDN: ${image.attachmentId}")
|
||||
|
||||
val encryptedContent = TransportManager.downloadFile(image.attachmentId, downloadTag)
|
||||
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
|
||||
val decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
||||
encryptedContent,
|
||||
decryptedKeyAndNonce
|
||||
)
|
||||
|
||||
if (decrypted != null) {
|
||||
bitmap = base64ToBitmapSafe(decrypted)
|
||||
|
||||
// Сохраняем локально
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = context,
|
||||
blob = decrypted,
|
||||
attachmentId = image.attachmentId,
|
||||
publicKey = image.senderPublicKey,
|
||||
privateKey = privateKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (bitmap == null) {
|
||||
loadError = "Failed to load image"
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load image: ${e.message}", e)
|
||||
loadError = e.message
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reset zoom when image changes
|
||||
LaunchedEffect(image.attachmentId) {
|
||||
scale = 1f
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.onSizeChanged { containerSize = it }
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = { onTap() },
|
||||
onDoubleTap = { tapOffset ->
|
||||
if (scale > 1.1f) {
|
||||
// Zoom out
|
||||
scale = 1f
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
} else {
|
||||
// Zoom in to tap point
|
||||
scale = 2.5f
|
||||
val centerX = containerSize.width / 2f
|
||||
val centerY = containerSize.height / 2f
|
||||
offsetX = (centerX - tapOffset.x) * 1.5f
|
||||
offsetY = (centerY - tapOffset.y) * 1.5f
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
var isVerticalDragging = false
|
||||
|
||||
forEachGesture {
|
||||
awaitPointerEventScope {
|
||||
// Wait for first down
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
|
||||
var zoom = 1f
|
||||
var pastTouchSlop = false
|
||||
val touchSlop = viewConfiguration.touchSlop
|
||||
var lockedToDismiss = false
|
||||
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
val canceled = event.changes.any { it.isConsumed }
|
||||
|
||||
if (!canceled) {
|
||||
val zoomChange = event.calculateZoom()
|
||||
val panChange = event.calculatePan()
|
||||
|
||||
if (!pastTouchSlop) {
|
||||
zoom *= zoomChange
|
||||
val centroidSize = event.calculateCentroidSize(useCurrent = false)
|
||||
val touchMoved = abs(panChange.x) > touchSlop || abs(panChange.y) > touchSlop
|
||||
val zoomMotion = abs(1 - zoom) * centroidSize > touchSlop
|
||||
|
||||
if (touchMoved || zoomMotion) {
|
||||
pastTouchSlop = true
|
||||
|
||||
// Decide: vertical dismiss or zoom/pan?
|
||||
if (scale <= 1.05f && zoomChange == 1f &&
|
||||
abs(panChange.y) > abs(panChange.x) * 1.5f) {
|
||||
lockedToDismiss = true
|
||||
isVerticalDragging = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pastTouchSlop) {
|
||||
if (lockedToDismiss) {
|
||||
// Vertical drag for dismiss
|
||||
onVerticalDrag(panChange.y)
|
||||
event.changes.forEach { it.consume() }
|
||||
} else {
|
||||
// Zoom and pan
|
||||
val newScale = (scale * zoomChange).coerceIn(minScale, maxScale)
|
||||
|
||||
// Calculate max offsets based on zoom
|
||||
val maxX = (containerSize.width * (newScale - 1) / 2f).coerceAtLeast(0f)
|
||||
val maxY = (containerSize.height * (newScale - 1) / 2f).coerceAtLeast(0f)
|
||||
|
||||
val newOffsetX = (offsetX + panChange.x).coerceIn(-maxX, maxX)
|
||||
val newOffsetY = (offsetY + panChange.y).coerceIn(-maxY, maxY)
|
||||
|
||||
scale = newScale
|
||||
offsetX = newOffsetX
|
||||
offsetY = newOffsetY
|
||||
|
||||
// Consume if zoomed to prevent pager swipe
|
||||
if (scale > 1.05f) {
|
||||
event.changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (event.changes.any { it.pressed })
|
||||
|
||||
// Pointer up - end drag
|
||||
if (isVerticalDragging) {
|
||||
isVerticalDragging = false
|
||||
onDragEnd()
|
||||
}
|
||||
|
||||
// Snap back if scale is close to 1
|
||||
if (scale < 1.05f) {
|
||||
scale = 1f
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when {
|
||||
isLoading -> {
|
||||
CircularProgressIndicator(
|
||||
color = Color.White,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
}
|
||||
loadError != null -> {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.PhotoOff,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.5f),
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Failed to load image",
|
||||
color = Color.White.copy(alpha = 0.5f),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
bitmap != null -> {
|
||||
Image(
|
||||
bitmap = bitmap!!.asImageBitmap(),
|
||||
contentDescription = "Photo",
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = animatedScale
|
||||
scaleY = animatedScale
|
||||
translationX = animatedOffsetX
|
||||
translationY = animatedOffsetY
|
||||
},
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Безопасное декодирование base64 в Bitmap
|
||||
*/
|
||||
private fun base64ToBitmapSafe(base64String: String): Bitmap? {
|
||||
return try {
|
||||
// Убираем возможные префиксы data:image/...
|
||||
val cleanBase64 = if (base64String.contains(",")) {
|
||||
base64String.substringAfter(",")
|
||||
} else {
|
||||
base64String
|
||||
}
|
||||
|
||||
val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to decode base64 to bitmap: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечение download tag из preview
|
||||
*/
|
||||
private fun getDownloadTag(preview: String): String {
|
||||
return if (preview.contains("::")) {
|
||||
preview.split("::").firstOrNull() ?: ""
|
||||
} else if (isUUID(preview)) {
|
||||
preview
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка является ли строка UUID
|
||||
*/
|
||||
private fun isUUID(str: String): Boolean {
|
||||
return str.matches(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}$"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔧 Helper: Извлечь все изображения из списка ChatMessage
|
||||
*/
|
||||
fun extractImagesFromMessages(
|
||||
messages: List<ChatMessage>,
|
||||
currentPublicKey: String,
|
||||
opponentPublicKey: String,
|
||||
opponentName: String
|
||||
): List<ViewableImage> {
|
||||
return messages
|
||||
.flatMap { message ->
|
||||
message.attachments
|
||||
.filter { it.type == AttachmentType.IMAGE }
|
||||
.map { attachment ->
|
||||
ViewableImage(
|
||||
attachmentId = attachment.id,
|
||||
preview = attachment.preview,
|
||||
blob = attachment.blob,
|
||||
chachaKey = message.chachaKey,
|
||||
senderPublicKey = if (message.isOutgoing) currentPublicKey else opponentPublicKey,
|
||||
senderName = if (message.isOutgoing) "You" else opponentName,
|
||||
timestamp = message.timestamp,
|
||||
width = attachment.width,
|
||||
height = attachment.height
|
||||
)
|
||||
}
|
||||
}
|
||||
.sortedBy { it.timestamp }
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔧 Helper: Найти индекс изображения по attachmentId
|
||||
*/
|
||||
fun findImageIndex(images: List<ViewableImage>, attachmentId: String): Int {
|
||||
return images.indexOfFirst { it.attachmentId == attachmentId }.coerceAtLeast(0)
|
||||
}
|
||||
Reference in New Issue
Block a user