feat: Add image viewer functionality with swipe and zoom capabilities

This commit is contained in:
k1ngsterr1
2026-01-28 01:07:44 +05:00
parent 8702539d09
commit 29b023c39f
4 changed files with 715 additions and 23 deletions

View File

@@ -59,6 +59,7 @@ import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition import com.airbnb.lottie.compose.rememberLottieComposition
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.ui.chats.models.*
@@ -176,7 +177,11 @@ fun ChatDetailScreen(
// 📨 Forward: показывать ли выбор чата // 📨 Forward: показывать ли выбор чата
var showForwardPicker by remember { mutableStateOf(false) } 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) } var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
// 📷 Camera launcher // 📷 Camera launcher
@@ -1706,12 +1711,16 @@ fun ChatDetailScreen(
} }
}, },
onSwipeToReply = { onSwipeToReply = {
viewModel // Не разрешаем reply на сообщения с аватаркой
.setReplyMessages( val hasAvatar = message.attachments.any { it.type == AttachmentType.AVATAR }
listOf( if (!hasAvatar) {
message viewModel
.setReplyMessages(
listOf(
message
)
) )
) }
}, },
onReplyClick = { onReplyClick = {
messageId messageId
@@ -1731,6 +1740,17 @@ fun ChatDetailScreen(
.deleteMessage( .deleteMessage(
message.id 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 } // Закрытие 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) { if (showDeleteConfirm) {
AlertDialog( AlertDialog(

View File

@@ -82,6 +82,7 @@ fun MessageAttachments(
messageStatus: MessageStatus = MessageStatus.READ, messageStatus: MessageStatus = MessageStatus.READ,
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
currentUserPublicKey: String = "", currentUserPublicKey: String = "",
onImageClick: (attachmentId: String) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (attachments.isEmpty()) return if (attachments.isEmpty()) return
@@ -104,7 +105,8 @@ fun MessageAttachments(
isOutgoing = isOutgoing, isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus messageStatus = messageStatus,
onImageClick = onImageClick
) )
} }
@@ -161,6 +163,7 @@ fun ImageCollage(
isDarkTheme: Boolean, isDarkTheme: Boolean,
timestamp: java.util.Date, timestamp: java.util.Date,
messageStatus: MessageStatus = MessageStatus.READ, messageStatus: MessageStatus = MessageStatus.READ,
onImageClick: (attachmentId: String) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val count = attachments.size val count = attachments.size
@@ -185,7 +188,8 @@ fun ImageCollage(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus, messageStatus = messageStatus,
showTimeOverlay = true showTimeOverlay = true,
onImageClick = onImageClick
) )
} }
2 -> { 2 -> {
@@ -206,7 +210,8 @@ fun ImageCollage(
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus, messageStatus = messageStatus,
showTimeOverlay = index == count - 1, showTimeOverlay = index == count - 1,
aspectRatio = 1f aspectRatio = 1f,
onImageClick = onImageClick
) )
} }
} }
@@ -232,7 +237,8 @@ fun ImageCollage(
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus, messageStatus = messageStatus,
showTimeOverlay = false, showTimeOverlay = false,
fillMaxSize = true fillMaxSize = true,
onImageClick = onImageClick
) )
} }
// Два маленьких справа // Два маленьких справа
@@ -251,7 +257,8 @@ fun ImageCollage(
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus, messageStatus = messageStatus,
showTimeOverlay = false, showTimeOverlay = false,
fillMaxSize = true fillMaxSize = true,
onImageClick = onImageClick
) )
} }
Box(modifier = Modifier.weight(1f).fillMaxWidth()) { Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
@@ -265,7 +272,8 @@ fun ImageCollage(
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus, messageStatus = messageStatus,
showTimeOverlay = true, showTimeOverlay = true,
fillMaxSize = true fillMaxSize = true,
onImageClick = onImageClick
) )
} }
} }
@@ -292,7 +300,8 @@ fun ImageCollage(
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus, messageStatus = messageStatus,
showTimeOverlay = false, showTimeOverlay = false,
fillMaxSize = true fillMaxSize = true,
onImageClick = onImageClick
) )
} }
Box(modifier = Modifier.weight(1f).aspectRatio(1f)) { Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
@@ -306,7 +315,8 @@ fun ImageCollage(
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus, messageStatus = messageStatus,
showTimeOverlay = false, showTimeOverlay = false,
fillMaxSize = true fillMaxSize = true,
onImageClick = onImageClick
) )
} }
} }
@@ -325,7 +335,8 @@ fun ImageCollage(
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus, messageStatus = messageStatus,
showTimeOverlay = false, showTimeOverlay = false,
fillMaxSize = true fillMaxSize = true,
onImageClick = onImageClick
) )
} }
Box(modifier = Modifier.weight(1f).aspectRatio(1f)) { Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
@@ -339,7 +350,8 @@ fun ImageCollage(
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus, messageStatus = messageStatus,
showTimeOverlay = true, showTimeOverlay = true,
fillMaxSize = true fillMaxSize = true,
onImageClick = onImageClick
) )
} }
} }
@@ -367,7 +379,8 @@ fun ImageCollage(
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus, messageStatus = messageStatus,
showTimeOverlay = false, showTimeOverlay = false,
fillMaxSize = true fillMaxSize = true,
onImageClick = onImageClick
) )
} }
Box(modifier = Modifier.weight(1f).aspectRatio(1f)) { Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
@@ -381,7 +394,8 @@ fun ImageCollage(
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus, messageStatus = messageStatus,
showTimeOverlay = false, showTimeOverlay = false,
fillMaxSize = true fillMaxSize = true,
onImageClick = onImageClick
) )
} }
} }
@@ -406,7 +420,8 @@ fun ImageCollage(
timestamp = timestamp, timestamp = timestamp,
messageStatus = messageStatus, messageStatus = messageStatus,
showTimeOverlay = isLastItem, showTimeOverlay = isLastItem,
fillMaxSize = true fillMaxSize = true,
onImageClick = onImageClick
) )
} }
} }
@@ -443,7 +458,8 @@ fun ImageAttachment(
messageStatus: MessageStatus = MessageStatus.READ, messageStatus: MessageStatus = MessageStatus.READ,
showTimeOverlay: Boolean = true, showTimeOverlay: Boolean = true,
aspectRatio: Float? = null, aspectRatio: Float? = null,
fillMaxSize: Boolean = false fillMaxSize: Boolean = false,
onImageClick: (attachmentId: String) -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -688,7 +704,8 @@ fun ImageAttachment(
when (downloadStatus) { when (downloadStatus) {
DownloadStatus.NOT_DOWNLOADED -> download() DownloadStatus.NOT_DOWNLOADED -> download()
DownloadStatus.DOWNLOADED -> { DownloadStatus.DOWNLOADED -> {
// TODO: Open image viewer // 📸 Open image viewer
onImageClick(attachment.id)
} }
DownloadStatus.ERROR -> download() DownloadStatus.ERROR -> download()
else -> {} else -> {}

View File

@@ -148,7 +148,8 @@ fun MessageBubble(
onSwipeToReply: () -> Unit = {}, onSwipeToReply: () -> Unit = {},
onReplyClick: (String) -> Unit = {}, onReplyClick: (String) -> Unit = {},
onRetry: () -> Unit = {}, onRetry: () -> Unit = {},
onDelete: () -> Unit = {} onDelete: () -> Unit = {},
onImageClick: (attachmentId: String) -> Unit = {}
) { ) {
// Swipe-to-reply state // Swipe-to-reply state
var swipeOffset by remember { mutableStateOf(0f) } var swipeOffset by remember { mutableStateOf(0f) }
@@ -385,7 +386,8 @@ fun MessageBubble(
timestamp = message.timestamp, timestamp = message.timestamp,
messageStatus = message.status, messageStatus = message.status,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
currentUserPublicKey = currentUserPublicKey currentUserPublicKey = currentUserPublicKey,
onImageClick = onImageClick
) )
if (message.text.isNotEmpty()) { if (message.text.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))

View File

@@ -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)
}