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.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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user