fix: enhance avatar expansion and collapse animations with overscroll support and haptic feedback in OtherProfileScreen
This commit is contained in:
@@ -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 ->
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user