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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -841,14 +841,14 @@ fun MainScreen(
)
}
// 🖼️ Image viewer state — disable swipe-back when photo is fullscreen
var isImageViewerOpen by remember { mutableStateOf(false) }
// 🔒 Lock swipe-back while chat overlays are open (image viewer/editor/media picker/camera).
var isChatSwipeLocked by remember { mutableStateOf(false) }
SwipeBackContainer(
isVisible = selectedUser != null,
onBack = { popChatAndChildren() },
isDarkTheme = isDarkTheme,
swipeEnabled = !isImageViewerOpen
swipeEnabled = !isChatSwipeLocked
) {
selectedUser?.let { currentChatUser ->
// Экран чата
@@ -870,7 +870,7 @@ fun MainScreen(
},
isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository,
onImageViewerChanged = { isOpen -> isImageViewerOpen = isOpen }
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked }
)
}
}
@@ -928,7 +928,9 @@ fun MainScreen(
user = currentOtherUser,
isDarkTheme = isDarkTheme,
onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } },
avatarRepository = avatarRepository
avatarRepository = avatarRepository,
currentUserPublicKey = accountPublicKey,
currentUserPrivateKey = accountPrivateKey
)
}
}

View File

@@ -10,6 +10,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by
@@ -49,6 +50,9 @@ class PreferencesManager(private val context: Context) {
// Pinned Chats (max 3)
val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys
// Muted Chats (stored as "account::opponentKey")
val MUTED_CHATS = stringSetPreferencesKey("muted_chats")
}
// ═════════════════════════════════════════════════════════════
@@ -238,4 +242,63 @@ class PreferencesManager(private val context: Context) {
}
return wasPinned
}
// ═════════════════════════════════════════════════════════════
// 🔕 MUTED CHATS
// ═════════════════════════════════════════════════════════════
private fun buildMutedKey(account: String, opponentKey: String): String {
val trimmedAccount = account.trim()
val trimmedOpponent = opponentKey.trim()
return if (trimmedAccount.isBlank()) trimmedOpponent else "$trimmedAccount::$trimmedOpponent"
}
private fun parseMutedKeyForAccount(rawKey: String, account: String): String? {
val trimmedAccount = account.trim()
if (rawKey.isBlank()) return null
// Legacy format support: plain opponentKey without account prefix.
if ("::" !in rawKey) return rawKey
val parts = rawKey.split("::", limit = 2)
if (parts.size != 2) return null
val keyAccount = parts[0]
val opponentKey = parts[1]
return if (trimmedAccount.isBlank() || keyAccount == trimmedAccount) opponentKey else null
}
val mutedChats: Flow<Set<String>> =
context.dataStore.data.map { preferences -> preferences[MUTED_CHATS] ?: emptySet() }
fun mutedChatsForAccount(account: String): Flow<Set<String>> =
mutedChats.map { muted ->
muted.mapNotNull { parseMutedKeyForAccount(it, account) }.toSet()
}
suspend fun isChatMuted(account: String, opponentKey: String): Boolean {
if (opponentKey.isBlank()) return false
val current = mutedChats.first()
return current.contains(buildMutedKey(account, opponentKey)) || current.contains(opponentKey)
}
suspend fun setChatMuted(account: String, opponentKey: String, muted: Boolean) {
if (opponentKey.isBlank()) return
val scopedKey = buildMutedKey(account, opponentKey)
context.dataStore.edit { preferences ->
val current = preferences[MUTED_CHATS] ?: emptySet()
preferences[MUTED_CHATS] =
if (muted) {
current + scopedKey
} else {
current - scopedKey - opponentKey
}
}
}
suspend fun toggleMuteChat(account: String, opponentKey: String): Boolean {
if (opponentKey.isBlank()) return false
val mutedNow = !isChatMuted(account, opponentKey)
setChatMuted(account, opponentKey, mutedNow)
return mutedNow
}
}

View File

@@ -11,9 +11,12 @@ import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.PreferencesManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
/**
* Firebase Cloud Messaging Service для обработки push-уведомлений
@@ -87,6 +90,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
if (isAppInForeground) {
return
}
val senderKey = senderPublicKey?.trim().orEmpty()
if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) {
return
}
createNotificationChannel()
@@ -183,4 +190,16 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
prefs.edit().putString("fcm_token", token).apply()
}
/** Проверка: замьючен ли диалог для текущего аккаунта */
private fun isDialogMuted(senderPublicKey: String): Boolean {
if (senderPublicKey.isBlank()) return false
return runCatching {
val accountManager = AccountManager(applicationContext)
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty()
runBlocking {
PreferencesManager(applicationContext).isChatMuted(currentAccount, senderPublicKey)
}
}.getOrDefault(false)
}
}

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
}