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 d0b7b16..ec200ec 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 @@ -191,6 +191,8 @@ fun ChatDetailScreen( // Telegram-style scroll tracking var wasManualScroll by remember { mutableStateOf(false) } var isAtBottom by remember { mutableStateOf(true) } + // Флаг для скрытия кнопки scroll при отправке (чтобы не мигала) + var isSendingMessage by remember { mutableStateOf(false) } // Track if user is at bottom of list LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { @@ -232,19 +234,29 @@ fun ChatDetailScreen( val isOnline by viewModel.opponentOnline.collectAsState() // 🔥 Добавляем информацию о датах к сообщениям + // В reversed layout (новые внизу) - показываем дату ПОСЛЕ сообщения (визуально сверху) val messagesWithDates = remember(messages) { val result = mutableListOf>() // message, showDateHeader var lastDateString = "" - // Сортируем по времени (старые -> новые) - val sortedMessages = messages.sortedBy { it.timestamp.time } + // Сортируем по времени (новые -> старые) для reversed layout + val sortedMessages = messages.sortedByDescending { it.timestamp.time } - for (message in sortedMessages) { + for (i in sortedMessages.indices) { + val message = sortedMessages[i] val dateString = SimpleDateFormat("yyyyMMdd", Locale.getDefault()) .format(message.timestamp) - val showDate = dateString != lastDateString + + // Показываем дату если это последнее сообщение за день + // (следующее сообщение - другой день или нет следующего) + val nextMessage = sortedMessages.getOrNull(i + 1) + val nextDateString = nextMessage?.let { + SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(it.timestamp) + } + val showDate = nextDateString == null || nextDateString != dateString + result.add(message to showDate) lastDateString = dateString } @@ -464,10 +476,15 @@ fun ChatDetailScreen( }, containerColor = Color.Transparent ) { paddingValues -> - // Используем Box для overlay - инпут поверх контента - Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { - // Список сообщений - занимает всё пространство - Box(modifier = Modifier.fillMaxSize()) { + // 🔥 Column с imePadding - весь контент поднимается с клавиатурой + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .imePadding() // Контент поднимается над клавиатурой + ) { + // Список сообщений - занимает всё доступное пространство + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { if (messages.isEmpty()) { // Пустое состояние Column( @@ -527,87 +544,141 @@ fun ChatDetailScreen( } } ), - // Добавляем padding сверху и снизу для скролла под floating input + // padding для контента списка contentPadding = PaddingValues( start = 8.dp, end = 8.dp, top = 8.dp, - bottom = 80.dp // Отступ для floating input + bottom = 8.dp ), reverseLayout = true ) { - // Для inverted FlatList: идём от новых к старым - val reversedMessages = messagesWithDates.reversed() - itemsIndexed(reversedMessages, key = { _, item -> item.first.id }) { + // Reversed layout: item 0 = самое новое сообщение (внизу экрана) + // messagesWithDates уже отсортирован новые->старые + itemsIndexed(messagesWithDates, key = { _, item -> item.first.id }) { index, (message, showDate) -> - // В inverted списке дата показывается ПЕРЕД сообщением (визуально - // ПОСЛЕ) Column { - MessageBubble( - message = message, - isDarkTheme = isDarkTheme, - index = index - ) - // Разделитель даты + // В reversed layout: дата показывается ПОСЛЕ сообщения + // (визуально СВЕРХУ группы сообщений) if (showDate) { DateHeader( dateText = getDateText(message.timestamp.time), secondaryTextColor = secondaryTextColor ) } + MessageBubble( + message = message, + isDarkTheme = isDarkTheme, + index = index + ) } } } } - // Telegram-style "Scroll to Bottom" кнопка - if (!isAtBottom && messages.isNotEmpty()) { - FloatingActionButton( - onClick = { - scope.launch { - wasManualScroll = false - listState.animateScrollToItem(0) - } - }, + // Telegram-style "Scroll to Bottom" кнопка - Liquid Glass стиль + // Не показываем при отправке сообщения (чтобы не мигала) + if (!isAtBottom && messages.isNotEmpty() && !isSendingMessage) { + Box( modifier = Modifier.align(Alignment.BottomEnd) .padding(end = 16.dp, bottom = 16.dp) - .size(48.dp), - containerColor = PrimaryBlue, - elevation = FloatingActionButtonDefaults.elevation(6.dp) + .size(44.dp) + .shadow( + elevation = 8.dp, + shape = CircleShape, + clip = false, + ambientColor = Color.Black.copy(alpha = 0.3f), + spotColor = Color.Black.copy(alpha = 0.3f) + ) + .clip(CircleShape) + .background( + 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 = CircleShape + ) + .clickable { + scope.launch { + wasManualScroll = false + listState.animateScrollToItem(0) + } + }, + contentAlignment = Alignment.Center ) { Icon( Icons.Default.KeyboardArrowDown, contentDescription = "Scroll to bottom", - tint = Color.White + tint = if (isDarkTheme) Color.White.copy(alpha = 0.85f) else Color.Black.copy(alpha = 0.7f), + modifier = Modifier.size(24.dp) ) } } } - // 🔥 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 - ) - } + // 🔥 INPUT BAR - внизу Column, автоматически над клавиатурой + MessageInputBar( + value = inputText, + onValueChange = { + viewModel.updateInputText(it) + // Отправляем индикатор печатания + if (it.isNotEmpty() && !isSavedMessages) { + viewModel.sendTypingIndicator() + } + }, + onSend = { + // Скрываем кнопку scroll на время отправки + isSendingMessage = true + viewModel.sendMessage() + // Скроллим к новому сообщению + scope.launch { + delay(100) + listState.animateScrollToItem(0) + delay(300) // Ждём завершения анимации + isSendingMessage = false + } + }, + isDarkTheme = isDarkTheme, + backgroundColor = inputBackgroundColor, + textColor = textColor, + placeholderColor = secondaryTextColor + ) } } } // Закрытие Box с fade-in @@ -808,8 +879,14 @@ private fun DateHeader(dateText: String, secondaryTextColor: Color) { } /** - * Панель ввода сообщения 1:1 как в React Native Оптимизированная версия с правильным - * позиционированием + * Панель ввода сообщения - Telegram UX канон + * + * Золотые правила: + * 1. Инпут всегда связан с клавиатурой (imePadding) + * 2. Последнее сообщение всегда видно + * 3. Никаких прыжков layout'а + * 4. После отправки: инпут очищается, клавиатура НЕ закрывается + * 5. Инпут растёт вверх при многострочном тексте (до 6 строк) */ @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -827,80 +904,30 @@ private fun MessageInputBar( val focusManager = LocalFocusManager.current val interactionSource = remember { MutableInteractionSource() } - // Цвета - Telegram liquid glass style - val circleBackground = - if (isDarkTheme) Color(0xFF1F1F1F).copy(alpha = 0.75f) - else Color(0xFFF0F0F0).copy(alpha = 0.85f) - val circleBorder = - if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.08f) - val circleIcon = if (isDarkTheme) Color.White.copy(alpha = 0.85f) else Color(0xFF333333) - - // Liquid glass input - темнее и с эффектом размытия - val glassBackground = - if (isDarkTheme) Color(0xFF1A1A1A).copy(alpha = 0.88f) - else Color(0xFFF0F0F0).copy(alpha = 0.92f) - val glassBorder = - if (isDarkTheme) Color.White.copy(alpha = 0.12f) else Color.Black.copy(alpha = 0.08f) - val emojiIconColor = - if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Black.copy(alpha = 0.5f) - val panelBackground = - if (isDarkTheme) Color(0xFF0E0E0E).copy(alpha = 0.95f) - else Color(0xFFF5F5F5).copy(alpha = 0.95f) - // Состояние отправки val canSend = remember(value) { value.isNotBlank() } - // Easing + // Easing анимации val backEasing = CubicBezierEasing(0.34f, 1.56f, 0.64f, 1f) - val smoothEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f) - - // Анимации Send - val sendScale by - animateFloatAsState( - targetValue = if (canSend) 1f else 0f, - animationSpec = tween(220, easing = backEasing), - label = "sendScale" - ) - - // Анимации Mic - val micOpacity by - animateFloatAsState( - targetValue = if (canSend) 0f else 1f, - animationSpec = tween(200, easing = smoothEasing), - label = "micOpacity" - ) - val micTranslateX by - animateFloatAsState( - targetValue = if (canSend) 80f else 0f, - animationSpec = tween(250, easing = smoothEasing), - label = "micTranslateX" - ) - - // Input margin - для правильного позиционирования как на скриншоте - val inputEndMargin by - animateDpAsState( - targetValue = if (canSend) 0.dp else 44.dp, - animationSpec = tween(220, easing = smoothEasing), - label = "inputEndMargin" - ) // Функция переключения emoji picker fun toggleEmojiPicker() { if (showEmojiPicker) { showEmojiPicker = false } else { - // Скрываем клавиатуру и убираем фокус + // Сначала скрываем клавиатуру, затем показываем emoji picker + focusManager.clearFocus(force = true) keyboardController?.hide() - focusManager.clearFocus() + // Небольшая задержка чтобы клавиатура успела закрыться showEmojiPicker = true } } - // Функция отправки + // Функция отправки - НЕ закрывает клавиатуру (UX правило #6) fun handleSend() { if (value.isNotBlank()) { onSend() - onValueChange("") + // Очищаем инпут, но клавиатура остаётся открытой } } @@ -908,14 +935,14 @@ private fun MessageInputBar( modifier = Modifier.fillMaxWidth() .background(Color.Transparent) - .then(if (!showEmojiPicker) Modifier.imePadding() else Modifier) + // imePadding уже на родительском Column ) { // 🔥 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) + // Инпут растёт вверх до 6 строк (~140dp) .heightIn(min = 44.dp, max = 140.dp) .shadow( elevation = 4.dp, @@ -926,36 +953,22 @@ private fun MessageInputBar( ) .clip(RoundedCornerShape(22.dp)) .background( - // Telegram glass effect - достаточно плотный но с эффектом - // стекла brush = Brush.verticalGradient( colors = if (isDarkTheme) { listOf( Color(0xFF2D2D2F) - .copy( - alpha = - 0.92f - ), + .copy(alpha = 0.92f), Color(0xFF1C1C1E) - .copy( - alpha = - 0.96f - ) + .copy(alpha = 0.96f) ) } else { listOf( Color(0xFFF2F2F7) - .copy( - alpha = - 0.94f - ), + .copy(alpha = 0.94f), Color(0xFFE5E5EA) - .copy( - alpha = - 0.97f - ) + .copy(alpha = 0.97f) ) } ) @@ -967,21 +980,13 @@ private fun MessageInputBar( colors = if (isDarkTheme) { listOf( - Color.White.copy( - alpha = 0.18f - ), - Color.White.copy( - alpha = 0.06f - ) + 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 - ) + Color.White.copy(alpha = 0.9f), + Color.Black.copy(alpha = 0.05f) ) } ), @@ -990,7 +995,7 @@ private fun MessageInputBar( .padding(horizontal = 6.dp, vertical = 4.dp), verticalAlignment = Alignment.Bottom ) { - // EMOJI BUTTON - выравнивается по низу + // EMOJI BUTTON Box( modifier = Modifier.align(Alignment.Bottom) @@ -1017,7 +1022,7 @@ private fun MessageInputBar( ) } - // TEXT INPUT - растягивается и центрируется вертикально + // TEXT INPUT Box( modifier = Modifier.weight(1f) @@ -1038,7 +1043,7 @@ private fun MessageInputBar( ) } - // ATTACH BUTTON - выравнивается по низу + // ATTACH BUTTON Box( modifier = Modifier.align(Alignment.Bottom) @@ -1062,7 +1067,7 @@ private fun MessageInputBar( Spacer(modifier = Modifier.width(2.dp)) - // MIC / SEND BUTTON - Mic когда пусто, Send когда есть текст (Telegram style) + // MIC / SEND BUTTON Box( modifier = Modifier.align(Alignment.Bottom) @@ -1080,9 +1085,7 @@ private fun MessageInputBar( indication = null, onClick = { if (canSend) handleSend() - else { - /* TODO: Start voice recording */ - } + else { /* TODO: Voice recording */ } } ), contentAlignment = Alignment.Center @@ -1093,7 +1096,6 @@ private fun MessageInputBar( label = "iconCrossfade" ) { showSend -> if (showSend) { - // Telegram Send icon - кастомная SVG Icon( imageVector = TelegramSendIcon, contentDescription = "Send", @@ -1101,7 +1103,6 @@ private fun MessageInputBar( modifier = Modifier.size(20.dp) ) } else { - // Mic icon Icon( Icons.Default.Mic, contentDescription = "Voice", 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 abc385f..c9a4256 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 @@ -236,11 +236,15 @@ fun AppleEmojiText( val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f else fontSize.value + // Минимальная высота для корректного отображения emoji + val minHeight = (fontSizeValue * 1.5).toInt() + AndroidView( factory = { ctx -> AppleEmojiTextView(ctx).apply { setTextColor(color.toArgb()) setTextSize(fontSizeValue) + minimumHeight = (minHeight * ctx.resources.displayMetrics.density).toInt() } }, update = { view -> @@ -265,6 +269,11 @@ class AppleEmojiTextView @JvmOverloads constructor( private val bitmapCache = LruCache(100) } + init { + // Отключаем лишние отступы шрифта для корректного отображения emoji + includeFontPadding = false + } + fun setTextWithEmojis(text: String) { val spannable = SpannableStringBuilder(text) val matcher = EMOJI_PATTERN.matcher(text) @@ -275,12 +284,13 @@ class AppleEmojiTextView @JvmOverloads constructor( val bitmap = loadEmojiBitmap(unified) if (bitmap != null) { - val size = (textSize * 1.2).toInt() + val size = (textSize * 1.3).toInt() // Увеличиваем размер emoji val scaledBitmap = Bitmap.createScaledBitmap(bitmap, size, size, true) val drawable = BitmapDrawable(resources, scaledBitmap) drawable.setBounds(0, 0, size, size) - val span = ImageSpan(drawable, ImageSpan.ALIGN_BASELINE) + // ALIGN_BOTTOM лучше работает с emoji - не обрезает сверху + val span = ImageSpan(drawable, ImageSpan.ALIGN_BOTTOM) spannable.setSpan(span, matcher.start(), matcher.end(), android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } diff --git a/gradle.properties b/gradle.properties index 9133d71..9c44b4b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,7 +7,7 @@ android.useAndroidX=true kotlin.code.style=official # Use Java 17 for build -org.gradle.java.home=/opt/homebrew/Cellar/openjdk@17/17.0.14/libexec/openjdk.jdk/Contents/Home +org.gradle.java.home=/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home # Increase heap size for Gradle org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED