feat: Refactor MessageInputBar for improved emoji picker and input field design

This commit is contained in:
k1ngsterr1
2026-01-12 22:41:00 +05:00
parent dda07d80af
commit b8a2334042

View File

@@ -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<Pair<Int, Int>> // Unicode диапазоны для этой категории
val label: String,
val icon: ImageVector,
val ranges: List<Pair<Int, Int>>
)
// Стандартный порядок категорий как в 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<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>()
object EmojiCache {
private var allEmojis: List<String>? = null
private var emojisByCategory: Map<String, List<String>>? = 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<String> {
return if (categoryKey == "All") {
allEmojis ?: emptyList()
} else {
emojisByCategory?.get(categoryKey) ?: emptyList()
}
}
return result
private fun groupEmojis(allEmojis: List<String>): Map<String, List<String>> {
val result = mutableMapOf<String, MutableList<String>>()
val usedEmojis = mutableSetOf<String>()
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))
}
}