feat: enhance image viewer navigation with tap animations and controls visibility
This commit is contained in:
1
app/src/main/assets/lottie/earth.json
Normal file
1
app/src/main/assets/lottie/earth.json
Normal file
File diff suppressed because one or more lines are too long
1
app/src/main/assets/lottie/folder.json
Normal file
1
app/src/main/assets/lottie/folder.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user