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()
) { 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 ->

View File

@@ -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<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)
var currentImageUri by remember { mutableStateOf(imageUri) }
@@ -135,15 +263,6 @@ fun ImageEditorScreen(
var photoEditor by remember { mutableStateOf<PhotoEditor?>(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
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(

View File

@@ -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<MediaItem>) -> 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<List<MediaItem>>(emptyList()) }
@@ -98,6 +115,9 @@ fun MediaPickerBottomSheet(
// Editor state - when user taps on a photo, open editor
var editingItem by remember { mutableStateOf<MediaItem?>(null) }
// 📍 Позиция thumbnail для Telegram-style анимации
var thumbnailPosition by remember { mutableStateOf<ThumbnailPosition?>(null) }
// Uri фото, только что сделанного с камеры (для редактирования)
var pendingPhotoUri by remember { mutableStateOf<Uri?>(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<MediaItem>,
selectedItems: Set<Long>,
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<ThumbnailPosition?>(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() }
)
}

View File

@@ -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()

View File

@@ -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<String>,
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
)
}
}

View File

@@ -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<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
// ═══════════════════════════════════════════════════════════