From ec299bb41599c87dacc52fbf621fe49ca955fc97 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Mon, 12 Jan 2026 02:59:16 +0500 Subject: [PATCH] feat: Enhance AppleEmojiPicker to load all emojis from assets and group them by category --- .../messenger/ui/chats/ChatDetailScreen.kt | 135 ++++------ .../ui/components/AppleEmojiEditText.kt | 1 + .../ui/components/AppleEmojiPicker.kt | 241 +++++++++++------- 3 files changed, 194 insertions(+), 183 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 16c82ec..5d1c019 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 @@ -9,7 +9,6 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items @@ -56,11 +55,8 @@ import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.onboarding.PrimaryBlue import android.view.inputmethod.InputMethodManager import android.content.Context -import android.graphics.Rect -import android.view.ViewTreeObserver import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.platform.LocalDensity import java.text.SimpleDateFormat import java.util.* import kotlinx.coroutines.delay @@ -176,10 +172,11 @@ fun ChatDetailScreen( ) { val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current - val backgroundColor = if (isDarkTheme) Color(0xFF0E0E0E) else Color(0xFFEFEFF3) + // Цвета как в React Native themes.ts + val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0) + val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF2F3F5) // � Fade-in анимация для всего экрана var isVisible by remember { mutableStateOf(false) } @@ -288,40 +285,13 @@ fun ChatDetailScreen( // 🔥 Обработка системной кнопки назад BackHandler { hideKeyboardAndBack() } - // 🔥 Ручное отслеживание высоты клавиатуры - val view = LocalView.current - val density = LocalDensity.current - var keyboardHeight by remember { mutableStateOf(0.dp) } - - DisposableEffect(view) { - val listener = ViewTreeObserver.OnGlobalLayoutListener { - val rect = Rect() - view.getWindowVisibleDisplayFrame(rect) - val screenHeight = view.rootView.height - val keyboardHeightPx = screenHeight - rect.bottom - - // Учитываем navigation bar (если клавиатура меньше 150px - это не клавиатура) - val actualKeyboardHeight = if (keyboardHeightPx > 150) keyboardHeightPx else 0 - - keyboardHeight = with(density) { actualKeyboardHeight.toDp() } - } - - view.viewTreeObserver.addOnGlobalLayoutListener(listener) - + // 🔥 Cleanup при выходе из экрана + DisposableEffect(Unit) { onDispose { - view.viewTreeObserver.removeOnGlobalLayoutListener(listener) focusManager.clearFocus() keyboardController?.hide() } } - - // Автоматический скролл вниз при появлении клавиатуры - LaunchedEffect(keyboardHeight) { - if (keyboardHeight > 0.dp && messages.isNotEmpty()) { - delay(50) - listState.animateScrollToItem(0) - } - } // Инициализируем ViewModel с ключами и открываем диалог LaunchedEffect(user.publicKey) { @@ -354,7 +324,10 @@ fun ChatDetailScreen( ) // 🚀 Весь контент с fade-in анимацией - Box(modifier = Modifier.fillMaxSize().graphicsLayer { alpha = screenAlpha }) { + Box(modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = screenAlpha } + ) { // Telegram-style solid header background (без blur) val headerBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) @@ -577,23 +550,17 @@ fun ChatDetailScreen( ) } }, - containerColor = Color.Transparent + containerColor = backgroundColor // Фон всего чата ) { paddingValues -> - // 🔥 Box с фиксированным Input внизу и сообщениями выше - Box( + // 🔥 Простой Column - сообщения сверху, инпут снизу + // Клавиатура сама поднимает контент через adjustResize + Column( modifier = Modifier .fillMaxSize() .padding(paddingValues) ) { - // Список сообщений - занимает всё пространство, с padding снизу для input и клавиатуры - val inputBarHeight = 60.dp - val bottomPadding = inputBarHeight + keyboardHeight - - Box( - modifier = Modifier - .fillMaxSize() - .padding(bottom = bottomPadding) - ) { + // Список сообщений - занимает всё доступное пространство + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { if (messages.isEmpty()) { // Пустое состояние Column( @@ -759,42 +726,35 @@ fun ChatDetailScreen( ) } } - } // Закрытие внутреннего Box для сообщений - - // 🔥 INPUT BAR - фиксированный элемент внизу экрана - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .padding(bottom = keyboardHeight) // Ручной padding над клавиатурой - ) { - 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 - ) } + + // 🔥 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 @@ -1148,10 +1108,7 @@ private fun MessageInputBar( } Column( - modifier = - Modifier.fillMaxWidth() - .background(Color.Transparent) - // imePadding уже на родительском Column + modifier = Modifier.fillMaxWidth() ) { // 🔥 TELEGRAM-STYLE FLOATING LIQUID GLASS INPUT Row( @@ -1344,9 +1301,5 @@ private fun MessageInputBar( onClose = { showEmojiPicker = false } ) } - - if (!showEmojiPicker) { - Spacer(modifier = Modifier.navigationBarsPadding()) - } } } 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 c9a4256..704eef0 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 @@ -87,6 +87,7 @@ class AppleEmojiEditTextView @JvmOverloads constructor( init { // Настраиваем EditText background = null + setBackgroundColor(android.graphics.Color.TRANSPARENT) setPadding(0, 0, 0, 0) gravity = Gravity.CENTER_VERTICAL or Gravity.START isSingleLine = false 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 a556adc..d486336 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 @@ -34,105 +34,150 @@ import com.rosetta.messenger.ui.onboarding.PrimaryBlue /** * Apple Emoji Picker с PNG изображениями - * Категории и эмодзи как в React Native версии + * Загружает ВСЕ эмодзи из assets/emoji директории */ // Категории эмодзи data class EmojiCategory( val key: String, - val label: String // unified код для иконки категории + val label: String, // unified код для иконки категории + val ranges: List> // Unicode диапазоны для этой категории ) -// Данные одного эмодзи -data class EmojiItem( - val unified: String, - val shortName: String, - val category: String -) - -// Категории как в RN +// Стандартный порядок категорий как в iOS/Android/WhatsApp val EMOJI_CATEGORIES = listOf( - EmojiCategory("Smileys & Emotion", "1f600"), // 😀 - EmojiCategory("People & Body", "1f44b"), // 👋 - EmojiCategory("Animals & Nature", "1f431"), // 🐱 - EmojiCategory("Food & Drink", "1f34e"), // 🍎 - EmojiCategory("Travel & Places", "2708-fe0f"), // ✈️ - EmojiCategory("Activities", "26bd"), // ⚽ - EmojiCategory("Objects", "1f4a1"), // 💡 - EmojiCategory("Symbols", "2764-fe0f"), // ❤️ - EmojiCategory("Flags", "1f3f3-fe0f") // 🏳️ + // 😀 Smileys & Emotion: лица, эмоции, руки + EmojiCategory("Smileys", "1f600", listOf( + 0x1F600 to 0x1F64F, // Emoticons + 0x1F910 to 0x1F92F, // Face with... + 0x1F970 to 0x1F9FF, // More faces + 0x263A to 0x263A, // ☺ + 0x2639 to 0x2639 // ☹ + )), + // 👋 People & Body: люди, жесты, части тела + EmojiCategory("People", "1f44b", listOf( + 0x1F466 to 0x1F4FF, // People + 0x1F9D0 to 0x1F9DF, // More people + 0x270A to 0x270D, // Hands + 0x261D to 0x261D, // ☝ + 0x1F440 to 0x1F465 // Body parts + )), + // 🐱 Animals & Nature: животные, растения, природа + EmojiCategory("Animals", "1f431", listOf( + 0x1F400 to 0x1F43F, // Animals + 0x1F980 to 0x1F9AE, // More animals + 0x1F330 to 0x1F335, // Plants + 0x1F337 to 0x1F34F, // Flowers, fruits + 0x2618 to 0x2618 // ☘ + )), + // 🍎 Food & Drink: еда, напитки + EmojiCategory("Food", "1f34e", listOf( + 0x1F345 to 0x1F37F, // Food + 0x1F950 to 0x1F96F, // More food + 0x1F9C0 to 0x1F9CB, // Cheese, drinks + 0x1FAD0 to 0x1FAD9, // New food + 0x2615 to 0x2615 // ☕ + )), + // ✈️ Travel & Places: транспорт, места, здания + EmojiCategory("Travel", "2708-fe0f", listOf( + 0x1F680 to 0x1F6FF, // Transport + 0x1F3D4 to 0x1F3DF, // Places + 0x1F3E0 to 0x1F3F0, // Buildings + 0x2708 to 0x2708, // ✈ + 0x26F0 to 0x26FF // Mountains, etc + )), + // ⚽ Activities: спорт, игры, хобби + EmojiCategory("Activities", "26bd", listOf( + 0x1F3A0 to 0x1F3CA, // Activities + 0x1F3CB to 0x1F3D3, // Sports + 0x1F93C to 0x1F94F, // More sports + 0x26BD to 0x26BE, // ⚽⚾ + 0x265F to 0x2660, // Chess + 0x1F9E0 to 0x1F9FF // Games + )), + // 💡 Objects: предметы, инструменты + EmojiCategory("Objects", "1f4a1", listOf( + 0x1F4A1 to 0x1F4FF, // Objects (lightbulb to...) + 0x1F500 to 0x1F5FF, // More objects + 0x1F6E0 to 0x1F6EF, // Tools + 0x1FA70 to 0x1FAFF, // New objects + 0x2328 to 0x2328 // ⌨ + )), + // ❤️ Symbols: сердца, знаки, символы + EmojiCategory("Symbols", "2764-fe0f", listOf( + 0x2764 to 0x2764, // ❤ + 0x1F490 to 0x1F49F, // Hearts + 0x2600 to 0x26FF, // Misc symbols + 0x2700 to 0x27BF, // Dingbats + 0x1F170 to 0x1F1FF, // Squared letters (before flags) + 0x00A9 to 0x00AE, // ©® + 0x203C to 0x3299 // Misc + )), + // 🏳️ Flags: флаги стран + EmojiCategory("Flags", "1f3f3-fe0f", listOf( + 0x1F1E0 to 0x1F1FF, // Regional indicators (flags) + 0x1F3F3 to 0x1F3F4, // 🏳🏴 + 0x1F3C1 to 0x1F3C1, // 🏁 + 0x1F6A9 to 0x1F6A9 // 🚩 + )) ) -// Эмодзи по категориям (основные) -val EMOJIS_BY_CATEGORY = mapOf( - "Smileys & Emotion" to listOf( - "1f600", "1f603", "1f604", "1f601", "1f606", "1f605", "1f923", "1f602", - "1f642", "1f643", "1f609", "1f60a", "1f607", "1f970", "1f60d", "1f929", - "1f618", "1f617", "263a-fe0f", "1f61a", "1f619", "1f972", "1f60b", "1f61b", - "1f61c", "1f92a", "1f61d", "1f911", "1f917", "1f92d", "1f92b", "1f914", - "1f910", "1f928", "1f610", "1f611", "1f636", "1f60f", "1f612", "1f644", - "1f62c", "1f925", "1f60c", "1f614", "1f62a", "1f924", "1f634", "1f637", - "1f912", "1f915", "1f922", "1f92e", "1f927", "1f975", "1f976", "1f974", - "1f635", "1f92f", "1f920", "1f973", "1f978", "1f60e", "1f913", "1f9d0" - ), - "People & Body" to listOf( - "1f44b", "1f91a", "1f590-fe0f", "270b", "1f596", "1f44c", "1f90c", "1f90f", - "270c-fe0f", "1f91e", "1f91f", "1f918", "1f919", "1f448", "1f449", "1f446", - "1f595", "1f447", "261d-fe0f", "1f44d", "1f44e", "270a", "1f44a", "1f91b", - "1f91c", "1f44f", "1f64c", "1f450", "1f932", "1f91d", "1f64f", "270d-fe0f", - "1f485", "1f933", "1f4aa", "1f9be", "1f9bf", "1f9b5", "1f9b6", "1f442", - "1f9bb", "1f443", "1f9e0", "1fac0", "1fac1", "1f9b7", "1f9b4", "1f440" - ), - "Animals & Nature" to listOf( - "1f435", "1f412", "1f98d", "1f9a7", "1f436", "1f415", "1f9ae", "1f415-200d-1f9ba", - "1f429", "1f43a", "1f98a", "1f99d", "1f431", "1f408", "1f408-200d-2b1b", "1f981", - "1f42f", "1f405", "1f406", "1f434", "1f40e", "1f984", "1f993", "1f98c", - "1f9ac", "1f42e", "1f402", "1f403", "1f404", "1f437", "1f416", "1f417", - "1f43d", "1f40f", "1f411", "1f410", "1f42a", "1f42b", "1f999", "1f992" - ), - "Food & Drink" to listOf( - "1f347", "1f348", "1f349", "1f34a", "1f34b", "1f34c", "1f34d", "1f96d", - "1f34e", "1f34f", "1f350", "1f351", "1f352", "1f353", "1fad0", "1f95d", - "1f345", "1fad2", "1f965", "1f951", "1f346", "1f954", "1f955", "1f33d", - "1f336-fe0f", "1fad1", "1f952", "1f96c", "1f966", "1f9c4", "1f9c5", "1f344", - "1f95c", "1f330", "1f35e", "1f950", "1f956", "1fad3", "1f968", "1f96f" - ), - "Travel & Places" to listOf( - "1f697", "1f695", "1f699", "1f68c", "1f68e", "1f3ce-fe0f", "1f693", "1f691", - "1f692", "1f690", "1f69b", "1f69c", "1f6f5", "1f3cd-fe0f", "1f6b2", "1f6f4", - "1f6f9", "1f6fc", "1f68f", "1f6e3-fe0f", "1f6e4-fe0f", "26fd", "1f6a8", "1f6a5", - "1f6a6", "1f6d1", "1f6a7", "2708-fe0f", "1f6eb", "1f6ec", "1f6e9-fe0f", "1f4ba", - "1f681", "1f69f", "1f6a0", "1f6a1", "1f6f0-fe0f", "1f680", "1f6f8", "1f6f6" - ), - "Activities" to listOf( - "26bd", "1f3c0", "1f3c8", "26be", "1f94e", "1f3be", "1f3d0", "1f3c9", - "1f94f", "1f3b1", "1f3d3", "1f3f8", "1f3d2", "1f3d1", "1f94d", "1f3cf", - "1f945", "26f3", "1f3bf", "1f6f7", "1f3af", "1fa80", "1fa81", "1f3b3", - "1f3ae", "1f3b2", "1f9e9", "1f3ad", "1f3a8", "1f9f5", "1f9f6", "1f3bc", - "1f3b5", "1f3b6", "1f399-fe0f", "1f39a-fe0f", "1f39b-fe0f", "1f3a4", "1f3a7", "1f4fb" - ), - "Objects" to listOf( - "1f4a1", "1f526", "1f3ee", "1fa94", "1f4d4", "1f4d5", "1f4d6", "1f4d7", - "1f4d8", "1f4d9", "1f4da", "1f4d3", "1f4d2", "1f4c3", "1f4dc", "1f4c4", - "1f4f0", "1f5de-fe0f", "1f4d1", "1f516", "1f3f7-fe0f", "1f4b0", "1fa99", "1f4b4", - "1f4b5", "1f4b6", "1f4b7", "1f4b8", "1f4b3", "1f9fe", "1f4b9", "1f4e7", - "1f4e8", "1f4e9", "1f4e4", "1f4e5", "1f4e6", "1f4eb", "1f4ea", "1f4ec" - ), - "Symbols" to listOf( - "2764-fe0f", "1f9e1", "1f49b", "1f49a", "1f499", "1f49c", "1f5a4", "1f90d", - "1f90e", "1f494", "2763-fe0f", "1f495", "1f49e", "1f493", "1f497", "1f496", - "1f498", "1f49d", "1f49f", "2665-fe0f", "1f4af", "1f4a2", "1f4a5", "1f4ab", - "1f4a6", "1f4a8", "1f573-fe0f", "1f4ac", "1f441-fe0f-200d-1f5e8-fe0f", "1f5e8-fe0f", - "1f5ef-fe0f", "1f4ad", "1f4a4", "1f44b", "1f91a", "1f590-fe0f", "270b", "1f596" - ), - "Flags" to listOf( - "1f3f3-fe0f", "1f3f4", "1f3c1", "1f6a9", "1f38c", "1f3f4-200d-2620-fe0f", - "1f1e6-1f1e8", "1f1e6-1f1e9", "1f1e6-1f1ea", "1f1e6-1f1eb", "1f1e6-1f1ec", - "1f1e6-1f1ee", "1f1e6-1f1f1", "1f1e6-1f1f2", "1f1e6-1f1f4", "1f1e6-1f1f6", - "1f1e6-1f1f7", "1f1e6-1f1f8", "1f1e6-1f1f9", "1f1e6-1f1fa", "1f1e6-1f1fc", - "1f1e6-1f1fd", "1f1e6-1f1ff", "1f1e7-1f1e6", "1f1e7-1f1e7", "1f1e7-1f1e9" - ) -) +/** + * Проверяет, попадает ли emoji в диапазон категории + */ +fun emojiMatchesCategory(emoji: String, category: EmojiCategory): Boolean { + val unified = emoji.lowercase().split("-").firstOrNull() ?: return false + val codePoint = try { unified.toInt(16) } catch (e: Exception) { return false } + return category.ranges.any { (start, end) -> codePoint in start..end } +} + +/** + * Загружает все эмодзи из assets/emoji + */ +fun loadAllEmojisFromAssets(context: Context): List { + return try { + context.assets.list("emoji") + ?.filter { it.endsWith(".png") } + ?.map { it.removeSuffix(".png") } + ?.sorted() + ?: emptyList() + } catch (e: Exception) { + emptyList() + } +} + +/** + * Группирует эмодзи по категориям на основе Unicode диапазонов + */ +fun groupEmojisByCategory(allEmojis: List): Map> { + val result = mutableMapOf>() + val usedEmojis = mutableSetOf() + + // Инициализируем категории + EMOJI_CATEGORIES.forEach { category -> + result[category.key] = mutableListOf() + } + + // Группируем по Unicode диапазонам + for (emoji in allEmojis) { + for (category in EMOJI_CATEGORIES) { + if (emojiMatchesCategory(emoji, category) && emoji !in usedEmojis) { + result[category.key]?.add(emoji) + usedEmojis.add(emoji) + break + } + } + } + + // Добавляем неклассифицированные в Symbols + for (emoji in allEmojis) { + if (emoji !in usedEmojis) { + result["Symbols"]?.add(emoji) + } + } + + return result +} /** * Конвертирует unified код в emoji символ @@ -271,6 +316,7 @@ fun CategoryButton( /** * Apple Emoji Picker Panel - Liquid Glass стиль + * Загружает ВСЕ эмодзи из assets */ @Composable fun AppleEmojiPickerPanel( @@ -279,12 +325,23 @@ fun AppleEmojiPickerPanel( onClose: () -> Unit, modifier: Modifier = Modifier ) { + val context = LocalContext.current var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) } val gridState = rememberLazyGridState() - // Мемоизируем текущие эмодзи - val currentEmojis = remember(selectedCategory.key) { - EMOJIS_BY_CATEGORY[selectedCategory.key] ?: emptyList() + // Загружаем ВСЕ эмодзи из assets один раз + val allEmojis = remember { + loadAllEmojisFromAssets(context) + } + + // Группируем по категориям + val emojisByCategory = remember(allEmojis) { + groupEmojisByCategory(allEmojis) + } + + // Текущие эмодзи для выбранной категории + val currentEmojis = remember(selectedCategory.key, emojisByCategory) { + emojisByCategory[selectedCategory.key] ?: emptyList() } // Сбрасываем скролл при смене категории