feat: enhance image viewer navigation with tap animations and controls visibility

This commit is contained in:
2026-02-09 21:14:10 +05:00
parent efdb93d136
commit 3c37a3b0e5
12 changed files with 1620 additions and 376 deletions

View File

@@ -222,6 +222,34 @@ fun ChatDetailScreen(
// Триггер для возврата фокуса на инпут после отправки фото из редактора
var inputFocusTrigger by remember { mutableStateOf(0) }
// Блокируем swipe-back родительского экрана, пока открыт fullscreen/media overlay.
val shouldLockParentSwipeBack by
remember(
showImageViewer,
showMediaPicker,
showEmojiPicker,
pendingCameraPhotoUri,
pendingGalleryImages,
showInAppCamera
) {
derivedStateOf {
showImageViewer ||
showMediaPicker ||
showEmojiPicker ||
pendingCameraPhotoUri != null ||
pendingGalleryImages.isNotEmpty() ||
showInAppCamera
}
}
LaunchedEffect(shouldLockParentSwipeBack) {
onImageViewerChanged(shouldLockParentSwipeBack)
}
DisposableEffect(Unit) {
onDispose { onImageViewerChanged(false) }
}
// <20>📷 Camera launcher
val cameraLauncher =
rememberLauncherForActivityResult(

View File

@@ -1420,11 +1420,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** 🔥 Очистить reply/forward */
fun clearReplyMessages() {
viewModelScope.launch {
delay(350) // Задержка после закрытия панели (анимация fadeOut + shrinkVertically)
_replyMessages.value = emptyList()
_isForwardMode.value = false
}
_replyMessages.value = emptyList()
_isForwardMode.value = false
}
/** 🔥 Удалить сообщение (для ошибки отправки) */
@@ -1793,6 +1790,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
attachmentsJson = optimisticAttachmentsJson,
opponentPublicKey = recipient
)
// Обновляем dialogs сразу, чтобы в списке чатов мгновенно показать "Photo" + часы
// даже если пользователь вышел из экрана чата во время загрузки.
saveDialog(
lastMessage = if (text.isNotEmpty()) text else "photo",
timestamp = timestamp,
opponentPublicKey = recipient
)
} catch (_: Exception) {
}

View File

@@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import android.util.Log
import android.view.inputmethod.InputMethodManager
@@ -54,6 +55,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.exifinterface.media.ExifInterface
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
import com.rosetta.messenger.ui.components.AppleEmojiTextField
@@ -159,6 +161,10 @@ fun ImageEditorScreen(
// Запуск enter анимации (пропускаем если из камеры — уже alpha=1)
LaunchedEffect(Unit) {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
if (!skipEnterAnimation) {
animationProgress.animateTo(
targetValue = 1f,
@@ -314,12 +320,25 @@ fun ImageEditorScreen(
result.data?.let { data ->
UCrop.getOutput(data)?.let { croppedUri ->
currentImageUri = croppedUri
photoEditorView?.source?.setImageURI(croppedUri)
}
}
}
}
// Загружаем изображение с корректным EXIF, чтобы в редакторе не было переворота/зеркала.
LaunchedEffect(currentImageUri, photoEditorView) {
val targetView = photoEditorView ?: return@LaunchedEffect
val orientedBitmap = withContext(Dispatchers.IO) {
loadBitmapRespectExif(context, currentImageUri)
}
orientedBitmap?.let { bitmap ->
targetView.source.setImageBitmap(bitmap)
targetView.source.scaleType = ImageView.ScaleType.FIT_CENTER
targetView.source.adjustViewBounds = true
targetView.source.setPadding(0, 0, 0, 0)
}
}
BackHandler { animatedDismiss() }
// ═══════════════════════════════════════════════════════════════
@@ -396,7 +415,6 @@ fun ImageEditorScreen(
// Простой FIT_CENTER - показывает ВСЁ фото, центрирует
source.apply {
setImageURI(currentImageUri)
scaleType = ImageView.ScaleType.FIT_CENTER
adjustViewBounds = true
setPadding(0, 0, 0, 0)
@@ -605,27 +623,35 @@ fun ImageEditorScreen(
onToggleEmojiPicker = { toggleEmojiPicker() },
onEditTextViewCreated = { editTextView = it },
onSend = {
// 🚀 Сохраняем caption для использования после анимации
if (isSaving || isClosing) return@TelegramCaptionBar
val captionToSend = caption
val uriToSend = currentImageUri
// ✈️ Сразу запускаем fade-out анимацию (как в Telegram)
// Фото появится в чате через optimistic UI
scope.launch {
saveEditedImage(context, photoEditor, photoEditorView, uriToSend) { savedUri ->
if (savedUri != null) {
// Вызываем callback (он запустит sendImageMessage с optimistic UI)
if (onSaveWithCaption != null) {
onSaveWithCaption(savedUri, captionToSend)
} else {
onSave(savedUri)
}
}
isSaving = true
val savedUri =
saveEditedImageSync(
context = context,
photoEditor = photoEditor,
photoEditorView = photoEditorView,
imageUri = uriToSend
)
isSaving = false
val finalUri = savedUri ?: uriToSend
// Сначала отправляем, затем закрываем экран
if (onSaveWithCaption != null) {
onSaveWithCaption(finalUri, captionToSend)
} else {
onSave(finalUri)
}
animatedDismiss()
}
// 🎬 Плавно закрываем экран (fade-out)
animatedDismiss()
}
)
@@ -735,12 +761,15 @@ fun ImageEditorScreen(
if (!showCaptionInput) {
scope.launch {
isSaving = true
saveEditedImage(context, photoEditor, photoEditorView, currentImageUri) { savedUri ->
isSaving = false
if (savedUri != null) {
onSave(savedUri)
}
}
val savedUri =
saveEditedImageSync(
context = context,
photoEditor = photoEditor,
photoEditorView = photoEditorView,
imageUri = currentImageUri
)
isSaving = false
onSave(savedUri ?: currentImageUri)
}
}
}
@@ -1155,7 +1184,7 @@ private fun TelegramCaptionBar(
}
}
/** Save edited image and return the URI - returns ORIGINAL without processing */
/** Save edited image and return the URI (with all editor changes). */
private suspend fun saveEditedImage(
context: Context,
photoEditor: PhotoEditor?,
@@ -1163,9 +1192,13 @@ private suspend fun saveEditedImage(
imageUri: Uri,
onResult: (Uri?) -> Unit
) {
// Просто возвращаем оригинальный URI без обработки через PhotoEditor
// Это устраняет проблему с обрезкой изображений
onResult(imageUri)
saveEditedImageOld(
context = context,
photoEditor = photoEditor,
photoEditorView = photoEditorView,
imageUri = imageUri,
onResult = onResult
)
}
/** OLD VERSION - Save edited image with crop logic (disabled due to cropping issues) */
@@ -1183,126 +1216,50 @@ private suspend fun saveEditedImageOld(
return
}
withContext(Dispatchers.IO) {
try {
// Загружаем оригинальное изображение и сохраняем его напрямую
// PhotoEditor с setClipSourceImage(true) должен обрезать черные полосы автоматически
val tempFile = File(context.cacheDir, "temp_${System.currentTimeMillis()}.png")
try {
val tempFile = File(context.cacheDir, "edited_${System.currentTimeMillis()}.png")
val saveSettings = SaveSettings.Builder()
.setClearViewsEnabled(false)
.setTransparencyEnabled(true)
.setCompressFormat(Bitmap.CompressFormat.PNG)
.setCompressQuality(100)
.build()
val saveSettings = SaveSettings.Builder()
.setClearViewsEnabled(false)
.setTransparencyEnabled(true)
.build()
val savedPath = suspendCancellableCoroutine<String?> { continuation ->
photoEditor.saveAsFile(
tempFile.absolutePath,
saveSettings,
object : PhotoEditor.OnSaveListener {
override fun onSuccess(imagePath: String) {
continuation.resume(imagePath)
}
override fun onFailure(exception: Exception) {
continuation.resume(null)
}
val savedPath = suspendCancellableCoroutine<String?> { continuation ->
photoEditor.saveAsFile(
tempFile.absolutePath,
saveSettings,
object : PhotoEditor.OnSaveListener {
override fun onSuccess(imagePath: String) {
continuation.resume(imagePath)
}
)
}
if (savedPath == null) {
// Ошибка сохранения - возвращаем оригинал
withContext(Dispatchers.Main) { onResult(imageUri) }
return@withContext
}
// Загружаем сохраненное изображение
val savedBitmap = BitmapFactory.decodeFile(savedPath)
if (savedBitmap == null) {
withContext(Dispatchers.Main) { onResult(Uri.fromFile(tempFile)) }
return@withContext
}
// Получаем размеры оригинального изображения
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(imageUri)?.use { stream ->
BitmapFactory.decodeStream(stream, null, options)
}
val imageWidth = options.outWidth
val imageHeight = options.outHeight
if (imageWidth <= 0 || imageHeight <= 0) {
withContext(Dispatchers.Main) { onResult(Uri.fromFile(tempFile)) }
return@withContext
}
val viewWidth = savedBitmap.width
val viewHeight = savedBitmap.height
// Соотношение сторон оригинала и сохраненного
val originalRatio = imageWidth.toFloat() / imageHeight
val savedRatio = viewWidth.toFloat() / viewHeight
// Если соотношения примерно равны - черных полос нет, возвращаем как есть
if (kotlin.math.abs(originalRatio - savedRatio) < 0.01f) {
savedBitmap.recycle()
withContext(Dispatchers.Main) { onResult(Uri.fromFile(tempFile)) }
return@withContext
}
// Вычисляем где находится изображение (FIT_CENTER логика)
val scale = minOf(
viewWidth.toFloat() / imageWidth,
viewHeight.toFloat() / imageHeight
override fun onFailure(exception: Exception) {
continuation.resume(null)
}
}
)
val scaledWidth = (imageWidth * scale).toInt()
val scaledHeight = (imageHeight * scale).toInt()
val left = ((viewWidth - scaledWidth) / 2).coerceAtLeast(0)
val top = ((viewHeight - scaledHeight) / 2).coerceAtLeast(0)
val cropWidth = scaledWidth.coerceIn(1, viewWidth - left)
val cropHeight = scaledHeight.coerceIn(1, viewHeight - top)
// Обрезаем черные полосы
val croppedBitmap = Bitmap.createBitmap(
savedBitmap,
left,
top,
cropWidth,
cropHeight
)
// Сохраняем обрезанное изображение
val finalFile = File(context.cacheDir, "edited_${System.currentTimeMillis()}.png")
java.io.FileOutputStream(finalFile).use { out ->
croppedBitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
savedBitmap.recycle()
croppedBitmap.recycle()
tempFile.delete()
withContext(Dispatchers.Main) {
onResult(Uri.fromFile(finalFile))
}
} catch (e: Exception) {
// При ошибке возвращаем оригинал
withContext(Dispatchers.Main) { onResult(imageUri) }
}
// Возвращаем сохраненный файл без ручного кропа, чтобы не резать изображение
onResult(savedPath?.let { Uri.fromFile(File(it)) } ?: imageUri)
} catch (e: Exception) {
onResult(imageUri)
}
}
/** Save edited image synchronously - returns ORIGINAL URI without any processing */
/** Save edited image synchronously (with all editor changes). */
private suspend fun saveEditedImageSync(
context: Context,
photoEditor: PhotoEditor?,
photoEditorView: PhotoEditorView?,
imageUri: Uri
): Uri? {
// Просто возвращаем оригинальный URI без обработки
// PhotoEditor вызывает проблемы с обрезкой - пользователь получает оригинал
return imageUri
return saveEditedImageSyncOld(
context = context,
photoEditor = photoEditor,
photoEditorView = photoEditorView,
imageUri = imageUri
)
}
/** Save edited image synchronously - OLD VERSION with crop logic (disabled) */
@@ -1318,109 +1275,37 @@ private suspend fun saveEditedImageSyncOld(
return imageUri
}
return withContext(Dispatchers.IO) {
try {
val tempFile = File(
context.cacheDir,
"temp_${System.currentTimeMillis()}_${(0..9999).random()}.png"
)
return try {
val tempFile = File(
context.cacheDir,
"edited_${System.currentTimeMillis()}_${(0..9999).random()}.png"
)
val saveSettings = SaveSettings.Builder()
.setClearViewsEnabled(false)
.setTransparencyEnabled(true)
.build()
val saveSettings = SaveSettings.Builder()
.setClearViewsEnabled(false)
.setTransparencyEnabled(true)
.setCompressFormat(Bitmap.CompressFormat.PNG)
.setCompressQuality(100)
.build()
val savedPath = suspendCancellableCoroutine<String?> { continuation ->
photoEditor.saveAsFile(
tempFile.absolutePath,
saveSettings,
object : PhotoEditor.OnSaveListener {
override fun onSuccess(imagePath: String) {
continuation.resume(imagePath)
}
override fun onFailure(exception: Exception) {
continuation.resume(null)
}
val savedPath = suspendCancellableCoroutine<String?> { continuation ->
photoEditor.saveAsFile(
tempFile.absolutePath,
saveSettings,
object : PhotoEditor.OnSaveListener {
override fun onSuccess(imagePath: String) {
continuation.resume(imagePath)
}
)
}
if (savedPath == null) {
// Ошибка - возвращаем оригинал
return@withContext imageUri
}
// Загружаем сохраненное изображение
val savedBitmap = BitmapFactory.decodeFile(savedPath)
?: return@withContext Uri.fromFile(tempFile)
// Получаем размеры оригинального изображения
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(imageUri)?.use { stream ->
BitmapFactory.decodeStream(stream, null, options)
}
val imageWidth = options.outWidth
val imageHeight = options.outHeight
if (imageWidth <= 0 || imageHeight <= 0) {
return@withContext Uri.fromFile(tempFile)
}
val viewWidth = savedBitmap.width
val viewHeight = savedBitmap.height
// Соотношение сторон оригинала и сохраненного
val originalRatio = imageWidth.toFloat() / imageHeight
val savedRatio = viewWidth.toFloat() / viewHeight
// Если соотношения примерно равны - черных полос нет
if (kotlin.math.abs(originalRatio - savedRatio) < 0.01f) {
savedBitmap.recycle()
return@withContext Uri.fromFile(tempFile)
}
// Вычисляем где находится изображение (FIT_CENTER логика)
val scale = minOf(
viewWidth.toFloat() / imageWidth,
viewHeight.toFloat() / imageHeight
override fun onFailure(exception: Exception) {
continuation.resume(null)
}
}
)
val scaledWidth = (imageWidth * scale).toInt()
val scaledHeight = (imageHeight * scale).toInt()
val left = ((viewWidth - scaledWidth) / 2).coerceAtLeast(0)
val top = ((viewHeight - scaledHeight) / 2).coerceAtLeast(0)
val cropWidth = scaledWidth.coerceIn(1, viewWidth - left)
val cropHeight = scaledHeight.coerceIn(1, viewHeight - top)
// Обрезаем черные полосы
val croppedBitmap = Bitmap.createBitmap(
savedBitmap,
left,
top,
cropWidth,
cropHeight
)
// Сохраняем обрезанное изображение
val finalFile = File(
context.cacheDir,
"edited_${System.currentTimeMillis()}_${(0..9999).random()}.png"
)
java.io.FileOutputStream(finalFile).use { out ->
croppedBitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
savedBitmap.recycle()
croppedBitmap.recycle()
tempFile.delete()
Uri.fromFile(finalFile)
} catch (e: Exception) {
// При ошибке возвращаем оригинал
imageUri
}
savedPath?.let { Uri.fromFile(File(it)) } ?: imageUri
} catch (e: Exception) {
imageUri
}
}
@@ -1474,6 +1359,7 @@ fun MultiImageEditorScreen(
val context = LocalContext.current
val scope = rememberCoroutineScope()
val view = LocalView.current
val focusManager = LocalFocusManager.current
// ═══════════════════════════════════════════════════════════════
// 🎬 TELEGRAM-STYLE FADE ANIMATION
@@ -1483,6 +1369,10 @@ fun MultiImageEditorScreen(
// Запуск enter анимации
LaunchedEffect(Unit) {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
animationProgress.animateTo(
targetValue = 1f,
animationSpec = tween(
@@ -1615,14 +1505,13 @@ fun MultiImageEditorScreen(
// Загружаем изображение
scope.launch(Dispatchers.IO) {
try {
val inputStream =
ctx.contentResolver.openInputStream(imagesWithCaptions[page].uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
val bitmap = loadBitmapRespectExif(ctx, imagesWithCaptions[page].uri)
withContext(Dispatchers.Main) {
source.apply {
setImageBitmap(bitmap)
if (bitmap != null) {
setImageBitmap(bitmap)
}
scaleType = ImageView.ScaleType.FIT_CENTER
adjustViewBounds = true
setPadding(0, 0, 0, 0)
@@ -1630,6 +1519,7 @@ fun MultiImageEditorScreen(
val editor = PhotoEditor.Builder(ctx, this@apply)
.setPinchTextScalable(true)
.setClipSourceImage(true)
.build()
photoEditors[page] = editor
}
@@ -1645,14 +1535,14 @@ fun MultiImageEditorScreen(
if (currentUri != null) {
scope.launch(Dispatchers.IO) {
try {
val inputStream =
context.contentResolver.openInputStream(currentUri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
val bitmap = loadBitmapRespectExif(context, currentUri)
withContext(Dispatchers.Main) {
view.source.setImageBitmap(bitmap)
if (bitmap != null) {
view.source.setImageBitmap(bitmap)
}
view.source.scaleType = ImageView.ScaleType.FIT_CENTER
view.source.adjustViewBounds = true
}
} catch (e: Exception) {
// Handle error
@@ -1860,9 +1750,9 @@ fun MultiImageEditorScreen(
.clickable(enabled = !isSaving && !isClosing) {
// 🚀 Сохраняем копию данных перед анимацией
val imagesToSend = imagesWithCaptions.toList()
// 🎬 Запускаем fade-out анимацию сразу
scope.launch {
isSaving = true
val savedImages = mutableListOf<ImageWithCaption>()
for (i in imagesToSend.indices) {
@@ -1884,10 +1774,11 @@ fun MultiImageEditorScreen(
// Вызываем callback (он запустит sendImageGroup с optimistic UI)
onSendAll(savedImages)
isSaving = false
// Закрываем после завершения сохранения/отправки
animatedDismiss()
}
// ✈️ Плавно закрываем экран (fade-out)
animatedDismiss()
},
contentAlignment = Alignment.Center
) {
@@ -1907,6 +1798,60 @@ fun MultiImageEditorScreen(
}
}
private fun loadBitmapRespectExif(context: Context, uri: Uri): Bitmap? {
return try {
val orientation = readExifOrientation(context, uri)
val decodedBitmap =
context.contentResolver.openInputStream(uri)?.use { input ->
BitmapFactory.decodeStream(input)
} ?: return null
applyExifOrientation(decodedBitmap, orientation)
} catch (_: Exception) {
null
}
}
private fun readExifOrientation(context: Context, uri: Uri): Int {
return try {
context.contentResolver.openInputStream(uri)?.use { input ->
ExifInterface(input).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
} ?: ExifInterface.ORIENTATION_NORMAL
} catch (_: Exception) {
ExifInterface.ORIENTATION_NORMAL
}
}
private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.preScale(-1f, 1f)
}
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(270f)
matrix.preScale(-1f, 1f)
}
else -> return bitmap
}
return try {
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true).also {
if (it != bitmap) bitmap.recycle()
}
} catch (_: Exception) {
bitmap
}
}
/** Async image loader */
@Composable
private fun AsyncImageLoader(uri: Uri, modifier: Modifier = Modifier) {

View File

@@ -1,5 +1,6 @@
package com.rosetta.messenger.ui.chats.components
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
@@ -62,6 +63,9 @@ import kotlin.math.absoluteValue
import kotlin.math.roundToInt
private const val TAG = "ImageViewerScreen"
private const val EDGE_TAP_FADE_DURATION_MS = 120
private const val EDGE_TAP_FADE_START_ALPHA = 0.6f
private const val IMAGE_VIEWER_CACHE_SIZE = 8
/**
* Telegram-style CubicBezierInterpolator (0.25, 0.1, 0.25, 1.0)
@@ -212,6 +216,23 @@ fun ImageViewerScreen(
// UI visibility state
var showControls by remember { mutableStateOf(true) }
var isTapNavigationInProgress by remember { mutableStateOf(false) }
val edgeTapFadeAlpha = remember { Animatable(1f) }
val imageBitmapCache =
remember {
object : LinkedHashMap<String, Bitmap>(IMAGE_VIEWER_CACHE_SIZE, 0.75f, true) {
override fun removeEldestEntry(
eldest: MutableMap.MutableEntry<String, Bitmap>?
): Boolean = size > IMAGE_VIEWER_CACHE_SIZE
}
}
fun getCachedBitmap(attachmentId: String): Bitmap? =
synchronized(imageBitmapCache) { imageBitmapCache[attachmentId] }
fun cacheBitmap(attachmentId: String, bitmap: Bitmap) {
synchronized(imageBitmapCache) { imageBitmapCache[attachmentId] = bitmap }
}
// ═══════════════════════════════════════════════════════════
// 🎬 SHARED ELEMENT TRANSFORM - вычисляем трансформацию
@@ -313,6 +334,25 @@ fun ImageViewerScreen(
val currentImage = images.getOrNull(pagerState.currentPage)
val dateFormat = remember { SimpleDateFormat("d MMMM, HH:mm", Locale.getDefault()) }
// Prefetch ближайших изображений, чтобы при свайпе не было спиннера.
LaunchedEffect(pagerState.currentPage, images, privateKey) {
val current = pagerState.currentPage
val prefetchIndexes =
listOf(current - 2, current - 1, current + 1, current + 2).filter { it in images.indices }
prefetchIndexes.forEach { index ->
val prefetchImage = images[index]
if (getCachedBitmap(prefetchImage.attachmentId) != null) return@forEach
launch(Dispatchers.IO) {
val bitmap = loadBitmapForViewerImage(context, prefetchImage, privateKey)
if (bitmap != null) {
cacheBitmap(prefetchImage.attachmentId, bitmap)
}
}
}
}
BackHandler {
closeWithAnimation()
}
@@ -336,7 +376,7 @@ fun ImageViewerScreen(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
alpha = dismissAlpha.value
alpha = dismissAlpha.value * edgeTapFadeAlpha.value
translationY = animatedOffsetY.value
// 🔥 Shared element transform при входе/выходе
@@ -357,7 +397,7 @@ fun ImageViewerScreen(
pageSpacing = 30.dp, // Telegram: dp(30) между фото
key = { images[it].attachmentId },
userScrollEnabled = !isAnimating, // Отключаем скролл во время анимации
beyondBoundsPageCount = 0,
beyondBoundsPageCount = 1,
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
snapPositionalThreshold = 0.5f
@@ -393,21 +433,44 @@ fun ImageViewerScreen(
ZoomableImage(
image = image,
privateKey = privateKey,
cachedBitmap = getCachedBitmap(image.attachmentId),
onBitmapLoaded = { id, bitmap -> cacheBitmap(id, bitmap) },
onTap = { tapOffset ->
// 👆 Tap on left/right edge (20% zone) to navigate
val edgeZone = screenSize.width * 0.20f
val tapX = tapOffset.x
val screenW = screenSize.width.toFloat()
when {
tapX < edgeZone && pagerState.currentPage > 0 -> {
scope.launch {
pagerState.scrollToPage(pagerState.currentPage - 1)
fun animateToPage(targetPage: Int) {
if (targetPage == pagerState.currentPage) return
if (isTapNavigationInProgress || pagerState.isScrollInProgress) return
scope.launch {
isTapNavigationInProgress = true
try {
// Мгновенный отклик: сразу переключаем страницу и даём короткий fade-in.
edgeTapFadeAlpha.stop()
edgeTapFadeAlpha.snapTo(EDGE_TAP_FADE_START_ALPHA)
pagerState.scrollToPage(targetPage)
edgeTapFadeAlpha.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = EDGE_TAP_FADE_DURATION_MS,
easing = FastOutSlowInEasing
)
)
} finally {
isTapNavigationInProgress = false
}
}
}
when {
tapX < edgeZone && pagerState.currentPage > 0 -> {
animateToPage(pagerState.currentPage - 1)
}
tapX > screenW - edgeZone && pagerState.currentPage < images.size - 1 -> {
scope.launch {
pagerState.scrollToPage(pagerState.currentPage + 1)
}
animateToPage(pagerState.currentPage + 1)
}
else -> showControls = !showControls
}
@@ -534,6 +597,8 @@ fun ImageViewerScreen(
private fun ZoomableImage(
image: ViewableImage,
privateKey: String,
cachedBitmap: Bitmap? = null,
onBitmapLoaded: (String, Bitmap) -> Unit = { _, _ -> },
onTap: (Offset) -> Unit,
onVerticalDrag: (Float, Float) -> Unit = { _, _ -> }, // dragAmount, velocity
onDragEnd: () -> Unit = {}
@@ -541,9 +606,9 @@ private fun ZoomableImage(
val context = LocalContext.current
val scope = rememberCoroutineScope()
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
var bitmap by remember(image.attachmentId, cachedBitmap) { mutableStateOf(cachedBitmap) }
var previewBitmap by remember { mutableStateOf<Bitmap?>(null) }
var isLoading by remember { mutableStateOf(true) }
var isLoading by remember(image.attachmentId, cachedBitmap) { mutableStateOf(cachedBitmap == null) }
var loadError by remember { mutableStateOf<String?>(null) }
// Zoom and pan state with animation
@@ -589,68 +654,26 @@ private fun ZoomableImage(
}
// Load image
LaunchedEffect(image.attachmentId) {
LaunchedEffect(image.attachmentId, cachedBitmap) {
if (cachedBitmap != null) {
bitmap = cachedBitmap
loadError = null
isLoading = false
return@LaunchedEffect
}
isLoading = true
loadError = null
withContext(Dispatchers.IO) {
try {
// 1. Если blob уже есть
if (image.blob.isNotEmpty()) {
bitmap = base64ToBitmapSafe(image.blob)
if (bitmap != null) {
isLoading = false
return@withContext
}
}
// 2. Пробуем из локального файла
val localBlob = AttachmentFileManager.readAttachment(
context, image.attachmentId, image.senderPublicKey, privateKey
)
if (localBlob != null) {
bitmap = base64ToBitmapSafe(localBlob)
if (bitmap != null) {
isLoading = false
return@withContext
}
}
// 3. Скачиваем с CDN
val downloadTag = getDownloadTag(image.preview)
if (downloadTag.isNotEmpty()) {
val encryptedContent = TransportManager.downloadFile(image.attachmentId, downloadTag)
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
val decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent,
decryptedKeyAndNonce
)
if (decrypted != null) {
bitmap = base64ToBitmapSafe(decrypted)
// Сохраняем локально
AttachmentFileManager.saveAttachment(
context = context,
blob = decrypted,
attachmentId = image.attachmentId,
publicKey = image.senderPublicKey,
privateKey = privateKey
)
}
}
if (bitmap == null) {
loadError = "Failed to load image"
}
} catch (e: Exception) {
loadError = e.message
}
isLoading = false
val loadedBitmap = withContext(Dispatchers.IO) { loadBitmapForViewerImage(context, image, privateKey) }
if (loadedBitmap != null) {
bitmap = loadedBitmap
onBitmapLoaded(image.attachmentId, loadedBitmap)
loadError = null
} else {
loadError = "Failed to load image"
}
isLoading = false
}
// Reset zoom when image changes
@@ -866,6 +889,57 @@ private fun ZoomableImage(
}
}
/**
* Загружает Bitmap для ImageViewer:
* 1) из in-message blob
* 2) из локального encrypted attachment файла
* 3) с transport (с последующим сохранением в локальный файл)
*/
private suspend fun loadBitmapForViewerImage(
context: Context,
image: ViewableImage,
privateKey: String
): Bitmap? {
return try {
// 1. Если blob уже есть в сообщении
if (image.blob.isNotEmpty()) {
base64ToBitmapSafe(image.blob)?.let { return it }
}
// 2. Пробуем прочитать из локального encrypted cache
val localBlob =
AttachmentFileManager.readAttachment(context, image.attachmentId, image.senderPublicKey, privateKey)
if (localBlob != null) {
base64ToBitmapSafe(localBlob)?.let { return it }
}
// 3. Скачиваем и расшифровываем с transport
val downloadTag = getDownloadTag(image.preview)
if (downloadTag.isEmpty()) return null
val encryptedContent = TransportManager.downloadFile(image.attachmentId, downloadTag)
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
val decrypted =
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
?: return null
val decodedBitmap = base64ToBitmapSafe(decrypted) ?: return null
// Сохраняем локально для следующих открытий/свайпов
AttachmentFileManager.saveAttachment(
context = context,
blob = decrypted,
attachmentId = image.attachmentId,
publicKey = image.senderPublicKey,
privateKey = privateKey
)
decodedBitmap
} catch (_: Exception) {
null
}
}
/**
* Безопасное декодирование base64 в Bitmap
*/

View File

@@ -210,7 +210,7 @@ fun MediaPickerBottomSheet(
val screenHeightPx = with(density) { screenHeight.toPx() }
// 🔄 Высоты в пикселях для точного контроля
val collapsedHeightPx = screenHeightPx * 0.45f // Свёрнутое - 45% экрана
val collapsedHeightPx = screenHeightPx * 0.50f // Свёрнутое - 50% экрана (легче тянуть дальше вверх)
val expandedHeightPx = screenHeightPx * 0.88f // Развёрнутое - 88% экрана
val minHeightPx = screenHeightPx * 0.2f // Минимум при свайпе вниз
@@ -303,7 +303,8 @@ fun MediaPickerBottomSheet(
// Пороги основаны на velocity (скорости свайпа) - не на позиции!
// velocity < 0 = свайп вверх, velocity > 0 = свайп вниз
val velocityThreshold = 300f // Маленький порог для лёгкого свайпа
val velocityThreshold = 180f // Более низкий порог: легче раскрыть sheet свайпом вверх
val expandSnapThreshold = collapsedHeightPx + (expandedHeightPx - collapsedHeightPx) * 0.35f
when {
// Быстрый свайп вниз при минимальной высоте - закрываем
@@ -341,7 +342,7 @@ fun MediaPickerBottomSheet(
}
}
// Без velocity - snap к ближайшему
currentHeight > (collapsedHeightPx + expandedHeightPx) / 2 -> {
currentHeight > expandSnapThreshold -> {
isExpanded = true
sheetHeightPx.animateTo(
expandedHeightPx,
@@ -484,12 +485,13 @@ fun MediaPickerBottomSheet(
},
onVerticalDrag = { change, dragAmount ->
change.consume()
// Сохраняем velocity (dragAmount в пикселях)
// Усиливаем чувствительность в 1.5 раза
lastDragVelocity = dragAmount * 1.5f
// Для подъёма вверх усиливаем жест, вниз делаем немного мягче.
val adjustedDragAmount =
if (dragAmount < 0f) dragAmount * 1.25f else dragAmount * 0.9f
lastDragVelocity = adjustedDragAmount * 1.8f
// 🔥 Меняем высоту в реальном времени
val newHeight = (sheetHeightPx.value - dragAmount)
val newHeight = (sheetHeightPx.value - adjustedDragAmount)
.coerceIn(minHeightPx, expandedHeightPx)
animationScope.launch {
sheetHeightPx.snapTo(newHeight)

View File

@@ -89,6 +89,23 @@ fun MessageInputBar(
inputFocusTrigger: Int = 0
) {
val hasReply = replyMessages.isNotEmpty()
val liveReplyMessages =
remember(replyMessages, displayReplyMessages) {
if (displayReplyMessages.isNotEmpty()) displayReplyMessages else replyMessages
}
var animatedReplyMessages by remember {
mutableStateOf<List<ChatViewModel.ReplyMessage>>(emptyList())
}
var animatedForwardMode by remember { mutableStateOf(isForwardMode) }
LaunchedEffect(liveReplyMessages, isForwardMode) {
if (liveReplyMessages.isNotEmpty()) {
animatedReplyMessages = liveReplyMessages
animatedForwardMode = isForwardMode
}
}
val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope()
val context = LocalContext.current
@@ -309,12 +326,19 @@ fun MessageInputBar(
shrinkTowards = Alignment.Bottom
)
) {
val panelReplyMessages =
if (liveReplyMessages.isNotEmpty()) liveReplyMessages
else animatedReplyMessages
val panelIsForwardMode =
if (liveReplyMessages.isNotEmpty()) isForwardMode
else animatedForwardMode
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
if (displayReplyMessages.isNotEmpty()) {
onReplyClick(displayReplyMessages.first().messageId)
if (panelReplyMessages.isNotEmpty()) {
onReplyClick(panelReplyMessages.first().messageId)
}
}
.background(backgroundColor)
@@ -330,30 +354,30 @@ fun MessageInputBar(
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (isForwardMode)
"Forward message${if (displayReplyMessages.size > 1) "s" else ""}"
text = if (panelIsForwardMode)
"Forward message${if (panelReplyMessages.size > 1) "s" else ""}"
else
"Reply to ${if (displayReplyMessages.size == 1 && !displayReplyMessages.first().isOutgoing) chatTitle else "You"}",
"Reply to ${if (panelReplyMessages.size == 1 && !panelReplyMessages.first().isOutgoing) chatTitle else "You"}",
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
color = PrimaryBlue,
maxLines = 1
)
Spacer(modifier = Modifier.height(2.dp))
if (displayReplyMessages.isNotEmpty()) {
val msg = displayReplyMessages.first()
if (panelReplyMessages.isNotEmpty()) {
val msg = panelReplyMessages.first()
val hasImageAttachment = msg.attachments.any {
it.type == AttachmentType.IMAGE
}
AppleEmojiText(
text = if (displayReplyMessages.size == 1) {
text = if (panelReplyMessages.size == 1) {
if (msg.text.isEmpty() && hasImageAttachment) {
"Photo"
} else {
val shortText = msg.text.take(40)
if (shortText.length < msg.text.length) "$shortText..." else shortText
}
} else "${displayReplyMessages.size} messages",
} else "${panelReplyMessages.size} messages",
fontSize = 13.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f)
else Color.Black.copy(alpha = 0.5f),
@@ -365,8 +389,8 @@ fun MessageInputBar(
}
// 🔥 Превью изображения (как в Telegram)
if (displayReplyMessages.size == 1) {
val msg = displayReplyMessages.first()
if (panelReplyMessages.size == 1) {
val msg = panelReplyMessages.first()
val imageAttachment = msg.attachments.find {
it.type == AttachmentType.IMAGE
}