feat: Add Telegram-style scroll tracking and "Scroll to Bottom" button in ChatDetailScreen
This commit is contained in:
@@ -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)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user