feat: Add custom Telegram send icon and implement floating input for message entry in ChatDetailScreen

This commit is contained in:
senseiGai
2026-01-11 21:23:18 +05:00
parent 9f4e561107
commit dac62b16ed
2 changed files with 267 additions and 196 deletions

View File

@@ -29,7 +29,12 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
@@ -57,6 +62,51 @@ import kotlinx.coroutines.launch
// 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)
/** Telegram Send Icon (самолетик) - кастомная SVG иконка */
private val TelegramSendIcon: ImageVector
get() =
ImageVector.Builder(
name = "TelegramSend",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
)
.apply {
path(
fill = null,
stroke = SolidColor(Color.White),
strokeLineWidth = 2f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
// Path 1: M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0
// 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112
// 1.11z
moveTo(14.536f, 21.686f)
arcToRelative(0.5f, 0.5f, 0f, false, false, 0.937f, -0.024f)
lineToRelative(6.5f, -19f)
arcToRelative(0.496f, 0.496f, 0f, false, false, -0.635f, -0.635f)
lineToRelative(-19f, 6.5f)
arcToRelative(0.5f, 0.5f, 0f, false, false, -0.024f, 0.937f)
lineToRelative(7.93f, 3.18f)
arcToRelative(2f, 2f, 0f, false, true, 1.112f, 1.11f)
close()
}
path(
fill = null,
stroke = SolidColor(Color.White),
strokeLineWidth = 2f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
// Path 2: m21.854 2.147-10.94 10.939
moveTo(21.854f, 2.147f)
lineToRelative(-10.94f, 10.939f)
}
}
.build()
/** Модель сообщения (Legacy - для совместимости) */ /** Модель сообщения (Legacy - для совместимости) */
data class ChatMessage( data class ChatMessage(
val id: String, val id: String,
@@ -415,9 +465,10 @@ fun ChatDetailScreen(
}, },
containerColor = Color.Transparent containerColor = Color.Transparent
) { paddingValues -> ) { paddingValues ->
Column(modifier = Modifier.fillMaxSize().padding(paddingValues)) { // Используем Box для overlay - инпут поверх контента
// Список сообщений Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
Box(modifier = Modifier.weight(1f).fillMaxWidth()) { // Список сообщений - занимает всё пространство
Box(modifier = Modifier.fillMaxSize()) {
if (messages.isEmpty()) { if (messages.isEmpty()) {
// Пустое состояние // Пустое состояние
Column( Column(
@@ -477,14 +528,13 @@ fun ChatDetailScreen(
} }
} }
), ),
// Добавляем padding сверху и снизу для скролла под glass // Добавляем padding сверху и снизу для скролла под floating input
// header/input
contentPadding = contentPadding =
PaddingValues( PaddingValues(
start = 8.dp, start = 8.dp,
end = 8.dp, end = 8.dp,
top = 8.dp, top = 8.dp,
bottom = 8.dp bottom = 80.dp // Отступ для floating input
), ),
reverseLayout = true reverseLayout = true
) { ) {
@@ -538,7 +588,8 @@ fun ChatDetailScreen(
} }
} }
// Поле ввода сообщения // 🔥 FLOATING INPUT - поверх контента внизу экрана
Box(modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth()) {
MessageInputBar( MessageInputBar(
value = inputText, value = inputText,
onValueChange = { onValueChange = {
@@ -559,6 +610,7 @@ fun ChatDetailScreen(
) )
} }
} }
}
} // Закрытие Box с fade-in } // Закрытие Box с fade-in
// Диалог логов // Диалог логов
@@ -856,15 +908,15 @@ private fun MessageInputBar(
Column( Column(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.background(Color.Transparent)
.then(if (!showEmojiPicker) Modifier.imePadding() else Modifier) .then(if (!showEmojiPicker) Modifier.imePadding() else Modifier)
) { ) {
// 🔥 TELEGRAM-STYLE LIQUID GLASS INPUT - точь-в-точь как в Telegram // 🔥 TELEGRAM-STYLE FLOATING LIQUID GLASS INPUT
// Единый liquid glass контейнер без фона
Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) {
// Единый liquid glass контейнер
Row( Row(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 16.dp)
.heightIn(min = 44.dp, max = 140.dp) .heightIn(min = 44.dp, max = 140.dp)
.shadow( .shadow(
elevation = 4.dp, elevation = 4.dp,
@@ -875,13 +927,14 @@ private fun MessageInputBar(
) )
.clip(RoundedCornerShape(22.dp)) .clip(RoundedCornerShape(22.dp))
.background( .background(
// Telegram liquid glass - более темный и глубокий // Telegram glass effect - достаточно плотный но с эффектом
// стекла
brush = brush =
Brush.verticalGradient( Brush.verticalGradient(
colors = colors =
if (isDarkTheme) { if (isDarkTheme) {
listOf( listOf(
Color(0xFF2C2C2E) Color(0xFF2D2D2F)
.copy( .copy(
alpha = alpha =
0.92f 0.92f
@@ -897,31 +950,29 @@ private fun MessageInputBar(
Color(0xFFF2F2F7) Color(0xFFF2F2F7)
.copy( .copy(
alpha = alpha =
0.95f 0.94f
), ),
Color(0xFFE5E5EA) Color(0xFFE5E5EA)
.copy( .copy(
alpha = alpha =
0.98f 0.97f
) )
) )
} }
) )
) )
.border( .border(
width = 0.5.dp, width = 1.dp,
brush = brush =
Brush.verticalGradient( Brush.verticalGradient(
colors = colors =
if (isDarkTheme) { if (isDarkTheme) {
listOf( listOf(
Color.White.copy( Color.White.copy(
alpha = alpha = 0.18f
0.12f
), ),
Color.White.copy( Color.White.copy(
alpha = alpha = 0.06f
0.04f
) )
) )
} else { } else {
@@ -930,18 +981,17 @@ private fun MessageInputBar(
alpha = 0.9f alpha = 0.9f
), ),
Color.Black.copy( Color.Black.copy(
alpha = alpha = 0.05f
0.03f
) )
) )
} }
), ),
shape = RoundedCornerShape(22.dp) shape = RoundedCornerShape(22.dp)
) )
.padding(start = 6.dp, end = 6.dp, top = 2.dp, bottom = 2.dp), .padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// EMOJI BUTTON - слева внутри контейнера // EMOJI BUTTON - слева внизу контейнера
Box( Box(
modifier = modifier =
Modifier.size(36.dp) Modifier.size(36.dp)
@@ -969,7 +1019,7 @@ private fun MessageInputBar(
// TEXT INPUT - растягивается // TEXT INPUT - растягивается
Box( Box(
modifier = Modifier.weight(1f).padding(vertical = 10.dp, horizontal = 4.dp), modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart contentAlignment = Alignment.CenterStart
) { ) {
AppleEmojiTextField( AppleEmojiTextField(
@@ -985,7 +1035,7 @@ private fun MessageInputBar(
) )
} }
// ATTACH BUTTON - показывается всегда // ATTACH BUTTON - показывается всегда внизу
Box( Box(
modifier = modifier =
Modifier.size(36.dp).clip(CircleShape).clickable( Modifier.size(36.dp).clip(CircleShape).clickable(
@@ -1000,42 +1050,58 @@ private fun MessageInputBar(
tint = tint =
if (isDarkTheme) Color.White.copy(alpha = 0.65f) if (isDarkTheme) Color.White.copy(alpha = 0.65f)
else Color.Black.copy(alpha = 0.55f), else Color.Black.copy(alpha = 0.55f),
modifier = Modifier.size(24.dp).graphicsLayer { rotationZ = 45f } modifier = Modifier.size(24.dp).graphicsLayer { rotationZ = -45f }
) )
} }
Spacer(modifier = Modifier.width(2.dp)) Spacer(modifier = Modifier.width(2.dp))
// SEND BUTTON - появляется только когда есть текст (как в Telegram) // MIC / SEND BUTTON - Mic когда пусто, Send когда есть текст (Telegram style)
androidx.compose.animation.AnimatedVisibility(
visible = canSend,
enter =
scaleIn(
initialScale = 0.6f,
animationSpec = tween(200, easing = backEasing)
) + fadeIn(animationSpec = tween(150)),
exit =
scaleOut(targetScale = 0.6f, animationSpec = tween(150)) +
fadeOut(animationSpec = tween(100))
) {
Box( Box(
modifier = modifier =
Modifier.size(36.dp) Modifier.size(36.dp)
.clip(CircleShape) .clip(CircleShape)
.background(PrimaryBlue) .then(
if (canSend) {
Modifier.background(PrimaryBlue)
} else {
Modifier
}
)
.clickable( .clickable(
interactionSource = interactionSource, interactionSource = interactionSource,
indication = null, indication = null,
onClick = { handleSend() } onClick = {
if (canSend) handleSend()
else {
/* TODO: Start voice recording */
}
}
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// Telegram-style send icon (самолетик) androidx.compose.animation.Crossfade(
targetState = canSend,
animationSpec = tween(150),
label = "iconCrossfade"
) { showSend ->
if (showSend) {
// Telegram Send icon - кастомная SVG
Icon( Icon(
imageVector = Icons.Default.Send, imageVector = TelegramSendIcon,
contentDescription = "Send", contentDescription = "Send",
tint = Color.White, tint = Color.White,
modifier = Modifier.size(22.dp).graphicsLayer { rotationZ = -45f } modifier = Modifier.size(20.dp)
)
} else {
// Mic icon
Icon(
Icons.Default.Mic,
contentDescription = "Voice",
tint =
if (isDarkTheme) Color.White.copy(alpha = 0.65f)
else Color.Black.copy(alpha = 0.55f),
modifier = Modifier.size(22.dp)
) )
} }
} }

View File

@@ -109,7 +109,11 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
fun setTextWithEmojis(newText: String) { fun setTextWithEmojis(newText: String) {
if (newText == text.toString()) return if (newText == text.toString()) return
isUpdating = true isUpdating = true
val cursorPos = selectionStart
setText(newText) setText(newText)
// Восстанавливаем позицию курсора в конец текста
val newCursorPos = if (cursorPos >= 0) newText.length else cursorPos
setSelection(newCursorPos.coerceIn(0, newText.length))
isUpdating = false isUpdating = false
replaceEmojisWithImages(editableText) replaceEmojisWithImages(editableText)
} }
@@ -158,8 +162,9 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
} }
} }
// Восстанавливаем курсор, убедившись что он в допустимых пределах
if (cursorPosition >= 0 && cursorPosition <= editable.length) { if (cursorPosition >= 0 && cursorPosition <= editable.length) {
setSelection(cursorPosition) post { setSelection(cursorPosition.coerceIn(0, editable.length)) }
} }
} finally { } finally {
isUpdating = false isUpdating = false