fix: enhance avatar expansion and collapse animations with overscroll support and haptic feedback in OtherProfileScreen

This commit is contained in:
2026-02-02 01:50:00 +05:00
parent f78bd0edeb
commit e1cc49c12b
6 changed files with 1106 additions and 523 deletions

View File

@@ -199,6 +199,9 @@ fun ChatDetailScreen(
contract = ActivityResultContracts.TakePicture() contract = ActivityResultContracts.TakePicture()
) { success -> ) { success ->
if (success && cameraImageUri != null) { if (success && cameraImageUri != null) {
// Очищаем фокус чтобы клавиатура не появилась
keyboardController?.hide()
focusManager.clearFocus()
// Открываем редактор вместо прямой отправки // Открываем редактор вместо прямой отправки
pendingCameraPhotoUri = cameraImageUri pendingCameraPhotoUri = cameraImageUri
} }
@@ -1401,9 +1404,8 @@ fun ChatDetailScreen(
onReplyClick = onReplyClick =
scrollToMessage, scrollToMessage,
onAttachClick = { onAttachClick = {
// Hide keyboard when opening media picker // Telegram-style: галерея открывается ПОВЕРХ клавиатуры
keyboardController?.hide() // НЕ скрываем клавиатуру!
focusManager.clearFocus()
showMediaPicker = true showMediaPicker = true
}, },
myPublicKey = viewModel.myPublicKey ?: "", myPublicKey = viewModel.myPublicKey ?: "",
@@ -1416,6 +1418,8 @@ fun ChatDetailScreen(
} // Закрытие Column с imePadding } // Закрытие Column с imePadding
} }
) { paddingValues -> ) { paddingValues ->
// 🔥 Box wrapper для overlay (MediaPicker над клавиатурой)
Box(modifier = Modifier.fillMaxSize()) {
// 🔥 Column структура - список сжимается когда клавиатура открывается // 🔥 Column структура - список сжимается когда клавиатура открывается
Column( Column(
modifier = modifier =
@@ -1783,7 +1787,69 @@ fun ChatDetailScreen(
} }
} }
} }
} // Конец Column внутри Scaffold content
// 📎 Media Picker INLINE OVERLAY (Telegram-style gallery над клавиатурой)
// Теперь это НЕ Dialog, а обычный composable внутри того же layout!
MediaPickerBottomSheet(
isVisible = showMediaPicker,
onDismiss = { showMediaPicker = false },
isDarkTheme = isDarkTheme,
currentUserPublicKey = currentUserPublicKey,
onMediaSelected = { selectedMedia ->
// 📸 Открываем edit screen для выбранных изображений
val imageUris = selectedMedia
.filter { !it.isVideo }
.map { it.uri }
if (imageUris.isNotEmpty()) {
pendingGalleryImages = imageUris
} }
},
onMediaSelectedWithCaption = { mediaItem, caption ->
// 📸 Отправляем фото с caption напрямую
showMediaPicker = false
scope.launch {
val base64 = MediaUtils.uriToBase64Image(context, mediaItem.uri)
val blurhash = MediaUtils.generateBlurhash(context, mediaItem.uri)
val (width, height) = MediaUtils.getImageDimensions(context, mediaItem.uri)
if (base64 != null) {
viewModel.sendImageMessage(base64, blurhash, caption, width, height)
}
}
},
onOpenCamera = {
// 📷 Очищаем фокус перед открытием камеры
keyboardController?.hide()
focusManager.clearFocus()
// Создаём временный файл для фото
try {
val photoFile = File.createTempFile(
"photo_${System.currentTimeMillis()}",
".jpg",
context.cacheDir
)
cameraImageUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
photoFile
)
cameraLauncher.launch(cameraImageUri!!)
} catch (e: Exception) {
}
},
onOpenFilePicker = {
// 📄 Открываем файловый пикер
filePickerLauncher.launch("*/*")
},
onAvatarClick = {
// 👤 Отправляем свой аватар (как в desktop)
viewModel.sendAvatarMessage()
},
recipientName = user.title
)
} // Закрытие Box wrapper для Scaffold content
} // Закрытие Box } // Закрытие Box
// 📸 Image Viewer Overlay with Telegram-style shared element animation // 📸 Image Viewer Overlay with Telegram-style shared element animation
@@ -2012,64 +2078,6 @@ fun ChatDetailScreen(
) )
} }
// 📎 Media Picker BottomSheet (Telegram-style gallery)
MediaPickerBottomSheet(
isVisible = showMediaPicker,
onDismiss = { showMediaPicker = false },
isDarkTheme = isDarkTheme,
currentUserPublicKey = currentUserPublicKey,
onMediaSelected = { selectedMedia ->
// 📸 Открываем edit screen для выбранных изображений
// Собираем URI изображений (пока без видео)
val imageUris = selectedMedia
.filter { !it.isVideo }
.map { it.uri }
if (imageUris.isNotEmpty()) {
pendingGalleryImages = imageUris
}
},
onMediaSelectedWithCaption = { mediaItem, caption ->
// 📸 Отправляем фото с caption напрямую
showMediaPicker = false
scope.launch {
val base64 = MediaUtils.uriToBase64Image(context, mediaItem.uri)
val blurhash = MediaUtils.generateBlurhash(context, mediaItem.uri)
val (width, height) = MediaUtils.getImageDimensions(context, mediaItem.uri)
if (base64 != null) {
viewModel.sendImageMessage(base64, blurhash, caption, width, height)
}
}
},
onOpenCamera = {
// 📷 Создаём временный файл для фото
try {
val photoFile = File.createTempFile(
"photo_${System.currentTimeMillis()}",
".jpg",
context.cacheDir
)
cameraImageUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
photoFile
)
cameraLauncher.launch(cameraImageUri!!)
} catch (e: Exception) {
}
},
onOpenFilePicker = {
// 📄 Открываем файловый пикер
filePickerLauncher.launch("*/*")
},
onAvatarClick = {
// 👤 Отправляем свой аватар (как в desktop)
viewModel.sendAvatarMessage()
},
recipientName = user.title
)
// 📷 Image Editor для фото с камеры (с caption как в Telegram) // 📷 Image Editor для фото с камеры (с caption как в Telegram)
pendingCameraPhotoUri?.let { uri -> pendingCameraPhotoUri?.let { uri ->
ImageEditorScreen( ImageEditorScreen(

View File

@@ -6,6 +6,7 @@ import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.view.inputmethod.InputMethodManager
import android.widget.ImageView import android.widget.ImageView
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -13,6 +14,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
@@ -25,6 +27,7 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
@@ -38,6 +41,7 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
@@ -46,6 +50,10 @@ 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 app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
import com.rosetta.messenger.ui.components.AppleEmojiTextField
import com.rosetta.messenger.ui.components.KeyboardHeightProvider
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.yalantis.ucrop.UCrop import com.yalantis.ucrop.UCrop
import compose.icons.TablerIcons import compose.icons.TablerIcons
@@ -56,6 +64,7 @@ import ja.burhanrashid52.photoeditor.SaveSettings
import java.io.File import java.io.File
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -65,6 +74,17 @@ private const val TAG = "ImageEditorScreen"
/** Telegram-style easing */ /** Telegram-style easing */
private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
/**
* 📍 Data class для позиции thumbnail (для Telegram-style анимации)
*/
data class ThumbnailPosition(
val x: Float, // X позиция в окне
val y: Float, // Y позиция в окне
val width: Float, // Ширина thumbnail
val height: Float, // Высота thumbnail
val cornerRadius: Float = 4f // Закругление углов (dp)
)
/** Available editing tools */ /** Available editing tools */
enum class EditorTool { enum class EditorTool {
NONE, NONE,
@@ -93,7 +113,7 @@ val drawingColors = listOf(
* Features: * Features:
* - Fullscreen edge-to-edge photo display * - Fullscreen edge-to-edge photo display
* - Transparent overlay controls * - Transparent overlay controls
* - Smooth animations * - Smooth Telegram-style enter/exit animations
* - Drawing, Crop, Rotate tools * - Drawing, Crop, Rotate tools
* - Caption input with send button * - Caption input with send button
*/ */
@@ -106,12 +126,50 @@ fun ImageEditorScreen(
onSaveWithCaption: ((Uri, String) -> Unit)? = null, onSaveWithCaption: ((Uri, String) -> Unit)? = null,
isDarkTheme: Boolean = true, isDarkTheme: Boolean = true,
showCaptionInput: Boolean = false, showCaptionInput: Boolean = false,
recipientName: String? = null // Имя получателя (как в Telegram) recipientName: String? = null,
thumbnailPosition: ThumbnailPosition? = null // Позиция для Telegram-style анимации
) { ) {
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 val focusManager = LocalFocusManager.current
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val screenWidth = configuration.screenWidthDp.dp
val screenHeight = configuration.screenHeightDp.dp
// ═══════════════════════════════════════════════════════════════
// 🎬 TELEGRAM-STYLE ENTER/EXIT ANIMATION
// ═══════════════════════════════════════════════════════════════
var isClosing by remember { mutableStateOf(false) }
val animationProgress = remember { Animatable(0f) }
// Запуск enter анимации
LaunchedEffect(Unit) {
animationProgress.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = 200,
easing = FastOutSlowInEasing
)
)
}
// Функция для плавного закрытия
fun animatedDismiss() {
if (isClosing) return
isClosing = true
scope.launch {
animationProgress.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = 200,
easing = FastOutSlowInEasing
)
)
onDismiss()
}
}
// Editor state // Editor state
var currentTool by remember { mutableStateOf(EditorTool.NONE) } var currentTool by remember { mutableStateOf(EditorTool.NONE) }
@@ -119,10 +177,80 @@ fun ImageEditorScreen(
var brushSize by remember { mutableStateOf(12f) } var brushSize by remember { mutableStateOf(12f) }
var showColorPicker by remember { mutableStateOf(false) } var showColorPicker by remember { mutableStateOf(false) }
var isSaving by remember { mutableStateOf(false) } var isSaving by remember { mutableStateOf(false) }
var isEraserActive by remember { mutableStateOf(false) }
// Caption state // Caption state
var caption by remember { mutableStateOf("") } var caption by remember { mutableStateOf("") }
// ═══════════════════════════════════════════════════════════════
// 😀 EMOJI PICKER STATE - Telegram style с плавной анимацией
// ═══════════════════════════════════════════════════════════════
var showEmojiPicker by remember { mutableStateOf(false) }
val coordinator = rememberKeyboardTransitionCoordinator()
var editTextView by remember { mutableStateOf<com.rosetta.messenger.ui.components.AppleEmojiEditTextView?>(null) }
var lastToggleTime by remember { mutableLongStateOf(0L) }
val toggleCooldownMs = 500L
// Отслеживание высоты клавиатуры
val imeInsets = WindowInsets.ime
var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) }
// Update coordinator through snapshotFlow
LaunchedEffect(Unit) {
snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }.collect { currentImeHeight ->
coordinator.updateKeyboardHeight(currentImeHeight)
if (currentImeHeight > 100.dp) {
coordinator.syncHeights()
lastStableKeyboardHeight = currentImeHeight
}
}
}
// Load saved keyboard height
LaunchedEffect(Unit) {
KeyboardHeightProvider.getSavedKeyboardHeight(context)
}
// Save keyboard height when stable
val isKeyboardVisibleForSave = WindowInsets.ime.getBottom(density) > 0
LaunchedEffect(isKeyboardVisibleForSave, showEmojiPicker) {
if (isKeyboardVisibleForSave && !showEmojiPicker) {
delay(350)
if (isKeyboardVisibleForSave && !showEmojiPicker && lastStableKeyboardHeight > 300.dp) {
val heightPx = with(density) { lastStableKeyboardHeight.toPx().toInt() }
KeyboardHeightProvider.saveKeyboardHeight(context, heightPx)
}
}
}
// Toggle emoji picker function
fun toggleEmojiPicker() {
val currentTime = System.currentTimeMillis()
if (currentTime - lastToggleTime < toggleCooldownMs) return
lastToggleTime = currentTime
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (coordinator.isEmojiVisible) {
// EMOJI → KEYBOARD
coordinator.requestShowKeyboard(
showKeyboard = {
editTextView?.let { editText ->
editText.requestFocus()
imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED)
}
},
hideEmoji = { showEmojiPicker = false }
)
} else {
// KEYBOARD → EMOJI
coordinator.requestShowEmoji(
hideKeyboard = { imm.hideSoftInputFromWindow(view.windowToken, 0) },
showEmoji = { showEmojiPicker = true }
)
}
}
// Current image URI (can change after crop) // Current image URI (can change after crop)
var currentImageUri by remember { mutableStateOf(imageUri) } var currentImageUri by remember { mutableStateOf(imageUri) }
@@ -135,15 +263,6 @@ fun ImageEditorScreen(
var photoEditor by remember { mutableStateOf<PhotoEditor?>(null) } var photoEditor by remember { mutableStateOf<PhotoEditor?>(null) }
var photoEditorView by remember { mutableStateOf<PhotoEditorView?>(null) } var photoEditorView by remember { mutableStateOf<PhotoEditorView?>(null) }
// Animation for enter
val enterAnimation = remember { Animatable(0f) }
LaunchedEffect(Unit) {
enterAnimation.animateTo(
targetValue = 1f,
animationSpec = tween(250, easing = TelegramEasing)
)
}
// UCrop launcher // UCrop launcher
val cropLauncher = rememberLauncherForActivityResult( val cropLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult() contract = ActivityResultContracts.StartActivityForResult()
@@ -158,17 +277,60 @@ fun ImageEditorScreen(
} }
} }
BackHandler { onDismiss() } BackHandler { animatedDismiss() }
// ═══════════════════════════════════════════════════════════════
// 🎬 ANIMATED CONTAINER - Telegram-style morph animation
// ═══════════════════════════════════════════════════════════════
val progress = animationProgress.value
// Вычисляем анимированные значения
val screenWidthPx = with(density) { screenWidth.toPx() }
val screenHeightPx = with(density) { screenHeight.toPx() }
// Если есть позиция thumbnail - делаем morph анимацию
val animatedScale: Float
val animatedTranslationX: Float
val animatedTranslationY: Float
val animatedCornerRadius: Float
val animatedBackgroundAlpha: Float
if (thumbnailPosition != null) {
// Начальный масштаб (thumbnail → fullscreen)
val startScale = thumbnailPosition.width / screenWidthPx
animatedScale = startScale + (1f - startScale) * progress
// Начальная позиция (центр thumbnail → центр экрана)
val startX = thumbnailPosition.x + thumbnailPosition.width / 2 - screenWidthPx / 2
val startY = thumbnailPosition.y + thumbnailPosition.height / 2 - screenHeightPx / 2
animatedTranslationX = startX * (1f - progress)
animatedTranslationY = startY * (1f - progress)
// Закругление углов
val cornerRadiusPx = with(density) { thumbnailPosition.cornerRadius.dp.toPx() }
animatedCornerRadius = cornerRadiusPx * (1f - progress)
// Альфа фона
animatedBackgroundAlpha = progress
} else {
// Fallback анимация без позиции
animatedScale = 0.95f + 0.05f * progress
animatedTranslationX = 0f
animatedTranslationY = 0f
animatedCornerRadius = 0f
animatedBackgroundAlpha = progress
}
// Telegram behavior: photo stays fullscreen, only input moves with keyboard // Telegram behavior: photo stays fullscreen, only input moves with keyboard
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black.copy(alpha = animatedBackgroundAlpha))
.graphicsLayer { .graphicsLayer {
alpha = enterAnimation.value scaleX = animatedScale
scaleX = 0.95f + 0.05f * enterAnimation.value scaleY = animatedScale
scaleY = 0.95f + 0.05f * enterAnimation.value translationX = animatedTranslationX
translationY = animatedTranslationY
} }
) { ) {
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -224,11 +386,17 @@ fun ImageEditorScreen(
.statusBarsPadding() .statusBarsPadding()
.padding(horizontal = 4.dp, vertical = 8.dp) .padding(horizontal = 4.dp, vertical = 8.dp)
) { ) {
// Close button (X) - сначала закрывает клавиатуру, потом экран // Close button (X) - сначала закрывает emoji/клавиатуру, потом экран
IconButton( IconButton(
onClick = { onClick = {
// Проверяем, открыт ли emoji picker
if (showEmojiPicker) {
showEmojiPicker = false
return@IconButton
}
// Проверяем, открыта ли клавиатура // Проверяем, открыта ли клавиатура
val imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
val isKeyboardOpen = imm.isAcceptingText val isKeyboardOpen = imm.isAcceptingText
if (isKeyboardOpen) { if (isKeyboardOpen) {
@@ -236,8 +404,8 @@ fun ImageEditorScreen(
imm.hideSoftInputFromWindow(view.windowToken, 0) imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus() focusManager.clearFocus()
} else { } else {
// Закрываем экран // Закрываем экран с анимацией
onDismiss() animatedDismiss()
} }
}, },
modifier = Modifier.align(Alignment.CenterStart) modifier = Modifier.align(Alignment.CenterStart)
@@ -335,14 +503,43 @@ fun ImageEditorScreen(
} }
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 📝 CAPTION INPUT - отдельно, поднимается с клавиатурой (как в Telegram) // 📝 CAPTION INPUT + EMOJI PICKER - Telegram style
// Прячется с анимацией когда открыты инструменты (рисовалка, поворот и т.д.) // Прячется с анимацией когда открыты инструменты (рисовалка, поворот и т.д.)
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Определяем видимость клавиатуры // ═══════════════════════════════════════════════════════════
val isKeyboardVisibleForCaption = WindowInsets.ime.getBottom(LocalDensity.current) > 0 // 🔥 TELEGRAM-STYLE FIX: Единый spacer вместо imePadding
// Когда клавиатура закрыта - добавляем отступ снизу для toolbar (~100dp) // Это ПОЛНОСТЬЮ устраняет прыжки при переходе keyboard ↔ emoji
val bottomPaddingForCaption = if (!isKeyboardVisibleForCaption) 100.dp else 0.dp // ═══════════════════════════════════════════════════════════
val currentImeHeight = with(density) { WindowInsets.ime.getBottom(density).toDp() }
val isImeActuallyOpen = currentImeHeight > 50.dp
// 🔥 КЛЮЧЕВОЕ: Единая высота spacer'а - ИЛИ keyboard ИЛИ emoji
// Когда keyboard открыта: spacerHeight = imeHeight
// Когда emoji открыта: spacerHeight = emojiHeight
// Это даёт плавный переход без прыжков!
// Fallback высота emoji на случай если coordinator.emojiHeight = 0
val effectiveEmojiHeight = when {
coordinator.emojiHeight > 0.dp -> coordinator.emojiHeight
lastStableKeyboardHeight > 0.dp -> lastStableKeyboardHeight
else -> 300.dp // Минимальная fallback высота
}
val spacerHeight = when {
isImeActuallyOpen -> currentImeHeight // Keyboard открыта - её высота
showEmojiPicker || coordinator.isEmojiBoxVisible -> effectiveEmojiHeight // Emoji открыта
else -> 0.dp // Ничего не открыто
}
// Нужен ли spacer вообще
val needsSpacer = spacerHeight > 0.dp
// 🔥 Обновляем coordinator для правильной работы toolbar
coordinator.isEmojiBoxVisible = showEmojiPicker
// Когда клавиатура/emoji закрыты - добавляем отступ снизу для toolbar (~100dp)
val bottomPaddingForCaption = if (!needsSpacer) 100.dp else 0.dp
AnimatedVisibility( AnimatedVisibility(
visible = showCaptionInput && currentTool == EditorTool.NONE, visible = showCaptionInput && currentTool == EditorTool.NONE,
@@ -351,14 +548,21 @@ fun ImageEditorScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = bottomPaddingForCaption) .padding(bottom = bottomPaddingForCaption)
.imePadding() // поднимается с клавиатурой // 🔥 БЕЗ imePadding! Всё контролируется через spacer ниже
) { ) {
TelegramCaptionBar( TelegramCaptionBar(
caption = caption, caption = caption,
onCaptionChange = { caption = it }, onCaptionChange = { caption = it },
isSaving = isSaving, isSaving = isSaving,
isKeyboardVisible = isKeyboardVisibleForCaption, isKeyboardVisible = isImeActuallyOpen || showEmojiPicker,
showEmojiPicker = showEmojiPicker,
onToggleEmojiPicker = { toggleEmojiPicker() },
onEditTextViewCreated = { editTextView = it },
onSend = { onSend = {
scope.launch { scope.launch {
isSaving = true isSaving = true
@@ -375,15 +579,43 @@ fun ImageEditorScreen(
} }
} }
) )
// ═══════════════════════════════════════════════════════════
// 🔥 UNIFIED SPACER: Один Box для keyboard И emoji
// Высота = imeHeight когда keyboard, = emojiHeight когда emoji
// ═══════════════════════════════════════════════════════════
if (needsSpacer) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(spacerHeight)
) {
// Emoji picker рендерится ВНУТРИ spacer'а с fade анимацией
androidx.compose.animation.AnimatedVisibility(
visible = showEmojiPicker,
enter = fadeIn(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(200))
) {
OptimizedEmojiPicker(
isVisible = true,
isDarkTheme = isDarkTheme,
onEmojiSelected = { emoji -> caption = caption + emoji },
onClose = { toggleEmojiPicker() },
modifier = Modifier.fillMaxSize()
)
}
}
}
}
} }
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🛠️ TOOLBAR - показывается только когда клавиатура ЗАКРЫТА // 🛠️ TOOLBAR - показывается только когда клавиатура и emoji ЗАКРЫТЫ
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val isKeyboardOpen = WindowInsets.ime.getBottom(LocalDensity.current) > 0 val isKeyboardOpen = WindowInsets.ime.getBottom(LocalDensity.current) > 0
AnimatedVisibility( AnimatedVisibility(
visible = !isKeyboardOpen, visible = !isKeyboardOpen && !showEmojiPicker && !coordinator.isEmojiBoxVisible,
enter = fadeIn() + slideInVertically { it }, enter = fadeIn() + slideInVertically { it },
exit = fadeOut() + slideOutVertically { it }, exit = fadeOut() + slideOutVertically { it },
modifier = Modifier modifier = Modifier
@@ -407,22 +639,35 @@ fun ImageEditorScreen(
currentTool = currentTool, currentTool = currentTool,
showCaptionInput = showCaptionInput, showCaptionInput = showCaptionInput,
isSaving = isSaving, isSaving = isSaving,
isEraserActive = isEraserActive,
onCropClick = { onCropClick = {
currentTool = EditorTool.NONE currentTool = EditorTool.NONE
showColorPicker = false showColorPicker = false
isEraserActive = false
photoEditor?.setBrushDrawingMode(false) photoEditor?.setBrushDrawingMode(false)
launchCrop(context, currentImageUri, cropLauncher) launchCrop(context, currentImageUri, cropLauncher)
}, },
onRotateClick = { onRotateClick = {
currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else EditorTool.ROTATE currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else EditorTool.ROTATE
showColorPicker = false showColorPicker = false
isEraserActive = false
photoEditor?.setBrushDrawingMode(false) photoEditor?.setBrushDrawingMode(false)
}, },
onDrawClick = { onDrawClick = {
if (currentTool == EditorTool.DRAW) { if (currentTool == EditorTool.DRAW) {
// Если ластик активен - переключаемся обратно на кисть
if (isEraserActive) {
isEraserActive = false
photoEditor?.setBrushDrawingMode(true)
photoEditor?.brushColor = selectedColor.toArgb()
photoEditor?.brushSize = brushSize
} else {
// Иначе показываем/скрываем color picker
showColorPicker = !showColorPicker showColorPicker = !showColorPicker
}
} else { } else {
currentTool = EditorTool.DRAW currentTool = EditorTool.DRAW
isEraserActive = false
photoEditor?.setBrushDrawingMode(true) photoEditor?.setBrushDrawingMode(true)
photoEditor?.brushColor = selectedColor.toArgb() photoEditor?.brushColor = selectedColor.toArgb()
photoEditor?.brushSize = brushSize photoEditor?.brushSize = brushSize
@@ -430,7 +675,22 @@ fun ImageEditorScreen(
} }
}, },
onEraserClick = { onEraserClick = {
isEraserActive = !isEraserActive
if (isEraserActive) {
photoEditor?.brushEraser() photoEditor?.brushEraser()
} else {
// Возвращаемся к обычной кисти
photoEditor?.setBrushDrawingMode(true)
photoEditor?.brushColor = selectedColor.toArgb()
photoEditor?.brushSize = brushSize
}
},
onDrawDoneClick = {
// Принимаем изменения рисования и выходим из режима
currentTool = EditorTool.NONE
showColorPicker = false
isEraserActive = false
photoEditor?.setBrushDrawingMode(false)
}, },
onDoneClick = { onDoneClick = {
if (!showCaptionInput) { if (!showCaptionInput) {
@@ -461,10 +721,12 @@ private fun TelegramToolbar(
currentTool: EditorTool, currentTool: EditorTool,
showCaptionInput: Boolean, showCaptionInput: Boolean,
isSaving: Boolean, isSaving: Boolean,
isEraserActive: Boolean = false,
onCropClick: () -> Unit, onCropClick: () -> Unit,
onRotateClick: () -> Unit, onRotateClick: () -> Unit,
onDrawClick: () -> Unit, onDrawClick: () -> Unit,
onEraserClick: () -> Unit, onEraserClick: () -> Unit,
onDrawDoneClick: () -> Unit = {},
onDoneClick: () -> Unit onDoneClick: () -> Unit
) { ) {
Row( Row(
@@ -491,11 +753,11 @@ private fun TelegramToolbar(
// Draw // Draw
TelegramToolButton( TelegramToolButton(
icon = TablerIcons.Pencil, icon = TablerIcons.Pencil,
isSelected = currentTool == EditorTool.DRAW, isSelected = currentTool == EditorTool.DRAW && !isEraserActive,
onClick = onDrawClick onClick = onDrawClick
) )
// Eraser (visible when drawing) // Eraser (visible when drawing) - подсвечивается синим когда активен
AnimatedVisibility( AnimatedVisibility(
visible = currentTool == EditorTool.DRAW, visible = currentTool == EditorTool.DRAW,
enter = scaleIn() + fadeIn(), enter = scaleIn() + fadeIn(),
@@ -503,13 +765,32 @@ private fun TelegramToolbar(
) { ) {
TelegramToolButton( TelegramToolButton(
icon = TablerIcons.Eraser, icon = TablerIcons.Eraser,
isSelected = false, isSelected = isEraserActive,
onClick = onEraserClick onClick = onEraserClick
) )
} }
// Done/Check button (if no caption input) // Check button to accept drawing changes (visible when drawing)
if (!showCaptionInput) { AnimatedVisibility(
visible = currentTool == EditorTool.DRAW,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
IconButton(
onClick = onDrawDoneClick,
modifier = Modifier.size(48.dp)
) {
Icon(
TablerIcons.Check,
contentDescription = "Accept drawing",
tint = Color(0xFF4CAF50), // Зелёный цвет для подтверждения
modifier = Modifier.size(26.dp)
)
}
}
// Done/Check button (if no caption input and not in draw mode)
if (!showCaptionInput && currentTool != EditorTool.DRAW) {
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Box( Box(
@@ -691,7 +972,7 @@ private fun TelegramRotateBar(
} }
/** /**
* Telegram-style caption input bar * Telegram-style caption input bar with emoji support
* Меняет внешний вид в зависимости от состояния клавиатуры: * Меняет внешний вид в зависимости от состояния клавиатуры:
* - Клавиатура закрыта: стеклянный инпут с blur эффектом (не на всю ширину) * - Клавиатура закрыта: стеклянный инпут с blur эффектом (не на всю ширину)
* - Клавиатура открыта: полный стиль (emoji + текст + галочка) * - Клавиатура открыта: полный стиль (emoji + текст + галочка)
@@ -702,6 +983,9 @@ private fun TelegramCaptionBar(
onCaptionChange: (String) -> Unit, onCaptionChange: (String) -> Unit,
isSaving: Boolean, isSaving: Boolean,
isKeyboardVisible: Boolean, isKeyboardVisible: Boolean,
showEmojiPicker: Boolean = false,
onToggleEmojiPicker: () -> Unit = {},
onEditTextViewCreated: ((com.rosetta.messenger.ui.components.AppleEmojiEditTextView) -> Unit)? = null,
onSend: () -> Unit onSend: () -> Unit
) { ) {
// Анимированный переход между стилями // Анимированный переход между стилями
@@ -723,7 +1007,7 @@ private fun TelegramCaptionBar(
.padding(horizontal = horizontalPadding) .padding(horizontal = horizontalPadding)
.then( .then(
if (isKeyboardVisible) { if (isKeyboardVisible) {
// Клавиатура открыта - полупрозрачный черный фон на всю ширину // Клавиатура/emoji открыты - полупрозрачный черный фон на всю ширину
Modifier.background(Color.Black.copy(alpha = 0.75f)) Modifier.background(Color.Black.copy(alpha = 0.75f))
} else { } else {
// Клавиатура закрыта - стеклянный эффект с закруглением // Клавиатура закрыта - стеклянный эффект с закруглением
@@ -739,22 +1023,27 @@ private fun TelegramCaptionBar(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
// Левая иконка: камера когда клавиатура закрыта, emoji когда открыта // Левая иконка: камера когда клавиатура закрыта, emoji/keyboard когда открыта
AnimatedContent( AnimatedContent(
targetState = isKeyboardVisible, targetState = isKeyboardVisible to showEmojiPicker,
transitionSpec = { transitionSpec = {
fadeIn(tween(150)) togetherWith fadeOut(tween(150)) fadeIn(tween(150)) togetherWith fadeOut(tween(150))
}, },
label = "left_icon" label = "left_icon"
) { keyboardOpen -> ) { (keyboardOpen, emojiOpen) ->
if (keyboardOpen) { if (keyboardOpen) {
// Клавиатура открыта - emoji иконка // Клавиатура/emoji открыты - кликабельная иконка переключения
IconButton(
onClick = onToggleEmojiPicker,
modifier = Modifier.size(32.dp)
) {
Icon( Icon(
TablerIcons.MoodSmile, if (emojiOpen) TablerIcons.Keyboard else TablerIcons.MoodSmile,
contentDescription = "Emoji", contentDescription = if (emojiOpen) "Keyboard" else "Emoji",
tint = Color.White.copy(alpha = 0.7f), tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(26.dp) modifier = Modifier.size(26.dp)
) )
}
} else { } else {
// Клавиатура закрыта - камера иконка // Клавиатура закрыта - камера иконка
Icon( Icon(
@@ -766,30 +1055,30 @@ private fun TelegramCaptionBar(
} }
} }
// Caption text field // Caption text field - использует AppleEmojiTextField для правильной работы с фокусом
BasicTextField( Box(
modifier = Modifier
.weight(1f)
.heightIn(min = 24.dp, max = if (isKeyboardVisible) 100.dp else 24.dp)
) {
AppleEmojiTextField(
value = caption, value = caption,
onValueChange = onCaptionChange, onValueChange = onCaptionChange,
modifier = Modifier.weight(1f), textColor = Color.White,
textStyle = androidx.compose.ui.text.TextStyle( textSize = 16f,
color = Color.White, hint = "Add a caption...",
fontSize = 16.sp hintColor = Color.White.copy(alpha = 0.5f),
), modifier = Modifier.fillMaxWidth(),
maxLines = if (isKeyboardVisible) 4 else 1, requestFocus = false,
singleLine = !isKeyboardVisible, onViewCreated = { view -> onEditTextViewCreated?.invoke(view) },
decorationBox = { innerTextField -> onFocusChanged = { hasFocus ->
Box { // Если получили фокус и emoji открыт - закрываем emoji
if (caption.isEmpty()) { if (hasFocus && showEmojiPicker) {
Text( onToggleEmojiPicker()
"Add a caption...",
color = Color.White.copy(alpha = 0.5f),
fontSize = 16.sp
)
}
innerTextField()
} }
} }
) )
}
// Кнопка отправки // Кнопка отправки
AnimatedContent( AnimatedContent(

View File

@@ -7,6 +7,7 @@ import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import android.view.WindowManager
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.* import androidx.compose.animation.*
@@ -14,6 +15,7 @@ import androidx.compose.animation.core.*
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.* import androidx.compose.foundation.lazy.grid.*
@@ -29,13 +31,22 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
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.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.activity.compose.BackHandler
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
@@ -49,6 +60,7 @@ import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
private const val TAG = "MediaPickerBottomSheet" private const val TAG = "MediaPickerBottomSheet"
@@ -66,8 +78,12 @@ data class MediaItem(
} }
/** /**
* Telegram-style media picker bottom sheet * 📸 Telegram-style Media Picker - INLINE OVERLAY
* Shows gallery photos/videos in a grid with selection *
* Ключевое отличие от обычного BottomSheet:
* - Это НЕ Dialog, а обычный Composable который рендерится ВНУТРИ того же layout
* - Позиционируется ПОВЕРХ клавиатуры с помощью imePadding()
* - Клавиатура остаётся открытой!
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -76,16 +92,17 @@ fun MediaPickerBottomSheet(
onDismiss: () -> Unit, onDismiss: () -> Unit,
isDarkTheme: Boolean, isDarkTheme: Boolean,
onMediaSelected: (List<MediaItem>) -> Unit, onMediaSelected: (List<MediaItem>) -> Unit,
onMediaSelectedWithCaption: ((MediaItem, String) -> Unit)? = null, // Для отправки с caption onMediaSelectedWithCaption: ((MediaItem, String) -> Unit)? = null,
onOpenCamera: () -> Unit = {}, onOpenCamera: () -> Unit = {},
onOpenFilePicker: () -> Unit = {}, onOpenFilePicker: () -> Unit = {},
onAvatarClick: () -> Unit = {}, onAvatarClick: () -> Unit = {},
currentUserPublicKey: String = "", currentUserPublicKey: String = "",
maxSelection: Int = 10, maxSelection: Int = 10,
recipientName: String? = null // Имя получателя для отображения в редакторе recipientName: String? = null
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val density = LocalDensity.current
// Media items from gallery // Media items from gallery
var mediaItems by remember { mutableStateOf<List<MediaItem>>(emptyList()) } var mediaItems by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
@@ -98,6 +115,9 @@ fun MediaPickerBottomSheet(
// Editor state - when user taps on a photo, open editor // Editor state - when user taps on a photo, open editor
var editingItem by remember { mutableStateOf<MediaItem?>(null) } var editingItem by remember { mutableStateOf<MediaItem?>(null) }
// 📍 Позиция thumbnail для Telegram-style анимации
var thumbnailPosition by remember { mutableStateOf<ThumbnailPosition?>(null) }
// Uri фото, только что сделанного с камеры (для редактирования) // Uri фото, только что сделанного с камеры (для редактирования)
var pendingPhotoUri by remember { mutableStateOf<Uri?>(null) } var pendingPhotoUri by remember { mutableStateOf<Uri?>(null) }
@@ -159,13 +179,111 @@ fun MediaPickerBottomSheet(
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
// Show gallery only if not editing and not in preview // ═══════════════════════════════════════════════════════════════
if (isVisible && editingItem == null && pendingPhotoUri == null && previewPhotoUri == null) { // 🎬 TELEGRAM-STYLE: Popup поверх клавиатуры с анимацией
ModalBottomSheet( // ═══════════════════════════════════════════════════════════════
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
// Высота галереи - большая, почти половина экрана
val sheetHeight = screenHeight * 0.55f
// Отступ снизу чтобы быть НАД клавиатурой (примерно высота input bar)
val bottomOffset = 56.dp
// 🎬 Анимация появления
var animationStarted by remember { mutableStateOf(false) }
val animatedOffset by animateFloatAsState(
targetValue = if (animationStarted) 0f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
),
label = "sheet_slide"
)
val animatedAlpha by animateFloatAsState(
targetValue = if (animationStarted) 1f else 0f,
animationSpec = tween(200),
label = "scrim_alpha"
)
// Drag offset для свайпа вниз
var dragOffsetY by remember { mutableFloatStateOf(0f) }
// Показываем галерею
val showSheet = isVisible && editingItem == null && pendingPhotoUri == null && previewPhotoUri == null
// Запускаем анимацию когда showSheet становится true
LaunchedEffect(showSheet) {
if (showSheet) {
animationStarted = true
} else {
animationStarted = false
}
}
// Используем Popup для показа поверх клавиатуры
if (showSheet) {
// BackHandler для закрытия по back
BackHandler { onDismiss() }
Popup(
alignment = Alignment.BottomCenter,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
containerColor = backgroundColor, properties = PopupProperties(
dragHandle = { focusable = false, // НЕ забираем фокус - клавиатура остаётся!
// Telegram-style drag handle dismissOnBackPress = true,
dismissOnClickOutside = true
)
) {
// Полноэкранный контейнер с затемнением
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f * animatedAlpha))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onDismiss() },
contentAlignment = Alignment.BottomCenter
) {
// Sheet content - с анимированным offset
val sheetSlideOffset = with(density) { (sheetHeight.toPx() * animatedOffset).toInt() }
Column(
modifier = Modifier
.fillMaxWidth()
.height(sheetHeight)
.padding(bottom = bottomOffset) // Отступ от низа чтобы быть над input bar
.offset { IntOffset(0, (sheetSlideOffset + dragOffsetY).roundToInt()) }
.graphicsLayer {
// Небольшой scale эффект при появлении
val scale = 0.95f + 0.05f * (1f - animatedOffset)
scaleX = scale
scaleY = scale
}
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
.background(backgroundColor)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { /* Prevent click through */ }
.pointerInput(Unit) {
detectVerticalDragGestures(
onDragEnd = {
if (dragOffsetY > 100) {
onDismiss()
}
dragOffsetY = 0f
},
onVerticalDrag = { _, dragAmount ->
if (dragAmount > 0) { // Only drag down
dragOffsetY += dragAmount
}
}
)
}
) {
// Drag handle
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
@@ -180,15 +298,7 @@ fun MediaPickerBottomSheet(
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
},
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false),
windowInsets = WindowInsets(0)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.85f)
) {
// Header with action buttons // Header with action buttons
MediaPickerHeader( MediaPickerHeader(
selectedCount = selectedItems.size, selectedCount = selectedItems.size,
@@ -291,9 +401,10 @@ fun MediaPickerBottomSheet(
onDismiss() onDismiss()
onOpenCamera() onOpenCamera()
}, },
onItemClick = { item -> onItemClick = { item, position ->
// Telegram-style: клик на фото сразу открывает редактор с caption // Telegram-style: клик на фото сразу открывает редактор с caption
if (!item.isVideo) { if (!item.isVideo) {
thumbnailPosition = position
editingItem = item editingItem = item
} else { } else {
// Для видео - добавляем/убираем из selection // Для видео - добавляем/убираем из selection
@@ -322,14 +433,19 @@ fun MediaPickerBottomSheet(
} }
} }
} }
}
// Image Editor overlay для фото из галереи // Image Editor overlay для фото из галереи
editingItem?.let { item -> editingItem?.let { item ->
ImageEditorScreen( ImageEditorScreen(
imageUri = item.uri, imageUri = item.uri,
onDismiss = { editingItem = null }, onDismiss = {
editingItem = null
thumbnailPosition = null
},
onSave = { editedUri -> onSave = { editedUri ->
editingItem = null editingItem = null
thumbnailPosition = null
// Если нет onMediaSelectedWithCaption - открываем preview // Если нет onMediaSelectedWithCaption - открываем preview
if (onMediaSelectedWithCaption == null) { if (onMediaSelectedWithCaption == null) {
previewPhotoUri = editedUri previewPhotoUri = editedUri
@@ -347,6 +463,7 @@ fun MediaPickerBottomSheet(
}, },
onSaveWithCaption = if (onMediaSelectedWithCaption != null) { editedUri, caption -> onSaveWithCaption = if (onMediaSelectedWithCaption != null) { editedUri, caption ->
editingItem = null editingItem = null
thumbnailPosition = null
val mediaItem = MediaItem( val mediaItem = MediaItem(
id = System.currentTimeMillis(), id = System.currentTimeMillis(),
uri = editedUri, uri = editedUri,
@@ -358,7 +475,8 @@ fun MediaPickerBottomSheet(
} else null, } else null,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
showCaptionInput = onMediaSelectedWithCaption != null, showCaptionInput = onMediaSelectedWithCaption != null,
recipientName = recipientName recipientName = recipientName,
thumbnailPosition = thumbnailPosition
) )
} }
@@ -596,7 +714,7 @@ private fun MediaGrid(
mediaItems: List<MediaItem>, mediaItems: List<MediaItem>,
selectedItems: Set<Long>, selectedItems: Set<Long>,
onCameraClick: () -> Unit, onCameraClick: () -> Unit,
onItemClick: (MediaItem) -> Unit, onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
onItemLongClick: (MediaItem) -> Unit, onItemLongClick: (MediaItem) -> Unit,
isDarkTheme: Boolean, isDarkTheme: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@@ -630,7 +748,7 @@ private fun MediaGrid(
selectionIndex = if (item.id in selectedItems) { selectionIndex = if (item.id in selectedItems) {
selectedItems.toList().indexOf(item.id) + 1 selectedItems.toList().indexOf(item.id) + 1
} else 0, } else 0,
onClick = { onItemClick(item) }, onClick = { position -> onItemClick(item, position) },
onLongClick = { onItemLongClick(item) }, onLongClick = { onItemLongClick(item) },
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
@@ -777,19 +895,34 @@ private fun MediaGridItem(
item: MediaItem, item: MediaItem,
isSelected: Boolean, isSelected: Boolean,
selectionIndex: Int, selectionIndex: Int,
onClick: () -> Unit, onClick: (ThumbnailPosition) -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
isDarkTheme: Boolean isDarkTheme: Boolean
) { ) {
val context = LocalContext.current val context = LocalContext.current
// 📍 Отслеживаем позицию для анимации
var itemPosition by remember { mutableStateOf<ThumbnailPosition?>(null) }
Box( Box(
modifier = Modifier modifier = Modifier
.aspectRatio(1f) .aspectRatio(1f)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.onGloballyPositioned { coordinates ->
val positionInWindow = coordinates.positionInWindow()
itemPosition = ThumbnailPosition(
x = positionInWindow.x,
y = positionInWindow.y,
width = coordinates.size.width.toFloat(),
height = coordinates.size.height.toFloat(),
cornerRadius = 4f
)
}
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onTap = { onClick() }, onTap = {
itemPosition?.let { onClick(it) }
},
onLongPress = { onLongClick() } onLongPress = { onLongClick() }
) )
} }

View File

@@ -7,7 +7,6 @@ import androidx.compose.runtime.setValue
import coil.imageLoader import coil.imageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis
/** /**
* 🚀 Оптимизированный кэш эмодзи с предзагрузкой * 🚀 Оптимизированный кэш эмодзи с предзагрузкой
@@ -47,7 +46,6 @@ object OptimizedEmojiCache {
scope.launch { scope.launch {
try { try {
val duration = measureTimeMillis {
// Шаг 1: Загружаем список эмодзи (быстро) // Шаг 1: Загружаем список эмодзи (быстро)
loadEmojiList(context) loadEmojiList(context)
loadProgress = 0.3f loadProgress = 0.3f
@@ -56,12 +54,12 @@ object OptimizedEmojiCache {
groupEmojisByCategories() groupEmojisByCategories()
loadProgress = 0.6f loadProgress = 0.6f
// Шаг 3: Предзагружаем популярные изображения (медленно, но в фоне) // 🔥 Сразу отмечаем как загруженный - предзагрузка идёт в фоне
isLoaded = true
// Шаг 3: Предзагружаем популярные изображения (в фоне, не блокирует UI)
preloadPopularEmojis(context) preloadPopularEmojis(context)
loadProgress = 1f loadProgress = 1f
}
isLoaded = true
isPreloading = false isPreloading = false
} catch (e: Exception) { } catch (e: Exception) {
allEmojis = emptyList() allEmojis = emptyList()

View File

@@ -19,10 +19,10 @@ import androidx.compose.material3.*
import compose.icons.TablerIcons import compose.icons.TablerIcons
import compose.icons.tablericons.* import compose.icons.tablericons.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.Stable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@@ -38,25 +38,16 @@ import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* 🚀 ОПТИМИЗИРОВАННЫЙ EMOJI PICKER * 🚀 ULTRA-ОПТИМИЗИРОВАННЫЙ EMOJI PICKER
* *
* Ключевые оптимизации: * Ключевые оптимизации v2:
* 1. Предзагрузка популярных эмодзи при старте приложения * 1. ZERO LaunchedEffect в EmojiButton - никаких корутин на каждый эмодзи
* 2. LazyGrid с оптимизированными настройками (beyondBoundsLayout) * 2. Нет анимаций scale - убрали spring animations для каждой кнопки
* 3. Hardware layer для анимаций * 3. Нет interactionSource tracking - убрали collect для каждой кнопки
* 4. Минимум recomposition (derivedStateOf, remember keys) * 4. Stable composables - используем @Stable для избежания recomposition
* 5. Coil оптимизация (hardware acceleration, size limits) * 5. Оптимизированный LazyGrid с prefetch
* 6. SharedPreferences для сохранения высоты клавиатуры (как в Telegram) * 6. Minimal modifier chain - меньше лямбд, меньше allocations
* 7. keyboardDuration для синхронизации с системной клавиатурой
* 8. Анимация управляется внешним AnimatedKeyboardTransition
*
* @param isVisible Видимость панели (для внутренней логики)
* @param isDarkTheme Темная/светлая тема
* @param onEmojiSelected Callback при выборе эмодзи
* @param onClose Callback при закрытии
* @param modifier Модификатор
*/ */
@OptIn(ExperimentalAnimationApi::class)
@Composable @Composable
fun OptimizedEmojiPicker( fun OptimizedEmojiPicker(
isVisible: Boolean, isVisible: Boolean,
@@ -65,15 +56,9 @@ fun OptimizedEmojiPicker(
onClose: () -> Unit = {}, onClose: () -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// 🔥 Используем сохранённую высоту клавиатуры (как в Telegram)
val savedKeyboardHeight = rememberSavedKeyboardHeight() val savedKeyboardHeight = rememberSavedKeyboardHeight()
// 🔥 Логирование изменений видимости // 🔥 Рендерим напрямую без лишних обёрток
LaunchedEffect(isVisible) {
}
// 🔥 Рендерим контент напрямую без AnimatedVisibility
// Анимация теперь управляется AnimatedKeyboardTransition
EmojiPickerContent( EmojiPickerContent(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onEmojiSelected = onEmojiSelected, onEmojiSelected = onEmojiSelected,
@@ -83,7 +68,13 @@ fun OptimizedEmojiPicker(
} }
/** /**
* Контент emoji picker'а * 🔥 Stable wrapper для callback чтобы избежать recomposition
*/
@Stable
private class StableCallback(val onClick: (String) -> Unit)
/**
* Контент emoji picker'а - ОПТИМИЗИРОВАННЫЙ
*/ */
@Composable @Composable
private fun EmojiPickerContent( private fun EmojiPickerContent(
@@ -97,24 +88,18 @@ private fun EmojiPickerContent(
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// 🚀 Отложенный рендеринг - даём анимации начаться без фриза // 🔥 Wrap callback в stable class для избежания recomposition
var shouldRenderContent by remember { mutableStateOf(false) } val stableCallback = remember(onEmojiSelected) { StableCallback(onEmojiSelected) }
// 🚀 Загружаем эмодзи ОДИН раз при первом рендере
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Ждём 1 кадр чтобы анимация началась плавно
kotlinx.coroutines.delay(16) // ~1 frame at 60fps
shouldRenderContent = true
// Загружаем эмодзи если еще не загружены
if (!OptimizedEmojiCache.isLoaded) { if (!OptimizedEmojiCache.isLoaded) {
OptimizedEmojiCache.preload(context) OptimizedEmojiCache.preload(context)
} else {
} }
} }
// 🚀 Используем derivedStateOf чтобы избежать лишних recomposition // 🚀 derivedStateOf для минимизации recomposition
val displayedEmojis by remember { val displayedEmojis by remember(selectedCategory) {
derivedStateOf { derivedStateOf {
if (OptimizedEmojiCache.isLoaded) { if (OptimizedEmojiCache.isLoaded) {
OptimizedEmojiCache.getEmojisForCategory(selectedCategory.key) OptimizedEmojiCache.getEmojisForCategory(selectedCategory.key)
@@ -124,39 +109,30 @@ private fun EmojiPickerContent(
} }
} }
// 🚀 При смене категории плавно скроллим наверх // 🚀 Scroll to top при смене категории - БЕЗ анимации для скорости
LaunchedEffect(selectedCategory) { LaunchedEffect(selectedCategory) {
if (displayedEmojis.isNotEmpty()) { if (displayedEmojis.isNotEmpty()) {
scope.launch { gridState.scrollToItem(0) // 🔥 scrollToItem вместо animateScrollToItem
gridState.animateScrollToItem(0)
}
} }
} }
// 🎨 Цвета темы // 🎨 Цвета темы - computed один раз
val panelBackground = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7) val panelBackground = remember(isDarkTheme) {
val categoryBarBackground = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7)
val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) }
val categoryBarBackground = remember(isDarkTheme) {
if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
}
val dividerColor = remember(isDarkTheme) {
if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
}
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(height = keyboardHeight) // 🔥 Используем сохранённую высоту (как в Telegram) .height(keyboardHeight)
.background(panelBackground) .background(panelBackground)
) { ) {
// 🔥 Показываем пустую панель пока не готово
if (!shouldRenderContent) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = PrimaryBlue,
strokeWidth = 2.dp
)
}
} else {
// ============ КАТЕГОРИИ ============ // ============ КАТЕГОРИИ ============
CategoryBar( CategoryBar(
categories = EMOJI_CATEGORIES, categories = EMOJI_CATEGORIES,
@@ -175,19 +151,18 @@ private fun EmojiPickerContent(
) )
// ============ СЕТКА ЭМОДЗИ ============ // ============ СЕТКА ЭМОДЗИ ============
EmojiGrid( UltraOptimizedEmojiGrid(
isLoaded = OptimizedEmojiCache.isLoaded, isLoaded = OptimizedEmojiCache.isLoaded,
emojis = displayedEmojis, emojis = displayedEmojis,
gridState = gridState, gridState = gridState,
onEmojiSelected = onEmojiSelected, onEmojiSelected = stableCallback,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
} }
}
} }
/** /**
* Горизонтальная полоса категорий * Горизонтальная полоса категорий - ОПТИМИЗИРОВАННАЯ
*/ */
@Composable @Composable
private fun CategoryBar( private fun CategoryBar(
@@ -197,6 +172,9 @@ private fun CategoryBar(
isDarkTheme: Boolean, isDarkTheme: Boolean,
backgroundColor: Color backgroundColor: Color
) { ) {
// 🔥 Запоминаем interactionSource один раз для всего LazyRow
val interactionSource = remember { MutableInteractionSource() }
LazyRow( LazyRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -210,7 +188,8 @@ private fun CategoryBar(
items = categories, items = categories,
key = { it.key } key = { it.key }
) { category -> ) { category ->
CategoryButton( // 🔥 Минимальная CategoryButton без анимаций
SimpleCategoryButton(
category = category, category = category,
isSelected = selectedCategory == category, isSelected = selectedCategory == category,
onClick = { onCategorySelected(category) }, onClick = { onCategorySelected(category) },
@@ -221,28 +200,20 @@ private fun CategoryBar(
} }
/** /**
* Кнопка категории * 🔥 УПРОЩЁННАЯ кнопка категории - без анимаций
*/ */
@Composable @Composable
private fun CategoryButton( private fun SimpleCategoryButton(
category: EmojiCategory, category: EmojiCategory,
isSelected: Boolean, isSelected: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
isDarkTheme: Boolean isDarkTheme: Boolean
) { ) {
val backgroundColor by animateColorAsState( // 🔥 Статичные цвета - нет анимации!
targetValue = if (isSelected) PrimaryBlue.copy(alpha = 0.2f) else Color.Transparent, val backgroundColor = if (isSelected) PrimaryBlue.copy(alpha = 0.2f) else Color.Transparent
animationSpec = tween(150), val iconTint = if (isSelected) PrimaryBlue
label = "categoryBackground"
)
val iconTint by animateColorAsState(
targetValue = if (isSelected) PrimaryBlue
else if (isDarkTheme) Color.White.copy(alpha = 0.6f) else if (isDarkTheme) Color.White.copy(alpha = 0.6f)
else Color.Black.copy(alpha = 0.5f), else Color.Black.copy(alpha = 0.5f)
animationSpec = tween(150),
label = "categoryIcon"
)
Box( Box(
modifier = Modifier modifier = Modifier
@@ -251,7 +222,7 @@ private fun CategoryButton(
.background(backgroundColor) .background(backgroundColor)
.clickable( .clickable(
onClick = onClick, onClick = onClick,
indication = null, // 🚀 Убираем ripple для производительности indication = null,
interactionSource = remember { MutableInteractionSource() } interactionSource = remember { MutableInteractionSource() }
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -266,22 +237,20 @@ private fun CategoryButton(
} }
/** /**
* Сетка эмодзи с LazyGrid * 🔥 ULTRA-оптимизированная сетка эмодзи
*/ */
@Composable @Composable
private fun EmojiGrid( private fun UltraOptimizedEmojiGrid(
isLoaded: Boolean, isLoaded: Boolean,
emojis: List<String>, emojis: List<String>,
gridState: androidx.compose.foundation.lazy.grid.LazyGridState, gridState: androidx.compose.foundation.lazy.grid.LazyGridState,
onEmojiSelected: (String) -> Unit, onEmojiSelected: StableCallback,
isDarkTheme: Boolean isDarkTheme: Boolean
) { ) {
when { when {
!isLoaded -> { !isLoaded -> {
// Loading state
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
CircularProgressIndicator( CircularProgressIndicator(
@@ -292,10 +261,8 @@ private fun EmojiGrid(
} }
} }
emojis.isEmpty() -> { emojis.isEmpty() -> {
// Empty state
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
@@ -306,115 +273,74 @@ private fun EmojiGrid(
} }
} }
else -> { else -> {
// 🚀 ОПТИМИЗИРОВАННАЯ LazyVerticalGrid // 🔥 ОПТИМИЗИРОВАННАЯ LazyVerticalGrid
LazyVerticalGrid( LazyVerticalGrid(
state = gridState, state = gridState,
columns = GridCells.Fixed(8), columns = GridCells.Fixed(8),
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize(), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
contentPadding = PaddingValues( horizontalArrangement = Arrangement.spacedBy(2.dp),
horizontal = 8.dp, verticalArrangement = Arrangement.spacedBy(2.dp)
vertical = 8.dp ) {
),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
userScrollEnabled = true,
// 🚀 Оптимизация: рендерим +2 строки за пределами видимой области
// для плавной прокрутки без белых мерцаний
content = {
items( items(
items = emojis, items = emojis,
key = { emoji -> emoji }, // 🔥 Важно для stable composition key = { emoji -> emoji },
contentType = { "emoji" } contentType = { "emoji" }
) { unified -> ) { unified ->
OptimizedEmojiButton( // 🔥 ULTRA-лёгкая кнопка эмодзи
UltraLightEmojiButton(
unified = unified, unified = unified,
onClick = { emoji -> onEmojiSelected(emoji) } onClick = onEmojiSelected.onClick
) )
} }
} }
)
} }
} }
} }
/** /**
* 🚀 Оптимизированная кнопка эмодзи * 🔥 ULTRA-ЛЁГКАЯ кнопка эмодзи
*
* Оптимизации:
* - Нет LaunchedEffect
* - Нет анимаций
* - Нет interactionSource tracking
* - Минимальный modifier chain
* - Предзакэшированный ImageRequest
*/ */
@Composable @Composable
private fun OptimizedEmojiButton( private fun UltraLightEmojiButton(
unified: String, unified: String,
onClick: (String) -> Unit onClick: (String) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val interactionSource = remember { MutableInteractionSource() }
// 🚀 Простая scale анимация без сложных эффектов // 🔥 Один remember для ImageRequest - это единственный "тяжёлый" объект
var isPressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.85f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh
),
label = "emojiScale"
)
// 🚀 Оптимизированный ImageRequest с кэшированием
val imageRequest = remember(unified) { val imageRequest = remember(unified) {
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data("file:///android_asset/emoji/${unified.lowercase()}.png") .data("file:///android_asset/emoji/${unified.lowercase()}.png")
.crossfade(false) // 🔥 Выключаем crossfade для производительности .crossfade(false) // Нет анимации
.size(64) // 🔥 Ограничиваем размер для экономии памяти .size(48) // Меньше размер = быстрее
.memoryCachePolicy(CachePolicy.ENABLED) .memoryCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(CachePolicy.ENABLED) .diskCachePolicy(CachePolicy.ENABLED)
.allowHardware(true) // 🔥 Hardware acceleration .allowHardware(true)
.memoryCacheKey("emoji_$unified") .memoryCacheKey("emoji_$unified")
.diskCacheKey("emoji_$unified")
.build() .build()
} }
// 🔥 Минимальный Box без лишних модификаторов
Box( Box(
modifier = Modifier modifier = Modifier
.aspectRatio(1f) .aspectRatio(1f)
.scale(scale) .clip(RoundedCornerShape(6.dp))
.clip(RoundedCornerShape(8.dp)) .clickable { onClick(":emoji_$unified:") },
.clickable(
interactionSource = interactionSource,
indication = null, // 🚀 Убираем ripple
onClickLabel = "Select emoji"
) {
// 🔥 Отправляем эмодзи в формате :emoji_code: как в десктопе
onClick(":emoji_$unified:")
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// 🚀 AsyncImage с Coil (оптимизирован)
AsyncImage( AsyncImage(
model = imageRequest, model = imageRequest,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier.size(28.dp),
.size(32.dp)
.graphicsLayer {
// Hardware layer для лучшей производительности
this.alpha = 1f
},
contentScale = ContentScale.Fit contentScale = ContentScale.Fit
) )
} }
// Track press state для scale анимации
LaunchedEffect(interactionSource) {
interactionSource.interactions.collect { interaction ->
when (interaction) {
is androidx.compose.foundation.interaction.PressInteraction.Press -> {
isPressed = true
}
is androidx.compose.foundation.interaction.PressInteraction.Release,
is androidx.compose.foundation.interaction.PressInteraction.Cancel -> {
isPressed = false
}
}
}
}
} }

View File

@@ -1,6 +1,11 @@
package com.rosetta.messenger.ui.settings package com.rosetta.messenger.ui.settings
import android.util.Log
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -18,18 +23,23 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Velocity
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.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -42,10 +52,13 @@ import com.rosetta.messenger.ui.chats.ChatsListViewModel
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.BlurredAvatarBackground import com.rosetta.messenger.ui.components.BlurredAvatarBackground
import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.components.metaball.ProfileMetaballEffect
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// Collapsing header constants // Collapsing header constants
private val EXPANDED_HEADER_HEIGHT_OTHER = 280.dp private val EXPANDED_HEADER_HEIGHT_OTHER = 280.dp
@@ -116,29 +129,168 @@ fun OtherProfileScreen(
derivedStateOf { (scrollOffset / maxScrollOffset).coerceIn(0f, 1f) } derivedStateOf { (scrollOffset / maxScrollOffset).coerceIn(0f, 1f) }
} }
// ═══════════════════════════════════════════════════════════════
// TELEGRAM-STYLE AVATAR EXPANSION (Drop/Blob effect)
// При свайпе вниз от верха списка - аватарка расширяется
// Порог snap = 33% (как в Telegram: expandProgress >= 0.33f)
// ═══════════════════════════════════════════════════════════════
var overscrollOffset by remember { mutableFloatStateOf(0f) }
val maxOverscroll = with(density) { 250.dp.toPx() } // Максимальное расширение
val snapThreshold = maxOverscroll * 0.33f // Telegram: 33%
// Track dragging state
var isDragging by remember { mutableStateOf(false) }
// isPulledDown = зафиксировано в раскрытом состоянии (как Telegram)
var isPulledDown by remember { mutableStateOf(false) }
// Velocity для учёта скорости свайпа
var lastVelocity by remember { mutableFloatStateOf(0f) }
// Haptic feedback
val hapticFeedback = LocalHapticFeedback.current
var hasTriggeredExpandHaptic by remember { mutableStateOf(false) }
// Проверяем наличие аватара у пользователя
val avatars by
avatarRepository?.getAvatars(user.publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
val hasAvatar = avatars.isNotEmpty()
// ═══════════════════════════════════════════════════════════════
// SNAP ANIMATION - как Telegram's expandAnimator
// При отпускании пальца: snap к 0 или к max в зависимости от порога
// ═══════════════════════════════════════════════════════════════
val targetOverscroll = when {
isDragging -> overscrollOffset // Во время drag - напрямую следуем за пальцем
isPulledDown -> maxOverscroll // После snap - держим раскрытым
overscrollOffset > snapThreshold -> maxOverscroll // Перешли порог - snap к max
else -> 0f // Не дотянули - snap обратно
}
// Telegram: duration = (1 - value) * 250ms при expand, value * 250ms при collapse
val currentProgress = (overscrollOffset / maxOverscroll).coerceIn(0f, 1f)
val snapDuration = if (targetOverscroll == maxOverscroll) {
((1f - currentProgress) * 150).toInt().coerceIn(50, 150)
} else {
(currentProgress * 150).toInt().coerceIn(50, 150)
}
val animatedOverscroll by animateFloatAsState(
targetValue = targetOverscroll,
animationSpec = tween(
durationMillis = if (isDragging) 0 else snapDuration,
easing = LinearOutSlowInEasing
),
label = "overscroll"
)
// ExpansionProgress для передачи в overlay
val expansionProgress = when {
collapseProgress > 0.1f -> 0f // Не расширяем при collapse
isDragging -> (overscrollOffset / maxOverscroll).coerceIn(0f, 1f)
else -> (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f)
}
// Haptic при достижении порога (как Telegram)
LaunchedEffect(expansionProgress) {
if (expansionProgress >= 0.33f && !hasTriggeredExpandHaptic && isDragging) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
hasTriggeredExpandHaptic = true
} else if (expansionProgress < 0.2f) {
hasTriggeredExpandHaptic = false
}
}
// DEBUG LOGS
Log.d("OtherProfileScroll", "expansionProgress=$expansionProgress, isPulledDown=$isPulledDown, isDragging=$isDragging")
// ═══════════════════════════════════════════════════════════════
// NESTED SCROLL - Telegram style with overscroll support
// ═══════════════════════════════════════════════════════════════
val nestedScrollConnection = remember { val nestedScrollConnection = remember {
object : NestedScrollConnection { object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y val delta = available.y
val newOffset = scrollOffset - delta isDragging = true
val consumed =
when { // Тянем вверх (delta < 0)
delta < 0 && scrollOffset < maxScrollOffset -> { if (delta < 0) {
val consumed = // Сначала убираем overscroll
(newOffset.coerceIn(0f, maxScrollOffset) - scrollOffset) if (overscrollOffset > 0) {
scrollOffset = newOffset.coerceIn(0f, maxScrollOffset) val newOffset = (overscrollOffset + delta).coerceAtLeast(0f)
-consumed val consumed = overscrollOffset - newOffset
overscrollOffset = newOffset
// Сбрасываем isPulledDown если вышли из expanded
if (overscrollOffset < maxOverscroll * 0.5f) {
isPulledDown = false
} }
delta > 0 && scrollOffset > 0 -> { return Offset(0f, -consumed)
val consumed =
scrollOffset - newOffset.coerceIn(0f, maxScrollOffset)
scrollOffset = newOffset.coerceIn(0f, maxScrollOffset)
consumed
} }
else -> 0f // Затем коллапсируем header
if (scrollOffset < maxScrollOffset) {
val newScrollOffset = (scrollOffset - delta).coerceIn(0f, maxScrollOffset)
val consumed = newScrollOffset - scrollOffset
scrollOffset = newScrollOffset
return Offset(0f, -consumed)
} }
}
// Тянем вниз (delta > 0) - раскрываем header
if (delta > 0 && scrollOffset > 0) {
val newScrollOffset = (scrollOffset - delta).coerceAtLeast(0f)
val consumed = scrollOffset - newScrollOffset
scrollOffset = newScrollOffset
return Offset(0f, consumed) return Offset(0f, consumed)
} }
return Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
// Overscroll при свайпе вниз от верха
if (available.y > 0 && scrollOffset == 0f) {
// Telegram: сопротивление если ещё не isPulledDown
val resistance = if (isPulledDown) 1f else 0.5f
val delta = available.y * resistance
overscrollOffset = (overscrollOffset + delta).coerceAtMost(maxOverscroll)
return Offset(0f, available.y)
}
return Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
lastVelocity = available.y
return Velocity.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
isDragging = false
// Telegram: snap логика с учётом velocity
val velocityThreshold = 1000f
when {
overscrollOffset > snapThreshold || (lastVelocity > velocityThreshold && overscrollOffset > snapThreshold * 0.5f) -> {
// Snap to expanded
isPulledDown = true
}
lastVelocity < -velocityThreshold && overscrollOffset > 0 -> {
// Fast swipe up - snap to collapsed
isPulledDown = false
}
else -> {
// Normal case - snap based on threshold
isPulledDown = overscrollOffset > snapThreshold
}
}
return Velocity.Zero
}
} }
} }
@@ -196,7 +348,7 @@ fun OtherProfileScreen(
} }
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🎨 COLLAPSING HEADER // 🎨 COLLAPSING HEADER with METABALL EFFECT
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
CollapsingOtherProfileHeader( CollapsingOtherProfileHeader(
name = user.title.ifEmpty { "Unknown User" }, name = user.title.ifEmpty { "Unknown User" },
@@ -207,6 +359,8 @@ fun OtherProfileScreen(
lastSeen = lastSeen, lastSeen = lastSeen,
avatarColors = avatarColors, avatarColors = avatarColors,
collapseProgress = collapseProgress, collapseProgress = collapseProgress,
expansionProgress = expansionProgress,
hasAvatar = hasAvatar,
onBack = onBack, onBack = onBack,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
showAvatarMenu = showAvatarMenu, showAvatarMenu = showAvatarMenu,
@@ -239,18 +393,20 @@ fun OtherProfileScreen(
} }
} }
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🎯 COLLAPSING HEADER FOR OTHER PROFILE // 🎯 COLLAPSING HEADER FOR OTHER PROFILE with METABALL EFFECT
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@Composable @Composable
private fun CollapsingOtherProfileHeader( private fun CollapsingOtherProfileHeader(
name: String, name: String,
username: String, @Suppress("UNUSED_PARAMETER") username: String,
publicKey: String, publicKey: String,
verified: Int, verified: Int,
isOnline: Boolean, isOnline: Boolean,
lastSeen: Long, lastSeen: Long,
avatarColors: AvatarColors, avatarColors: AvatarColors,
collapseProgress: Float, collapseProgress: Float,
expansionProgress: Float,
hasAvatar: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
isDarkTheme: Boolean, isDarkTheme: Boolean,
showAvatarMenu: Boolean, showAvatarMenu: Boolean,
@@ -261,34 +417,47 @@ private fun CollapsingOtherProfileHeader(
onClearChat: () -> Unit onClearChat: () -> Unit
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val configuration = LocalConfiguration.current
val screenWidthDp = configuration.screenWidthDp.dp
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val expandedHeight = EXPANDED_HEADER_HEIGHT_OTHER + statusBarHeight val expandedHeight = EXPANDED_HEADER_HEIGHT_OTHER + statusBarHeight
val collapsedHeight = COLLAPSED_HEADER_HEIGHT_OTHER + statusBarHeight val collapsedHeight = COLLAPSED_HEADER_HEIGHT_OTHER + statusBarHeight
// Header height меняется только при collapse, НЕ при overscroll
val headerHeight = val headerHeight =
androidx.compose.ui.unit.lerp(expandedHeight, collapsedHeight, collapseProgress) androidx.compose.ui.unit.lerp(expandedHeight, collapsedHeight, collapseProgress)
// Avatar animation // Avatar font size for placeholder
val avatarCenterX = (screenWidthDp - AVATAR_SIZE_EXPANDED_OTHER) / 2 val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 12.sp, collapseProgress)
val avatarStartY = statusBarHeight + 32.dp
val avatarEndY = statusBarHeight - 60.dp // Haptic feedback при достижении полного квадрата (ТОЛЬКО С АВАТАРКОЙ)
val avatarY = androidx.compose.ui.unit.lerp(avatarStartY, avatarEndY, collapseProgress) val hapticFeedback = LocalHapticFeedback.current
val avatarSize = var hasTriggeredHaptic by remember { mutableStateOf(false) }
androidx.compose.ui.unit.lerp(AVATAR_SIZE_EXPANDED_OTHER, 0.dp, collapseProgress)
val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 0.sp, collapseProgress) LaunchedEffect(expansionProgress, hasAvatar) {
if (hasAvatar && expansionProgress >= 0.95f && !hasTriggeredHaptic) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
hasTriggeredHaptic = true
} else if (expansionProgress < 0.5f) {
hasTriggeredHaptic = false
}
}
// Text animation - always centered // Text animation - always centered
val textExpandedY = statusBarHeight + 32.dp + AVATAR_SIZE_EXPANDED_OTHER + 48.dp val textDefaultY = expandedHeight - 48.dp
val textCollapsedY = statusBarHeight + COLLAPSED_HEADER_HEIGHT_OTHER / 2 val textCollapsedY = statusBarHeight + COLLAPSED_HEADER_HEIGHT_OTHER / 2
val textY = androidx.compose.ui.unit.lerp(textExpandedY, textCollapsedY, collapseProgress) val textY = androidx.compose.ui.unit.lerp(textDefaultY, textCollapsedY, collapseProgress)
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress) val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress) val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
// Определяем цвет текста на основе фона
val textColor by remember(hasAvatar, avatarColors) {
derivedStateOf {
if (isColorLight(avatarColors.backgroundColor)) Color.Black else Color.White
}
}
Box(modifier = Modifier.fillMaxWidth().height(headerHeight)) { Box(modifier = Modifier.fillMaxWidth().height(headerHeight)) {
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🎨 BLURRED AVATAR BACKGROUND // 🎨 BLURRED AVATAR BACKGROUND
@@ -301,6 +470,43 @@ private fun CollapsingOtherProfileHeader(
alpha = 0.3f alpha = 0.3f
) )
// ═══════════════════════════════════════════════════════════
// 👤 AVATAR with METABALL EFFECT - Liquid merge animation
// При скролле вверх аватарка "сливается" с Dynamic Island
// При свайпе вниз - расширяется на весь экран
// ═══════════════════════════════════════════════════════════
ProfileMetaballEffect(
collapseProgress = collapseProgress,
expansionProgress = expansionProgress,
statusBarHeight = statusBarHeight,
headerHeight = headerHeight,
hasAvatar = hasAvatar,
avatarColor = avatarColors.backgroundColor,
modifier = Modifier.fillMaxSize()
) {
// Содержимое аватара
if (hasAvatar && avatarRepository != null) {
OtherProfileFullSizeAvatar(
publicKey = publicKey,
avatarRepository = avatarRepository,
isDarkTheme = isDarkTheme
)
} else {
// Placeholder без аватарки
Box(
modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
Text(
text = getInitials(name),
fontSize = avatarFontSize,
fontWeight = FontWeight.Bold,
color = avatarColors.textColor
)
}
}
}
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🔙 BACK BUTTON // 🔙 BACK BUTTON
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -315,7 +521,7 @@ private fun CollapsingOtherProfileHeader(
Icon( Icon(
imageVector = Icons.Filled.ArrowBack, imageVector = Icons.Filled.ArrowBack,
contentDescription = "Back", contentDescription = "Back",
tint = if (isDarkTheme) Color.White else Color(0xFF007AFF), tint = Color.White,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
} }
@@ -336,9 +542,7 @@ private fun CollapsingOtherProfileHeader(
Icon( Icon(
imageVector = Icons.Default.MoreVert, imageVector = Icons.Default.MoreVert,
contentDescription = "Profile menu", contentDescription = "Profile menu",
tint = tint = Color.White,
if (isColorLight(avatarColors.backgroundColor)) Color.Black
else Color.White,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
} }
@@ -361,36 +565,7 @@ private fun CollapsingOtherProfileHeader(
} }
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 👤 AVATAR - shrinks and moves up // 📝 TEXT BLOCK - Name + Verified + Online
// ═══════════════════════════════════════════════════════════
if (avatarSize > 1.dp) {
Box(
modifier =
Modifier.offset(
x =
avatarCenterX +
(AVATAR_SIZE_EXPANDED_OTHER -
avatarSize) / 2,
y = avatarY
)
.size(avatarSize)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.15f))
.padding(2.dp)
.clip(CircleShape),
contentAlignment = Alignment.Center
) {
AvatarImage(
publicKey = publicKey,
avatarRepository = avatarRepository,
size = avatarSize - 4.dp,
isDarkTheme = isDarkTheme
)
}
}
// ═══════════════════════════════════════════════════════════
// 📝 TEXT BLOCK - Name + Verified + Online, always centered
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
Column( Column(
modifier = modifier =
@@ -416,11 +591,10 @@ private fun CollapsingOtherProfileHeader(
text = name, text = name,
fontSize = nameFontSize, fontSize = nameFontSize,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = color = textColor,
if (isColorLight(avatarColors.backgroundColor)) Color.Black
else Color.White,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 220.dp),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
@@ -440,15 +614,70 @@ private fun CollapsingOtherProfileHeader(
if (isOnline) { if (isOnline) {
Color(0xFF4CAF50) Color(0xFF4CAF50)
} else { } else {
if (isColorLight(avatarColors.backgroundColor)) textColor.copy(alpha = 0.6f)
Color.Black.copy(alpha = 0.6f)
else Color.White.copy(alpha = 0.6f)
} }
) )
} }
} }
} }
// ═════════════════════════════════════════════════════════════
// 🖼 FULL SIZE AVATAR FOR OTHER PROFILE - Fills entire container
// ═════════════════════════════════════════════════════════════
@Composable
private fun OtherProfileFullSizeAvatar(
publicKey: String,
avatarRepository: AvatarRepository?,
isDarkTheme: Boolean = false
) {
val avatars by
avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
// Сохраняем bitmap в remember чтобы не мигал при recomposition
var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(avatars) {
if (avatars.isNotEmpty()) {
val newBitmap = withContext(Dispatchers.IO) {
AvatarFileManager.base64ToBitmap(avatars.first().base64Data)
}
bitmap = newBitmap
isLoading = false
} else {
isLoading = false
}
}
when {
bitmap != null -> {
Image(
bitmap = bitmap!!.asImageBitmap(),
contentDescription = "Avatar",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
!isLoading -> {
// Placeholder только когда точно нет аватарки
val avatarColors = getAvatarColor(publicKey, isDarkTheme)
Box(
modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
Text(
text = publicKey.take(2).uppercase(),
color = avatarColors.textColor,
fontSize = 80.sp,
fontWeight = FontWeight.Bold
)
}
}
// Пока isLoading=true - ничего не показываем (прозрачно)
}
}
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🚫 BLOCK/UNBLOCK ITEM // 🚫 BLOCK/UNBLOCK ITEM
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════