From dac62b16eda4d7447f1af9e3db25776441d746ea Mon Sep 17 00:00:00 2001 From: senseiGai Date: Sun, 11 Jan 2026 21:23:18 +0500 Subject: [PATCH] feat: Add custom Telegram send icon and implement floating input for message entry in ChatDetailScreen --- .../messenger/ui/chats/ChatDetailScreen.kt | 456 ++++++++++-------- .../ui/components/AppleEmojiEditText.kt | 7 +- 2 files changed, 267 insertions(+), 196 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 6c2bece..05f9c1e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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) ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt index 5669a3a..abc385f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt @@ -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