feat: Add custom Telegram send icon and implement floating input for message entry in ChatDetailScreen
This commit is contained in:
@@ -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,25 +588,27 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Поле ввода сообщения
|
||||
MessageInputBar(
|
||||
value = inputText,
|
||||
onValueChange = {
|
||||
viewModel.updateInputText(it)
|
||||
// Отправляем индикатор печатания
|
||||
if (it.isNotEmpty() && !isSavedMessages) {
|
||||
viewModel.sendTypingIndicator()
|
||||
}
|
||||
},
|
||||
onSend = {
|
||||
viewModel.sendMessage()
|
||||
// ProtocolManager.addLog("📤 Sending message...")
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
backgroundColor = inputBackgroundColor,
|
||||
textColor = textColor,
|
||||
placeholderColor = secondaryTextColor
|
||||
)
|
||||
// 🔥 FLOATING INPUT - поверх контента внизу экрана
|
||||
Box(modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth()) {
|
||||
MessageInputBar(
|
||||
value = inputText,
|
||||
onValueChange = {
|
||||
viewModel.updateInputText(it)
|
||||
// Отправляем индикатор печатания
|
||||
if (it.isNotEmpty() && !isSavedMessages) {
|
||||
viewModel.sendTypingIndicator()
|
||||
}
|
||||
},
|
||||
onSend = {
|
||||
viewModel.sendMessage()
|
||||
// ProtocolManager.addLog("📤 Sending message...")
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
backgroundColor = inputBackgroundColor,
|
||||
textColor = textColor,
|
||||
placeholderColor = secondaryTextColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} // Закрытие Box с fade-in
|
||||
@@ -856,186 +908,200 @@ 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 контейнер
|
||||
Row(
|
||||
// 🔥 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,
|
||||
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.fillMaxWidth()
|
||||
.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 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
|
||||
Modifier.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = { toggleEmojiPicker() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// EMOJI BUTTON - слева внутри контейнера
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
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 =
|
||||
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(24.dp).graphicsLayer { rotationZ = 45f }
|
||||
)
|
||||
}
|
||||
else Color.Black.copy(alpha = 0.55f)
|
||||
},
|
||||
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)
|
||||
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(
|
||||
modifier =
|
||||
Modifier.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(PrimaryBlue)
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = { handleSend() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Telegram-style send icon (самолетик)
|
||||
// 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)
|
||||
else Color.Black.copy(alpha = 0.55f),
|
||||
modifier = Modifier.size(24.dp).graphicsLayer { rotationZ = -45f }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
|
||||
// MIC / SEND BUTTON - Mic когда пусто, Send когда есть текст (Telegram style)
|
||||
Box(
|
||||
modifier =
|
||||
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(
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user