From 29b023c39f6eaf2852eb038652f9cce320588295 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Wed, 28 Jan 2026 01:07:44 +0500 Subject: [PATCH] feat: Add image viewer functionality with swipe and zoom capabilities --- .../messenger/ui/chats/ChatDetailScreen.kt | 49 +- .../chats/components/AttachmentComponents.kt | 47 +- .../chats/components/ChatDetailComponents.kt | 6 +- .../ui/chats/components/ImageViewerScreen.kt | 636 ++++++++++++++++++ 4 files changed, 715 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 32267fb..b5bef7c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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 для сохранения фото + // � Image Viewer state + var showImageViewer by remember { mutableStateOf(false) } + var imageViewerInitialIndex by remember { mutableStateOf(0) } + + // �📷 Camera: URI для сохранения фото var cameraImageUri by remember { mutableStateOf(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( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 22fcf2c..b52b125 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -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 -> {} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index aceba56..778b037 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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)) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt new file mode 100644 index 0000000..557d5c5 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt @@ -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, + 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(null) } + var isLoading by remember { mutableStateOf(true) } + var loadError by remember { mutableStateOf(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, + currentPublicKey: String, + opponentPublicKey: String, + opponentName: String +): List { + 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, attachmentId: String): Int { + return images.indexOfFirst { it.attachmentId == attachmentId }.coerceAtLeast(0) +}