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() }
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user