fix: fix image components behavior
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
|
||||
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.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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user