From e1cc49c12b1a953e088dd1d7f9265771322ea4f0 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Mon, 2 Feb 2026 01:50:00 +0500 Subject: [PATCH] fix: enhance avatar expansion and collapse animations with overscroll support and haptic feedback in OtherProfileScreen --- .../messenger/ui/chats/ChatDetailScreen.kt | 172 +++---- .../ui/chats/components/ImageEditorScreen.kt | 463 ++++++++++++++---- .../components/MediaPickerBottomSheet.kt | 219 +++++++-- .../ui/components/OptimizedEmojiCache.kt | 28 +- .../ui/components/OptimizedEmojiPicker.kt | 374 ++++++-------- .../ui/settings/OtherProfileScreen.kt | 373 +++++++++++--- 6 files changed, 1106 insertions(+), 523 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 7e150af..5f8625a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -199,6 +199,9 @@ fun ChatDetailScreen( contract = ActivityResultContracts.TakePicture() ) { success -> if (success && cameraImageUri != null) { + // Очищаем фокус чтобы клавиатура не появилась + keyboardController?.hide() + focusManager.clearFocus() // Открываем редактор вместо прямой отправки pendingCameraPhotoUri = cameraImageUri } @@ -1401,9 +1404,8 @@ fun ChatDetailScreen( onReplyClick = scrollToMessage, onAttachClick = { - // Hide keyboard when opening media picker - keyboardController?.hide() - focusManager.clearFocus() + // Telegram-style: галерея открывается ПОВЕРХ клавиатуры + // НЕ скрываем клавиатуру! showMediaPicker = true }, myPublicKey = viewModel.myPublicKey ?: "", @@ -1416,26 +1418,28 @@ fun ChatDetailScreen( } // Закрытие Column с imePadding } ) { paddingValues -> - // 🔥 Column структура - список сжимается когда клавиатура открывается - Column( - modifier = - Modifier.fillMaxSize() - .padding(paddingValues) - .background(backgroundColor) - ) { - // Список сообщений - занимает всё доступное место - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - when { - // 🔥 СКЕЛЕТОН - показываем пока загружаются - // сообщения - isLoading -> { - MessageSkeletonList( - isDarkTheme = isDarkTheme, - modifier = Modifier.fillMaxSize() - ) - } - // Пустое состояние (нет сообщений) - messages.isEmpty() -> { + // 🔥 Box wrapper для overlay (MediaPicker над клавиатурой) + Box(modifier = Modifier.fillMaxSize()) { + // 🔥 Column структура - список сжимается когда клавиатура открывается + Column( + modifier = + Modifier.fillMaxSize() + .padding(paddingValues) + .background(backgroundColor) + ) { + // Список сообщений - занимает всё доступное место + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + when { + // 🔥 СКЕЛЕТОН - показываем пока загружаются + // сообщения + isLoading -> { + MessageSkeletonList( + isDarkTheme = isDarkTheme, + modifier = Modifier.fillMaxSize() + ) + } + // Пустое состояние (нет сообщений) + messages.isEmpty() -> { Column( modifier = Modifier.fillMaxSize() @@ -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 // 📸 Image Viewer Overlay with Telegram-style shared element animation @@ -2011,64 +2077,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) pendingCameraPhotoUri?.let { uri -> diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt index 4b1e730..1fc61bd 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt @@ -6,6 +6,7 @@ import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri +import android.view.inputmethod.InputMethodManager import android.widget.ImageView import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult @@ -13,6 +14,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.foundation.* import androidx.compose.foundation.ExperimentalFoundationApi 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.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* 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.toArgb import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager 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.sp 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.yalantis.ucrop.UCrop import compose.icons.TablerIcons @@ -56,6 +64,7 @@ import ja.burhanrashid52.photoeditor.SaveSettings import java.io.File import kotlin.coroutines.resume import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext @@ -65,6 +74,17 @@ private const val TAG = "ImageEditorScreen" /** Telegram-style easing */ 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 */ enum class EditorTool { NONE, @@ -93,7 +113,7 @@ val drawingColors = listOf( * Features: * - Fullscreen edge-to-edge photo display * - Transparent overlay controls - * - Smooth animations + * - Smooth Telegram-style enter/exit animations * - Drawing, Crop, Rotate tools * - Caption input with send button */ @@ -106,12 +126,50 @@ fun ImageEditorScreen( onSaveWithCaption: ((Uri, String) -> Unit)? = null, isDarkTheme: Boolean = true, showCaptionInput: Boolean = false, - recipientName: String? = null // Имя получателя (как в Telegram) + recipientName: String? = null, + thumbnailPosition: ThumbnailPosition? = null // Позиция для Telegram-style анимации ) { val context = LocalContext.current val scope = rememberCoroutineScope() val view = LocalView.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 var currentTool by remember { mutableStateOf(EditorTool.NONE) } @@ -119,10 +177,80 @@ fun ImageEditorScreen( var brushSize by remember { mutableStateOf(12f) } var showColorPicker by remember { mutableStateOf(false) } var isSaving by remember { mutableStateOf(false) } + var isEraserActive by remember { mutableStateOf(false) } // Caption state var caption by remember { mutableStateOf("") } + // ═══════════════════════════════════════════════════════════════ + // 😀 EMOJI PICKER STATE - Telegram style с плавной анимацией + // ═══════════════════════════════════════════════════════════════ + var showEmojiPicker by remember { mutableStateOf(false) } + val coordinator = rememberKeyboardTransitionCoordinator() + var editTextView by remember { mutableStateOf(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) var currentImageUri by remember { mutableStateOf(imageUri) } @@ -135,15 +263,6 @@ fun ImageEditorScreen( var photoEditor by remember { mutableStateOf(null) } var photoEditorView by remember { mutableStateOf(null) } - // Animation for enter - val enterAnimation = remember { Animatable(0f) } - LaunchedEffect(Unit) { - enterAnimation.animateTo( - targetValue = 1f, - animationSpec = tween(250, easing = TelegramEasing) - ) - } - // UCrop launcher val cropLauncher = rememberLauncherForActivityResult( 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 Box( modifier = Modifier .fillMaxSize() - .background(Color.Black) + .background(Color.Black.copy(alpha = animatedBackgroundAlpha)) .graphicsLayer { - alpha = enterAnimation.value - scaleX = 0.95f + 0.05f * enterAnimation.value - scaleY = 0.95f + 0.05f * enterAnimation.value + scaleX = animatedScale + scaleY = animatedScale + translationX = animatedTranslationX + translationY = animatedTranslationY } ) { // ═══════════════════════════════════════════════════════════ @@ -224,11 +386,17 @@ fun ImageEditorScreen( .statusBarsPadding() .padding(horizontal = 4.dp, vertical = 8.dp) ) { - // Close button (X) - сначала закрывает клавиатуру, потом экран + // Close button (X) - сначала закрывает emoji/клавиатуру, потом экран IconButton( 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 if (isKeyboardOpen) { @@ -236,8 +404,8 @@ fun ImageEditorScreen( imm.hideSoftInputFromWindow(view.windowToken, 0) focusManager.clearFocus() } else { - // Закрываем экран - onDismiss() + // Закрываем экран с анимацией + animatedDismiss() } }, 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 - // Когда клавиатура закрыта - добавляем отступ снизу для toolbar (~100dp) - val bottomPaddingForCaption = if (!isKeyboardVisibleForCaption) 100.dp else 0.dp + // ═══════════════════════════════════════════════════════════ + // 🔥 TELEGRAM-STYLE FIX: Единый spacer вместо imePadding + // Это ПОЛНОСТЬЮ устраняет прыжки при переходе keyboard ↔ emoji + // ═══════════════════════════════════════════════════════════ + 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( visible = showCaptionInput && currentTool == EditorTool.NONE, @@ -351,39 +548,74 @@ fun ImageEditorScreen( modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter) - .padding(bottom = bottomPaddingForCaption) - .imePadding() // поднимается с клавиатурой ) { - TelegramCaptionBar( - caption = caption, - onCaptionChange = { caption = it }, - isSaving = isSaving, - isKeyboardVisible = isKeyboardVisibleForCaption, - onSend = { - scope.launch { - isSaving = true - saveEditedImage(context, photoEditor, photoEditorView, currentImageUri) { savedUri -> - isSaving = false - if (savedUri != null) { - if (onSaveWithCaption != null) { - onSaveWithCaption(savedUri, caption) - } else { - onSave(savedUri) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = bottomPaddingForCaption) + // 🔥 БЕЗ imePadding! Всё контролируется через spacer ниже + ) { + TelegramCaptionBar( + caption = caption, + onCaptionChange = { caption = it }, + isSaving = isSaving, + isKeyboardVisible = isImeActuallyOpen || showEmojiPicker, + showEmojiPicker = showEmojiPicker, + onToggleEmojiPicker = { toggleEmojiPicker() }, + onEditTextViewCreated = { editTextView = it }, + onSend = { + scope.launch { + isSaving = true + saveEditedImage(context, photoEditor, photoEditorView, currentImageUri) { savedUri -> + isSaving = false + if (savedUri != null) { + if (onSaveWithCaption != null) { + onSaveWithCaption(savedUri, caption) + } else { + onSave(savedUri) + } } } } } + ) + + // ═══════════════════════════════════════════════════════════ + // 🔥 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 AnimatedVisibility( - visible = !isKeyboardOpen, + visible = !isKeyboardOpen && !showEmojiPicker && !coordinator.isEmojiBoxVisible, enter = fadeIn() + slideInVertically { it }, exit = fadeOut() + slideOutVertically { it }, modifier = Modifier @@ -407,22 +639,35 @@ fun ImageEditorScreen( currentTool = currentTool, showCaptionInput = showCaptionInput, isSaving = isSaving, + isEraserActive = isEraserActive, onCropClick = { currentTool = EditorTool.NONE showColorPicker = false + isEraserActive = false photoEditor?.setBrushDrawingMode(false) launchCrop(context, currentImageUri, cropLauncher) }, onRotateClick = { currentTool = if (currentTool == EditorTool.ROTATE) EditorTool.NONE else EditorTool.ROTATE showColorPicker = false + isEraserActive = false photoEditor?.setBrushDrawingMode(false) }, onDrawClick = { if (currentTool == EditorTool.DRAW) { - showColorPicker = !showColorPicker + // Если ластик активен - переключаемся обратно на кисть + if (isEraserActive) { + isEraserActive = false + photoEditor?.setBrushDrawingMode(true) + photoEditor?.brushColor = selectedColor.toArgb() + photoEditor?.brushSize = brushSize + } else { + // Иначе показываем/скрываем color picker + showColorPicker = !showColorPicker + } } else { currentTool = EditorTool.DRAW + isEraserActive = false photoEditor?.setBrushDrawingMode(true) photoEditor?.brushColor = selectedColor.toArgb() photoEditor?.brushSize = brushSize @@ -430,7 +675,22 @@ fun ImageEditorScreen( } }, onEraserClick = { - photoEditor?.brushEraser() + isEraserActive = !isEraserActive + if (isEraserActive) { + 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 = { if (!showCaptionInput) { @@ -461,10 +721,12 @@ private fun TelegramToolbar( currentTool: EditorTool, showCaptionInput: Boolean, isSaving: Boolean, + isEraserActive: Boolean = false, onCropClick: () -> Unit, onRotateClick: () -> Unit, onDrawClick: () -> Unit, onEraserClick: () -> Unit, + onDrawDoneClick: () -> Unit = {}, onDoneClick: () -> Unit ) { Row( @@ -491,11 +753,11 @@ private fun TelegramToolbar( // Draw TelegramToolButton( icon = TablerIcons.Pencil, - isSelected = currentTool == EditorTool.DRAW, + isSelected = currentTool == EditorTool.DRAW && !isEraserActive, onClick = onDrawClick ) - // Eraser (visible when drawing) + // Eraser (visible when drawing) - подсвечивается синим когда активен AnimatedVisibility( visible = currentTool == EditorTool.DRAW, enter = scaleIn() + fadeIn(), @@ -503,13 +765,32 @@ private fun TelegramToolbar( ) { TelegramToolButton( icon = TablerIcons.Eraser, - isSelected = false, + isSelected = isEraserActive, onClick = onEraserClick ) } - // Done/Check button (if no caption input) - if (!showCaptionInput) { + // ✅ Check button to accept drawing changes (visible when drawing) + 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)) Box( @@ -691,7 +972,7 @@ private fun TelegramRotateBar( } /** - * Telegram-style caption input bar + * Telegram-style caption input bar with emoji support * Меняет внешний вид в зависимости от состояния клавиатуры: * - Клавиатура закрыта: стеклянный инпут с blur эффектом (не на всю ширину) * - Клавиатура открыта: полный стиль (emoji + текст + галочка) @@ -702,6 +983,9 @@ private fun TelegramCaptionBar( onCaptionChange: (String) -> Unit, isSaving: Boolean, isKeyboardVisible: Boolean, + showEmojiPicker: Boolean = false, + onToggleEmojiPicker: () -> Unit = {}, + onEditTextViewCreated: ((com.rosetta.messenger.ui.components.AppleEmojiEditTextView) -> Unit)? = null, onSend: () -> Unit ) { // Анимированный переход между стилями @@ -710,7 +994,7 @@ private fun TelegramCaptionBar( animationSpec = tween(200, easing = TelegramEasing), label = "corner" ) - + val horizontalPadding by animateDpAsState( targetValue = if (isKeyboardVisible) 0.dp else 12.dp, animationSpec = tween(200, easing = TelegramEasing), @@ -723,7 +1007,7 @@ private fun TelegramCaptionBar( .padding(horizontal = horizontalPadding) .then( if (isKeyboardVisible) { - // Клавиатура открыта - полупрозрачный черный фон на всю ширину + // Клавиатура/emoji открыты - полупрозрачный черный фон на всю ширину Modifier.background(Color.Black.copy(alpha = 0.75f)) } else { // Клавиатура закрыта - стеклянный эффект с закруглением @@ -739,22 +1023,27 @@ private fun TelegramCaptionBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - // Левая иконка: камера когда клавиатура закрыта, emoji когда открыта + // Левая иконка: камера когда клавиатура закрыта, emoji/keyboard когда открыта AnimatedContent( - targetState = isKeyboardVisible, + targetState = isKeyboardVisible to showEmojiPicker, transitionSpec = { fadeIn(tween(150)) togetherWith fadeOut(tween(150)) }, label = "left_icon" - ) { keyboardOpen -> + ) { (keyboardOpen, emojiOpen) -> if (keyboardOpen) { - // Клавиатура открыта - emoji иконка - Icon( - TablerIcons.MoodSmile, - contentDescription = "Emoji", - tint = Color.White.copy(alpha = 0.7f), - modifier = Modifier.size(26.dp) - ) + // Клавиатура/emoji открыты - кликабельная иконка переключения + IconButton( + onClick = onToggleEmojiPicker, + modifier = Modifier.size(32.dp) + ) { + Icon( + if (emojiOpen) TablerIcons.Keyboard else TablerIcons.MoodSmile, + contentDescription = if (emojiOpen) "Keyboard" else "Emoji", + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(26.dp) + ) + } } else { // Клавиатура закрыта - камера иконка Icon( @@ -766,30 +1055,30 @@ private fun TelegramCaptionBar( } } - // Caption text field - BasicTextField( - value = caption, - onValueChange = onCaptionChange, - modifier = Modifier.weight(1f), - textStyle = androidx.compose.ui.text.TextStyle( - color = Color.White, - fontSize = 16.sp - ), - maxLines = if (isKeyboardVisible) 4 else 1, - singleLine = !isKeyboardVisible, - decorationBox = { innerTextField -> - Box { - if (caption.isEmpty()) { - Text( - "Add a caption...", - color = Color.White.copy(alpha = 0.5f), - fontSize = 16.sp - ) + // Caption text field - использует AppleEmojiTextField для правильной работы с фокусом + Box( + modifier = Modifier + .weight(1f) + .heightIn(min = 24.dp, max = if (isKeyboardVisible) 100.dp else 24.dp) + ) { + AppleEmojiTextField( + value = caption, + onValueChange = onCaptionChange, + textColor = Color.White, + textSize = 16f, + hint = "Add a caption...", + hintColor = Color.White.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth(), + requestFocus = false, + onViewCreated = { view -> onEditTextViewCreated?.invoke(view) }, + onFocusChanged = { hasFocus -> + // Если получили фокус и emoji открыт - закрываем emoji + if (hasFocus && showEmojiPicker) { + onToggleEmojiPicker() } - innerTextField() } - } - ) + ) + } // Кнопка отправки AnimatedContent( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt index 3feee53..10ade13 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt @@ -7,6 +7,7 @@ import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.provider.MediaStore +import android.view.WindowManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.* @@ -14,6 +15,7 @@ import androidx.compose.animation.core.* import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.foundation.* import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* 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.input.pointer.pointerInput 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.LocalDensity import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp 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.Preview import androidx.camera.lifecycle.ProcessCameraProvider @@ -49,6 +60,7 @@ import com.rosetta.messenger.ui.onboarding.PrimaryBlue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.math.roundToInt private const val TAG = "MediaPickerBottomSheet" @@ -66,8 +78,12 @@ data class MediaItem( } /** - * Telegram-style media picker bottom sheet - * Shows gallery photos/videos in a grid with selection + * 📸 Telegram-style Media Picker - INLINE OVERLAY + * + * Ключевое отличие от обычного BottomSheet: + * - Это НЕ Dialog, а обычный Composable который рендерится ВНУТРИ того же layout + * - Позиционируется ПОВЕРХ клавиатуры с помощью imePadding() + * - Клавиатура остаётся открытой! */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -76,16 +92,17 @@ fun MediaPickerBottomSheet( onDismiss: () -> Unit, isDarkTheme: Boolean, onMediaSelected: (List) -> Unit, - onMediaSelectedWithCaption: ((MediaItem, String) -> Unit)? = null, // Для отправки с caption + onMediaSelectedWithCaption: ((MediaItem, String) -> Unit)? = null, onOpenCamera: () -> Unit = {}, onOpenFilePicker: () -> Unit = {}, onAvatarClick: () -> Unit = {}, currentUserPublicKey: String = "", maxSelection: Int = 10, - recipientName: String? = null // Имя получателя для отображения в редакторе + recipientName: String? = null ) { val context = LocalContext.current val scope = rememberCoroutineScope() + val density = LocalDensity.current // Media items from gallery var mediaItems by remember { mutableStateOf>(emptyList()) } @@ -98,6 +115,9 @@ fun MediaPickerBottomSheet( // Editor state - when user taps on a photo, open editor var editingItem by remember { mutableStateOf(null) } + // 📍 Позиция thumbnail для Telegram-style анимации + var thumbnailPosition by remember { mutableStateOf(null) } + // Uri фото, только что сделанного с камеры (для редактирования) var pendingPhotoUri by remember { mutableStateOf(null) } @@ -159,42 +179,132 @@ fun MediaPickerBottomSheet( val textColor = if (isDarkTheme) Color.White else Color.Black 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) { - ModalBottomSheet( + // ═══════════════════════════════════════════════════════════════ + // 🎬 TELEGRAM-STYLE: Popup поверх клавиатуры с анимацией + // ═══════════════════════════════════════════════════════════════ + 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, - containerColor = backgroundColor, - dragHandle = { - // Telegram-style drag handle - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(8.dp)) - Box( - modifier = Modifier - .width(36.dp) - .height(4.dp) - .clip(RoundedCornerShape(2.dp)) - .background(secondaryTextColor.copy(alpha = 0.3f)) - ) - Spacer(modifier = Modifier.height(8.dp)) - } - }, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false), - windowInsets = WindowInsets(0) + properties = PopupProperties( + focusable = false, // НЕ забираем фокус - клавиатура остаётся! + dismissOnBackPress = true, + dismissOnClickOutside = true + ) ) { - Column( + // Полноэкранный контейнер с затемнением + Box( modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.85f) + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.4f * animatedAlpha)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onDismiss() }, + contentAlignment = Alignment.BottomCenter ) { - // Header with action buttons - MediaPickerHeader( - selectedCount = selectedItems.size, - onDismiss = onDismiss, - onSend = { - val selected = mediaItems.filter { it.id in selectedItems } + // 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( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .width(36.dp) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(secondaryTextColor.copy(alpha = 0.3f)) + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + // Header with action buttons + MediaPickerHeader( + selectedCount = selectedItems.size, + onDismiss = onDismiss, + onSend = { + val selected = mediaItems.filter { it.id in selectedItems } onMediaSelected(selected) onDismiss() }, @@ -291,9 +401,10 @@ fun MediaPickerBottomSheet( onDismiss() onOpenCamera() }, - onItemClick = { item -> + onItemClick = { item, position -> // Telegram-style: клик на фото сразу открывает редактор с caption if (!item.isVideo) { + thumbnailPosition = position editingItem = item } else { // Для видео - добавляем/убираем из selection @@ -321,15 +432,20 @@ fun MediaPickerBottomSheet( Spacer(modifier = Modifier.navigationBarsPadding()) } } + } } // Image Editor overlay для фото из галереи editingItem?.let { item -> ImageEditorScreen( imageUri = item.uri, - onDismiss = { editingItem = null }, + onDismiss = { + editingItem = null + thumbnailPosition = null + }, onSave = { editedUri -> editingItem = null + thumbnailPosition = null // Если нет onMediaSelectedWithCaption - открываем preview if (onMediaSelectedWithCaption == null) { previewPhotoUri = editedUri @@ -347,6 +463,7 @@ fun MediaPickerBottomSheet( }, onSaveWithCaption = if (onMediaSelectedWithCaption != null) { editedUri, caption -> editingItem = null + thumbnailPosition = null val mediaItem = MediaItem( id = System.currentTimeMillis(), uri = editedUri, @@ -358,7 +475,8 @@ fun MediaPickerBottomSheet( } else null, isDarkTheme = isDarkTheme, showCaptionInput = onMediaSelectedWithCaption != null, - recipientName = recipientName + recipientName = recipientName, + thumbnailPosition = thumbnailPosition ) } @@ -596,7 +714,7 @@ private fun MediaGrid( mediaItems: List, selectedItems: Set, onCameraClick: () -> Unit, - onItemClick: (MediaItem) -> Unit, + onItemClick: (MediaItem, ThumbnailPosition) -> Unit, onItemLongClick: (MediaItem) -> Unit, isDarkTheme: Boolean, modifier: Modifier = Modifier @@ -630,7 +748,7 @@ private fun MediaGrid( selectionIndex = if (item.id in selectedItems) { selectedItems.toList().indexOf(item.id) + 1 } else 0, - onClick = { onItemClick(item) }, + onClick = { position -> onItemClick(item, position) }, onLongClick = { onItemLongClick(item) }, isDarkTheme = isDarkTheme ) @@ -777,19 +895,34 @@ private fun MediaGridItem( item: MediaItem, isSelected: Boolean, selectionIndex: Int, - onClick: () -> Unit, + onClick: (ThumbnailPosition) -> Unit, onLongClick: () -> Unit, isDarkTheme: Boolean ) { val context = LocalContext.current + // 📍 Отслеживаем позицию для анимации + var itemPosition by remember { mutableStateOf(null) } + Box( modifier = Modifier .aspectRatio(1f) .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) { detectTapGestures( - onTap = { onClick() }, + onTap = { + itemPosition?.let { onClick(it) } + }, onLongPress = { onLongClick() } ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiCache.kt b/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiCache.kt index f3a2c2d..38f6428 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiCache.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiCache.kt @@ -7,7 +7,6 @@ import androidx.compose.runtime.setValue import coil.imageLoader import coil.request.ImageRequest import kotlinx.coroutines.* -import kotlin.system.measureTimeMillis /** * 🚀 Оптимизированный кэш эмодзи с предзагрузкой @@ -47,21 +46,20 @@ object OptimizedEmojiCache { scope.launch { try { - val duration = measureTimeMillis { - // Шаг 1: Загружаем список эмодзи (быстро) - loadEmojiList(context) - loadProgress = 0.3f - - // Шаг 2: Группируем по категориям (средне) - groupEmojisByCategories() - loadProgress = 0.6f - - // Шаг 3: Предзагружаем популярные изображения (медленно, но в фоне) - preloadPopularEmojis(context) - loadProgress = 1f - } - + // Шаг 1: Загружаем список эмодзи (быстро) + loadEmojiList(context) + loadProgress = 0.3f + + // Шаг 2: Группируем по категориям (средне) + groupEmojisByCategories() + loadProgress = 0.6f + + // 🔥 Сразу отмечаем как загруженный - предзагрузка идёт в фоне isLoaded = true + + // Шаг 3: Предзагружаем популярные изображения (в фоне, не блокирует UI) + preloadPopularEmojis(context) + loadProgress = 1f isPreloading = false } catch (e: Exception) { allEmojis = emptyList() diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt b/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt index b63395b..c70bb8c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt @@ -19,10 +19,10 @@ import androidx.compose.material3.* import compose.icons.TablerIcons import compose.icons.tablericons.* import androidx.compose.runtime.* +import androidx.compose.runtime.Stable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector @@ -38,25 +38,16 @@ import com.rosetta.messenger.ui.onboarding.PrimaryBlue import kotlinx.coroutines.launch /** - * 🚀 ОПТИМИЗИРОВАННЫЙ EMOJI PICKER - * - * Ключевые оптимизации: - * 1. Предзагрузка популярных эмодзи при старте приложения - * 2. LazyGrid с оптимизированными настройками (beyondBoundsLayout) - * 3. Hardware layer для анимаций - * 4. Минимум recomposition (derivedStateOf, remember keys) - * 5. Coil оптимизация (hardware acceleration, size limits) - * 6. SharedPreferences для сохранения высоты клавиатуры (как в Telegram) - * 7. keyboardDuration для синхронизации с системной клавиатурой - * 8. Анимация управляется внешним AnimatedKeyboardTransition - * - * @param isVisible Видимость панели (для внутренней логики) - * @param isDarkTheme Темная/светлая тема - * @param onEmojiSelected Callback при выборе эмодзи - * @param onClose Callback при закрытии - * @param modifier Модификатор + * 🚀 ULTRA-ОПТИМИЗИРОВАННЫЙ EMOJI PICKER + * + * Ключевые оптимизации v2: + * 1. ZERO LaunchedEffect в EmojiButton - никаких корутин на каждый эмодзи + * 2. Нет анимаций scale - убрали spring animations для каждой кнопки + * 3. Нет interactionSource tracking - убрали collect для каждой кнопки + * 4. Stable composables - используем @Stable для избежания recomposition + * 5. Оптимизированный LazyGrid с prefetch + * 6. Minimal modifier chain - меньше лямбд, меньше allocations */ -@OptIn(ExperimentalAnimationApi::class) @Composable fun OptimizedEmojiPicker( isVisible: Boolean, @@ -65,15 +56,9 @@ fun OptimizedEmojiPicker( onClose: () -> Unit = {}, modifier: Modifier = Modifier ) { - // 🔥 Используем сохранённую высоту клавиатуры (как в Telegram) val savedKeyboardHeight = rememberSavedKeyboardHeight() - - // 🔥 Логирование изменений видимости - LaunchedEffect(isVisible) { - } - - // 🔥 Рендерим контент напрямую без AnimatedVisibility - // Анимация теперь управляется AnimatedKeyboardTransition + + // 🔥 Рендерим напрямую без лишних обёрток EmojiPickerContent( isDarkTheme = isDarkTheme, 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 private fun EmojiPickerContent( @@ -96,25 +87,19 @@ private fun EmojiPickerContent( var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) } val gridState = rememberLazyGridState() val scope = rememberCoroutineScope() - - // 🚀 Отложенный рендеринг - даём анимации начаться без фриза - var shouldRenderContent by remember { mutableStateOf(false) } - + + // 🔥 Wrap callback в stable class для избежания recomposition + val stableCallback = remember(onEmojiSelected) { StableCallback(onEmojiSelected) } + + // 🚀 Загружаем эмодзи ОДИН раз при первом рендере LaunchedEffect(Unit) { - - // Ждём 1 кадр чтобы анимация началась плавно - kotlinx.coroutines.delay(16) // ~1 frame at 60fps - shouldRenderContent = true - - // Загружаем эмодзи если еще не загружены if (!OptimizedEmojiCache.isLoaded) { OptimizedEmojiCache.preload(context) - } else { } } - - // 🚀 Используем derivedStateOf чтобы избежать лишних recomposition - val displayedEmojis by remember { + + // 🚀 derivedStateOf для минимизации recomposition + val displayedEmojis by remember(selectedCategory) { derivedStateOf { if (OptimizedEmojiCache.isLoaded) { OptimizedEmojiCache.getEmojisForCategory(selectedCategory.key) @@ -123,71 +108,61 @@ private fun EmojiPickerContent( } } } - - // 🚀 При смене категории плавно скроллим наверх + + // 🚀 Scroll to top при смене категории - БЕЗ анимации для скорости LaunchedEffect(selectedCategory) { if (displayedEmojis.isNotEmpty()) { - scope.launch { - gridState.animateScrollToItem(0) - } + gridState.scrollToItem(0) // 🔥 scrollToItem вместо animateScrollToItem } } - - // 🎨 Цвета темы - val panelBackground = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7) - val categoryBarBackground = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White - val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) - + + // 🎨 Цвета темы - computed один раз + val panelBackground = remember(isDarkTheme) { + if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7) + } + 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( modifier = modifier .fillMaxWidth() - .height(height = keyboardHeight) // 🔥 Используем сохранённую высоту (как в Telegram) + .height(keyboardHeight) .background(panelBackground) ) { - // 🔥 Показываем пустую панель пока не готово - if (!shouldRenderContent) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = PrimaryBlue, - strokeWidth = 2.dp - ) - } - } else { - // ============ КАТЕГОРИИ ============ - CategoryBar( - categories = EMOJI_CATEGORIES, - selectedCategory = selectedCategory, - onCategorySelected = { selectedCategory = it }, - isDarkTheme = isDarkTheme, - backgroundColor = categoryBarBackground - ) - - // ============ РАЗДЕЛИТЕЛЬ ============ - Divider( - modifier = Modifier - .fillMaxWidth() - .height(0.5.dp), - color = dividerColor - ) - - // ============ СЕТКА ЭМОДЗИ ============ - EmojiGrid( - isLoaded = OptimizedEmojiCache.isLoaded, - emojis = displayedEmojis, - gridState = gridState, - onEmojiSelected = onEmojiSelected, - isDarkTheme = isDarkTheme - ) - } + // ============ КАТЕГОРИИ ============ + CategoryBar( + categories = EMOJI_CATEGORIES, + selectedCategory = selectedCategory, + onCategorySelected = { selectedCategory = it }, + isDarkTheme = isDarkTheme, + backgroundColor = categoryBarBackground + ) + + // ============ РАЗДЕЛИТЕЛЬ ============ + Divider( + modifier = Modifier + .fillMaxWidth() + .height(0.5.dp), + color = dividerColor + ) + + // ============ СЕТКА ЭМОДЗИ ============ + UltraOptimizedEmojiGrid( + isLoaded = OptimizedEmojiCache.isLoaded, + emojis = displayedEmojis, + gridState = gridState, + onEmojiSelected = stableCallback, + isDarkTheme = isDarkTheme + ) } } /** - * Горизонтальная полоса категорий + * Горизонтальная полоса категорий - ОПТИМИЗИРОВАННАЯ */ @Composable private fun CategoryBar( @@ -197,6 +172,9 @@ private fun CategoryBar( isDarkTheme: Boolean, backgroundColor: Color ) { + // 🔥 Запоминаем interactionSource один раз для всего LazyRow + val interactionSource = remember { MutableInteractionSource() } + LazyRow( modifier = Modifier .fillMaxWidth() @@ -210,7 +188,8 @@ private fun CategoryBar( items = categories, key = { it.key } ) { category -> - CategoryButton( + // 🔥 Минимальная CategoryButton без анимаций + SimpleCategoryButton( category = category, isSelected = selectedCategory == category, onClick = { onCategorySelected(category) }, @@ -221,29 +200,21 @@ private fun CategoryBar( } /** - * Кнопка категории + * 🔥 УПРОЩЁННАЯ кнопка категории - без анимаций */ @Composable -private fun CategoryButton( +private fun SimpleCategoryButton( category: EmojiCategory, isSelected: Boolean, onClick: () -> Unit, isDarkTheme: Boolean ) { - val backgroundColor by animateColorAsState( - targetValue = if (isSelected) PrimaryBlue.copy(alpha = 0.2f) else Color.Transparent, - animationSpec = tween(150), - label = "categoryBackground" - ) - - val iconTint by animateColorAsState( - targetValue = if (isSelected) PrimaryBlue - else if (isDarkTheme) Color.White.copy(alpha = 0.6f) - else Color.Black.copy(alpha = 0.5f), - animationSpec = tween(150), - label = "categoryIcon" - ) - + // 🔥 Статичные цвета - нет анимации! + val backgroundColor = if (isSelected) PrimaryBlue.copy(alpha = 0.2f) else Color.Transparent + val iconTint = if (isSelected) PrimaryBlue + else if (isDarkTheme) Color.White.copy(alpha = 0.6f) + else Color.Black.copy(alpha = 0.5f) + Box( modifier = Modifier .size(44.dp) @@ -251,7 +222,7 @@ private fun CategoryButton( .background(backgroundColor) .clickable( onClick = onClick, - indication = null, // 🚀 Убираем ripple для производительности + indication = null, interactionSource = remember { MutableInteractionSource() } ), contentAlignment = Alignment.Center @@ -266,22 +237,20 @@ private fun CategoryButton( } /** - * Сетка эмодзи с LazyGrid + * 🔥 ULTRA-оптимизированная сетка эмодзи */ @Composable -private fun EmojiGrid( +private fun UltraOptimizedEmojiGrid( isLoaded: Boolean, emojis: List, gridState: androidx.compose.foundation.lazy.grid.LazyGridState, - onEmojiSelected: (String) -> Unit, + onEmojiSelected: StableCallback, isDarkTheme: Boolean ) { when { !isLoaded -> { - // Loading state Box( - modifier = Modifier - .fillMaxSize(), + modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { CircularProgressIndicator( @@ -292,10 +261,8 @@ private fun EmojiGrid( } } emojis.isEmpty() -> { - // Empty state Box( - modifier = Modifier - .fillMaxSize(), + modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( @@ -306,115 +273,74 @@ private fun EmojiGrid( } } else -> { - // 🚀 ОПТИМИЗИРОВАННАЯ LazyVerticalGrid + // 🔥 ОПТИМИЗИРОВАННАЯ LazyVerticalGrid LazyVerticalGrid( state = gridState, columns = GridCells.Fixed(8), - modifier = Modifier - .fillMaxSize(), - contentPadding = PaddingValues( - horizontal = 8.dp, - vertical = 8.dp - ), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - userScrollEnabled = true, - // 🚀 Оптимизация: рендерим +2 строки за пределами видимой области - // для плавной прокрутки без белых мерцаний - content = { - items( - items = emojis, - key = { emoji -> emoji }, // 🔥 Важно для stable composition - contentType = { "emoji" } - ) { unified -> - OptimizedEmojiButton( - unified = unified, - onClick = { emoji -> onEmojiSelected(emoji) } - ) - } - } - ) - } - } -} - -/** - * 🚀 Оптимизированная кнопка эмодзи - */ -@Composable -private fun OptimizedEmojiButton( - unified: String, - onClick: (String) -> Unit -) { - val context = LocalContext.current - val interactionSource = remember { MutableInteractionSource() } - - // 🚀 Простая scale анимация без сложных эффектов - 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) { - ImageRequest.Builder(context) - .data("file:///android_asset/emoji/${unified.lowercase()}.png") - .crossfade(false) // 🔥 Выключаем crossfade для производительности - .size(64) // 🔥 Ограничиваем размер для экономии памяти - .memoryCachePolicy(CachePolicy.ENABLED) - .diskCachePolicy(CachePolicy.ENABLED) - .allowHardware(true) // 🔥 Hardware acceleration - .memoryCacheKey("emoji_$unified") - .diskCacheKey("emoji_$unified") - .build() - } - - Box( - modifier = Modifier - .aspectRatio(1f) - .scale(scale) - .clip(RoundedCornerShape(8.dp)) - .clickable( - interactionSource = interactionSource, - indication = null, // 🚀 Убираем ripple - onClickLabel = "Select emoji" + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) ) { - // 🔥 Отправляем эмодзи в формате :emoji_code: как в десктопе - onClick(":emoji_$unified:") - }, - contentAlignment = Alignment.Center - ) { - // 🚀 AsyncImage с Coil (оптимизирован) - AsyncImage( - model = imageRequest, - contentDescription = null, - modifier = Modifier - .size(32.dp) - .graphicsLayer { - // Hardware layer для лучшей производительности - this.alpha = 1f - }, - 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 + items( + items = emojis, + key = { emoji -> emoji }, + contentType = { "emoji" } + ) { unified -> + // 🔥 ULTRA-лёгкая кнопка эмодзи + UltraLightEmojiButton( + unified = unified, + onClick = onEmojiSelected.onClick + ) } } } } } + +/** + * 🔥 ULTRA-ЛЁГКАЯ кнопка эмодзи + * + * Оптимизации: + * - Нет LaunchedEffect + * - Нет анимаций + * - Нет interactionSource tracking + * - Минимальный modifier chain + * - Предзакэшированный ImageRequest + */ +@Composable +private fun UltraLightEmojiButton( + unified: String, + onClick: (String) -> Unit +) { + val context = LocalContext.current + + // 🔥 Один remember для ImageRequest - это единственный "тяжёлый" объект + val imageRequest = remember(unified) { + ImageRequest.Builder(context) + .data("file:///android_asset/emoji/${unified.lowercase()}.png") + .crossfade(false) // Нет анимации + .size(48) // Меньше размер = быстрее + .memoryCachePolicy(CachePolicy.ENABLED) + .diskCachePolicy(CachePolicy.ENABLED) + .allowHardware(true) + .memoryCacheKey("emoji_$unified") + .build() + } + + // 🔥 Минимальный Box без лишних модификаторов + Box( + modifier = Modifier + .aspectRatio(1f) + .clip(RoundedCornerShape(6.dp)) + .clickable { onClick(":emoji_$unified:") }, + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = imageRequest, + contentDescription = null, + modifier = Modifier.size(28.dp), + contentScale = ContentScale.Fit + ) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index c0cd9f3..cf39bd7 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -1,6 +1,11 @@ package com.rosetta.messenger.ui.settings +import android.util.Log 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.clickable import androidx.compose.foundation.layout.* @@ -18,18 +23,23 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap 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.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp 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.BlurredAvatarBackground 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.utils.AvatarFileManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext // Collapsing header constants private val EXPANDED_HEADER_HEIGHT_OTHER = 280.dp @@ -116,28 +129,167 @@ fun OtherProfileScreen( 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 { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val delta = available.y - val newOffset = scrollOffset - delta - val consumed = - when { - delta < 0 && scrollOffset < maxScrollOffset -> { - val consumed = - (newOffset.coerceIn(0f, maxScrollOffset) - scrollOffset) - scrollOffset = newOffset.coerceIn(0f, maxScrollOffset) - -consumed - } - delta > 0 && scrollOffset > 0 -> { - val consumed = - scrollOffset - newOffset.coerceIn(0f, maxScrollOffset) - scrollOffset = newOffset.coerceIn(0f, maxScrollOffset) - consumed - } - else -> 0f + isDragging = true + + // Тянем вверх (delta < 0) + if (delta < 0) { + // Сначала убираем overscroll + if (overscrollOffset > 0) { + val newOffset = (overscrollOffset + delta).coerceAtLeast(0f) + val consumed = overscrollOffset - newOffset + overscrollOffset = newOffset + // Сбрасываем isPulledDown если вышли из expanded + if (overscrollOffset < maxOverscroll * 0.5f) { + isPulledDown = false } - return Offset(0f, consumed) + return Offset(0f, -consumed) + } + // Затем коллапсируем 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.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( name = user.title.ifEmpty { "Unknown User" }, @@ -207,6 +359,8 @@ fun OtherProfileScreen( lastSeen = lastSeen, avatarColors = avatarColors, collapseProgress = collapseProgress, + expansionProgress = expansionProgress, + hasAvatar = hasAvatar, onBack = onBack, isDarkTheme = isDarkTheme, showAvatarMenu = showAvatarMenu, @@ -239,18 +393,20 @@ fun OtherProfileScreen( } } // ═══════════════════════════════════════════════════════════ -// 🎯 COLLAPSING HEADER FOR OTHER PROFILE +// 🎯 COLLAPSING HEADER FOR OTHER PROFILE with METABALL EFFECT // ═══════════════════════════════════════════════════════════ @Composable private fun CollapsingOtherProfileHeader( name: String, - username: String, + @Suppress("UNUSED_PARAMETER") username: String, publicKey: String, verified: Int, isOnline: Boolean, lastSeen: Long, avatarColors: AvatarColors, collapseProgress: Float, + expansionProgress: Float, + hasAvatar: Boolean, onBack: () -> Unit, isDarkTheme: Boolean, showAvatarMenu: Boolean, @@ -261,34 +417,47 @@ private fun CollapsingOtherProfileHeader( onClearChat: () -> Unit ) { val density = LocalDensity.current - val configuration = LocalConfiguration.current - val screenWidthDp = configuration.screenWidthDp.dp val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val expandedHeight = EXPANDED_HEADER_HEIGHT_OTHER + statusBarHeight val collapsedHeight = COLLAPSED_HEADER_HEIGHT_OTHER + statusBarHeight + // Header height меняется только при collapse, НЕ при overscroll val headerHeight = androidx.compose.ui.unit.lerp(expandedHeight, collapsedHeight, collapseProgress) - // Avatar animation - val avatarCenterX = (screenWidthDp - AVATAR_SIZE_EXPANDED_OTHER) / 2 - val avatarStartY = statusBarHeight + 32.dp - val avatarEndY = statusBarHeight - 60.dp - val avatarY = androidx.compose.ui.unit.lerp(avatarStartY, avatarEndY, collapseProgress) - val avatarSize = - androidx.compose.ui.unit.lerp(AVATAR_SIZE_EXPANDED_OTHER, 0.dp, collapseProgress) - val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 0.sp, collapseProgress) + // Avatar font size for placeholder + val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 12.sp, collapseProgress) + + // Haptic feedback при достижении полного квадрата (ТОЛЬКО С АВАТАРКОЙ) + val hapticFeedback = LocalHapticFeedback.current + var hasTriggeredHaptic by remember { mutableStateOf(false) } + + 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 - 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 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 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)) { // ═══════════════════════════════════════════════════════════ // 🎨 BLURRED AVATAR BACKGROUND @@ -301,6 +470,43 @@ private fun CollapsingOtherProfileHeader( 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 // ═══════════════════════════════════════════════════════════ @@ -315,7 +521,7 @@ private fun CollapsingOtherProfileHeader( Icon( imageVector = Icons.Filled.ArrowBack, contentDescription = "Back", - tint = if (isDarkTheme) Color.White else Color(0xFF007AFF), + tint = Color.White, modifier = Modifier.size(24.dp) ) } @@ -336,9 +542,7 @@ private fun CollapsingOtherProfileHeader( Icon( imageVector = Icons.Default.MoreVert, contentDescription = "Profile menu", - tint = - if (isColorLight(avatarColors.backgroundColor)) Color.Black - else Color.White, + tint = Color.White, modifier = Modifier.size(24.dp) ) } @@ -361,36 +565,7 @@ private fun CollapsingOtherProfileHeader( } // ═══════════════════════════════════════════════════════════ - // 👤 AVATAR - shrinks and moves up - // ═══════════════════════════════════════════════════════════ - 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 + // 📝 TEXT BLOCK - Name + Verified + Online // ═══════════════════════════════════════════════════════════ Column( modifier = @@ -416,11 +591,10 @@ private fun CollapsingOtherProfileHeader( text = name, fontSize = nameFontSize, fontWeight = FontWeight.SemiBold, - color = - if (isColorLight(avatarColors.backgroundColor)) Color.Black - else Color.White, + color = textColor, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 220.dp), textAlign = TextAlign.Center ) @@ -440,15 +614,70 @@ private fun CollapsingOtherProfileHeader( if (isOnline) { Color(0xFF4CAF50) } else { - if (isColorLight(avatarColors.backgroundColor)) - Color.Black.copy(alpha = 0.6f) - else Color.White.copy(alpha = 0.6f) + textColor.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(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 // ═══════════════════════════════════════════════════════════