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