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
|
// 🔒 Lock swipe-back while chat overlays are open (image viewer/editor/media picker/camera).
|
||||||
var isImageViewerOpen by remember { mutableStateOf(false) }
|
var isChatSwipeLocked by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
SwipeBackContainer(
|
SwipeBackContainer(
|
||||||
isVisible = selectedUser != null,
|
isVisible = selectedUser != null,
|
||||||
onBack = { popChatAndChildren() },
|
onBack = { popChatAndChildren() },
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
swipeEnabled = !isImageViewerOpen
|
swipeEnabled = !isChatSwipeLocked
|
||||||
) {
|
) {
|
||||||
selectedUser?.let { currentChatUser ->
|
selectedUser?.let { currentChatUser ->
|
||||||
// Экран чата
|
// Экран чата
|
||||||
@@ -870,7 +870,7 @@ fun MainScreen(
|
|||||||
},
|
},
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
onImageViewerChanged = { isOpen -> isImageViewerOpen = isOpen }
|
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -928,7 +928,9 @@ fun MainScreen(
|
|||||||
user = currentOtherUser,
|
user = currentOtherUser,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } },
|
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.core.stringSetPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
private val Context.dataStore: DataStore<Preferences> by
|
private val Context.dataStore: DataStore<Preferences> by
|
||||||
@@ -49,6 +50,9 @@ class PreferencesManager(private val context: Context) {
|
|||||||
|
|
||||||
// Pinned Chats (max 3)
|
// Pinned Chats (max 3)
|
||||||
val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys
|
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
|
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.google.firebase.messaging.RemoteMessage
|
||||||
import com.rosetta.messenger.MainActivity
|
import com.rosetta.messenger.MainActivity
|
||||||
import com.rosetta.messenger.R
|
import com.rosetta.messenger.R
|
||||||
|
import com.rosetta.messenger.data.AccountManager
|
||||||
|
import com.rosetta.messenger.data.PreferencesManager
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Firebase Cloud Messaging Service для обработки push-уведомлений
|
* Firebase Cloud Messaging Service для обработки push-уведомлений
|
||||||
@@ -87,6 +90,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
if (isAppInForeground) {
|
if (isAppInForeground) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val senderKey = senderPublicKey?.trim().orEmpty()
|
||||||
|
if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
|
|
||||||
@@ -183,4 +190,16 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
|
val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
|
||||||
prefs.edit().putString("fcm_token", token).apply()
|
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) }
|
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
|
// <20>📷 Camera launcher
|
||||||
val cameraLauncher =
|
val cameraLauncher =
|
||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
|
|||||||
@@ -1420,11 +1420,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
/** 🔥 Очистить reply/forward */
|
/** 🔥 Очистить reply/forward */
|
||||||
fun clearReplyMessages() {
|
fun clearReplyMessages() {
|
||||||
viewModelScope.launch {
|
_replyMessages.value = emptyList()
|
||||||
delay(350) // Задержка после закрытия панели (анимация fadeOut + shrinkVertically)
|
_isForwardMode.value = false
|
||||||
_replyMessages.value = emptyList()
|
|
||||||
_isForwardMode.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 🔥 Удалить сообщение (для ошибки отправки) */
|
/** 🔥 Удалить сообщение (для ошибки отправки) */
|
||||||
@@ -1793,6 +1790,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
attachmentsJson = optimisticAttachmentsJson,
|
attachmentsJson = optimisticAttachmentsJson,
|
||||||
opponentPublicKey = recipient
|
opponentPublicKey = recipient
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Обновляем dialogs сразу, чтобы в списке чатов мгновенно показать "Photo" + часы
|
||||||
|
// даже если пользователь вышел из экрана чата во время загрузки.
|
||||||
|
saveDialog(
|
||||||
|
lastMessage = if (text.isNotEmpty()) text else "photo",
|
||||||
|
timestamp = timestamp,
|
||||||
|
opponentPublicKey = recipient
|
||||||
|
)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Matrix
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.inputmethod.InputMethodManager
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
||||||
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
|
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
|
||||||
import com.rosetta.messenger.ui.components.AppleEmojiTextField
|
import com.rosetta.messenger.ui.components.AppleEmojiTextField
|
||||||
@@ -159,6 +161,10 @@ fun ImageEditorScreen(
|
|||||||
|
|
||||||
// Запуск enter анимации (пропускаем если из камеры — уже alpha=1)
|
// Запуск enter анимации (пропускаем если из камеры — уже alpha=1)
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
|
focusManager.clearFocus()
|
||||||
|
|
||||||
if (!skipEnterAnimation) {
|
if (!skipEnterAnimation) {
|
||||||
animationProgress.animateTo(
|
animationProgress.animateTo(
|
||||||
targetValue = 1f,
|
targetValue = 1f,
|
||||||
@@ -314,12 +320,25 @@ fun ImageEditorScreen(
|
|||||||
result.data?.let { data ->
|
result.data?.let { data ->
|
||||||
UCrop.getOutput(data)?.let { croppedUri ->
|
UCrop.getOutput(data)?.let { croppedUri ->
|
||||||
currentImageUri = 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() }
|
BackHandler { animatedDismiss() }
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -396,7 +415,6 @@ fun ImageEditorScreen(
|
|||||||
|
|
||||||
// Простой FIT_CENTER - показывает ВСЁ фото, центрирует
|
// Простой FIT_CENTER - показывает ВСЁ фото, центрирует
|
||||||
source.apply {
|
source.apply {
|
||||||
setImageURI(currentImageUri)
|
|
||||||
scaleType = ImageView.ScaleType.FIT_CENTER
|
scaleType = ImageView.ScaleType.FIT_CENTER
|
||||||
adjustViewBounds = true
|
adjustViewBounds = true
|
||||||
setPadding(0, 0, 0, 0)
|
setPadding(0, 0, 0, 0)
|
||||||
@@ -605,27 +623,35 @@ fun ImageEditorScreen(
|
|||||||
onToggleEmojiPicker = { toggleEmojiPicker() },
|
onToggleEmojiPicker = { toggleEmojiPicker() },
|
||||||
onEditTextViewCreated = { editTextView = it },
|
onEditTextViewCreated = { editTextView = it },
|
||||||
onSend = {
|
onSend = {
|
||||||
// 🚀 Сохраняем caption для использования после анимации
|
if (isSaving || isClosing) return@TelegramCaptionBar
|
||||||
|
|
||||||
val captionToSend = caption
|
val captionToSend = caption
|
||||||
val uriToSend = currentImageUri
|
val uriToSend = currentImageUri
|
||||||
|
|
||||||
// ✈️ Сразу запускаем fade-out анимацию (как в Telegram)
|
|
||||||
// Фото появится в чате через optimistic UI
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
saveEditedImage(context, photoEditor, photoEditorView, uriToSend) { savedUri ->
|
isSaving = true
|
||||||
if (savedUri != null) {
|
|
||||||
// Вызываем callback (он запустит sendImageMessage с optimistic UI)
|
val savedUri =
|
||||||
if (onSaveWithCaption != null) {
|
saveEditedImageSync(
|
||||||
onSaveWithCaption(savedUri, captionToSend)
|
context = context,
|
||||||
} else {
|
photoEditor = photoEditor,
|
||||||
onSave(savedUri)
|
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) {
|
if (!showCaptionInput) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
isSaving = true
|
isSaving = true
|
||||||
saveEditedImage(context, photoEditor, photoEditorView, currentImageUri) { savedUri ->
|
val savedUri =
|
||||||
isSaving = false
|
saveEditedImageSync(
|
||||||
if (savedUri != null) {
|
context = context,
|
||||||
onSave(savedUri)
|
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(
|
private suspend fun saveEditedImage(
|
||||||
context: Context,
|
context: Context,
|
||||||
photoEditor: PhotoEditor?,
|
photoEditor: PhotoEditor?,
|
||||||
@@ -1163,9 +1192,13 @@ private suspend fun saveEditedImage(
|
|||||||
imageUri: Uri,
|
imageUri: Uri,
|
||||||
onResult: (Uri?) -> Unit
|
onResult: (Uri?) -> Unit
|
||||||
) {
|
) {
|
||||||
// Просто возвращаем оригинальный URI без обработки через PhotoEditor
|
saveEditedImageOld(
|
||||||
// Это устраняет проблему с обрезкой изображений
|
context = context,
|
||||||
onResult(imageUri)
|
photoEditor = photoEditor,
|
||||||
|
photoEditorView = photoEditorView,
|
||||||
|
imageUri = imageUri,
|
||||||
|
onResult = onResult
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** OLD VERSION - Save edited image with crop logic (disabled due to cropping issues) */
|
/** OLD VERSION - Save edited image with crop logic (disabled due to cropping issues) */
|
||||||
@@ -1183,126 +1216,50 @@ private suspend fun saveEditedImageOld(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
try {
|
||||||
try {
|
val tempFile = File(context.cacheDir, "edited_${System.currentTimeMillis()}.png")
|
||||||
// Загружаем оригинальное изображение и сохраняем его напрямую
|
val saveSettings = SaveSettings.Builder()
|
||||||
// PhotoEditor с setClipSourceImage(true) должен обрезать черные полосы автоматически
|
.setClearViewsEnabled(false)
|
||||||
val tempFile = File(context.cacheDir, "temp_${System.currentTimeMillis()}.png")
|
.setTransparencyEnabled(true)
|
||||||
|
.setCompressFormat(Bitmap.CompressFormat.PNG)
|
||||||
|
.setCompressQuality(100)
|
||||||
|
.build()
|
||||||
|
|
||||||
val saveSettings = SaveSettings.Builder()
|
val savedPath = suspendCancellableCoroutine<String?> { continuation ->
|
||||||
.setClearViewsEnabled(false)
|
photoEditor.saveAsFile(
|
||||||
.setTransparencyEnabled(true)
|
tempFile.absolutePath,
|
||||||
.build()
|
saveSettings,
|
||||||
|
object : PhotoEditor.OnSaveListener {
|
||||||
val savedPath = suspendCancellableCoroutine<String?> { continuation ->
|
override fun onSuccess(imagePath: String) {
|
||||||
photoEditor.saveAsFile(
|
continuation.resume(imagePath)
|
||||||
tempFile.absolutePath,
|
|
||||||
saveSettings,
|
|
||||||
object : PhotoEditor.OnSaveListener {
|
|
||||||
override fun onSuccess(imagePath: String) {
|
|
||||||
continuation.resume(imagePath)
|
|
||||||
}
|
|
||||||
override fun onFailure(exception: Exception) {
|
|
||||||
continuation.resume(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
override fun onFailure(exception: Exception) {
|
||||||
}
|
continuation.resume(null)
|
||||||
|
}
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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(
|
private suspend fun saveEditedImageSync(
|
||||||
context: Context,
|
context: Context,
|
||||||
photoEditor: PhotoEditor?,
|
photoEditor: PhotoEditor?,
|
||||||
photoEditorView: PhotoEditorView?,
|
photoEditorView: PhotoEditorView?,
|
||||||
imageUri: Uri
|
imageUri: Uri
|
||||||
): Uri? {
|
): Uri? {
|
||||||
// Просто возвращаем оригинальный URI без обработки
|
return saveEditedImageSyncOld(
|
||||||
// PhotoEditor вызывает проблемы с обрезкой - пользователь получает оригинал
|
context = context,
|
||||||
return imageUri
|
photoEditor = photoEditor,
|
||||||
|
photoEditorView = photoEditorView,
|
||||||
|
imageUri = imageUri
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save edited image synchronously - OLD VERSION with crop logic (disabled) */
|
/** Save edited image synchronously - OLD VERSION with crop logic (disabled) */
|
||||||
@@ -1318,109 +1275,37 @@ private suspend fun saveEditedImageSyncOld(
|
|||||||
return imageUri
|
return imageUri
|
||||||
}
|
}
|
||||||
|
|
||||||
return withContext(Dispatchers.IO) {
|
return try {
|
||||||
try {
|
val tempFile = File(
|
||||||
val tempFile = File(
|
context.cacheDir,
|
||||||
context.cacheDir,
|
"edited_${System.currentTimeMillis()}_${(0..9999).random()}.png"
|
||||||
"temp_${System.currentTimeMillis()}_${(0..9999).random()}.png"
|
)
|
||||||
)
|
|
||||||
|
|
||||||
val saveSettings = SaveSettings.Builder()
|
val saveSettings = SaveSettings.Builder()
|
||||||
.setClearViewsEnabled(false)
|
.setClearViewsEnabled(false)
|
||||||
.setTransparencyEnabled(true)
|
.setTransparencyEnabled(true)
|
||||||
.build()
|
.setCompressFormat(Bitmap.CompressFormat.PNG)
|
||||||
|
.setCompressQuality(100)
|
||||||
|
.build()
|
||||||
|
|
||||||
val savedPath = suspendCancellableCoroutine<String?> { continuation ->
|
val savedPath = suspendCancellableCoroutine<String?> { continuation ->
|
||||||
photoEditor.saveAsFile(
|
photoEditor.saveAsFile(
|
||||||
tempFile.absolutePath,
|
tempFile.absolutePath,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
object : PhotoEditor.OnSaveListener {
|
object : PhotoEditor.OnSaveListener {
|
||||||
override fun onSuccess(imagePath: String) {
|
override fun onSuccess(imagePath: String) {
|
||||||
continuation.resume(imagePath)
|
continuation.resume(imagePath)
|
||||||
}
|
|
||||||
override fun onFailure(exception: Exception) {
|
|
||||||
continuation.resume(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
override fun onFailure(exception: Exception) {
|
||||||
}
|
continuation.resume(null)
|
||||||
|
}
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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 context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// 🎬 TELEGRAM-STYLE FADE ANIMATION
|
// 🎬 TELEGRAM-STYLE FADE ANIMATION
|
||||||
@@ -1483,6 +1369,10 @@ fun MultiImageEditorScreen(
|
|||||||
|
|
||||||
// Запуск enter анимации
|
// Запуск enter анимации
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
|
focusManager.clearFocus()
|
||||||
|
|
||||||
animationProgress.animateTo(
|
animationProgress.animateTo(
|
||||||
targetValue = 1f,
|
targetValue = 1f,
|
||||||
animationSpec = tween(
|
animationSpec = tween(
|
||||||
@@ -1615,14 +1505,13 @@ fun MultiImageEditorScreen(
|
|||||||
// Загружаем изображение
|
// Загружаем изображение
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val inputStream =
|
val bitmap = loadBitmapRespectExif(ctx, imagesWithCaptions[page].uri)
|
||||||
ctx.contentResolver.openInputStream(imagesWithCaptions[page].uri)
|
|
||||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
|
||||||
inputStream?.close()
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
source.apply {
|
source.apply {
|
||||||
setImageBitmap(bitmap)
|
if (bitmap != null) {
|
||||||
|
setImageBitmap(bitmap)
|
||||||
|
}
|
||||||
scaleType = ImageView.ScaleType.FIT_CENTER
|
scaleType = ImageView.ScaleType.FIT_CENTER
|
||||||
adjustViewBounds = true
|
adjustViewBounds = true
|
||||||
setPadding(0, 0, 0, 0)
|
setPadding(0, 0, 0, 0)
|
||||||
@@ -1630,6 +1519,7 @@ fun MultiImageEditorScreen(
|
|||||||
|
|
||||||
val editor = PhotoEditor.Builder(ctx, this@apply)
|
val editor = PhotoEditor.Builder(ctx, this@apply)
|
||||||
.setPinchTextScalable(true)
|
.setPinchTextScalable(true)
|
||||||
|
.setClipSourceImage(true)
|
||||||
.build()
|
.build()
|
||||||
photoEditors[page] = editor
|
photoEditors[page] = editor
|
||||||
}
|
}
|
||||||
@@ -1645,14 +1535,14 @@ fun MultiImageEditorScreen(
|
|||||||
if (currentUri != null) {
|
if (currentUri != null) {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val inputStream =
|
val bitmap = loadBitmapRespectExif(context, currentUri)
|
||||||
context.contentResolver.openInputStream(currentUri)
|
|
||||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
|
||||||
inputStream?.close()
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
view.source.setImageBitmap(bitmap)
|
if (bitmap != null) {
|
||||||
|
view.source.setImageBitmap(bitmap)
|
||||||
|
}
|
||||||
view.source.scaleType = ImageView.ScaleType.FIT_CENTER
|
view.source.scaleType = ImageView.ScaleType.FIT_CENTER
|
||||||
|
view.source.adjustViewBounds = true
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Handle error
|
// Handle error
|
||||||
@@ -1860,9 +1750,9 @@ fun MultiImageEditorScreen(
|
|||||||
.clickable(enabled = !isSaving && !isClosing) {
|
.clickable(enabled = !isSaving && !isClosing) {
|
||||||
// 🚀 Сохраняем копию данных перед анимацией
|
// 🚀 Сохраняем копию данных перед анимацией
|
||||||
val imagesToSend = imagesWithCaptions.toList()
|
val imagesToSend = imagesWithCaptions.toList()
|
||||||
|
|
||||||
// 🎬 Запускаем fade-out анимацию сразу
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
isSaving = true
|
||||||
val savedImages = mutableListOf<ImageWithCaption>()
|
val savedImages = mutableListOf<ImageWithCaption>()
|
||||||
|
|
||||||
for (i in imagesToSend.indices) {
|
for (i in imagesToSend.indices) {
|
||||||
@@ -1884,10 +1774,11 @@ fun MultiImageEditorScreen(
|
|||||||
|
|
||||||
// Вызываем callback (он запустит sendImageGroup с optimistic UI)
|
// Вызываем callback (он запустит sendImageGroup с optimistic UI)
|
||||||
onSendAll(savedImages)
|
onSendAll(savedImages)
|
||||||
|
|
||||||
|
isSaving = false
|
||||||
|
// Закрываем после завершения сохранения/отправки
|
||||||
|
animatedDismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✈️ Плавно закрываем экран (fade-out)
|
|
||||||
animatedDismiss()
|
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center
|
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 */
|
/** Async image loader */
|
||||||
@Composable
|
@Composable
|
||||||
private fun AsyncImageLoader(uri: Uri, modifier: Modifier = Modifier) {
|
private fun AsyncImageLoader(uri: Uri, modifier: Modifier = Modifier) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.rosetta.messenger.ui.chats.components
|
package com.rosetta.messenger.ui.chats.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
@@ -62,6 +63,9 @@ import kotlin.math.absoluteValue
|
|||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private const val TAG = "ImageViewerScreen"
|
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)
|
* Telegram-style CubicBezierInterpolator (0.25, 0.1, 0.25, 1.0)
|
||||||
@@ -212,6 +216,23 @@ fun ImageViewerScreen(
|
|||||||
|
|
||||||
// UI visibility state
|
// UI visibility state
|
||||||
var showControls by remember { mutableStateOf(true) }
|
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 - вычисляем трансформацию
|
// 🎬 SHARED ELEMENT TRANSFORM - вычисляем трансформацию
|
||||||
@@ -313,6 +334,25 @@ fun ImageViewerScreen(
|
|||||||
val currentImage = images.getOrNull(pagerState.currentPage)
|
val currentImage = images.getOrNull(pagerState.currentPage)
|
||||||
val dateFormat = remember { SimpleDateFormat("d MMMM, HH:mm", Locale.getDefault()) }
|
val dateFormat = remember { SimpleDateFormat("d MMMM, HH:mm", Locale.getDefault()) }
|
||||||
|
|
||||||
|
// 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 {
|
BackHandler {
|
||||||
closeWithAnimation()
|
closeWithAnimation()
|
||||||
}
|
}
|
||||||
@@ -336,7 +376,7 @@ fun ImageViewerScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
alpha = dismissAlpha.value
|
alpha = dismissAlpha.value * edgeTapFadeAlpha.value
|
||||||
translationY = animatedOffsetY.value
|
translationY = animatedOffsetY.value
|
||||||
|
|
||||||
// 🔥 Shared element transform при входе/выходе
|
// 🔥 Shared element transform при входе/выходе
|
||||||
@@ -357,7 +397,7 @@ fun ImageViewerScreen(
|
|||||||
pageSpacing = 30.dp, // Telegram: dp(30) между фото
|
pageSpacing = 30.dp, // Telegram: dp(30) между фото
|
||||||
key = { images[it].attachmentId },
|
key = { images[it].attachmentId },
|
||||||
userScrollEnabled = !isAnimating, // Отключаем скролл во время анимации
|
userScrollEnabled = !isAnimating, // Отключаем скролл во время анимации
|
||||||
beyondBoundsPageCount = 0,
|
beyondBoundsPageCount = 1,
|
||||||
flingBehavior = PagerDefaults.flingBehavior(
|
flingBehavior = PagerDefaults.flingBehavior(
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
snapPositionalThreshold = 0.5f
|
snapPositionalThreshold = 0.5f
|
||||||
@@ -393,21 +433,44 @@ fun ImageViewerScreen(
|
|||||||
ZoomableImage(
|
ZoomableImage(
|
||||||
image = image,
|
image = image,
|
||||||
privateKey = privateKey,
|
privateKey = privateKey,
|
||||||
|
cachedBitmap = getCachedBitmap(image.attachmentId),
|
||||||
|
onBitmapLoaded = { id, bitmap -> cacheBitmap(id, bitmap) },
|
||||||
onTap = { tapOffset ->
|
onTap = { tapOffset ->
|
||||||
// 👆 Tap on left/right edge (20% zone) to navigate
|
// 👆 Tap on left/right edge (20% zone) to navigate
|
||||||
val edgeZone = screenSize.width * 0.20f
|
val edgeZone = screenSize.width * 0.20f
|
||||||
val tapX = tapOffset.x
|
val tapX = tapOffset.x
|
||||||
val screenW = screenSize.width.toFloat()
|
val screenW = screenSize.width.toFloat()
|
||||||
when {
|
|
||||||
tapX < edgeZone && pagerState.currentPage > 0 -> {
|
fun animateToPage(targetPage: Int) {
|
||||||
scope.launch {
|
if (targetPage == pagerState.currentPage) return
|
||||||
pagerState.scrollToPage(pagerState.currentPage - 1)
|
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 -> {
|
tapX > screenW - edgeZone && pagerState.currentPage < images.size - 1 -> {
|
||||||
scope.launch {
|
animateToPage(pagerState.currentPage + 1)
|
||||||
pagerState.scrollToPage(pagerState.currentPage + 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else -> showControls = !showControls
|
else -> showControls = !showControls
|
||||||
}
|
}
|
||||||
@@ -534,6 +597,8 @@ fun ImageViewerScreen(
|
|||||||
private fun ZoomableImage(
|
private fun ZoomableImage(
|
||||||
image: ViewableImage,
|
image: ViewableImage,
|
||||||
privateKey: String,
|
privateKey: String,
|
||||||
|
cachedBitmap: Bitmap? = null,
|
||||||
|
onBitmapLoaded: (String, Bitmap) -> Unit = { _, _ -> },
|
||||||
onTap: (Offset) -> Unit,
|
onTap: (Offset) -> Unit,
|
||||||
onVerticalDrag: (Float, Float) -> Unit = { _, _ -> }, // dragAmount, velocity
|
onVerticalDrag: (Float, Float) -> Unit = { _, _ -> }, // dragAmount, velocity
|
||||||
onDragEnd: () -> Unit = {}
|
onDragEnd: () -> Unit = {}
|
||||||
@@ -541,9 +606,9 @@ private fun ZoomableImage(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
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 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) }
|
var loadError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
// Zoom and pan state with animation
|
// Zoom and pan state with animation
|
||||||
@@ -589,68 +654,26 @@ private fun ZoomableImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load image
|
// Load image
|
||||||
LaunchedEffect(image.attachmentId) {
|
LaunchedEffect(image.attachmentId, cachedBitmap) {
|
||||||
|
if (cachedBitmap != null) {
|
||||||
|
bitmap = cachedBitmap
|
||||||
|
loadError = null
|
||||||
|
isLoading = false
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
loadError = null
|
loadError = null
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
val loadedBitmap = withContext(Dispatchers.IO) { loadBitmapForViewerImage(context, image, privateKey) }
|
||||||
try {
|
if (loadedBitmap != null) {
|
||||||
// 1. Если blob уже есть
|
bitmap = loadedBitmap
|
||||||
if (image.blob.isNotEmpty()) {
|
onBitmapLoaded(image.attachmentId, loadedBitmap)
|
||||||
bitmap = base64ToBitmapSafe(image.blob)
|
loadError = null
|
||||||
if (bitmap != null) {
|
} else {
|
||||||
isLoading = false
|
loadError = "Failed to load image"
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset zoom when image changes
|
// 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
|
* Безопасное декодирование base64 в Bitmap
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ fun MediaPickerBottomSheet(
|
|||||||
val screenHeightPx = with(density) { screenHeight.toPx() }
|
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 expandedHeightPx = screenHeightPx * 0.88f // Развёрнутое - 88% экрана
|
||||||
val minHeightPx = screenHeightPx * 0.2f // Минимум при свайпе вниз
|
val minHeightPx = screenHeightPx * 0.2f // Минимум при свайпе вниз
|
||||||
|
|
||||||
@@ -303,7 +303,8 @@ fun MediaPickerBottomSheet(
|
|||||||
|
|
||||||
// Пороги основаны на velocity (скорости свайпа) - не на позиции!
|
// Пороги основаны на velocity (скорости свайпа) - не на позиции!
|
||||||
// velocity < 0 = свайп вверх, velocity > 0 = свайп вниз
|
// velocity < 0 = свайп вверх, velocity > 0 = свайп вниз
|
||||||
val velocityThreshold = 300f // Маленький порог для лёгкого свайпа
|
val velocityThreshold = 180f // Более низкий порог: легче раскрыть sheet свайпом вверх
|
||||||
|
val expandSnapThreshold = collapsedHeightPx + (expandedHeightPx - collapsedHeightPx) * 0.35f
|
||||||
|
|
||||||
when {
|
when {
|
||||||
// Быстрый свайп вниз при минимальной высоте - закрываем
|
// Быстрый свайп вниз при минимальной высоте - закрываем
|
||||||
@@ -341,7 +342,7 @@ fun MediaPickerBottomSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Без velocity - snap к ближайшему
|
// Без velocity - snap к ближайшему
|
||||||
currentHeight > (collapsedHeightPx + expandedHeightPx) / 2 -> {
|
currentHeight > expandSnapThreshold -> {
|
||||||
isExpanded = true
|
isExpanded = true
|
||||||
sheetHeightPx.animateTo(
|
sheetHeightPx.animateTo(
|
||||||
expandedHeightPx,
|
expandedHeightPx,
|
||||||
@@ -484,12 +485,13 @@ fun MediaPickerBottomSheet(
|
|||||||
},
|
},
|
||||||
onVerticalDrag = { change, dragAmount ->
|
onVerticalDrag = { change, dragAmount ->
|
||||||
change.consume()
|
change.consume()
|
||||||
// Сохраняем velocity (dragAmount в пикселях)
|
// Для подъёма вверх усиливаем жест, вниз делаем немного мягче.
|
||||||
// Усиливаем чувствительность в 1.5 раза
|
val adjustedDragAmount =
|
||||||
lastDragVelocity = dragAmount * 1.5f
|
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)
|
.coerceIn(minHeightPx, expandedHeightPx)
|
||||||
animationScope.launch {
|
animationScope.launch {
|
||||||
sheetHeightPx.snapTo(newHeight)
|
sheetHeightPx.snapTo(newHeight)
|
||||||
|
|||||||
@@ -89,6 +89,23 @@ fun MessageInputBar(
|
|||||||
inputFocusTrigger: Int = 0
|
inputFocusTrigger: Int = 0
|
||||||
) {
|
) {
|
||||||
val hasReply = replyMessages.isNotEmpty()
|
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 focusManager = LocalFocusManager.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -309,12 +326,19 @@ fun MessageInputBar(
|
|||||||
shrinkTowards = Alignment.Bottom
|
shrinkTowards = Alignment.Bottom
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
val panelReplyMessages =
|
||||||
|
if (liveReplyMessages.isNotEmpty()) liveReplyMessages
|
||||||
|
else animatedReplyMessages
|
||||||
|
val panelIsForwardMode =
|
||||||
|
if (liveReplyMessages.isNotEmpty()) isForwardMode
|
||||||
|
else animatedForwardMode
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable {
|
.clickable {
|
||||||
if (displayReplyMessages.isNotEmpty()) {
|
if (panelReplyMessages.isNotEmpty()) {
|
||||||
onReplyClick(displayReplyMessages.first().messageId)
|
onReplyClick(panelReplyMessages.first().messageId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
@@ -330,30 +354,30 @@ fun MessageInputBar(
|
|||||||
Spacer(modifier = Modifier.width(10.dp))
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = if (isForwardMode)
|
text = if (panelIsForwardMode)
|
||||||
"Forward message${if (displayReplyMessages.size > 1) "s" else ""}"
|
"Forward message${if (panelReplyMessages.size > 1) "s" else ""}"
|
||||||
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,
|
fontSize = 13.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = PrimaryBlue,
|
color = PrimaryBlue,
|
||||||
maxLines = 1
|
maxLines = 1
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
if (displayReplyMessages.isNotEmpty()) {
|
if (panelReplyMessages.isNotEmpty()) {
|
||||||
val msg = displayReplyMessages.first()
|
val msg = panelReplyMessages.first()
|
||||||
val hasImageAttachment = msg.attachments.any {
|
val hasImageAttachment = msg.attachments.any {
|
||||||
it.type == AttachmentType.IMAGE
|
it.type == AttachmentType.IMAGE
|
||||||
}
|
}
|
||||||
AppleEmojiText(
|
AppleEmojiText(
|
||||||
text = if (displayReplyMessages.size == 1) {
|
text = if (panelReplyMessages.size == 1) {
|
||||||
if (msg.text.isEmpty() && hasImageAttachment) {
|
if (msg.text.isEmpty() && hasImageAttachment) {
|
||||||
"Photo"
|
"Photo"
|
||||||
} else {
|
} else {
|
||||||
val shortText = msg.text.take(40)
|
val shortText = msg.text.take(40)
|
||||||
if (shortText.length < msg.text.length) "$shortText..." else shortText
|
if (shortText.length < msg.text.length) "$shortText..." else shortText
|
||||||
}
|
}
|
||||||
} else "${displayReplyMessages.size} messages",
|
} else "${panelReplyMessages.size} messages",
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f)
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f)
|
||||||
else Color.Black.copy(alpha = 0.5f),
|
else Color.Black.copy(alpha = 0.5f),
|
||||||
@@ -365,8 +389,8 @@ fun MessageInputBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 Превью изображения (как в Telegram)
|
// 🔥 Превью изображения (как в Telegram)
|
||||||
if (displayReplyMessages.size == 1) {
|
if (panelReplyMessages.size == 1) {
|
||||||
val msg = displayReplyMessages.first()
|
val msg = panelReplyMessages.first()
|
||||||
val imageAttachment = msg.attachments.find {
|
val imageAttachment = msg.attachments.find {
|
||||||
it.type == AttachmentType.IMAGE
|
it.type == AttachmentType.IMAGE
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user