From a6651049287258e4c2327b10cf017cd44aaf8b87 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 10 Jan 2026 20:24:40 +0500 Subject: [PATCH] feat: Enhance MessageInputBar with emoji picker and smooth send button animation --- .../messenger/ui/chats/ChatDetailScreen.kt | 407 ++++++++++++------ 1 file changed, 286 insertions(+), 121 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index b0a9547..995893e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -29,6 +29,9 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.foundation.border import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.ui.onboarding.PrimaryBlue @@ -385,6 +388,8 @@ private fun MessageBubble( /** * Панель ввода сообщения со стеклянным эффектом (Glass Morphism) * Все иконки внутри одного стеклянного инпута + * + Плавная анимация самолетика + * + Эмодзи пикер */ @Composable private fun MessageInputBar( @@ -396,6 +401,9 @@ private fun MessageInputBar( textColor: Color, placeholderColor: Color ) { + // Состояние эмодзи пикера + var showEmojiPicker by remember { mutableStateOf(false) } + // Цвета для glass morphism эффекта val glassBackground = if (isDarkTheme) Color(0xFF1A1A1A).copy(alpha = 0.85f) @@ -414,155 +422,312 @@ private fun MessageInputBar( val iconColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - // Основной контейнер - Box( - modifier = Modifier - .fillMaxWidth() - .background(glassBackground) + // Анимация для кнопки отправки + val sendButtonVisible = value.isNotBlank() + val sendButtonScale by animateFloatAsState( + targetValue = if (sendButtonVisible) 1f else 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "sendScale" + ) + val sendButtonRotation by animateFloatAsState( + targetValue = if (sendButtonVisible) 0f else -90f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "sendRotation" + ) + val sendButtonAlpha by animateFloatAsState( + targetValue = if (sendButtonVisible) 1f else 0f, + animationSpec = tween(200), + label = "sendAlpha" + ) + + Column( + modifier = Modifier.fillMaxWidth() ) { - Row( + // Эмодзи пикер (показывается над инпутом) + AnimatedVisibility( + visible = showEmojiPicker, + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ) + fadeIn(), + exit = slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(200) + ) + fadeOut() + ) { + EmojiPickerPanel( + isDarkTheme = isDarkTheme, + onEmojiSelected = { emoji -> + onValueChange(value + emoji) + }, + onClose = { showEmojiPicker = false } + ) + } + + // Основной контейнер + Box( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - .padding(bottom = 4.dp) - .navigationBarsPadding(), - verticalAlignment = Alignment.Bottom + .background(glassBackground) ) { - // Единый стеклянный контейнер для всего инпута - Box( + Row( modifier = Modifier - .weight(1f) - .heightIn(min = 44.dp, max = 120.dp) - .clip(RoundedCornerShape(22.dp)) - .background(inputGlass) - .border( - width = 1.dp, - color = inputBorder, - shape = RoundedCornerShape(22.dp) - ) - // Блик сверху для стеклянного эффекта - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = if (isDarkTheme) 0.06f else 0.4f), - Color.Transparent, - Color.Black.copy(alpha = if (isDarkTheme) 0.03f else 0.02f) - ), - startY = 0f, - endY = 80f - ), - shape = RoundedCornerShape(22.dp) - ) + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + .padding(bottom = 4.dp) + .navigationBarsPadding(), + verticalAlignment = Alignment.Bottom ) { - Row( + // Единый стеклянный контейнер для всего инпута + Box( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Кнопка смайликов (слева внутри инпута) - IconButton( - onClick = { /* TODO: Emoji picker */ }, - modifier = Modifier.size(36.dp) - ) { - Icon( - Icons.Default.EmojiEmotions, - contentDescription = "Emoji", - tint = iconColor, - modifier = Modifier.size(22.dp) + .weight(1f) + .heightIn(min = 44.dp, max = 120.dp) + .clip(RoundedCornerShape(22.dp)) + .background(inputGlass) + .border( + width = 1.dp, + color = inputBorder, + shape = RoundedCornerShape(22.dp) ) - } - - // Текстовое поле - Box( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp), - contentAlignment = Alignment.CenterStart - ) { - BasicTextField( - value = value, - onValueChange = onValueChange, - textStyle = androidx.compose.ui.text.TextStyle( - color = textColor, - fontSize = 16.sp + // Блик сверху для стеклянного эффекта + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = if (isDarkTheme) 0.06f else 0.4f), + Color.Transparent, + Color.Black.copy(alpha = if (isDarkTheme) 0.03f else 0.02f) + ), + startY = 0f, + endY = 80f ), - cursorBrush = SolidColor(PrimaryBlue), - modifier = Modifier.fillMaxWidth(), - maxLines = 5, - decorationBox = { innerTextField -> - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.CenterStart - ) { - if (value.isEmpty()) { - Text( - text = "Message", - color = placeholderColor.copy(alpha = 0.6f), - fontSize = 16.sp - ) + shape = RoundedCornerShape(22.dp) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Кнопка смайликов (слева внутри инпута) + IconButton( + onClick = { showEmojiPicker = !showEmojiPicker }, + modifier = Modifier.size(36.dp) + ) { + Icon( + if (showEmojiPicker) Icons.Default.Keyboard else Icons.Default.EmojiEmotions, + contentDescription = "Emoji", + tint = if (showEmojiPicker) PrimaryBlue else iconColor, + modifier = Modifier.size(22.dp) + ) + } + + // Текстовое поле + Box( + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp), + contentAlignment = Alignment.CenterStart + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = androidx.compose.ui.text.TextStyle( + color = textColor, + fontSize = 16.sp + ), + cursorBrush = SolidColor(PrimaryBlue), + modifier = Modifier.fillMaxWidth(), + maxLines = 5, + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterStart + ) { + if (value.isEmpty()) { + Text( + text = "Message", + color = placeholderColor.copy(alpha = 0.6f), + fontSize = 16.sp + ) + } + innerTextField() } - innerTextField() } - } - ) - } - - // Кнопка прикрепления (справа внутри инпута) - IconButton( - onClick = { /* TODO: Attachment picker */ }, - modifier = Modifier.size(36.dp) - ) { - Icon( - Icons.Default.AttachFile, - contentDescription = "Attach", - tint = iconColor, - modifier = Modifier.size(22.dp) - ) - } - - // Кнопка камеры (справа внутри инпута) - IconButton( - onClick = { /* TODO: Camera */ }, - modifier = Modifier.size(36.dp) - ) { - Icon( - Icons.Default.CameraAlt, - contentDescription = "Camera", - tint = iconColor, - modifier = Modifier.size(22.dp) - ) + ) + } + + // Кнопка прикрепления (справа внутри инпута) + IconButton( + onClick = { /* TODO: Attachment picker */ }, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.AttachFile, + contentDescription = "Attach", + tint = iconColor, + modifier = Modifier.size(22.dp) + ) + } + + // Кнопка камеры (справа внутри инпута) + IconButton( + onClick = { /* TODO: Camera */ }, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.CameraAlt, + contentDescription = "Camera", + tint = iconColor, + modifier = Modifier.size(22.dp) + ) + } } } - } - - Spacer(modifier = Modifier.width(8.dp)) - - // Кнопка отправки (появляется только когда есть текст) - AnimatedVisibility( - visible = value.isNotBlank(), - enter = scaleIn(animationSpec = tween(150)) + fadeIn(animationSpec = tween(150)), - exit = scaleOut(animationSpec = tween(150)) + fadeOut(animationSpec = tween(150)) - ) { - IconButton( - onClick = onSend, + + Spacer(modifier = Modifier.width(8.dp)) + + // Кнопка отправки с плавной анимацией + Box( modifier = Modifier .size(44.dp) + .graphicsLayer { + scaleX = sendButtonScale + scaleY = sendButtonScale + rotationZ = sendButtonRotation + alpha = sendButtonAlpha + } .clip(CircleShape) .background( brush = Brush.linearGradient( colors = listOf( PrimaryBlue, - PrimaryBlue.copy(alpha = 0.85f) + Color(0xFF5B8DEF) ) ) ) + .clickable(enabled = sendButtonVisible) { onSend() }, + contentAlignment = Alignment.Center ) { Icon( Icons.Default.Send, contentDescription = "Send", tint = Color.White, - modifier = Modifier.size(20.dp) + modifier = Modifier + .size(20.dp) + .graphicsLayer { + // Дополнительная микроанимация иконки + translationX = 2.dp.toPx() + translationY = -1.dp.toPx() + } + ) + } + } + } + } +} + +/** + * Панель выбора эмодзи с Apple-стиль эмодзи + */ +@Composable +private fun EmojiPickerPanel( + isDarkTheme: Boolean, + onEmojiSelected: (String) -> Unit, + onClose: () -> Unit +) { + val panelBackground = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7) + val categoryBackground = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFFFFFFF) + val textColor = if (isDarkTheme) Color.White else Color.Black + + // Категории эмодзи + val emojiCategories = remember { + listOf( + "😀" to listOf("😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉", "😊", "😇", "🥰", "😍", "🤩", "😘", "😗", "☺️", "😚", "😙", "🥲", "😋", "😛", "😜", "🤪", "😝", "🤑", "🤗", "🤭", "🤫", "🤔", "🤐", "🤨", "😐", "😑", "😶", "😏", "😒", "🙄", "😬", "🤥", "😌", "😔", "😪", "🤤", "😴", "😷"), + "❤️" to listOf("❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔", "❤️‍🔥", "❤️‍🩹", "💕", "💞", "💓", "💗", "💖", "💘", "💝", "💟", "♥️", "💌", "💋", "👄", "👅", "🫦"), + "👋" to listOf("👋", "🤚", "🖐️", "✋", "🖖", "👌", "🤌", "🤏", "✌️", "🤞", "🤟", "🤘", "🤙", "👈", "👉", "👆", "🖕", "👇", "☝️", "👍", "👎", "✊", "👊", "🤛", "🤜", "👏", "🙌", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳", "💪"), + "🔥" to listOf("🔥", "⭐", "🌟", "✨", "⚡", "💥", "💫", "🎉", "🎊", "🎈", "🎁", "🏆", "🥇", "🥈", "🥉", "🎯", "🎪", "🎭", "🎨", "🎬", "🎤", "🎧", "🎵", "🎶", "🎹", "🎸", "🎺", "🎷", "🪘", "🥁"), + "🐱" to listOf("🐱", "🐶", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🙈", "🙉", "🙊", "🐔", "🐧", "🐦", "🐤", "🦆", "🦅", "🦉", "🦇", "🐺", "🐗", "🐴", "🦄"), + "🍎" to listOf("🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐", "🍈", "🍒", "🍑", "🥭", "🍍", "🥥", "🥝", "🍅", "🍆", "🥑", "🥦", "🥬", "🥒", "🌶️", "🫑", "🌽", "🥕", "🫒", "🧄", "🧅", "🥔"), + "⚽" to listOf("⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", "🥏", "🎱", "🪀", "🏓", "🏸", "🏒", "🏑", "🥍", "🏏", "🪃", "🥅", "⛳", "🪁", "🏹", "🎣", "🤿", "🥊", "🥋", "🎽", "🛹", "🛼", "🛷"), + "🚗" to listOf("🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", "🚒", "🚐", "🛻", "🚚", "🚛", "🚜", "🏍️", "🛵", "🚲", "🛴", "🚨", "🚔", "🚍", "🚘", "🚖", "✈️", "🛫", "🛬", "🚀", "🛸", "🚁", "⛵") + ) + } + + var selectedCategory by remember { mutableStateOf(0) } + + Column( + modifier = Modifier + .fillMaxWidth() + .height(280.dp) + .background(panelBackground) + ) { + // Категории вверху + Row( + modifier = Modifier + .fillMaxWidth() + .background(categoryBackground) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + emojiCategories.forEachIndexed { index, (emoji, _) -> + val isSelected = selectedCategory == index + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background( + if (isSelected) PrimaryBlue.copy(alpha = 0.2f) else Color.Transparent + ) + .clickable { selectedCategory = index }, + contentAlignment = Alignment.Center + ) { + Text( + text = emoji, + fontSize = 20.sp + ) + } + } + } + + HorizontalDivider( + color = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE5E5EA), + thickness = 0.5.dp + ) + + // Сетка эмодзи + val emojis = emojiCategories[selectedCategory].second + + LazyVerticalGrid( + columns = GridCells.Fixed(8), + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(emojis.size) { index -> + Box( + modifier = Modifier + .aspectRatio(1f) + .clip(RoundedCornerShape(8.dp)) + .clickable { onEmojiSelected(emojis[index]) }, + contentAlignment = Alignment.Center + ) { + Text( + text = emojis[index], + fontSize = 28.sp ) } }