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.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)
// <20> 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())
}
}
}

View File

@@ -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

View File

@@ -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<Pair<Int, Int>> // 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<String> {
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<String>): Map<String, List<String>> {
val result = mutableMapOf<String, MutableList<String>>()
val usedEmojis = mutableSetOf<String>()
// Инициализируем категории
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()
}
// Сбрасываем скролл при смене категории