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 d01788a..7fd67ef 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 @@ -2298,34 +2298,59 @@ private fun MessageInputBar( } // Закрытие внешней Column с border } // End of else (not blocked) - // 🔥 APPLE EMOJI PICKER - БЕЗ анимации когда клавиатура открывается + // � APPLE EMOJI PICKER - ОПТИМИЗИРОВАННАЯ АНИМАЦИЯ + // Используем slide up + alpha вместо height анимации (нет relayout = супер плавно) if (!isBlocked) { - // Высота панели: 0 если клавиатура видна или эмодзи закрыты, иначе emojiPanelHeight - // НЕ анимируем когда клавиатура открыта (чтобы не было прыжка) - val targetHeight = if (isKeyboardVisible || !showEmojiPicker) 0.dp else emojiPanelHeight + // Панель ВСЕГДА в DOM (pre-render), просто скрыта через offset/alpha + val shouldShow = showEmojiPicker && !isKeyboardVisible - // Анимируем только когда клавиатура закрыта - val animatedHeight by animateDpAsState( - targetValue = targetHeight, - animationSpec = if (isKeyboardVisible) { - // Мгновенно когда клавиатура открывается - snap() + // 🚀 Animatable для максимального контроля (быстрее чем animateDpAsState) + val slideProgress = remember { Animatable(0f) } + + LaunchedEffect(shouldShow) { + if (shouldShow) { + // Открытие: быстрая spring анимация (critically damped = без пружинистости) + slideProgress.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = 1f, // Critically damped - без bounce + stiffness = 800f // Высокая жёсткость = быстро + ) + ) } else { - // Быстрая анимация как обычная клавиатура (200ms) - tween(durationMillis = 200, easing = FastOutSlowInEasing) - }, - label = "EmojiPanelHeight" - ) + // Закрытие: мгновенно если клавиатура открылась, иначе быстрая анимация + if (isKeyboardVisible) { + slideProgress.snapTo(0f) + } else { + slideProgress.animateTo( + targetValue = 0f, + animationSpec = tween(120, easing = FastOutSlowInEasing) + ) + } + } + } + + val panelHeightPx = with(LocalDensity.current) { (emojiPanelHeight - 16.dp).toPx() } Box( modifier = Modifier .fillMaxWidth() - .height(animatedHeight) - .padding(bottom = 16.dp) // 🔥 Отступ снизу для безопасной зоны + .height(emojiPanelHeight) + .padding(bottom = 16.dp) + .graphicsLayer { + // 🚀 Slide up анимация через translationY (НЕ вызывает relayout!) + translationY = panelHeightPx * (1f - slideProgress.value) + alpha = slideProgress.value + // Hardware layer для плавности + if (slideProgress.value > 0f && slideProgress.value < 1f) { + shadowElevation = 0f + } + } + .clipToBounds() // Обрезаем контент при slide ) { - // 🚀 Рендерим панель когда showEmojiPicker = true - // Высота контролируется через animatedHeight - if (showEmojiPicker) { + // 🚀 Панель ВСЕГДА рендерится (pre-render для instant open) + // Но скрыта через alpha = 0 когда не нужна + if (slideProgress.value > 0f || shouldShow) { AppleEmojiPickerPanel( isDarkTheme = isDarkTheme, onEmojiSelected = { emoji -> @@ -2336,7 +2361,7 @@ private fun MessageInputBar( }, modifier = Modifier .fillMaxWidth() - .height(emojiPanelHeight - 16.dp) // 🔥 Учитываем отступ + .height(emojiPanelHeight - 16.dp) ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 9155c0f..538173c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.sp import com.airbnb.lottie.compose.* import com.rosetta.messenger.R @@ -178,6 +179,9 @@ fun ChatsListScreen( // Protocol connection state val protocolState by ProtocolManager.state.collectAsState() + + // 🔥 Пользователи, которые сейчас печатают + val typingUsers by ProtocolManager.typingUsers.collectAsState() // Dialogs from database val dialogsList by chatsViewModel.dialogs.collectAsState() @@ -508,6 +512,7 @@ fun ChatsListScreen( DialogItem( dialog = dialog, isDarkTheme = isDarkTheme, + isTyping = typingUsers.contains(dialog.opponentKey), onClick = { val user = chatsViewModel.dialogToSearchUser(dialog) onUserSelect(user) @@ -664,6 +669,8 @@ fun ChatItem(chat: Chat, isDarkTheme: Boolean, onClick: () -> Unit) { text = chat.lastMessage, fontSize = 14.sp, color = secondaryTextColor, + maxLines = 1, + overflow = android.text.TextUtils.TruncateAt.END, modifier = Modifier.weight(1f) ) @@ -786,7 +793,7 @@ fun DrawerMenuItem( /** Элемент диалога из базы данных - ОПТИМИЗИРОВАННЫЙ */ @Composable -fun DialogItem(dialog: DialogUiModel, isDarkTheme: Boolean, onClick: () -> Unit) { +fun DialogItem(dialog: DialogUiModel, isDarkTheme: Boolean, isTyping: Boolean = false, onClick: () -> Unit) { // 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета и строки val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } val secondaryTextColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) } @@ -886,15 +893,22 @@ fun DialogItem(dialog: DialogUiModel, isDarkTheme: Boolean, onClick: () -> Unit) horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - // 🔥 Используем AppleEmojiText для отображения эмодзи - // Если есть непрочитанные - текст темнее - AppleEmojiText( - text = dialog.lastMessage.ifEmpty { "No messages" }, - fontSize = 14.sp, - color = if (dialog.unreadCount > 0) textColor.copy(alpha = 0.85f) else secondaryTextColor, - fontWeight = if (dialog.unreadCount > 0) FontWeight.Medium else FontWeight.Normal, - modifier = Modifier.weight(1f) - ) + // 🔥 Показываем typing индикатор или последнее сообщение + if (isTyping) { + TypingIndicatorSmall() + } else { + // 🔥 Используем AppleEmojiText для отображения эмодзи + // Если есть непрочитанные - текст темнее + AppleEmojiText( + text = dialog.lastMessage.ifEmpty { "No messages" }, + fontSize = 14.sp, + color = if (dialog.unreadCount > 0) textColor.copy(alpha = 0.85f) else secondaryTextColor, + fontWeight = if (dialog.unreadCount > 0) FontWeight.Medium else FontWeight.Normal, + maxLines = 1, + overflow = android.text.TextUtils.TruncateAt.END, + modifier = Modifier.weight(1f) + ) + } // Unread badge if (dialog.unreadCount > 0) { @@ -933,3 +947,50 @@ fun DialogItem(dialog: DialogUiModel, isDarkTheme: Boolean, onClick: () -> Unit) ) } } + +/** + * 🔥 Компактный индикатор typing для списка чатов + * Голубой текст "typing" с анимированными точками + */ +@Composable +fun TypingIndicatorSmall() { + val infiniteTransition = rememberInfiniteTransition(label = "typing") + val typingColor = PrimaryBlue + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(1.dp) + ) { + Text( + text = "typing", + fontSize = 14.sp, + color = typingColor, + fontWeight = FontWeight.Medium + ) + + // 3 анимированные точки + repeat(3) { index -> + val offsetY by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = -3f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 500, + delayMillis = index * 120, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "dot$index" + ) + + Text( + text = ".", + fontSize = 14.sp, + color = typingColor, + fontWeight = FontWeight.Medium, + modifier = Modifier.offset(y = offsetY.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 8487bb7..5ddf356 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 @@ -266,7 +266,9 @@ fun AppleEmojiText( modifier: Modifier = Modifier, color: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.White, fontSize: androidx.compose.ui.unit.TextUnit = androidx.compose.ui.unit.TextUnit.Unspecified, - fontWeight: androidx.compose.ui.text.font.FontWeight? = null + fontWeight: androidx.compose.ui.text.font.FontWeight? = null, + maxLines: Int = Int.MAX_VALUE, + overflow: android.text.TextUtils.TruncateAt? = null ) { val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f else fontSize.value @@ -291,12 +293,22 @@ fun AppleEmojiText( setTextSize(fontSizeValue) minimumHeight = (minHeight * ctx.resources.displayMetrics.density).toInt() setTypeface(typeface, typefaceStyle) + // 🔥 Поддержка maxLines и ellipsize + setMaxLines(maxLines) + if (overflow != null) { + ellipsize = overflow + } } }, update = { view -> view.setTextWithEmojis(text) view.setTextColor(color.toArgb()) view.setTypeface(view.typeface, typefaceStyle) + // 🔥 Обновляем maxLines и ellipsize + view.maxLines = maxLines + if (overflow != null) { + view.ellipsize = overflow + } }, modifier = modifier ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiPicker.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiPicker.kt index 8001ec9..8687f8b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiPicker.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiPicker.kt @@ -35,6 +35,7 @@ import coil.request.CachePolicy import coil.request.ImageRequest import com.rosetta.messenger.ui.onboarding.PrimaryBlue import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -554,18 +555,7 @@ fun CategoryButton( isDarkTheme: Boolean = true, modifier: Modifier = Modifier ) { - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - - val scale by animateFloatAsState( - targetValue = if (isPressed) 0.9f else 1f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessHigh - ), - label = "categoryScale" - ) - + // 🚀 Убрали анимацию scale для производительности val backgroundColor = if (isSelected) PrimaryBlue.copy(alpha = 0.2f) else Color.Transparent val iconTint = if (isSelected) PrimaryBlue else if (isDarkTheme) Color.White.copy(alpha = 0.6f) @@ -574,14 +564,9 @@ fun CategoryButton( Box( modifier = modifier .size(40.dp) - .scale(scale) .clip(CircleShape) .background(backgroundColor) - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = onClick - ), + .clickable(onClick = onClick), contentAlignment = Alignment.Center ) { Icon( @@ -614,20 +599,32 @@ fun AppleEmojiPickerPanel( } } - // Текущие эмодзи для выбранной категории - используем derivedStateOf для оптимизации - val currentEmojis by remember { - derivedStateOf { - if (EmojiCache.isLoaded) { - EmojiCache.getEmojisForCategory(selectedCategory.key) - } else { - emptyList() - } + // 🚀 Chunk loading: показываем эмодзи порциями для плавности + var loadedCount by remember { mutableStateOf(40) } // Начинаем с 40 (5 рядов) + + // Текущие эмодзи для выбранной категории + val allEmojis = remember(selectedCategory, EmojiCache.isLoaded) { + if (EmojiCache.isLoaded) { + EmojiCache.getEmojisForCategory(selectedCategory.key) + } else { + emptyList() } } - // 🚀 Убираем анимацию скролла для мгновенного переключения категорий + // 🚀 При смене категории сбрасываем чанки и постепенно догружаем LaunchedEffect(selectedCategory) { + loadedCount = 40 // Сразу показываем 40 эмодзи gridState.scrollToItem(0) + // Догружаем остальные чанками + while (loadedCount < allEmojis.size) { + delay(32) // 2 фрейма + loadedCount = minOf(loadedCount + 24, allEmojis.size) + } + } + + // Отображаемые эмодзи (с chunk loading) + val displayedEmojis = remember(allEmojis, loadedCount) { + allEmojis.take(loadedCount) } val panelBackground = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7) @@ -680,7 +677,7 @@ fun AppleEmojiPickerPanel( strokeWidth = 2.dp ) } - } else if (currentEmojis.isEmpty()) { + } else if (displayedEmojis.isEmpty()) { Box( modifier = Modifier .fillMaxWidth() @@ -694,7 +691,7 @@ fun AppleEmojiPickerPanel( ) } } else { - // 🚀 Оптимизированная LazyVerticalGrid для быстрого рендеринга + // 🚀 Оптимизированная LazyVerticalGrid с chunk loading LazyVerticalGrid( state = gridState, columns = GridCells.Fixed(8), @@ -706,7 +703,7 @@ fun AppleEmojiPickerPanel( contentPadding = PaddingValues(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 16.dp) ) { items( - items = currentEmojis, + items = displayedEmojis, key = { emoji -> emoji }, contentType = { "emoji" } ) { unified ->