From 5bab5a65f6e1adb633d0c487163d07ca45752112 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 11 Jan 2026 06:03:22 +0500 Subject: [PATCH] feat: Add Telegram-style scroll tracking and "Scroll to Bottom" button in ChatDetailScreen --- .../messenger/ui/chats/ChatDetailScreen.kt | 313 ++++------- .../messenger/ui/chats/TelegramInputBar.kt | 506 ++++++++++++++++++ 2 files changed, 601 insertions(+), 218 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/TelegramInputBar.kt 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 4463cdd..eab826b 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 @@ -66,6 +66,10 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.draw.shadow import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.geometry.Offset // Telegram's CubicBezier interpolator (0.199, 0.010, 0.279, 0.910) val TelegramEasing = CubicBezierEasing(0.199f, 0.010f, 0.279f, 0.910f) @@ -154,6 +158,16 @@ fun ChatDetailScreen( val listState = rememberLazyListState() val scope = rememberCoroutineScope() + // Telegram-style scroll tracking + var wasManualScroll by remember { mutableStateOf(false) } + var isAtBottom by remember { mutableStateOf(true) } + + // Track if user is at bottom of list + LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { + val isAtTop = listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + isAtBottom = isAtTop + } + // 🔥 Быстрое закрытие с fade-out анимацией val hideKeyboardAndBack: () -> Unit = { // Мгновенно убираем фокус и клавиатуру @@ -235,10 +249,13 @@ fun ChatDetailScreen( } } - // Прокрутка при новых сообщениях + // Telegram-style: Прокрутка при новых сообщениях только если пользователь в низу LaunchedEffect(messages.size) { if (messages.isNotEmpty()) { - listState.animateScrollToItem(0) + // При первой загрузке всегда скроллим вниз + if (!wasManualScroll || isAtBottom) { + listState.animateScrollToItem(0) + } } } @@ -254,25 +271,19 @@ fun ChatDetailScreen( .fillMaxSize() .graphicsLayer { alpha = screenAlpha } ) { - // Цвета для матового стекла (более прозрачные для лучшего blur эффекта) - val glassHeaderBackground = if (isDarkTheme) - Color(0xFF1A1A1A).copy(alpha = 0.7f) + // Telegram-style solid header background (без blur) + val headerBackground = if (isDarkTheme) + Color(0xFF212121) else - Color(0xFFF5F5F5).copy(alpha = 0.7f) - - val glassInputPanelBackground = if (isDarkTheme) - Color(0xFF1A1A1A).copy(alpha = 0.75f) - else - Color(0xFFF5F5F5).copy(alpha = 0.75f) + Color(0xFFFFFFFF) Scaffold( topBar = { - // Кастомный TopAppBar для чата с эффектом матового стекла + // Telegram-style TopAppBar - solid background без blur Box( modifier = Modifier .fillMaxWidth() - .background(glassHeaderBackground) - .blur(radius = 20.dp) // Blur эффект для frosted glass + .background(headerBackground) ) { Row( modifier = Modifier @@ -460,7 +471,22 @@ fun ChatDetailScreen( } else { LazyColumn( state = listState, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .nestedScroll(remember { + object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource + ): Offset { + // Отслеживаем ручную прокрутку пользователем + if (source == NestedScrollSource.Drag) { + wasManualScroll = true + } + return Offset.Zero + } + } + }), // Добавляем padding сверху и снизу для скролла под glass header/input contentPadding = PaddingValues( start = 8.dp, @@ -491,10 +517,34 @@ fun ChatDetailScreen( } } } + + // Telegram-style "Scroll to Bottom" кнопка + if (!isAtBottom && messages.isNotEmpty()) { + FloatingActionButton( + onClick = { + scope.launch { + wasManualScroll = false + listState.animateScrollToItem(0) + } + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 16.dp) + .size(48.dp), + containerColor = PrimaryBlue, + elevation = FloatingActionButtonDefaults.elevation(6.dp) + ) { + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = "Scroll to bottom", + tint = Color.White + ) + } + } } - // Поле ввода сообщения - MessageInputBar( + // Telegram-style input bar - exact 1:1 replica + TelegramInputBar( value = inputText, onValueChange = { viewModel.updateInputText(it) @@ -507,10 +557,7 @@ fun ChatDetailScreen( viewModel.sendMessage() ProtocolManager.addLog("📤 Sending message...") }, - isDarkTheme = isDarkTheme, - backgroundColor = inputBackgroundColor, - textColor = textColor, - placeholderColor = secondaryTextColor + isDarkTheme = isDarkTheme ) } } @@ -600,173 +647,7 @@ fun rememberMessageEnterAnimation(messageId: String): Pair { } /** - * 🚀 Telegram-style bubble shape с хвостиком - */ -class TelegramBubbleShape( - private val isOutgoing: Boolean, - private val radius: Dp = 18.dp, - private val tailSize: Dp = 6.dp -) : Shape { - override fun createOutline( - size: Size, - layoutDirection: LayoutDirection, - density: Density - ): Outline { - val path = Path() - val radiusPx = with(density) { radius.toPx() } - val tailSizePx = with(density) { tailSize.toPx() } - val padding = with(density) { 2.dp.toPx() } - - if (isOutgoing) { - // Исходящее сообщение - хвостик справа внизу - // Начинаем с правого нижнего угла (перед хвостиком) - path.moveTo(size.width - 2.6f * density.density, size.height - padding) - - // Линия к левому нижнему углу - path.lineTo(padding + radiusPx, size.height - padding) - - // Левый нижний угол - path.arcTo( - rect = Rect( - left = padding, - top = size.height - padding - radiusPx * 2, - right = padding + radiusPx * 2, - bottom = size.height - padding - ), - startAngleDegrees = 90f, - sweepAngleDegrees = 90f, - forceMoveTo = false - ) - - // Левая сторона вверх - path.lineTo(padding, padding + radiusPx) - - // Левый верхний угол - path.arcTo( - rect = Rect( - left = padding, - top = padding, - right = padding + radiusPx * 2, - bottom = padding + radiusPx * 2 - ), - startAngleDegrees = 180f, - sweepAngleDegrees = 90f, - forceMoveTo = false - ) - - // Верхняя сторона вправо - path.lineTo(size.width - 8.dp.toPx() * density.density - radiusPx, padding) - - // Правый верхний угол (с небольшим отступом для хвостика) - path.arcTo( - rect = Rect( - left = size.width - 8.dp.toPx() * density.density - radiusPx * 2, - top = padding, - right = size.width - 8.dp.toPx() * density.density, - bottom = padding + radiusPx * 2 - ), - startAngleDegrees = 270f, - sweepAngleDegrees = 90f, - forceMoveTo = false - ) - - // Правая сторона вниз (до хвостика) - path.lineTo( - size.width - 8.dp.toPx() * density.density, - size.height - padding - tailSizePx - 3.dp.toPx() * density.density - ) - - // Хвостик (маленькая дуга) - path.arcTo( - rect = Rect( - left = size.width - 8.dp.toPx() * density.density, - top = size.height - padding - tailSizePx * 2 - 9.dp.toPx() * density.density, - right = size.width - 7.dp.toPx() * density.density + tailSizePx * 2, - bottom = size.height - padding - 1.dp.toPx() * density.density - ), - startAngleDegrees = 180f, - sweepAngleDegrees = -83f, - forceMoveTo = false - ) - } else { - // Входящее сообщение - хвостик слева внизу - path.moveTo(2.6f * density.density, size.height - padding) - - // Линия к правому нижнему углу - path.lineTo(size.width - padding - radiusPx, size.height - padding) - - // Правый нижний угол - path.arcTo( - rect = Rect( - left = size.width - padding - radiusPx * 2, - top = size.height - padding - radiusPx * 2, - right = size.width - padding, - bottom = size.height - padding - ), - startAngleDegrees = 90f, - sweepAngleDegrees = -90f, - forceMoveTo = false - ) - - // Правая сторона вверх - path.lineTo(size.width - padding, padding + radiusPx) - - // Правый верхний угол - path.arcTo( - rect = Rect( - left = size.width - padding - radiusPx * 2, - top = padding, - right = size.width - padding, - bottom = padding + radiusPx * 2 - ), - startAngleDegrees = 0f, - sweepAngleDegrees = -90f, - forceMoveTo = false - ) - - // Верхняя сторона влево - path.lineTo(8.dp.toPx() * density.density + radiusPx, padding) - - // Левый верхний угол - path.arcTo( - rect = Rect( - left = 8.dp.toPx() * density.density, - top = padding, - right = 8.dp.toPx() * density.density + radiusPx * 2, - bottom = padding + radiusPx * 2 - ), - startAngleDegrees = 270f, - sweepAngleDegrees = -90f, - forceMoveTo = false - ) - - // Левая сторона вниз (до хвостика) - path.lineTo( - 8.dp.toPx() * density.density, - size.height - padding - tailSizePx - 3.dp.toPx() * density.density - ) - - // Хвостик (маленькая дуга) - path.arcTo( - rect = Rect( - left = 7.dp.toPx() * density.density - tailSizePx * 2, - top = size.height - padding - tailSizePx * 2 - 9.dp.toPx() * density.density, - right = 8.dp.toPx() * density.density, - bottom = size.height - padding - 1.dp.toPx() * density.density - ), - startAngleDegrees = 0f, - sweepAngleDegrees = 83f, - forceMoveTo = false - ) - } - - path.close() - return Outline.Generic(path) - } -} - -/** - * 🚀 Пузырек сообщения Telegram-style с хвостиком + * 🚀 Пузырек сообщения Telegram-style */ @Composable private fun MessageBubble( @@ -777,6 +658,7 @@ private fun MessageBubble( // Telegram-style enter animation val (alpha, translationY) = rememberMessageEnterAnimation(message.id) + // Telegram colors val bubbleColor = if (message.isOutgoing) { PrimaryBlue } else { @@ -789,12 +671,20 @@ private fun MessageBubble( if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) } + // Telegram bubble shape - простая форма с разными радиусами углов + val bubbleShape = RoundedCornerShape( + topStart = 18.dp, + topEnd = 18.dp, + bottomStart = if (message.isOutgoing) 18.dp else 4.dp, + bottomEnd = if (message.isOutgoing) 4.dp else 18.dp + ) + val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 2.dp) + .padding(horizontal = 8.dp, vertical = 1.dp) .graphicsLayer { this.alpha = alpha this.translationY = translationY @@ -803,36 +693,21 @@ private fun MessageBubble( ) { Box( modifier = Modifier - .widthIn(max = 280.dp) + .widthIn(max = 300.dp) .shadow( - elevation = if (message.isOutgoing) 0.dp else 0.5.dp, - shape = TelegramBubbleShape( - isOutgoing = message.isOutgoing, - radius = 18.dp, - tailSize = 6.dp - ), + elevation = 1.dp, + shape = bubbleShape, clip = false ) - .clip( - TelegramBubbleShape( - isOutgoing = message.isOutgoing, - radius = 18.dp, - tailSize = 6.dp - ) - ) + .clip(bubbleShape) .background(bubbleColor) - .padding( - start = if (message.isOutgoing) 12.dp else 16.dp, - end = if (message.isOutgoing) 16.dp else 12.dp, - top = 8.dp, - bottom = 8.dp - ) + .padding(horizontal = 12.dp, vertical = 7.dp) ) { Column { AppleEmojiText( text = message.text, color = textColor, - fontSize = 15.sp + fontSize = 16.sp ) Spacer(modifier = Modifier.height(2.dp)) Row( @@ -845,7 +720,7 @@ private fun MessageBubble( fontSize = 11.sp ) if (message.isOutgoing) { - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(3.dp)) Icon( when (message.status) { MessageStatus.SENDING -> Icons.Default.Schedule @@ -855,10 +730,10 @@ private fun MessageBubble( }, contentDescription = null, tint = if (message.status == MessageStatus.READ) - Color(0xFF4CAF50) + Color(0xFF4FC3F7) // Голубые галочки как в Telegram else timeColor, - modifier = Modifier.size(14.dp) + modifier = Modifier.size(16.dp) ) } } @@ -996,21 +871,23 @@ private fun MessageInputBar( .fillMaxWidth() .then(if (!showEmojiPicker) Modifier.imePadding() else Modifier) ) { + // Telegram-style input panel - solid background без blur + val inputPanelBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) + Box( modifier = Modifier .fillMaxWidth() - .background(panelBackground) - .blur(radius = 20.dp) // Blur эффект для frosted glass + .background(inputPanelBackground) ) { - // Верхняя линия для разделения (эффект стекла) + // Верхняя линия для разделения Box( modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() .height(0.5.dp) .background( - if (isDarkTheme) Color.White.copy(alpha = 0.1f) - else Color.Black.copy(alpha = 0.08f) + if (isDarkTheme) Color.White.copy(alpha = 0.12f) + else Color.Black.copy(alpha = 0.1f) ) ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/TelegramInputBar.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/TelegramInputBar.kt new file mode 100644 index 0000000..5e8204f --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/TelegramInputBar.kt @@ -0,0 +1,506 @@ +package com.rosetta.messenger.ui.chats + +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.rosetta.messenger.ui.components.AppleEmojiPickerPanel +import com.rosetta.messenger.ui.components.AppleEmojiTextField +// Using TelegramEasing from ChatDetailScreen.kt + +// Attach menu items +data class AttachMenuItem( + val icon: @Composable () -> Unit, + val label: String, + val color: Color, + val onClick: () -> Unit +) + +/** + * Telegram-style input bar - exact 1:1 replica + * Based on ChatActivityEnterView.java from Telegram Android source + */ +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +@Composable +fun TelegramInputBar( + value: String, + onValueChange: (String) -> Unit, + onSend: () -> Unit, + isDarkTheme: Boolean, + onAttachPhoto: () -> Unit = {}, + onAttachFile: () -> Unit = {}, + onAttachLocation: () -> Unit = {}, + onAttachContact: () -> Unit = {}, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + + // Focus & keyboard management + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + + // States + var showEmojiPicker by remember { mutableStateOf(false) } + var showAttachMenu by remember { mutableStateOf(false) } + var isKeyboardVisible by remember { mutableStateOf(false) } + + // Can send message + val canSend = value.isNotBlank() + + // Send button animation (250ms CubicBezier like Telegram) + val sendScale by animateFloatAsState( + targetValue = if (canSend) 1f else 0.1f, + animationSpec = tween(durationMillis = 250, easing = TelegramEasing), + label = "send scale" + ) + + val sendAlpha by animateFloatAsState( + targetValue = if (canSend) 1f else 0f, + animationSpec = tween(durationMillis = 250, easing = TelegramEasing), + label = "send alpha" + ) + + // Mic button animation + val micScale by animateFloatAsState( + targetValue = if (canSend) 0.1f else 1f, + animationSpec = tween(durationMillis = 250, easing = TelegramEasing), + label = "mic scale" + ) + + val micAlpha by animateFloatAsState( + targetValue = if (canSend) 0f else 1f, + animationSpec = tween(durationMillis = 250, easing = TelegramEasing), + label = "mic alpha" + ) + + // Attach button rotation animation (like Telegram) + val attachRotation by animateFloatAsState( + targetValue = if (showAttachMenu) 45f else 0f, + animationSpec = tween(durationMillis = 200, easing = TelegramEasing), + label = "attach rotation" + ) + + // Our custom colors (not Telegram's gray) + val primaryBlue = Color(0xFF007AFF) + val inputPanelBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) + val iconColor = if (isDarkTheme) Color.White.copy(alpha = 0.7f) else Color(0xFF8E8E93) + val activeIconColor = primaryBlue + val textColor = if (isDarkTheme) Color.White else Color.Black + val hintColor = if (isDarkTheme) Color.White.copy(alpha = 0.4f) else Color(0xFF999999) + val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.12f) else Color.Black.copy(alpha = 0.08f) + + // Attach menu items with colors + val attachMenuItems = remember { + listOf( + AttachMenuItem( + icon = { Icon(Icons.Default.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(24.dp)) }, + label = "Photo", + color = Color(0xFF007AFF), + onClick = onAttachPhoto + ), + AttachMenuItem( + icon = { Icon(Icons.Default.InsertDriveFile, contentDescription = null, tint = Color.White, modifier = Modifier.size(24.dp)) }, + label = "File", + color = Color(0xFF34C759), + onClick = onAttachFile + ), + AttachMenuItem( + icon = { Icon(Icons.Default.LocationOn, contentDescription = null, tint = Color.White, modifier = Modifier.size(24.dp)) }, + label = "Location", + color = Color(0xFFFF9500), + onClick = onAttachLocation + ), + AttachMenuItem( + icon = { Icon(Icons.Default.Person, contentDescription = null, tint = Color.White, modifier = Modifier.size(24.dp)) }, + label = "Contact", + color = Color(0xFFAF52DE), + onClick = onAttachContact + ) + ) + } + + // Toggle emoji picker (Telegram behavior) + fun toggleEmojiPicker() { + if (showEmojiPicker) { + // Closing emoji - show keyboard + showEmojiPicker = false + isKeyboardVisible = true + } else { + // Opening emoji - hide keyboard first + keyboardController?.hide() + focusManager.clearFocus() + showAttachMenu = false + showEmojiPicker = true + isKeyboardVisible = false + } + } + + // Open keyboard (when tapping on text field or closing emoji) + fun openKeyboard() { + showEmojiPicker = false + showAttachMenu = false + isKeyboardVisible = true + } + + // Toggle attach menu + fun toggleAttachMenu() { + if (showAttachMenu) { + showAttachMenu = false + } else { + keyboardController?.hide() + focusManager.clearFocus() + showEmojiPicker = false + showAttachMenu = true + isKeyboardVisible = false + } + } + + fun handleSend() { + if (value.isNotBlank()) { + onSend() + onValueChange("") + showEmojiPicker = false + showAttachMenu = false + } + } + + Column( + modifier = modifier + .fillMaxWidth() + .then(if (!showEmojiPicker && !showAttachMenu) Modifier.imePadding() else Modifier) + ) { + // Attach menu (bottom sheet style like Telegram) + AnimatedVisibility( + visible = showAttachMenu, + enter = expandVertically(expandFrom = Alignment.Bottom) + fadeIn(tween(200)), + exit = shrinkVertically(shrinkTowards = Alignment.Bottom) + fadeOut(tween(150)) + ) { + AttachMenuPanel( + items = attachMenuItems, + isDarkTheme = isDarkTheme, + onDismiss = { showAttachMenu = false } + ) + } + + // Telegram input panel container + Box( + modifier = Modifier + .fillMaxWidth() + .background(inputPanelBackground) + ) { + // Top divider (как в Telegram) + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(0.5.dp) + .background(dividerColor) + ) + + // textFieldContainer - padding(0, dp(1), 0, 0) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 1.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp, max = 192.dp) // DEFAULT_HEIGHT = 48dp, max 6 lines + .padding(start = 3.dp, end = 0.dp, bottom = 0.dp), + verticalAlignment = Alignment.Bottom + ) { + // EMOJI BUTTON - 48dp, toggles between emoji/keyboard icon + Box( + modifier = Modifier + .size(48.dp) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { toggleEmojiPicker() } + ) + .padding(7.5.dp), + contentAlignment = Alignment.Center + ) { + // Crossfade animation between emoji and keyboard icons + Crossfade( + targetState = showEmojiPicker, + animationSpec = tween(200), + label = "emoji icon" + ) { isEmojiOpen -> + Icon( + imageVector = if (isEmojiOpen) Icons.Default.Keyboard else Icons.Default.EmojiEmotions, + contentDescription = if (isEmojiOpen) "Keyboard" else "Emoji", + tint = if (isEmojiOpen) activeIconColor else iconColor, + modifier = Modifier.size(24.dp) + ) + } + } + + // MESSAGE EDIT TEXT CONTAINER - weight(1), margin right 50dp for attachLayout + Box( + modifier = Modifier + .weight(1f) + .heightIn(min = 48.dp, max = 192.dp) + .padding(end = 50.dp) + .clickable( + interactionSource = interactionSource, + indication = null + ) { + // Tap on text field - close emoji/attach, open keyboard + if (showEmojiPicker || showAttachMenu) { + openKeyboard() + } + }, + contentAlignment = Alignment.CenterStart + ) { + // EditText - setTextSize(18sp), setPadding(0, dp(9), 0, dp(10)) + AppleEmojiTextField( + value = value, + onValueChange = onValueChange, + textColor = textColor, + textSize = 18f, // EXACT 18sp from Telegram + hint = "Message", + hintColor = hintColor, + modifier = Modifier + .fillMaxWidth() + .padding(start = 0.dp, end = 0.dp, top = 9.dp, bottom = 10.dp) // EXACT padding from Telegram + ) + } + } + + // ATTACH LAYOUT (positioned absolutely at right) - contains attachButton + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .width(50.dp) + .height(48.dp) + .padding(end = 0.dp) + ) { + // ATTACH BUTTON - 48dp with rotation animation + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { toggleAttachMenu() } + ) + .padding(7.5.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.AttachFile, + contentDescription = "Attach", + tint = if (showAttachMenu) activeIconColor else iconColor, + modifier = Modifier + .size(24.dp) + .graphicsLayer { + rotationZ = 45f + attachRotation // Base 45° + animation when open + } + ) + } + } + + // SEND BUTTON CONTAINER - 100dp width, right position (overlay over attachLayout) + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .width(100.dp) + .height(48.dp) + ) { + // AUDIO/VIDEO BUTTON (microphone) - 48dp + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .size(48.dp) + .graphicsLayer { + scaleX = micScale + scaleY = micScale + alpha = micAlpha + } + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = !canSend + ) { /* TODO: voice recording */ } + .padding(7.5.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = "Voice", + tint = iconColor, + modifier = Modifier.size(24.dp) + ) + } + + // SEND BUTTON - 48dp with scale/alpha animation (250ms TelegramEasing) + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .size(48.dp) + .graphicsLayer { + scaleX = sendScale + scaleY = sendScale + alpha = sendAlpha + } + .clip(CircleShape) + .background(primaryBlue) // Our blue color + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = canSend, + onClick = { handleSend() } + ) + .padding(7.5.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Send, + contentDescription = "Send", + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + } + } + } + + // Emoji picker with animation + AnimatedVisibility( + visible = showEmojiPicker, + enter = expandVertically(expandFrom = Alignment.Bottom) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Bottom) + fadeOut() + ) { + AppleEmojiPickerPanel( + isDarkTheme = isDarkTheme, + onEmojiSelected = { emoji -> + onValueChange(value + emoji) + }, + onClose = { showEmojiPicker = false } + ) + } + + if (!showEmojiPicker && !showAttachMenu) { + Spacer(modifier = Modifier.navigationBarsPadding()) + } + } +} + +/** + * Attach Menu Panel - Telegram style bottom sheet with action buttons + */ +@Composable +private fun AttachMenuPanel( + items: List, + isDarkTheme: Boolean, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val panelBackground = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF8F8FA) + val labelColor = if (isDarkTheme) Color.White.copy(alpha = 0.9f) else Color.Black.copy(alpha = 0.8f) + val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.12f) else Color.Black.copy(alpha = 0.08f) + + Column( + modifier = modifier + .fillMaxWidth() + .background(panelBackground) + ) { + // Top divider + Box( + modifier = Modifier + .fillMaxWidth() + .height(0.5.dp) + .background(dividerColor) + ) + + // Buttons grid + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 20.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + items.forEach { item -> + AttachMenuButton( + item = item, + labelColor = labelColor, + onDismiss = onDismiss + ) + } + } + + Spacer(modifier = Modifier.navigationBarsPadding()) + } +} + +/** + * Single attach menu button with icon and label + */ +@Composable +private fun AttachMenuButton( + item: AttachMenuItem, + labelColor: Color, + onDismiss: () -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .clickable( + interactionSource = interactionSource, + indication = null + ) { + item.onClick() + onDismiss() + } + ) { + // Colored circle with icon + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(item.color) + .clickable( + interactionSource = interactionSource, + indication = null + ) { + item.onClick() + onDismiss() + }, + contentAlignment = Alignment.Center + ) { + item.icon() + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Label + Text( + text = item.label, + color = labelColor, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + } +}