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,25 +588,27 @@ fun ChatDetailScreen(
} }
} }
// Поле ввода сообщения // 🔥 FLOATING INPUT - поверх контента внизу экрана
MessageInputBar( Box(modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth()) {
value = inputText, MessageInputBar(
onValueChange = { value = inputText,
viewModel.updateInputText(it) onValueChange = {
// Отправляем индикатор печатания viewModel.updateInputText(it)
if (it.isNotEmpty() && !isSavedMessages) { // Отправляем индикатор печатания
viewModel.sendTypingIndicator() if (it.isNotEmpty() && !isSavedMessages) {
} viewModel.sendTypingIndicator()
}, }
onSend = { },
viewModel.sendMessage() onSend = {
// ProtocolManager.addLog("📤 Sending message...") viewModel.sendMessage()
}, // ProtocolManager.addLog("📤 Sending message...")
isDarkTheme = isDarkTheme, },
backgroundColor = inputBackgroundColor, isDarkTheme = isDarkTheme,
textColor = textColor, backgroundColor = inputBackgroundColor,
placeholderColor = secondaryTextColor textColor = textColor,
) placeholderColor = secondaryTextColor
)
}
} }
} }
} // Закрытие Box с fade-in } // Закрытие Box с fade-in
@@ -856,186 +908,200 @@ 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)) { Row(
// Единый liquid glass контейнер modifier =
Row( 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,
shape = RoundedCornerShape(22.dp),
clip = false,
ambientColor = Color.Black.copy(alpha = 0.2f),
spotColor = Color.Black.copy(alpha = 0.2f)
)
.clip(RoundedCornerShape(22.dp))
.background(
// Telegram glass effect - достаточно плотный но с эффектом
// стекла
brush =
Brush.verticalGradient(
colors =
if (isDarkTheme) {
listOf(
Color(0xFF2D2D2F)
.copy(
alpha =
0.92f
),
Color(0xFF1C1C1E)
.copy(
alpha =
0.96f
)
)
} else {
listOf(
Color(0xFFF2F2F7)
.copy(
alpha =
0.94f
),
Color(0xFFE5E5EA)
.copy(
alpha =
0.97f
)
)
}
)
)
.border(
width = 1.dp,
brush =
Brush.verticalGradient(
colors =
if (isDarkTheme) {
listOf(
Color.White.copy(
alpha = 0.18f
),
Color.White.copy(
alpha = 0.06f
)
)
} else {
listOf(
Color.White.copy(
alpha = 0.9f
),
Color.Black.copy(
alpha = 0.05f
)
)
}
),
shape = RoundedCornerShape(22.dp)
)
.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// EMOJI BUTTON - слева внизу контейнера
Box(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.size(36.dp)
.heightIn(min = 44.dp, max = 140.dp) .clip(CircleShape)
.shadow( .clickable(
elevation = 4.dp, interactionSource = interactionSource,
shape = RoundedCornerShape(22.dp), indication = null,
clip = false, onClick = { toggleEmojiPicker() }
ambientColor = Color.Black.copy(alpha = 0.2f), ),
spotColor = Color.Black.copy(alpha = 0.2f) contentAlignment = Alignment.Center
)
.clip(RoundedCornerShape(22.dp))
.background(
// Telegram liquid glass - более темный и глубокий
brush =
Brush.verticalGradient(
colors =
if (isDarkTheme) {
listOf(
Color(0xFF2C2C2E)
.copy(
alpha =
0.92f
),
Color(0xFF1C1C1E)
.copy(
alpha =
0.96f
)
)
} else {
listOf(
Color(0xFFF2F2F7)
.copy(
alpha =
0.95f
),
Color(0xFFE5E5EA)
.copy(
alpha =
0.98f
)
)
}
)
)
.border(
width = 0.5.dp,
brush =
Brush.verticalGradient(
colors =
if (isDarkTheme) {
listOf(
Color.White.copy(
alpha =
0.12f
),
Color.White.copy(
alpha =
0.04f
)
)
} else {
listOf(
Color.White.copy(
alpha = 0.9f
),
Color.Black.copy(
alpha =
0.03f
)
)
}
),
shape = RoundedCornerShape(22.dp)
)
.padding(start = 6.dp, end = 6.dp, top = 2.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
// EMOJI BUTTON - слева внутри контейнера Icon(
Box( if (showEmojiPicker) Icons.Default.Keyboard
modifier = else Icons.Default.SentimentSatisfiedAlt,
Modifier.size(36.dp) contentDescription = "Emoji",
.clip(CircleShape) tint =
.clickable( if (showEmojiPicker) PrimaryBlue
interactionSource = interactionSource, else {
indication = null,
onClick = { toggleEmojiPicker() }
),
contentAlignment = Alignment.Center
) {
Icon(
if (showEmojiPicker) Icons.Default.Keyboard
else Icons.Default.SentimentSatisfiedAlt,
contentDescription = "Emoji",
tint =
if (showEmojiPicker) PrimaryBlue
else {
if (isDarkTheme) Color.White.copy(alpha = 0.65f)
else Color.Black.copy(alpha = 0.55f)
},
modifier = Modifier.size(26.dp)
)
}
// TEXT INPUT - растягивается
Box(
modifier = Modifier.weight(1f).padding(vertical = 10.dp, horizontal = 4.dp),
contentAlignment = Alignment.CenterStart
) {
AppleEmojiTextField(
value = value,
onValueChange = { newValue -> onValueChange(newValue) },
textColor = textColor,
textSize = 17f,
hint = "Message",
hintColor =
if (isDarkTheme) Color.White.copy(alpha = 0.35f)
else Color.Black.copy(alpha = 0.35f),
modifier = Modifier.fillMaxWidth()
)
}
// ATTACH BUTTON - показывается всегда
Box(
modifier =
Modifier.size(36.dp).clip(CircleShape).clickable(
interactionSource = interactionSource,
indication = null
) { /* TODO: Attach */},
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Attachment,
contentDescription = "Attach",
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(26.dp)
} )
}
Spacer(modifier = Modifier.width(2.dp)) // TEXT INPUT - растягивается
Box(
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart
) {
AppleEmojiTextField(
value = value,
onValueChange = { newValue -> onValueChange(newValue) },
textColor = textColor,
textSize = 17f,
hint = "Message",
hintColor =
if (isDarkTheme) Color.White.copy(alpha = 0.35f)
else Color.Black.copy(alpha = 0.35f),
modifier = Modifier.fillMaxWidth()
)
}
// SEND BUTTON - появляется только когда есть текст (как в Telegram) // ATTACH BUTTON - показывается всегда внизу
androidx.compose.animation.AnimatedVisibility( Box(
visible = canSend, modifier =
enter = Modifier.size(36.dp).clip(CircleShape).clickable(
scaleIn( interactionSource = interactionSource,
initialScale = 0.6f, indication = null
animationSpec = tween(200, easing = backEasing) ) { /* TODO: Attach */},
) + fadeIn(animationSpec = tween(150)), contentAlignment = Alignment.Center
exit = ) {
scaleOut(targetScale = 0.6f, animationSpec = tween(150)) + Icon(
fadeOut(animationSpec = tween(100)) Icons.Default.Attachment,
) { contentDescription = "Attach",
Box( tint =
modifier = if (isDarkTheme) Color.White.copy(alpha = 0.65f)
Modifier.size(36.dp) else Color.Black.copy(alpha = 0.55f),
.clip(CircleShape) modifier = Modifier.size(24.dp).graphicsLayer { rotationZ = -45f }
.background(PrimaryBlue) )
.clickable( }
interactionSource = interactionSource,
indication = null, Spacer(modifier = Modifier.width(2.dp))
onClick = { handleSend() }
), // MIC / SEND BUTTON - Mic когда пусто, Send когда есть текст (Telegram style)
contentAlignment = Alignment.Center Box(
) { modifier =
// Telegram-style send icon (самолетик) Modifier.size(36.dp)
.clip(CircleShape)
.then(
if (canSend) {
Modifier.background(PrimaryBlue)
} else {
Modifier
}
)
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = {
if (canSend) handleSend()
else {
/* TODO: Start voice recording */
}
}
),
contentAlignment = Alignment.Center
) {
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