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