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: показывать ли выбор чата // 📨 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,8 +1764,8 @@ fun ChatDetailScreen(
message.id message.id
) )
}, },
onImageClick = { attachmentId -> onImageClick = { attachmentId, bounds ->
// 📸 Открыть просмотрщик фото // 📸 Открыть просмотрщик фото с shared element animation
val allImages = extractImagesFromMessages( val allImages = extractImagesFromMessages(
messages, messages,
currentUserPublicKey, currentUserPublicKey,
@@ -1772,6 +1773,7 @@ fun ChatDetailScreen(
user.title.ifEmpty { "User" } user.title.ifEmpty { "User" }
) )
imageViewerInitialIndex = findImageIndex(allImages, attachmentId) imageViewerInitialIndex = findImageIndex(allImages, attachmentId)
imageViewerSourceBounds = bounds
showImageViewer = true showImageViewer = true
} }
) )
@@ -1784,7 +1786,7 @@ 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,
@@ -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
) )
} }
@@ -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
) )
} }
} }

View File

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

View File

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

View File

@@ -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
)
/** /**
* Данные для просмотра изображения * Данные для просмотра изображения
*/ */
@@ -76,10 +99,11 @@ 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,7 +113,8 @@ 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()
@@ -100,6 +125,56 @@ fun ImageViewerScreen(
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) {
@@ -122,36 +197,79 @@ fun ImageViewerScreen(
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]
@@ -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)

View File

@@ -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
@@ -461,7 +462,8 @@ 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
@@ -470,7 +472,8 @@ private fun QuickActionsRow(
label = "Avatar", label = "Avatar",
backgroundColor = buttonColor, backgroundColor = buttonColor,
iconColor = iconColor, iconColor = iconColor,
onClick = onAvatarClick onClick = onAvatarClick,
animationDelay = 50
) )
// File button // File button
@@ -479,7 +482,8 @@ private fun QuickActionsRow(
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
@@ -582,6 +604,18 @@ private fun CameraGridItem(
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 {
mutableStateOf( mutableStateOf(
@@ -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(