fix: fix image components behavior
This commit is contained in:
@@ -180,9 +180,10 @@ fun ChatDetailScreen(
|
|||||||
// 📨 Forward: показывать ли выбор чата
|
// 📨 Forward: показывать ли выбор чата
|
||||||
var showForwardPicker by remember { mutableStateOf(false) }
|
var showForwardPicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// <EFBFBD> Image Viewer state
|
// 📸 Image Viewer state with Telegram-style shared element animation
|
||||||
var showImageViewer by remember { mutableStateOf(false) }
|
var showImageViewer by remember { mutableStateOf(false) }
|
||||||
var imageViewerInitialIndex by remember { mutableStateOf(0) }
|
var imageViewerInitialIndex by remember { mutableStateOf(0) }
|
||||||
|
var imageViewerSourceBounds by remember { mutableStateOf<ImageSourceBounds?>(null) }
|
||||||
|
|
||||||
// 📷 Camera: URI для сохранения фото
|
// 📷 Camera: URI для сохранения фото
|
||||||
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
|
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
@@ -1763,15 +1764,16 @@ fun ChatDetailScreen(
|
|||||||
message.id
|
message.id
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onImageClick = { attachmentId ->
|
onImageClick = { attachmentId, bounds ->
|
||||||
// 📸 Открыть просмотрщик фото
|
// 📸 Открыть просмотрщик фото с shared element animation
|
||||||
val allImages = extractImagesFromMessages(
|
val allImages = extractImagesFromMessages(
|
||||||
messages,
|
messages,
|
||||||
currentUserPublicKey,
|
currentUserPublicKey,
|
||||||
user.publicKey,
|
user.publicKey,
|
||||||
user.title.ifEmpty { "User" }
|
user.title.ifEmpty { "User" }
|
||||||
)
|
)
|
||||||
imageViewerInitialIndex = findImageIndex(allImages, attachmentId)
|
imageViewerInitialIndex = findImageIndex(allImages, attachmentId)
|
||||||
|
imageViewerSourceBounds = bounds
|
||||||
showImageViewer = true
|
showImageViewer = true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1784,10 +1786,10 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
} // Закрытие Box
|
} // Закрытие Box
|
||||||
|
|
||||||
// 📸 Image Viewer Overlay
|
// 📸 Image Viewer Overlay with Telegram-style shared element animation
|
||||||
if (showImageViewer) {
|
if (showImageViewer) {
|
||||||
val allImages = extractImagesFromMessages(
|
val allImages = extractImagesFromMessages(
|
||||||
messages,
|
messages,
|
||||||
currentUserPublicKey,
|
currentUserPublicKey,
|
||||||
user.publicKey,
|
user.publicKey,
|
||||||
user.title.ifEmpty { "User" }
|
user.title.ifEmpty { "User" }
|
||||||
@@ -1796,8 +1798,12 @@ fun ChatDetailScreen(
|
|||||||
images = allImages,
|
images = allImages,
|
||||||
initialIndex = imageViewerInitialIndex,
|
initialIndex = imageViewerInitialIndex,
|
||||||
privateKey = currentUserPrivateKey,
|
privateKey = currentUserPrivateKey,
|
||||||
onDismiss = { showImageViewer = false },
|
onDismiss = {
|
||||||
isDarkTheme = isDarkTheme
|
showImageViewer = false
|
||||||
|
imageViewerSourceBounds = null
|
||||||
|
},
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
sourceBounds = imageViewerSourceBounds
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2055,8 +2061,8 @@ fun ChatDetailScreen(
|
|||||||
pendingCameraPhotoUri?.let { uri ->
|
pendingCameraPhotoUri?.let { uri ->
|
||||||
ImageEditorScreen(
|
ImageEditorScreen(
|
||||||
imageUri = uri,
|
imageUri = uri,
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
pendingCameraPhotoUri = null
|
pendingCameraPhotoUri = null
|
||||||
},
|
},
|
||||||
onSave = { editedUri ->
|
onSave = { editedUri ->
|
||||||
// Fallback если onSaveWithCaption не сработал
|
// Fallback если onSaveWithCaption не сработал
|
||||||
@@ -2082,7 +2088,8 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
showCaptionInput = true
|
showCaptionInput = true,
|
||||||
|
recipientName = user.title
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2123,7 +2130,8 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme,
|
||||||
|
recipientName = user.title
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package com.rosetta.messenger.ui.chats.components
|
|||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -18,10 +20,14 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -72,7 +78,7 @@ fun MessageAttachments(
|
|||||||
avatarRepository: AvatarRepository? = null,
|
avatarRepository: AvatarRepository? = null,
|
||||||
currentUserPublicKey: String = "",
|
currentUserPublicKey: String = "",
|
||||||
hasCaption: Boolean = false, // Если есть caption - время показывается под фото, не на фото
|
hasCaption: Boolean = false, // Если есть caption - время показывается под фото, не на фото
|
||||||
onImageClick: (attachmentId: String) -> Unit = {},
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (attachments.isEmpty()) return
|
if (attachments.isEmpty()) return
|
||||||
@@ -156,7 +162,7 @@ fun ImageCollage(
|
|||||||
timestamp: java.util.Date,
|
timestamp: java.util.Date,
|
||||||
messageStatus: MessageStatus = MessageStatus.READ,
|
messageStatus: MessageStatus = MessageStatus.READ,
|
||||||
hasCaption: Boolean = false, // Если есть caption - время показывается под фото
|
hasCaption: Boolean = false, // Если есть caption - время показывается под фото
|
||||||
onImageClick: (attachmentId: String) -> Unit = {},
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val count = attachments.size
|
val count = attachments.size
|
||||||
@@ -458,11 +464,14 @@ fun ImageAttachment(
|
|||||||
showTimeOverlay: Boolean = true,
|
showTimeOverlay: Boolean = true,
|
||||||
aspectRatio: Float? = null,
|
aspectRatio: Float? = null,
|
||||||
fillMaxSize: Boolean = false,
|
fillMaxSize: Boolean = false,
|
||||||
onImageClick: (attachmentId: String) -> Unit = {}
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Bounds для shared element transition
|
||||||
|
var imageBounds by remember { mutableStateOf<Rect?>(null) }
|
||||||
|
|
||||||
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
||||||
var imageBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
var imageBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
var blurhashBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
var blurhashBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
@@ -686,17 +695,33 @@ fun ImageAttachment(
|
|||||||
else -> Modifier.size(220.dp)
|
else -> Modifier.size(220.dp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val cornerRadius = if (fillMaxSize) 0f else 12f
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
sizeModifier
|
sizeModifier
|
||||||
.clip(RoundedCornerShape(if (fillMaxSize) 0.dp else 12.dp))
|
.clip(RoundedCornerShape(cornerRadius.dp))
|
||||||
.background(Color.Transparent)
|
.background(Color.Transparent)
|
||||||
|
.onGloballyPositioned { coordinates ->
|
||||||
|
// Capture bounds for shared element transition
|
||||||
|
imageBounds = coordinates.boundsInWindow()
|
||||||
|
}
|
||||||
.clickable {
|
.clickable {
|
||||||
when (downloadStatus) {
|
when (downloadStatus) {
|
||||||
DownloadStatus.NOT_DOWNLOADED -> download()
|
DownloadStatus.NOT_DOWNLOADED -> download()
|
||||||
DownloadStatus.DOWNLOADED -> {
|
DownloadStatus.DOWNLOADED -> {
|
||||||
// 📸 Open image viewer
|
// 📸 Open image viewer with bounds for animation
|
||||||
onImageClick(attachment.id)
|
val bounds = imageBounds?.let {
|
||||||
|
ImageSourceBounds(
|
||||||
|
left = it.left,
|
||||||
|
top = it.top,
|
||||||
|
width = it.width,
|
||||||
|
height = it.height,
|
||||||
|
cornerRadius = cornerRadius,
|
||||||
|
thumbnailBitmap = imageBitmap
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onImageClick(attachment.id, bounds)
|
||||||
}
|
}
|
||||||
DownloadStatus.ERROR -> download()
|
DownloadStatus.ERROR -> download()
|
||||||
else -> {}
|
else -> {}
|
||||||
@@ -898,6 +923,18 @@ fun FileAttachment(
|
|||||||
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
||||||
var downloadProgress by remember { mutableStateOf(0f) }
|
var downloadProgress by remember { mutableStateOf(0f) }
|
||||||
|
|
||||||
|
// Bounce animation for icon
|
||||||
|
val iconScale = remember { Animatable(0f) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
iconScale.animateTo(
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = 0.5f, // Bouncy effect
|
||||||
|
stiffness = 400f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val preview = attachment.preview
|
val preview = attachment.preview
|
||||||
val downloadTag = getDownloadTag(preview)
|
val downloadTag = getDownloadTag(preview)
|
||||||
val (fileSize, fileName) = parseFilePreview(preview)
|
val (fileSize, fileName) = parseFilePreview(preview)
|
||||||
@@ -970,7 +1007,15 @@ fun FileAttachment(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// File icon с индикатором прогресса - круглая иконка как в desktop
|
// File icon с индикатором прогресса - круглая иконка как в desktop
|
||||||
Box(modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center) {
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = iconScale.value
|
||||||
|
scaleY = iconScale.value
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
// Круглый фон иконки
|
// Круглый фон иконки
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -1153,6 +1198,18 @@ fun AvatarAttachment(
|
|||||||
|
|
||||||
val timeFormat = remember { java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) }
|
val timeFormat = remember { java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) }
|
||||||
|
|
||||||
|
// Bounce animation for avatar
|
||||||
|
val avatarScale = remember { Animatable(0f) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
avatarScale.animateTo(
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = 0.5f, // Bouncy effect
|
||||||
|
stiffness = 400f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Определяем начальный статус (как в Desktop calcDownloadStatus для AVATAR)
|
// Определяем начальный статус (как в Desktop calcDownloadStatus для AVATAR)
|
||||||
LaunchedEffect(attachment.id) {
|
LaunchedEffect(attachment.id) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -1315,6 +1372,10 @@ fun AvatarAttachment(
|
|||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.size(56.dp)
|
Modifier.size(56.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = avatarScale.value
|
||||||
|
scaleY = avatarScale.value
|
||||||
|
}
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(
|
||||||
if (isOutgoing) Color.White.copy(0.15f)
|
if (isOutgoing) Color.White.copy(0.15f)
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ fun MessageBubble(
|
|||||||
onReplyClick: (String) -> Unit = {},
|
onReplyClick: (String) -> Unit = {},
|
||||||
onRetry: () -> Unit = {},
|
onRetry: () -> Unit = {},
|
||||||
onDelete: () -> Unit = {},
|
onDelete: () -> Unit = {},
|
||||||
onImageClick: (attachmentId: String) -> Unit = {}
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }
|
||||||
) {
|
) {
|
||||||
// Swipe-to-reply state
|
// Swipe-to-reply state
|
||||||
var swipeOffset by remember { mutableStateOf(0f) }
|
var swipeOffset by remember { mutableStateOf(0f) }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -22,14 +22,19 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.input.pointer.*
|
import androidx.compose.ui.input.pointer.*
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -57,6 +62,24 @@ import kotlin.math.roundToInt
|
|||||||
|
|
||||||
private const val TAG = "ImageViewerScreen"
|
private const val TAG = "ImageViewerScreen"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram-style CubicBezierInterpolator (0.25, 0.1, 0.25, 1.0)
|
||||||
|
* Это основной интерполятор используемый в Telegram для открытия фото
|
||||||
|
*/
|
||||||
|
private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Данные об источнике изображения для shared element transition
|
||||||
|
*/
|
||||||
|
data class ImageSourceBounds(
|
||||||
|
val left: Float,
|
||||||
|
val top: Float,
|
||||||
|
val width: Float,
|
||||||
|
val height: Float,
|
||||||
|
val cornerRadius: Float = 16f,
|
||||||
|
val thumbnailBitmap: Bitmap? = null
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Данные для просмотра изображения
|
* Данные для просмотра изображения
|
||||||
*/
|
*/
|
||||||
@@ -74,12 +97,13 @@ data class ViewableImage(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 📸 Полноэкранный просмотрщик фото в стиле Telegram
|
* 📸 Полноэкранный просмотрщик фото в стиле Telegram
|
||||||
*
|
*
|
||||||
* Функции:
|
* Функции:
|
||||||
|
* - Shared element transition анимация открытия/закрытия (как в Telegram)
|
||||||
* - Свайп влево/вправо для листания фото
|
* - Свайп влево/вправо для листания фото
|
||||||
* - Pinch-to-zoom
|
* - Pinch-to-zoom
|
||||||
* - Свайп вниз для закрытия
|
* - Свайп вниз для закрытия
|
||||||
* - Плавные анимации
|
* - Плавные анимации 200ms с CubicBezier easing
|
||||||
* - Индикатор позиции
|
* - Индикатор позиции
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@@ -89,73 +113,167 @@ fun ImageViewerScreen(
|
|||||||
initialIndex: Int,
|
initialIndex: Int,
|
||||||
privateKey: String,
|
privateKey: String,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
isDarkTheme: Boolean = true
|
isDarkTheme: Boolean = true,
|
||||||
|
sourceBounds: ImageSourceBounds? = null
|
||||||
) {
|
) {
|
||||||
if (images.isEmpty()) {
|
if (images.isEmpty()) {
|
||||||
onDismiss()
|
onDismiss()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 🎬 TELEGRAM-STYLE SHARED ELEMENT ANIMATION
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// Animation state: 0 = animating in, 1 = fully open, 2 = animating out
|
||||||
|
var animationState by remember { mutableStateOf(if (sourceBounds != null) 0 else 1) }
|
||||||
|
var isClosing by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Animation progress (0 = at source, 1 = fullscreen)
|
||||||
|
val animationProgress = remember { Animatable(if (sourceBounds != null) 0f else 1f) }
|
||||||
|
|
||||||
|
// Screen size
|
||||||
|
var screenSize by remember { mutableStateOf(IntSize.Zero) }
|
||||||
|
|
||||||
|
// Start enter animation
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (sourceBounds != null && animationState == 0) {
|
||||||
|
animationProgress.animateTo(
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 200,
|
||||||
|
easing = TelegramEasing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
animationState = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close with animation
|
||||||
|
fun closeWithAnimation() {
|
||||||
|
if (isClosing) return
|
||||||
|
isClosing = true
|
||||||
|
|
||||||
|
if (sourceBounds != null) {
|
||||||
|
scope.launch {
|
||||||
|
animationProgress.animateTo(
|
||||||
|
targetValue = 0f,
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 200,
|
||||||
|
easing = TelegramEasing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Закрываем клавиатуру при открытии
|
// Закрываем клавиатуру при открытии
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
val imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
|
val imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
|
||||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pager state
|
// Pager state
|
||||||
val pagerState = rememberPagerState(
|
val pagerState = rememberPagerState(
|
||||||
initialPage = initialIndex.coerceIn(0, images.size - 1),
|
initialPage = initialIndex.coerceIn(0, images.size - 1),
|
||||||
pageCount = { images.size }
|
pageCount = { images.size }
|
||||||
)
|
)
|
||||||
|
|
||||||
// UI visibility state
|
// UI visibility state
|
||||||
var showControls by remember { mutableStateOf(true) }
|
var showControls by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
// Drag to dismiss
|
// Drag to dismiss
|
||||||
var offsetY by remember { mutableStateOf(0f) }
|
var offsetY by remember { mutableStateOf(0f) }
|
||||||
var isDragging by remember { mutableStateOf(false) }
|
var isDragging by remember { mutableStateOf(false) }
|
||||||
val dismissThreshold = 200f
|
val dismissThreshold = 200f
|
||||||
|
|
||||||
// Animated background alpha based on drag
|
// Animated background alpha based on animation progress and drag
|
||||||
val backgroundAlpha by animateFloatAsState(
|
val baseAlpha = animationProgress.value
|
||||||
targetValue = if (isDragging) {
|
val dragAlpha = if (isDragging) {
|
||||||
(1f - (offsetY.absoluteValue / 500f)).coerceIn(0.3f, 1f)
|
(1f - (offsetY.absoluteValue / 500f)).coerceIn(0.3f, 1f)
|
||||||
} else 1f,
|
} else 1f
|
||||||
animationSpec = tween(150),
|
val backgroundAlpha = baseAlpha * dragAlpha
|
||||||
label = "backgroundAlpha"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Current image info
|
// Current image info
|
||||||
val currentImage = images.getOrNull(pagerState.currentPage)
|
val currentImage = images.getOrNull(pagerState.currentPage)
|
||||||
val dateFormat = remember { SimpleDateFormat("d MMMM, HH:mm", Locale.getDefault()) }
|
val dateFormat = remember { SimpleDateFormat("d MMMM, HH:mm", Locale.getDefault()) }
|
||||||
|
|
||||||
BackHandler {
|
BackHandler {
|
||||||
onDismiss()
|
closeWithAnimation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 🎬 CALCULATE ANIMATED TRANSFORMS (Telegram style)
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
val progress = animationProgress.value
|
||||||
|
|
||||||
|
// Calculate interpolated values for shared element transition
|
||||||
|
val animatedScale: Float
|
||||||
|
val animatedTranslationX: Float
|
||||||
|
val animatedTranslationY: Float
|
||||||
|
val animatedCornerRadius: Float
|
||||||
|
|
||||||
|
if (sourceBounds != null && screenSize.width > 0 && screenSize.height > 0) {
|
||||||
|
// Source state (thumbnail in chat)
|
||||||
|
val sourceScale = sourceBounds.width / screenSize.width.toFloat()
|
||||||
|
val sourceCenterX = sourceBounds.left + sourceBounds.width / 2f
|
||||||
|
val sourceCenterY = sourceBounds.top + sourceBounds.height / 2f
|
||||||
|
val screenCenterX = screenSize.width / 2f
|
||||||
|
val screenCenterY = screenSize.height / 2f
|
||||||
|
|
||||||
|
// Interpolate between source and fullscreen
|
||||||
|
animatedScale = sourceScale + (1f - sourceScale) * progress
|
||||||
|
animatedTranslationX = (sourceCenterX - screenCenterX) * (1f - progress)
|
||||||
|
animatedTranslationY = (sourceCenterY - screenCenterY) * (1f - progress)
|
||||||
|
animatedCornerRadius = sourceBounds.cornerRadius * (1f - progress)
|
||||||
|
} else {
|
||||||
|
animatedScale = 1f
|
||||||
|
animatedTranslationX = 0f
|
||||||
|
animatedTranslationY = 0f
|
||||||
|
animatedCornerRadius = 0f
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.onSizeChanged { screenSize = it }
|
||||||
.background(Color.Black.copy(alpha = backgroundAlpha))
|
.background(Color.Black.copy(alpha = backgroundAlpha))
|
||||||
) {
|
) {
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 📸 HORIZONTAL PAGER
|
// 📸 HORIZONTAL PAGER with shared element animation
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.offset { IntOffset(0, offsetY.roundToInt()) },
|
.graphicsLayer {
|
||||||
|
// Apply Telegram-style shared element transform
|
||||||
|
scaleX = animatedScale
|
||||||
|
scaleY = animatedScale
|
||||||
|
translationX = animatedTranslationX
|
||||||
|
translationY = animatedTranslationY + offsetY
|
||||||
|
// Clip with animated corner radius
|
||||||
|
clip = animatedCornerRadius > 0f
|
||||||
|
shape = if (animatedCornerRadius > 0f) {
|
||||||
|
RoundedCornerShape(animatedCornerRadius.dp)
|
||||||
|
} else {
|
||||||
|
RectangleShape
|
||||||
|
}
|
||||||
|
},
|
||||||
key = { images[it].attachmentId }
|
key = { images[it].attachmentId }
|
||||||
) { page ->
|
) { page ->
|
||||||
val image = images[page]
|
val image = images[page]
|
||||||
|
|
||||||
ZoomableImage(
|
ZoomableImage(
|
||||||
image = image,
|
image = image,
|
||||||
privateKey = privateKey,
|
privateKey = privateKey,
|
||||||
@@ -167,7 +285,7 @@ fun ImageViewerScreen(
|
|||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
isDragging = false
|
isDragging = false
|
||||||
if (offsetY.absoluteValue > dismissThreshold) {
|
if (offsetY.absoluteValue > dismissThreshold) {
|
||||||
onDismiss()
|
closeWithAnimation()
|
||||||
} else {
|
} else {
|
||||||
offsetY = 0f
|
offsetY = 0f
|
||||||
}
|
}
|
||||||
@@ -176,12 +294,12 @@ fun ImageViewerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 🎛️ TOP BAR
|
// 🎛️ TOP BAR (показываем только когда анимация завершена)
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showControls,
|
visible = showControls && animationState == 1 && !isClosing,
|
||||||
enter = fadeIn() + slideInVertically { -it },
|
enter = fadeIn(tween(150)) + slideInVertically { -it },
|
||||||
exit = fadeOut() + slideOutVertically { -it },
|
exit = fadeOut(tween(100)) + slideOutVertically { -it },
|
||||||
modifier = Modifier.align(Alignment.TopCenter)
|
modifier = Modifier.align(Alignment.TopCenter)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -195,7 +313,7 @@ fun ImageViewerScreen(
|
|||||||
) {
|
) {
|
||||||
// Back button
|
// Back button
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onDismiss,
|
onClick = { closeWithAnimation() },
|
||||||
modifier = Modifier.align(Alignment.CenterStart)
|
modifier = Modifier.align(Alignment.CenterStart)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -230,13 +348,13 @@ fun ImageViewerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 📍 PAGE INDICATOR (если больше 1 фото)
|
// 📍 PAGE INDICATOR (если больше 1 фото, показываем когда анимация завершена)
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
if (images.size > 1) {
|
if (images.size > 1) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showControls,
|
visible = showControls && animationState == 1 && !isClosing,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(tween(150)),
|
||||||
exit = fadeOut(),
|
exit = fadeOut(tween(100)),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.padding(bottom = 32.dp)
|
.padding(bottom = 32.dp)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
@@ -447,7 +448,7 @@ private fun QuickActionsRow(
|
|||||||
) {
|
) {
|
||||||
val buttonColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
val buttonColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
||||||
val iconColor = if (isDarkTheme) Color.White else Color.Black
|
val iconColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -461,25 +462,28 @@ private fun QuickActionsRow(
|
|||||||
label = "Camera",
|
label = "Camera",
|
||||||
backgroundColor = PrimaryBlue,
|
backgroundColor = PrimaryBlue,
|
||||||
iconColor = Color.White,
|
iconColor = Color.White,
|
||||||
onClick = onCameraClick
|
onClick = onCameraClick,
|
||||||
|
animationDelay = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
// Avatar button
|
// Avatar button
|
||||||
QuickActionButton(
|
QuickActionButton(
|
||||||
icon = TablerIcons.User,
|
icon = TablerIcons.User,
|
||||||
label = "Avatar",
|
label = "Avatar",
|
||||||
backgroundColor = buttonColor,
|
backgroundColor = buttonColor,
|
||||||
iconColor = iconColor,
|
iconColor = iconColor,
|
||||||
onClick = onAvatarClick
|
onClick = onAvatarClick,
|
||||||
|
animationDelay = 50
|
||||||
)
|
)
|
||||||
|
|
||||||
// File button
|
// File button
|
||||||
QuickActionButton(
|
QuickActionButton(
|
||||||
icon = TablerIcons.File,
|
icon = TablerIcons.File,
|
||||||
label = "File",
|
label = "File",
|
||||||
backgroundColor = buttonColor,
|
backgroundColor = buttonColor,
|
||||||
iconColor = iconColor,
|
iconColor = iconColor,
|
||||||
onClick = onFileClick
|
onClick = onFileClick,
|
||||||
|
animationDelay = 100
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,8 +494,22 @@ private fun QuickActionButton(
|
|||||||
label: String,
|
label: String,
|
||||||
backgroundColor: Color,
|
backgroundColor: Color,
|
||||||
iconColor: Color,
|
iconColor: Color,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit,
|
||||||
|
animationDelay: Int = 0
|
||||||
) {
|
) {
|
||||||
|
// Bounce animation for icon
|
||||||
|
val iconScale = remember { Animatable(0f) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
kotlinx.coroutines.delay(animationDelay.toLong())
|
||||||
|
iconScale.animateTo(
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = 0.5f, // Bouncy effect
|
||||||
|
stiffness = 400f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier.clickable(
|
modifier = Modifier.clickable(
|
||||||
@@ -503,6 +521,10 @@ private fun QuickActionButton(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(56.dp)
|
.size(56.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = iconScale.value
|
||||||
|
scaleY = iconScale.value
|
||||||
|
}
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(backgroundColor),
|
.background(backgroundColor),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
@@ -581,6 +603,18 @@ private fun CameraGridItem(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
||||||
|
|
||||||
|
// Bounce animation for camera icon
|
||||||
|
val iconScale = remember { Animatable(0f) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
iconScale.animateTo(
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = 0.5f,
|
||||||
|
stiffness = 400f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Check if camera permission is granted - use mutableState for reactivity
|
// Check if camera permission is granted - use mutableState for reactivity
|
||||||
var hasCameraPermission by remember {
|
var hasCameraPermission by remember {
|
||||||
@@ -655,7 +689,12 @@ private fun CameraGridItem(
|
|||||||
imageVector = TablerIcons.Camera,
|
imageVector = TablerIcons.Camera,
|
||||||
contentDescription = "Camera",
|
contentDescription = "Camera",
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = iconScale.value
|
||||||
|
scaleY = iconScale.value
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -668,7 +707,12 @@ private fun CameraGridItem(
|
|||||||
imageVector = TablerIcons.Camera,
|
imageVector = TablerIcons.Camera,
|
||||||
contentDescription = "Camera",
|
contentDescription = "Camera",
|
||||||
tint = PrimaryBlue,
|
tint = PrimaryBlue,
|
||||||
modifier = Modifier.size(40.dp)
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = iconScale.value
|
||||||
|
scaleY = iconScale.value
|
||||||
|
}
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
Reference in New Issue
Block a user