feat: Enhance AppleEmojiPicker to load all emojis from assets and group them by category

This commit is contained in:
k1ngsterr1
2026-01-12 02:59:16 +05:00
parent 7bac22850e
commit ec299bb415
3 changed files with 194 additions and 183 deletions

View File

@@ -9,7 +9,6 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.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 com.rosetta.messenger.ui.onboarding.PrimaryBlue
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.content.Context import android.content.Context
import android.graphics.Rect
import android.view.ViewTreeObserver
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.LocalDensity
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -176,10 +172,11 @@ fun ChatDetailScreen(
) { ) {
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.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 textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) 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)
// <20> Fade-in анимация для всего экрана // <20> Fade-in анимация для всего экрана
var isVisible by remember { mutableStateOf(false) } var isVisible by remember { mutableStateOf(false) }
@@ -288,40 +285,13 @@ fun ChatDetailScreen(
// 🔥 Обработка системной кнопки назад // 🔥 Обработка системной кнопки назад
BackHandler { hideKeyboardAndBack() } BackHandler { hideKeyboardAndBack() }
// 🔥 Ручное отслеживание высоты клавиатуры // 🔥 Cleanup при выходе из экрана
val view = LocalView.current DisposableEffect(Unit) {
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)
onDispose { onDispose {
view.viewTreeObserver.removeOnGlobalLayoutListener(listener)
focusManager.clearFocus() focusManager.clearFocus()
keyboardController?.hide() keyboardController?.hide()
} }
} }
// Автоматический скролл вниз при появлении клавиатуры
LaunchedEffect(keyboardHeight) {
if (keyboardHeight > 0.dp && messages.isNotEmpty()) {
delay(50)
listState.animateScrollToItem(0)
}
}
// Инициализируем ViewModel с ключами и открываем диалог // Инициализируем ViewModel с ключами и открываем диалог
LaunchedEffect(user.publicKey) { LaunchedEffect(user.publicKey) {
@@ -354,7 +324,10 @@ fun ChatDetailScreen(
) )
// 🚀 Весь контент с fade-in анимацией // 🚀 Весь контент с fade-in анимацией
Box(modifier = Modifier.fillMaxSize().graphicsLayer { alpha = screenAlpha }) { Box(modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = screenAlpha }
) {
// Telegram-style solid header background (без blur) // Telegram-style solid header background (без blur)
val headerBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) val headerBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF)
@@ -577,23 +550,17 @@ fun ChatDetailScreen(
) )
} }
}, },
containerColor = Color.Transparent containerColor = backgroundColor // Фон всего чата
) { paddingValues -> ) { paddingValues ->
// 🔥 Box с фиксированным Input внизу и сообщениями выше // 🔥 Простой Column - сообщения сверху, инпут снизу
Box( // Клавиатура сама поднимает контент через adjustResize
Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
) { ) {
// Список сообщений - занимает всё пространство, с padding снизу для input и клавиатуры // Список сообщений - занимает всё доступное пространство
val inputBarHeight = 60.dp Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
val bottomPadding = inputBarHeight + keyboardHeight
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = bottomPadding)
) {
if (messages.isEmpty()) { if (messages.isEmpty()) {
// Пустое состояние // Пустое состояние
Column( 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 } // Закрытие Box с fade-in
@@ -1148,10 +1108,7 @@ private fun MessageInputBar(
} }
Column( Column(
modifier = modifier = Modifier.fillMaxWidth()
Modifier.fillMaxWidth()
.background(Color.Transparent)
// imePadding уже на родительском Column
) { ) {
// 🔥 TELEGRAM-STYLE FLOATING LIQUID GLASS INPUT // 🔥 TELEGRAM-STYLE FLOATING LIQUID GLASS INPUT
Row( Row(
@@ -1344,9 +1301,5 @@ private fun MessageInputBar(
onClose = { showEmojiPicker = false } onClose = { showEmojiPicker = false }
) )
} }
if (!showEmojiPicker) {
Spacer(modifier = Modifier.navigationBarsPadding())
}
} }
} }

View File

@@ -87,6 +87,7 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
init { init {
// Настраиваем EditText // Настраиваем EditText
background = null background = null
setBackgroundColor(android.graphics.Color.TRANSPARENT)
setPadding(0, 0, 0, 0) setPadding(0, 0, 0, 0)
gravity = Gravity.CENTER_VERTICAL or Gravity.START gravity = Gravity.CENTER_VERTICAL or Gravity.START
isSingleLine = false isSingleLine = false

View File

@@ -34,105 +34,150 @@ import com.rosetta.messenger.ui.onboarding.PrimaryBlue
/** /**
* Apple Emoji Picker с PNG изображениями * Apple Emoji Picker с PNG изображениями
* Категории и эмодзи как в React Native версии * Загружает ВСЕ эмодзи из assets/emoji директории
*/ */
// Категории эмодзи // Категории эмодзи
data class EmojiCategory( data class EmojiCategory(
val key: String, val key: String,
val label: String // unified код для иконки категории val label: String, // unified код для иконки категории
val ranges: List<Pair<Int, Int>> // Unicode диапазоны для этой категории
) )
// Данные одного эмодзи // Стандартный порядок категорий как в iOS/Android/WhatsApp
data class EmojiItem(
val unified: String,
val shortName: String,
val category: String
)
// Категории как в RN
val EMOJI_CATEGORIES = listOf( val EMOJI_CATEGORIES = listOf(
EmojiCategory("Smileys & Emotion", "1f600"), // 😀 // 😀 Smileys & Emotion: лица, эмоции, руки
EmojiCategory("People & Body", "1f44b"), // 👋 EmojiCategory("Smileys", "1f600", listOf(
EmojiCategory("Animals & Nature", "1f431"), // 🐱 0x1F600 to 0x1F64F, // Emoticons
EmojiCategory("Food & Drink", "1f34e"), // 🍎 0x1F910 to 0x1F92F, // Face with...
EmojiCategory("Travel & Places", "2708-fe0f"), // ✈️ 0x1F970 to 0x1F9FF, // More faces
EmojiCategory("Activities", "26bd"), // 0x263A to 0x263A, //
EmojiCategory("Objects", "1f4a1"), // 💡 0x2639 to 0x2639 //
EmojiCategory("Symbols", "2764-fe0f"), // ❤️ )),
EmojiCategory("Flags", "1f3f3-fe0f") // 🏳️ // 👋 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( * Проверяет, попадает ли emoji в диапазон категории
"Smileys & Emotion" to listOf( */
"1f600", "1f603", "1f604", "1f601", "1f606", "1f605", "1f923", "1f602", fun emojiMatchesCategory(emoji: String, category: EmojiCategory): Boolean {
"1f642", "1f643", "1f609", "1f60a", "1f607", "1f970", "1f60d", "1f929", val unified = emoji.lowercase().split("-").firstOrNull() ?: return false
"1f618", "1f617", "263a-fe0f", "1f61a", "1f619", "1f972", "1f60b", "1f61b", val codePoint = try { unified.toInt(16) } catch (e: Exception) { return false }
"1f61c", "1f92a", "1f61d", "1f911", "1f917", "1f92d", "1f92b", "1f914", return category.ranges.any { (start, end) -> codePoint in start..end }
"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" * Загружает все эмодзи из assets/emoji
), */
"People & Body" to listOf( fun loadAllEmojisFromAssets(context: Context): List<String> {
"1f44b", "1f91a", "1f590-fe0f", "270b", "1f596", "1f44c", "1f90c", "1f90f", return try {
"270c-fe0f", "1f91e", "1f91f", "1f918", "1f919", "1f448", "1f449", "1f446", context.assets.list("emoji")
"1f595", "1f447", "261d-fe0f", "1f44d", "1f44e", "270a", "1f44a", "1f91b", ?.filter { it.endsWith(".png") }
"1f91c", "1f44f", "1f64c", "1f450", "1f932", "1f91d", "1f64f", "270d-fe0f", ?.map { it.removeSuffix(".png") }
"1f485", "1f933", "1f4aa", "1f9be", "1f9bf", "1f9b5", "1f9b6", "1f442", ?.sorted()
"1f9bb", "1f443", "1f9e0", "1fac0", "1fac1", "1f9b7", "1f9b4", "1f440" ?: emptyList()
), } catch (e: Exception) {
"Animals & Nature" to listOf( emptyList()
"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" * Группирует эмодзи по категориям на основе Unicode диапазонов
), */
"Food & Drink" to listOf( fun groupEmojisByCategory(allEmojis: List<String>): Map<String, List<String>> {
"1f347", "1f348", "1f349", "1f34a", "1f34b", "1f34c", "1f34d", "1f96d", val result = mutableMapOf<String, MutableList<String>>()
"1f34e", "1f34f", "1f350", "1f351", "1f352", "1f353", "1fad0", "1f95d", val usedEmojis = mutableSetOf<String>()
"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" EMOJI_CATEGORIES.forEach { category ->
), result[category.key] = mutableListOf()
"Travel & Places" to listOf( }
"1f697", "1f695", "1f699", "1f68c", "1f68e", "1f3ce-fe0f", "1f693", "1f691",
"1f692", "1f690", "1f69b", "1f69c", "1f6f5", "1f3cd-fe0f", "1f6b2", "1f6f4", // Группируем по Unicode диапазонам
"1f6f9", "1f6fc", "1f68f", "1f6e3-fe0f", "1f6e4-fe0f", "26fd", "1f6a8", "1f6a5", for (emoji in allEmojis) {
"1f6a6", "1f6d1", "1f6a7", "2708-fe0f", "1f6eb", "1f6ec", "1f6e9-fe0f", "1f4ba", for (category in EMOJI_CATEGORIES) {
"1f681", "1f69f", "1f6a0", "1f6a1", "1f6f0-fe0f", "1f680", "1f6f8", "1f6f6" if (emojiMatchesCategory(emoji, category) && emoji !in usedEmojis) {
), result[category.key]?.add(emoji)
"Activities" to listOf( usedEmojis.add(emoji)
"26bd", "1f3c0", "1f3c8", "26be", "1f94e", "1f3be", "1f3d0", "1f3c9", break
"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"
), // Добавляем неклассифицированные в Symbols
"Objects" to listOf( for (emoji in allEmojis) {
"1f4a1", "1f526", "1f3ee", "1fa94", "1f4d4", "1f4d5", "1f4d6", "1f4d7", if (emoji !in usedEmojis) {
"1f4d8", "1f4d9", "1f4da", "1f4d3", "1f4d2", "1f4c3", "1f4dc", "1f4c4", result["Symbols"]?.add(emoji)
"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"
), return result
"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"
)
)
/** /**
* Конвертирует unified код в emoji символ * Конвертирует unified код в emoji символ
@@ -271,6 +316,7 @@ fun CategoryButton(
/** /**
* Apple Emoji Picker Panel - Liquid Glass стиль * Apple Emoji Picker Panel - Liquid Glass стиль
* Загружает ВСЕ эмодзи из assets
*/ */
@Composable @Composable
fun AppleEmojiPickerPanel( fun AppleEmojiPickerPanel(
@@ -279,12 +325,23 @@ fun AppleEmojiPickerPanel(
onClose: () -> Unit, onClose: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current
var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) } var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) }
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
// Мемоизируем текущие эмодзи // Загружаем ВСЕ эмодзи из assets один раз
val currentEmojis = remember(selectedCategory.key) { val allEmojis = remember {
EMOJIS_BY_CATEGORY[selectedCategory.key] ?: emptyList() loadAllEmojisFromAssets(context)
}
// Группируем по категориям
val emojisByCategory = remember(allEmojis) {
groupEmojisByCategory(allEmojis)
}
// Текущие эмодзи для выбранной категории
val currentEmojis = remember(selectedCategory.key, emojisByCategory) {
emojisByCategory[selectedCategory.key] ?: emptyList()
} }
// Сбрасываем скролл при смене категории // Сбрасываем скролл при смене категории