From b8a23340424848ff62d52048fed56035df7e15db Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Mon, 12 Jan 2026 22:41:00 +0500 Subject: [PATCH] feat: Refactor MessageInputBar for improved emoji picker and input field design --- .../ui/components/AppleEmojiPicker.kt | 488 +++++++++--------- 1 file changed, 234 insertions(+), 254 deletions(-) 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 d486336..5840c71 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 @@ -1,28 +1,31 @@ package com.rosetta.messenger.ui.components import android.content.Context +import android.util.Log import androidx.compose.animation.core.* import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -31,152 +34,183 @@ import coil.compose.AsyncImage import coil.request.CachePolicy import coil.request.ImageRequest import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** - * Apple Emoji Picker с PNG изображениями - * Загружает ВСЕ эмодзи из assets/emoji директории + * 🍎 Apple Emoji Picker с PNG изображениями + * - Категория "Все" первая + * - Apple Style эмодзи из assets/emoji + * - Фоновая загрузка для оптимизации */ -// Категории эмодзи +// Категории эмодзи с Material иконками data class EmojiCategory( val key: String, - val label: String, // unified код для иконки категории - val ranges: List> // Unicode диапазоны для этой категории + val label: String, + val icon: ImageVector, + val ranges: List> ) -// Стандартный порядок категорий как в iOS/Android/WhatsApp +// Порядок категорий: "All" первая, затем стандартные val EMOJI_CATEGORIES = listOf( - // 😀 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 // ☹ + // 🔥 ALL - все эмодзи (первая категория) + EmojiCategory("All", "Все", Icons.Default.Apps, emptyList()), + // 😀 Smileys & Emotion + EmojiCategory("Smileys", "Смайлы", Icons.Default.SentimentSatisfied, listOf( + 0x1F600 to 0x1F64F, + 0x1F910 to 0x1F92F, + 0x1F970 to 0x1F9FF, + 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 + // 👋 People & Body + EmojiCategory("People", "Люди", Icons.Default.Person, listOf( + 0x1F466 to 0x1F4FF, + 0x1F9D0 to 0x1F9DF, + 0x270A to 0x270D, + 0x261D to 0x261D, + 0x1F440 to 0x1F465 )), - // 🐱 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 // ☘ + // 🐱 Animals & Nature + EmojiCategory("Animals", "Животные", Icons.Default.Pets, listOf( + 0x1F400 to 0x1F43F, + 0x1F980 to 0x1F9AE, + 0x1F330 to 0x1F335, + 0x1F337 to 0x1F34F, + 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 // ☕ + // 🍎 Food & Drink + EmojiCategory("Food", "Еда", Icons.Default.Restaurant, listOf( + 0x1F345 to 0x1F37F, + 0x1F950 to 0x1F96F, + 0x1F9C0 to 0x1F9CB, + 0x1FAD0 to 0x1FAD9, + 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 + // ✈️ Travel & Places + EmojiCategory("Travel", "Места", Icons.Default.Flight, listOf( + 0x1F680 to 0x1F6FF, + 0x1F3D4 to 0x1F3DF, + 0x1F3E0 to 0x1F3F0, + 0x2708 to 0x2708, + 0x26F0 to 0x26FF )), - // ⚽ 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 + // ⚽ Activities + EmojiCategory("Activities", "Спорт", Icons.Default.SportsSoccer, listOf( + 0x1F3A0 to 0x1F3CA, + 0x1F3CB to 0x1F3D3, + 0x1F93C to 0x1F94F, + 0x26BD to 0x26BE, + 0x265F to 0x2660, + 0x1F9E0 to 0x1F9FF )), - // 💡 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 // ⌨ + // 💡 Objects + EmojiCategory("Objects", "Объекты", Icons.Default.Lightbulb, listOf( + 0x1F4A1 to 0x1F4FF, + 0x1F500 to 0x1F5FF, + 0x1F6E0 to 0x1F6EF, + 0x1FA70 to 0x1FAFF, + 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 + // ❤️ Symbols + EmojiCategory("Symbols", "Символы", Icons.Default.Favorite, listOf( + 0x2764 to 0x2764, + 0x1F490 to 0x1F49F, + 0x2600 to 0x26FF, + 0x2700 to 0x27BF, + 0x1F170 to 0x1F1FF, + 0x00A9 to 0x00AE, + 0x203C to 0x3299 )), - // 🏳️ Flags: флаги стран - EmojiCategory("Flags", "1f3f3-fe0f", listOf( - 0x1F1E0 to 0x1F1FF, // Regional indicators (flags) - 0x1F3F3 to 0x1F3F4, // 🏳🏴 - 0x1F3C1 to 0x1F3C1, // 🏁 - 0x1F6A9 to 0x1F6A9 // 🚩 + // 🏳️ Flags + EmojiCategory("Flags", "Флаги", Icons.Default.Flag, listOf( + 0x1F1E0 to 0x1F1FF, + 0x1F3F3 to 0x1F3F4, + 0x1F3C1 to 0x1F3C1, + 0x1F6A9 to 0x1F6A9 )) ) /** * Проверяет, попадает ли emoji в диапазон категории */ -fun emojiMatchesCategory(emoji: String, category: EmojiCategory): Boolean { +private 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() +object EmojiCache { + private var allEmojis: List? = null + private var emojisByCategory: Map>? = null + var isLoaded by mutableStateOf(false) + private set - // Инициализируем категории - 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 + suspend fun loadEmojis(context: Context) { + if (allEmojis != null) { + isLoaded = true + return + } + + withContext(Dispatchers.IO) { + try { + val emojis = context.assets.list("emoji") + ?.filter { it.endsWith(".png") } + ?.map { it.removeSuffix(".png") } + ?.sorted() + ?: emptyList() + + allEmojis = emojis + emojisByCategory = groupEmojis(emojis) + Log.d("EmojiCache", "Loaded ${emojis.size} emojis") + } catch (e: Exception) { + Log.e("EmojiCache", "Error loading emojis", e) + allEmojis = emptyList() + emojisByCategory = emptyMap() } } + isLoaded = true } - // Добавляем неклассифицированные в Symbols - for (emoji in allEmojis) { - if (emoji !in usedEmojis) { - result["Symbols"]?.add(emoji) + fun getEmojisForCategory(categoryKey: String): List { + return if (categoryKey == "All") { + allEmojis ?: emptyList() + } else { + emojisByCategory?.get(categoryKey) ?: emptyList() } } - return result + private fun groupEmojis(allEmojis: List): Map> { + val result = mutableMapOf>() + val usedEmojis = mutableSetOf() + + EMOJI_CATEGORIES.filter { it.key != "All" }.forEach { category -> + result[category.key] = mutableListOf() + } + + for (emoji in allEmojis) { + for (category in EMOJI_CATEGORIES) { + if (category.key == "All") continue + if (emojiMatchesCategory(emoji, category) && emoji !in usedEmojis) { + result[category.key]?.add(emoji) + usedEmojis.add(emoji) + break + } + } + } + + for (emoji in allEmojis) { + if (emoji !in usedEmojis) { + result["Symbols"]?.add(emoji) + } + } + + return result + } } /** @@ -196,8 +230,7 @@ fun unifiedToEmoji(unified: String): String { } /** - * Кнопка эмодзи с PNG изображением и анимацией нажатия - * Оптимизирована с кэшированием + * Кнопка эмодзи с PNG изображением */ @Composable fun EmojiButton( @@ -218,16 +251,15 @@ fun EmojiButton( label = "emojiScale" ) - // Мемоизируем ImageRequest для оптимизации - используем assetFile fetcher val imageRequest = remember(unified, context) { ImageRequest.Builder(context) .data("file:///android_asset/emoji/${unified.lowercase()}.png") .crossfade(false) - .size(64) // Задаём размер для оптимизации памяти + .size(64) .memoryCachePolicy(CachePolicy.ENABLED) .diskCachePolicy(CachePolicy.ENABLED) - .memoryCacheKey("emoji_${unified}") - .diskCacheKey("emoji_${unified}") + .memoryCacheKey("emoji_$unified") + .diskCacheKey("emoji_$unified") .build() } @@ -247,14 +279,14 @@ fun EmojiButton( AsyncImage( model = imageRequest, contentDescription = null, - modifier = Modifier.size(32.dp), + modifier = Modifier.size(28.dp), contentScale = ContentScale.Fit ) } } /** - * Кнопка категории с PNG изображением + * Кнопка категории с Material иконкой */ @Composable fun CategoryButton( @@ -264,7 +296,6 @@ fun CategoryButton( isDarkTheme: Boolean = true, modifier: Modifier = Modifier ) { - val context = LocalContext.current val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() @@ -277,27 +308,17 @@ fun CategoryButton( label = "categoryScale" ) - // Мемоизируем ImageRequest с размером для оптимизации - val imageRequest = remember(category.label, context) { - ImageRequest.Builder(context) - .data("file:///android_asset/emoji/${category.label.lowercase()}.png") - .crossfade(false) - .size(48) - .memoryCachePolicy(CachePolicy.ENABLED) - .diskCachePolicy(CachePolicy.ENABLED) - .memoryCacheKey("category_${category.label}") - .diskCacheKey("category_${category.label}") - .build() - } + 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) + else Color.Black.copy(alpha = 0.5f) Box( modifier = modifier .size(40.dp) .scale(scale) .clip(CircleShape) - .background( - if (isSelected) PrimaryBlue.copy(alpha = 0.25f) else Color.Transparent - ) + .background(backgroundColor) .clickable( interactionSource = interactionSource, indication = null, @@ -305,18 +326,17 @@ fun CategoryButton( ), contentAlignment = Alignment.Center ) { - AsyncImage( - model = imageRequest, - contentDescription = category.key, - modifier = Modifier.size(24.dp), - contentScale = ContentScale.Fit + Icon( + imageVector = category.icon, + contentDescription = category.label, + tint = iconTint, + modifier = Modifier.size(22.dp) ) } } /** - * Apple Emoji Picker Panel - Liquid Glass стиль - * Загружает ВСЕ эмодзи из assets + * Apple Emoji Picker Panel */ @Composable fun AppleEmojiPickerPanel( @@ -326,22 +346,21 @@ fun AppleEmojiPickerPanel( modifier: Modifier = Modifier ) { val context = LocalContext.current - var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) } + var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) } // "All" по умолчанию val gridState = rememberLazyGridState() - // Загружаем ВСЕ эмодзи из assets один раз - val allEmojis = remember { - loadAllEmojisFromAssets(context) - } - - // Группируем по категориям - val emojisByCategory = remember(allEmojis) { - groupEmojisByCategory(allEmojis) + // Загружаем эмодзи в фоне + LaunchedEffect(Unit) { + EmojiCache.loadEmojis(context) } // Текущие эмодзи для выбранной категории - val currentEmojis = remember(selectedCategory.key, emojisByCategory) { - emojisByCategory[selectedCategory.key] ?: emptyList() + val currentEmojis = remember(selectedCategory.key, EmojiCache.isLoaded) { + if (EmojiCache.isLoaded) { + EmojiCache.getEmojisForCategory(selectedCategory.key) + } else { + emptyList() + } } // Сбрасываем скролл при смене категории @@ -349,94 +368,25 @@ fun AppleEmojiPickerPanel( gridState.scrollToItem(0) } - // Liquid Glass цвета - val glassBackground = if (isDarkTheme) { - Brush.verticalGradient( - colors = listOf( - Color(0xFF2D2D2F).copy(alpha = 0.95f), - Color(0xFF1C1C1E).copy(alpha = 0.98f) - ) - ) - } else { - Brush.verticalGradient( - colors = listOf( - Color(0xFFF2F2F7).copy(alpha = 0.96f), - Color(0xFFE5E5EA).copy(alpha = 0.98f) - ) - ) - } - - val glassBorder = if (isDarkTheme) { - Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = 0.15f), - Color.White.copy(alpha = 0.05f) - ) - ) - } else { - Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = 0.9f), - Color.Black.copy(alpha = 0.05f) - ) - ) - } - - val categoryBarBackground = if (isDarkTheme) Color(0xFF1A1A1C).copy(alpha = 0.9f) - else Color.White.copy(alpha = 0.85f) - val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.08f) - else Color.Black.copy(alpha = 0.06f) + val panelBackground = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7) + val categoryBarBackground = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White + val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) Column( modifier = modifier .fillMaxWidth() - .height(300.dp) - .shadow( - elevation = 8.dp, - shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), - clip = false, - ambientColor = Color.Black.copy(alpha = 0.3f), - spotColor = Color.Black.copy(alpha = 0.3f) - ) - .clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)) - .background(glassBackground) - .border( - width = 1.dp, - brush = glassBorder, - shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp) - ) + .background(panelBackground) ) { - // Ручка для свайпа (как в iOS) - Box( + // Категории - горизонтальный скролл + LazyRow( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .width(36.dp) - .height(4.dp) - .clip(RoundedCornerShape(2.dp)) - .background( - if (isDarkTheme) Color.White.copy(alpha = 0.3f) - else Color.Black.copy(alpha = 0.2f) - ) - ) - } - - // Категории сверху - тоже в glass стиле - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp) - .clip(RoundedCornerShape(16.dp)) .background(categoryBarBackground) - .padding(horizontal = 4.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.SpaceEvenly, + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { - EMOJI_CATEGORIES.forEach { category -> + items(EMOJI_CATEGORIES) { category -> CategoryButton( category = category, isSelected = selectedCategory == category, @@ -446,33 +396,63 @@ fun AppleEmojiPickerPanel( } } - Spacer(modifier = Modifier.height(8.dp)) - - // Сетка эмодзи с оптимизированной загрузкой - LazyVerticalGrid( - state = gridState, - columns = GridCells.Fixed(9), + // Разделитель + Box( modifier = Modifier .fillMaxWidth() - .weight(1f) - .padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - items( - items = currentEmojis, - key = { it } // Используем unified как ключ для оптимизации - ) { unified -> - EmojiButton( - unified = unified, - onClick = { emoji -> - onEmojiSelected(emoji) - } + .height(0.5.dp) + .background(dividerColor) + ) + + // Сетка эмодзи + if (!EmojiCache.isLoaded) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = PrimaryBlue, + strokeWidth = 2.dp ) } + } else if (currentEmojis.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Text( + text = "Нет эмодзи", + color = if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Black.copy(alpha = 0.4f), + fontSize = 14.sp + ) + } + } else { + LazyVerticalGrid( + state = gridState, + columns = GridCells.Fixed(8), + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + items( + items = currentEmojis, + key = { it } + ) { unified -> + EmojiButton( + unified = unified, + onClick = { emoji -> onEmojiSelected(emoji) } + ) + } + } } - - // Отступ снизу для navigation bar - Spacer(modifier = Modifier.height(8.dp)) } }