feat: Add Telegram-style scroll tracking and "Scroll to Bottom" button in ChatDetailScreen

This commit is contained in:
k1ngsterr1
2026-01-11 06:03:22 +05:00
parent a493bb7378
commit 5bab5a65f6
2 changed files with 601 additions and 218 deletions

View File

@@ -66,6 +66,10 @@ import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.animation.core.CubicBezierEasing 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) // Telegram's CubicBezier interpolator (0.199, 0.010, 0.279, 0.910)
val TelegramEasing = CubicBezierEasing(0.199f, 0.010f, 0.279f, 0.910f) val TelegramEasing = CubicBezierEasing(0.199f, 0.010f, 0.279f, 0.910f)
@@ -154,6 +158,16 @@ fun ChatDetailScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope() 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 анимацией // 🔥 Быстрое закрытие с fade-out анимацией
val hideKeyboardAndBack: () -> Unit = { val hideKeyboardAndBack: () -> Unit = {
// Мгновенно убираем фокус и клавиатуру // Мгновенно убираем фокус и клавиатуру
@@ -235,10 +249,13 @@ fun ChatDetailScreen(
} }
} }
// Прокрутка при новых сообщениях // Telegram-style: Прокрутка при новых сообщениях только если пользователь в низу
LaunchedEffect(messages.size) { LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
listState.animateScrollToItem(0) // При первой загрузке всегда скроллим вниз
if (!wasManualScroll || isAtBottom) {
listState.animateScrollToItem(0)
}
} }
} }
@@ -254,25 +271,19 @@ fun ChatDetailScreen(
.fillMaxSize() .fillMaxSize()
.graphicsLayer { alpha = screenAlpha } .graphicsLayer { alpha = screenAlpha }
) { ) {
// Цвета для матового стекла (более прозрачные для лучшего blur эффекта) // Telegram-style solid header background (без blur)
val glassHeaderBackground = if (isDarkTheme) val headerBackground = if (isDarkTheme)
Color(0xFF1A1A1A).copy(alpha = 0.7f) Color(0xFF212121)
else else
Color(0xFFF5F5F5).copy(alpha = 0.7f) Color(0xFFFFFFFF)
val glassInputPanelBackground = if (isDarkTheme)
Color(0xFF1A1A1A).copy(alpha = 0.75f)
else
Color(0xFFF5F5F5).copy(alpha = 0.75f)
Scaffold( Scaffold(
topBar = { topBar = {
// Кастомный TopAppBar для чата с эффектом матового стекла // Telegram-style TopAppBar - solid background без blur
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(glassHeaderBackground) .background(headerBackground)
.blur(radius = 20.dp) // Blur эффект для frosted glass
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -460,7 +471,22 @@ fun ChatDetailScreen(
} else { } else {
LazyColumn( LazyColumn(
state = listState, 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 // Добавляем padding сверху и снизу для скролла под glass header/input
contentPadding = PaddingValues( contentPadding = PaddingValues(
start = 8.dp, 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
)
}
}
} }
// Поле ввода сообщения // Telegram-style input bar - exact 1:1 replica
MessageInputBar( TelegramInputBar(
value = inputText, value = inputText,
onValueChange = { onValueChange = {
viewModel.updateInputText(it) viewModel.updateInputText(it)
@@ -507,10 +557,7 @@ fun ChatDetailScreen(
viewModel.sendMessage() viewModel.sendMessage()
ProtocolManager.addLog("📤 Sending message...") ProtocolManager.addLog("📤 Sending message...")
}, },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme
backgroundColor = inputBackgroundColor,
textColor = textColor,
placeholderColor = secondaryTextColor
) )
} }
} }
@@ -600,173 +647,7 @@ fun rememberMessageEnterAnimation(messageId: String): Pair<Float, Float> {
} }
/** /**
* 🚀 Telegram-style bubble shape с хвостиком * 🚀 Пузырек сообщения Telegram-style
*/
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 с хвостиком
*/ */
@Composable @Composable
private fun MessageBubble( private fun MessageBubble(
@@ -777,6 +658,7 @@ private fun MessageBubble(
// Telegram-style enter animation // Telegram-style enter animation
val (alpha, translationY) = rememberMessageEnterAnimation(message.id) val (alpha, translationY) = rememberMessageEnterAnimation(message.id)
// Telegram colors
val bubbleColor = if (message.isOutgoing) { val bubbleColor = if (message.isOutgoing) {
PrimaryBlue PrimaryBlue
} else { } else {
@@ -789,12 +671,20 @@ private fun MessageBubble(
if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) 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()) } val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 2.dp) .padding(horizontal = 8.dp, vertical = 1.dp)
.graphicsLayer { .graphicsLayer {
this.alpha = alpha this.alpha = alpha
this.translationY = translationY this.translationY = translationY
@@ -803,36 +693,21 @@ private fun MessageBubble(
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.widthIn(max = 280.dp) .widthIn(max = 300.dp)
.shadow( .shadow(
elevation = if (message.isOutgoing) 0.dp else 0.5.dp, elevation = 1.dp,
shape = TelegramBubbleShape( shape = bubbleShape,
isOutgoing = message.isOutgoing,
radius = 18.dp,
tailSize = 6.dp
),
clip = false clip = false
) )
.clip( .clip(bubbleShape)
TelegramBubbleShape(
isOutgoing = message.isOutgoing,
radius = 18.dp,
tailSize = 6.dp
)
)
.background(bubbleColor) .background(bubbleColor)
.padding( .padding(horizontal = 12.dp, vertical = 7.dp)
start = if (message.isOutgoing) 12.dp else 16.dp,
end = if (message.isOutgoing) 16.dp else 12.dp,
top = 8.dp,
bottom = 8.dp
)
) { ) {
Column { Column {
AppleEmojiText( AppleEmojiText(
text = message.text, text = message.text,
color = textColor, color = textColor,
fontSize = 15.sp fontSize = 16.sp
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Row( Row(
@@ -845,7 +720,7 @@ private fun MessageBubble(
fontSize = 11.sp fontSize = 11.sp
) )
if (message.isOutgoing) { if (message.isOutgoing) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(3.dp))
Icon( Icon(
when (message.status) { when (message.status) {
MessageStatus.SENDING -> Icons.Default.Schedule MessageStatus.SENDING -> Icons.Default.Schedule
@@ -855,10 +730,10 @@ private fun MessageBubble(
}, },
contentDescription = null, contentDescription = null,
tint = if (message.status == MessageStatus.READ) tint = if (message.status == MessageStatus.READ)
Color(0xFF4CAF50) Color(0xFF4FC3F7) // Голубые галочки как в Telegram
else else
timeColor, timeColor,
modifier = Modifier.size(14.dp) modifier = Modifier.size(16.dp)
) )
} }
} }
@@ -996,21 +871,23 @@ private fun MessageInputBar(
.fillMaxWidth() .fillMaxWidth()
.then(if (!showEmojiPicker) Modifier.imePadding() else Modifier) .then(if (!showEmojiPicker) Modifier.imePadding() else Modifier)
) { ) {
// Telegram-style input panel - solid background без blur
val inputPanelBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(panelBackground) .background(inputPanelBackground)
.blur(radius = 20.dp) // Blur эффект для frosted glass
) { ) {
// Верхняя линия для разделения (эффект стекла) // Верхняя линия для разделения
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.fillMaxWidth() .fillMaxWidth()
.height(0.5.dp) .height(0.5.dp)
.background( .background(
if (isDarkTheme) Color.White.copy(alpha = 0.1f) if (isDarkTheme) Color.White.copy(alpha = 0.12f)
else Color.Black.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.1f)
) )
) )

View File

@@ -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<AttachMenuItem>,
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
)
}
}