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.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 = {
|
||||||
|
// Не разрешаем reply на сообщения с аватаркой
|
||||||
|
val hasAvatar = message.attachments.any { it.type == AttachmentType.AVATAR }
|
||||||
|
if (!hasAvatar) {
|
||||||
viewModel
|
viewModel
|
||||||
.setReplyMessages(
|
.setReplyMessages(
|
||||||
listOf(
|
listOf(
|
||||||
message
|
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(
|
||||||
|
|||||||
@@ -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 -> {}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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