fix: fix image components behavior

This commit is contained in:
2026-02-01 21:12:08 +05:00
parent 1e9860a221
commit b44fd3da29
6 changed files with 1347 additions and 1006 deletions

View File

@@ -180,9 +180,10 @@ fun ChatDetailScreen(
// 📨 Forward: показывать ли выбор чата
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 imageViewerInitialIndex by remember { mutableStateOf(0) }
var imageViewerSourceBounds by remember { mutableStateOf<ImageSourceBounds?>(null) }
// 📷 Camera: URI для сохранения фото
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
@@ -1763,15 +1764,16 @@ fun ChatDetailScreen(
message.id
)
},
onImageClick = { attachmentId ->
// 📸 Открыть просмотрщик фото
onImageClick = { attachmentId, bounds ->
// 📸 Открыть просмотрщик фото с shared element animation
val allImages = extractImagesFromMessages(
messages,
messages,
currentUserPublicKey,
user.publicKey,
user.title.ifEmpty { "User" }
)
imageViewerInitialIndex = findImageIndex(allImages, attachmentId)
imageViewerSourceBounds = bounds
showImageViewer = true
}
)
@@ -1784,10 +1786,10 @@ fun ChatDetailScreen(
}
} // Закрытие Box
// 📸 Image Viewer Overlay
// 📸 Image Viewer Overlay with Telegram-style shared element animation
if (showImageViewer) {
val allImages = extractImagesFromMessages(
messages,
messages,
currentUserPublicKey,
user.publicKey,
user.title.ifEmpty { "User" }
@@ -1796,8 +1798,12 @@ fun ChatDetailScreen(
images = allImages,
initialIndex = imageViewerInitialIndex,
privateKey = currentUserPrivateKey,
onDismiss = { showImageViewer = false },
isDarkTheme = isDarkTheme
onDismiss = {
showImageViewer = false
imageViewerSourceBounds = null
},
isDarkTheme = isDarkTheme,
sourceBounds = imageViewerSourceBounds
)
}
@@ -2055,8 +2061,8 @@ fun ChatDetailScreen(
pendingCameraPhotoUri?.let { uri ->
ImageEditorScreen(
imageUri = uri,
onDismiss = {
pendingCameraPhotoUri = null
onDismiss = {
pendingCameraPhotoUri = null
},
onSave = { editedUri ->
// Fallback если onSaveWithCaption не сработал
@@ -2082,7 +2088,8 @@ fun ChatDetailScreen(
}
},
isDarkTheme = isDarkTheme,
showCaptionInput = true
showCaptionInput = true,
recipientName = user.title
)
}
@@ -2123,7 +2130,8 @@ fun ChatDetailScreen(
}
}
},
isDarkTheme = isDarkTheme
isDarkTheme = isDarkTheme,
recipientName = user.title
)
}
}

View File

@@ -3,7 +3,9 @@ package com.rosetta.messenger.ui.chats.components
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -18,10 +20,14 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.Color
import androidx.compose.ui.graphics.asImageBitmap
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -72,7 +78,7 @@ fun MessageAttachments(
avatarRepository: AvatarRepository? = null,
currentUserPublicKey: String = "",
hasCaption: Boolean = false, // Если есть caption - время показывается под фото, не на фото
onImageClick: (attachmentId: String) -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
modifier: Modifier = Modifier
) {
if (attachments.isEmpty()) return
@@ -156,7 +162,7 @@ fun ImageCollage(
timestamp: java.util.Date,
messageStatus: MessageStatus = MessageStatus.READ,
hasCaption: Boolean = false, // Если есть caption - время показывается под фото
onImageClick: (attachmentId: String) -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
modifier: Modifier = Modifier
) {
val count = attachments.size
@@ -458,11 +464,14 @@ fun ImageAttachment(
showTimeOverlay: Boolean = true,
aspectRatio: Float? = null,
fillMaxSize: Boolean = false,
onImageClick: (attachmentId: String) -> Unit = {}
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// Bounds для shared element transition
var imageBounds by remember { mutableStateOf<Rect?>(null) }
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
var imageBitmap by remember { mutableStateOf<Bitmap?>(null) }
var blurhashBitmap by remember { mutableStateOf<Bitmap?>(null) }
@@ -686,17 +695,33 @@ fun ImageAttachment(
else -> Modifier.size(220.dp)
}
val cornerRadius = if (fillMaxSize) 0f else 12f
Box(
modifier =
sizeModifier
.clip(RoundedCornerShape(if (fillMaxSize) 0.dp else 12.dp))
.clip(RoundedCornerShape(cornerRadius.dp))
.background(Color.Transparent)
.onGloballyPositioned { coordinates ->
// Capture bounds for shared element transition
imageBounds = coordinates.boundsInWindow()
}
.clickable {
when (downloadStatus) {
DownloadStatus.NOT_DOWNLOADED -> download()
DownloadStatus.DOWNLOADED -> {
// 📸 Open image viewer
onImageClick(attachment.id)
// 📸 Open image viewer with bounds for animation
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()
else -> {}
@@ -898,6 +923,18 @@ fun FileAttachment(
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
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 downloadTag = getDownloadTag(preview)
val (fileSize, fileName) = parseFilePreview(preview)
@@ -970,7 +1007,15 @@ fun FileAttachment(
verticalAlignment = Alignment.CenterVertically
) {
// 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(
modifier =
@@ -1153,6 +1198,18 @@ fun AvatarAttachment(
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)
LaunchedEffect(attachment.id) {
withContext(Dispatchers.IO) {
@@ -1315,6 +1372,10 @@ fun AvatarAttachment(
Box(
modifier =
Modifier.size(56.dp)
.graphicsLayer {
scaleX = avatarScale.value
scaleY = avatarScale.value
}
.clip(CircleShape)
.background(
if (isOutgoing) Color.White.copy(0.15f)

View File

@@ -231,7 +231,7 @@ fun MessageBubble(
onReplyClick: (String) -> Unit = {},
onRetry: () -> Unit = {},
onDelete: () -> Unit = {},
onImageClick: (attachmentId: String) -> Unit = {}
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }
) {
// Swipe-to-reply state
var swipeOffset by remember { mutableStateOf(0f) }

View File

@@ -22,14 +22,19 @@ 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.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.asImageBitmap
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.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
@@ -57,6 +62,24 @@ import kotlin.math.roundToInt
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
*
*
* Функции:
* - Shared element transition анимация открытия/закрытия (как в Telegram)
* - Свайп влево/вправо для листания фото
* - Pinch-to-zoom
* - Свайп вниз для закрытия
* - Плавные анимации
* - Плавные анимации 200ms с CubicBezier easing
* - Индикатор позиции
*/
@OptIn(ExperimentalFoundationApi::class)
@@ -89,73 +113,167 @@ fun ImageViewerScreen(
initialIndex: Int,
privateKey: String,
onDismiss: () -> Unit,
isDarkTheme: Boolean = true
isDarkTheme: Boolean = true,
sourceBounds: ImageSourceBounds? = null
) {
if (images.isEmpty()) {
onDismiss()
return
}
val scope = rememberCoroutineScope()
val context = LocalContext.current
val view = LocalView.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) {
val imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
}
// 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"
)
// Animated background alpha based on animation progress and drag
val baseAlpha = animationProgress.value
val dragAlpha = if (isDragging) {
(1f - (offsetY.absoluteValue / 500f)).coerceIn(0.3f, 1f)
} else 1f
val backgroundAlpha = baseAlpha * dragAlpha
// Current image info
val currentImage = images.getOrNull(pagerState.currentPage)
val dateFormat = remember { SimpleDateFormat("d MMMM, HH:mm", Locale.getDefault()) }
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(
modifier = Modifier
.fillMaxSize()
.onSizeChanged { screenSize = it }
.background(Color.Black.copy(alpha = backgroundAlpha))
) {
// ═══════════════════════════════════════════════════════════
// 📸 HORIZONTAL PAGER
// 📸 HORIZONTAL PAGER with shared element animation
// ═══════════════════════════════════════════════════════════
HorizontalPager(
state = pagerState,
modifier = Modifier
.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 }
) { page ->
val image = images[page]
ZoomableImage(
image = image,
privateKey = privateKey,
@@ -167,7 +285,7 @@ fun ImageViewerScreen(
onDragEnd = {
isDragging = false
if (offsetY.absoluteValue > dismissThreshold) {
onDismiss()
closeWithAnimation()
} else {
offsetY = 0f
}
@@ -176,12 +294,12 @@ fun ImageViewerScreen(
}
// ═══════════════════════════════════════════════════════════
// 🎛️ TOP BAR
// 🎛️ TOP BAR (показываем только когда анимация завершена)
// ═══════════════════════════════════════════════════════════
AnimatedVisibility(
visible = showControls,
enter = fadeIn() + slideInVertically { -it },
exit = fadeOut() + slideOutVertically { -it },
visible = showControls && animationState == 1 && !isClosing,
enter = fadeIn(tween(150)) + slideInVertically { -it },
exit = fadeOut(tween(100)) + slideOutVertically { -it },
modifier = Modifier.align(Alignment.TopCenter)
) {
Box(
@@ -195,7 +313,7 @@ fun ImageViewerScreen(
) {
// Back button
IconButton(
onClick = onDismiss,
onClick = { closeWithAnimation() },
modifier = Modifier.align(Alignment.CenterStart)
) {
Icon(
@@ -230,13 +348,13 @@ fun ImageViewerScreen(
}
// ═══════════════════════════════════════════════════════════
// 📍 PAGE INDICATOR (если больше 1 фото)
// 📍 PAGE INDICATOR (если больше 1 фото, показываем когда анимация завершена)
// ═══════════════════════════════════════════════════════════
if (images.size > 1) {
AnimatedVisibility(
visible = showControls,
enter = fadeIn(),
exit = fadeOut(),
visible = showControls && animationState == 1 && !isClosing,
enter = fadeIn(tween(150)),
exit = fadeOut(tween(100)),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 32.dp)

View File

@@ -11,6 +11,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -447,7 +448,7 @@ private fun QuickActionsRow(
) {
val buttonColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
val iconColor = if (isDarkTheme) Color.White else Color.Black
Row(
modifier = Modifier
.fillMaxWidth()
@@ -461,25 +462,28 @@ private fun QuickActionsRow(
label = "Camera",
backgroundColor = PrimaryBlue,
iconColor = Color.White,
onClick = onCameraClick
onClick = onCameraClick,
animationDelay = 0
)
// Avatar button
QuickActionButton(
icon = TablerIcons.User,
label = "Avatar",
backgroundColor = buttonColor,
iconColor = iconColor,
onClick = onAvatarClick
onClick = onAvatarClick,
animationDelay = 50
)
// File button
QuickActionButton(
icon = TablerIcons.File,
label = "File",
backgroundColor = buttonColor,
iconColor = iconColor,
onClick = onFileClick
onClick = onFileClick,
animationDelay = 100
)
}
}
@@ -490,8 +494,22 @@ private fun QuickActionButton(
label: String,
backgroundColor: 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(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable(
@@ -503,6 +521,10 @@ private fun QuickActionButton(
Box(
modifier = Modifier
.size(56.dp)
.graphicsLayer {
scaleX = iconScale.value
scaleY = iconScale.value
}
.clip(CircleShape)
.background(backgroundColor),
contentAlignment = Alignment.Center
@@ -581,6 +603,18 @@ private fun CameraGridItem(
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
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
var hasCameraPermission by remember {
@@ -655,7 +689,12 @@ private fun CameraGridItem(
imageVector = TablerIcons.Camera,
contentDescription = "Camera",
tint = Color.White,
modifier = Modifier.size(32.dp)
modifier = Modifier
.size(32.dp)
.graphicsLayer {
scaleX = iconScale.value
scaleY = iconScale.value
}
)
}
} else {
@@ -668,7 +707,12 @@ private fun CameraGridItem(
imageVector = TablerIcons.Camera,
contentDescription = "Camera",
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))
Text(