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.graphics.Brush
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.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
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)
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 - для совместимости) */
data class ChatMessage(
val id: String,
@@ -415,9 +465,10 @@ fun ChatDetailScreen(
},
containerColor = Color.Transparent
) { paddingValues ->
Column(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
// Список сообщений
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
// Используем Box для overlay - инпут поверх контента
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
// Список сообщений - занимает всё пространство
Box(modifier = Modifier.fillMaxSize()) {
if (messages.isEmpty()) {
// Пустое состояние
Column(
@@ -477,14 +528,13 @@ fun ChatDetailScreen(
}
}
),
// Добавляем padding сверху и снизу для скролла под glass
// header/input
// Добавляем padding сверху и снизу для скролла под floating input
contentPadding =
PaddingValues(
start = 8.dp,
end = 8.dp,
top = 8.dp,
bottom = 8.dp
bottom = 80.dp // Отступ для floating input
),
reverseLayout = true
) {
@@ -538,7 +588,8 @@ fun ChatDetailScreen(
}
}
// Поле ввода сообщения
// 🔥 FLOATING INPUT - поверх контента внизу экрана
Box(modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth()) {
MessageInputBar(
value = inputText,
onValueChange = {
@@ -559,6 +610,7 @@ fun ChatDetailScreen(
)
}
}
}
} // Закрытие Box с fade-in
// Диалог логов
@@ -856,15 +908,15 @@ private fun MessageInputBar(
Column(
modifier =
Modifier.fillMaxWidth()
.background(Color.Transparent)
.then(if (!showEmojiPicker) Modifier.imePadding() else Modifier)
) {
// 🔥 TELEGRAM-STYLE LIQUID GLASS INPUT - точь-в-точь как в Telegram
Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) {
// Единый liquid glass контейнер
// 🔥 TELEGRAM-STYLE FLOATING LIQUID GLASS INPUT
// Единый liquid glass контейнер без фона
Row(
modifier =
Modifier.fillMaxWidth()
.padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 16.dp)
.heightIn(min = 44.dp, max = 140.dp)
.shadow(
elevation = 4.dp,
@@ -875,13 +927,14 @@ private fun MessageInputBar(
)
.clip(RoundedCornerShape(22.dp))
.background(
// Telegram liquid glass - более темный и глубокий
// Telegram glass effect - достаточно плотный но с эффектом
// стекла
brush =
Brush.verticalGradient(
colors =
if (isDarkTheme) {
listOf(
Color(0xFF2C2C2E)
Color(0xFF2D2D2F)
.copy(
alpha =
0.92f
@@ -897,31 +950,29 @@ private fun MessageInputBar(
Color(0xFFF2F2F7)
.copy(
alpha =
0.95f
0.94f
),
Color(0xFFE5E5EA)
.copy(
alpha =
0.98f
0.97f
)
)
}
)
)
.border(
width = 0.5.dp,
width = 1.dp,
brush =
Brush.verticalGradient(
colors =
if (isDarkTheme) {
listOf(
Color.White.copy(
alpha =
0.12f
alpha = 0.18f
),
Color.White.copy(
alpha =
0.04f
alpha = 0.06f
)
)
} else {
@@ -930,18 +981,17 @@ private fun MessageInputBar(
alpha = 0.9f
),
Color.Black.copy(
alpha =
0.03f
alpha = 0.05f
)
)
}
),
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
) {
// EMOJI BUTTON - слева внутри контейнера
// EMOJI BUTTON - слева внизу контейнера
Box(
modifier =
Modifier.size(36.dp)
@@ -969,7 +1019,7 @@ private fun MessageInputBar(
// TEXT INPUT - растягивается
Box(
modifier = Modifier.weight(1f).padding(vertical = 10.dp, horizontal = 4.dp),
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart
) {
AppleEmojiTextField(
@@ -985,7 +1035,7 @@ private fun MessageInputBar(
)
}
// ATTACH BUTTON - показывается всегда
// ATTACH BUTTON - показывается всегда внизу
Box(
modifier =
Modifier.size(36.dp).clip(CircleShape).clickable(
@@ -1000,42 +1050,58 @@ private fun MessageInputBar(
tint =
if (isDarkTheme) Color.White.copy(alpha = 0.65f)
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))
// SEND BUTTON - появляется только когда есть текст (как в Telegram)
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))
) {
// MIC / SEND BUTTON - Mic когда пусто, Send когда есть текст (Telegram style)
Box(
modifier =
Modifier.size(36.dp)
.clip(CircleShape)
.background(PrimaryBlue)
.then(
if (canSend) {
Modifier.background(PrimaryBlue)
} else {
Modifier
}
)
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = { handleSend() }
onClick = {
if (canSend) handleSend()
else {
/* TODO: Start voice recording */
}
}
),
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(
imageVector = Icons.Default.Send,
imageVector = TelegramSendIcon,
contentDescription = "Send",
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) {
if (newText == text.toString()) return
isUpdating = true
val cursorPos = selectionStart
setText(newText)
// Восстанавливаем позицию курсора в конец текста
val newCursorPos = if (cursorPos >= 0) newText.length else cursorPos
setSelection(newCursorPos.coerceIn(0, newText.length))
isUpdating = false
replaceEmojisWithImages(editableText)
}
@@ -158,8 +162,9 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
}
}
// Восстанавливаем курсор, убедившись что он в допустимых пределах
if (cursorPosition >= 0 && cursorPosition <= editable.length) {
setSelection(cursorPosition)
post { setSelection(cursorPosition.coerceIn(0, editable.length)) }
}
} finally {
isUpdating = false