From 7bac22850e8db73253b08c973d8dd1b518daacd7 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Mon, 12 Jan 2026 02:09:56 +0500 Subject: [PATCH] feat: Optimize AppleEmojiPicker with caching and enhance UI with Liquid Glass style --- .../messenger/ui/chats/ChatDetailScreen.kt | 115 +++++++++---- .../ui/components/AppleEmojiPicker.kt | 161 +++++++++++++++--- 2 files changed, 214 insertions(+), 62 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 7ccfbf2..16c82ec 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 @@ -56,8 +56,11 @@ 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 @@ -284,14 +287,41 @@ fun ChatDetailScreen( // πŸ”₯ ΠžΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° систСмной ΠΊΠ½ΠΎΠΏΠΊΠΈ Π½Π°Π·Π°Π΄ BackHandler { hideKeyboardAndBack() } - - // πŸ”₯ Cleanup ΠΏΡ€ΠΈ Π²Ρ‹Ρ…ΠΎΠ΄Π΅ ΠΈΠ· экрана - DisposableEffect(Unit) { + + // πŸ”₯ Π ΡƒΡ‡Π½ΠΎΠ΅ отслСТиваниС высоты ΠΊΠ»Π°Π²ΠΈΠ°Ρ‚ΡƒΡ€Ρ‹ + 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) + 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) { @@ -549,15 +579,21 @@ fun ChatDetailScreen( }, containerColor = Color.Transparent ) { paddingValues -> - // πŸ”₯ Column с imePadding - вСсь ΠΊΠΎΠ½Ρ‚Π΅Π½Ρ‚ поднимаСтся с ΠΊΠ»Π°Π²ΠΈΠ°Ρ‚ΡƒΡ€ΠΎΠΉ - Column( + // πŸ”₯ Box с фиксированным Input Π²Π½ΠΈΠ·Ρƒ ΠΈ сообщСниями Π²Ρ‹ΡˆΠ΅ + Box( modifier = Modifier .fillMaxSize() .padding(paddingValues) - .imePadding() // ΠšΠΎΠ½Ρ‚Π΅Π½Ρ‚ поднимаСтся Π½Π°Π΄ ΠΊΠ»Π°Π²ΠΈΠ°Ρ‚ΡƒΡ€ΠΎΠΉ ) { - // Бписок сообщСний - Π·Π°Π½ΠΈΠΌΠ°Π΅Ρ‚ всё доступноС пространство - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + // Бписок сообщСний - Π·Π°Π½ΠΈΠΌΠ°Π΅Ρ‚ всё пространство, с padding снизу для input ΠΈ ΠΊΠ»Π°Π²ΠΈΠ°Ρ‚ΡƒΡ€Ρ‹ + val inputBarHeight = 60.dp + val bottomPadding = inputBarHeight + keyboardHeight + + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = bottomPadding) + ) { if (messages.isEmpty()) { // ΠŸΡƒΡΡ‚ΠΎΠ΅ состояниС Column( @@ -723,35 +759,42 @@ fun ChatDetailScreen( ) } } - } + } // Π—Π°ΠΊΡ€Ρ‹Ρ‚ΠΈΠ΅ Π²Π½ΡƒΡ‚Ρ€Π΅Π½Π½Π΅Π³ΠΎ Box для сообщСний - // πŸ”₯ 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 - ) + // πŸ”₯ 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 + ) + } } } } // Π—Π°ΠΊΡ€Ρ‹Ρ‚ΠΈΠ΅ Box с fade-in 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 7e283fb..a556adc 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 @@ -3,6 +3,7 @@ package com.rosetta.messenger.ui.components import android.content.Context 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 @@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.* 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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* @@ -18,12 +20,15 @@ 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.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage +import coil.request.CachePolicy import coil.request.ImageRequest import com.rosetta.messenger.ui.onboarding.PrimaryBlue @@ -147,6 +152,7 @@ fun unifiedToEmoji(unified: String): String { /** * Кнопка эмодзи с PNG ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ΠΌ ΠΈ Π°Π½ΠΈΠΌΠ°Ρ†ΠΈΠ΅ΠΉ наТатия + * ΠžΠΏΡ‚ΠΈΠΌΠΈΠ·ΠΈΡ€ΠΎΠ²Π°Π½Π° с ΠΊΡΡˆΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ΠΌ */ @Composable fun EmojiButton( @@ -167,6 +173,19 @@ 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) // Π—Π°Π΄Π°Ρ‘ΠΌ Ρ€Π°Π·ΠΌΠ΅Ρ€ для ΠΎΠΏΡ‚ΠΈΠΌΠΈΠ·Π°Ρ†ΠΈΠΈ памяти + .memoryCachePolicy(CachePolicy.ENABLED) + .diskCachePolicy(CachePolicy.ENABLED) + .memoryCacheKey("emoji_${unified}") + .diskCacheKey("emoji_${unified}") + .build() + } + Box( modifier = modifier .aspectRatio(1f) @@ -181,10 +200,7 @@ fun EmojiButton( contentAlignment = Alignment.Center ) { AsyncImage( - model = ImageRequest.Builder(context) - .data("file:///android_asset/emoji/${unified.lowercase()}.png") - .crossfade(false) - .build(), + model = imageRequest, contentDescription = null, modifier = Modifier.size(32.dp), contentScale = ContentScale.Fit @@ -200,6 +216,7 @@ fun CategoryButton( category: EmojiCategory, isSelected: Boolean, onClick: () -> Unit, + isDarkTheme: Boolean = true, modifier: Modifier = Modifier ) { val context = LocalContext.current @@ -215,13 +232,26 @@ 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() + } + Box( modifier = modifier .size(40.dp) .scale(scale) .clip(CircleShape) .background( - if (isSelected) PrimaryBlue.copy(alpha = 0.2f) else Color.Transparent + if (isSelected) PrimaryBlue.copy(alpha = 0.25f) else Color.Transparent ) .clickable( interactionSource = interactionSource, @@ -231,10 +261,7 @@ fun CategoryButton( contentAlignment = Alignment.Center ) { AsyncImage( - model = ImageRequest.Builder(context) - .data("file:///android_asset/emoji/${category.label.lowercase()}.png") - .crossfade(false) - .build(), + model = imageRequest, contentDescription = category.key, modifier = Modifier.size(24.dp), contentScale = ContentScale.Fit @@ -243,7 +270,7 @@ fun CategoryButton( } /** - * Apple Emoji Picker Panel + * Apple Emoji Picker Panel - Liquid Glass ΡΡ‚ΠΈΠ»ΡŒ */ @Composable fun AppleEmojiPickerPanel( @@ -253,25 +280,102 @@ fun AppleEmojiPickerPanel( modifier: Modifier = Modifier ) { var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) } + val gridState = rememberLazyGridState() - val panelBackground = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF8F8FA) - val categoryBackground = if (isDarkTheme) Color(0xFF2A2A2C) else Color(0xFFFFFFFF) - val dividerColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE5E5EA) + // ΠœΠ΅ΠΌΠΎΠΈΠ·ΠΈΡ€ΡƒΠ΅ΠΌ Ρ‚Π΅ΠΊΡƒΡ‰ΠΈΠ΅ эмодзи + val currentEmojis = remember(selectedCategory.key) { + EMOJIS_BY_CATEGORY[selectedCategory.key] ?: emptyList() + } - val currentEmojis = EMOJIS_BY_CATEGORY[selectedCategory.key] ?: emptyList() + // БбрасываСм скролл ΠΏΡ€ΠΈ смСнС ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ + LaunchedEffect(selectedCategory) { + 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) Column( modifier = modifier .fillMaxWidth() .height(300.dp) - .background(panelBackground) + .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) + ) ) { - // ΠšΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ свСрху + // Π ΡƒΡ‡ΠΊΠ° для свайпа (ΠΊΠ°ΠΊ Π² iOS) + Box( + 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() - .background(categoryBackground) - .padding(horizontal = 8.dp, vertical = 6.dp), + .padding(horizontal = 12.dp) + .clip(RoundedCornerShape(16.dp)) + .background(categoryBarBackground) + .padding(horizontal = 4.dp, vertical = 6.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { @@ -279,27 +383,29 @@ fun AppleEmojiPickerPanel( CategoryButton( category = category, isSelected = selectedCategory == category, - onClick = { selectedCategory = category } + onClick = { selectedCategory = category }, + isDarkTheme = isDarkTheme ) } } - Divider( - color = dividerColor, - thickness = 0.5.dp - ) + Spacer(modifier = Modifier.height(8.dp)) - // Π‘Π΅Ρ‚ΠΊΠ° эмодзи + // Π‘Π΅Ρ‚ΠΊΠ° эмодзи с ΠΎΠΏΡ‚ΠΈΠΌΠΈΠ·ΠΈΡ€ΠΎΠ²Π°Π½Π½ΠΎΠΉ Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠΎΠΉ LazyVerticalGrid( + state = gridState, columns = GridCells.Fixed(9), modifier = Modifier .fillMaxWidth() .weight(1f) - .padding(horizontal = 8.dp, vertical = 8.dp), + .padding(horizontal = 8.dp), horizontalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp) ) { - items(currentEmojis) { unified -> + items( + items = currentEmojis, + key = { it } // Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ unified ΠΊΠ°ΠΊ ΠΊΠ»ΡŽΡ‡ для ΠΎΠΏΡ‚ΠΈΠΌΠΈΠ·Π°Ρ†ΠΈΠΈ + ) { unified -> EmojiButton( unified = unified, onClick = { emoji -> @@ -308,5 +414,8 @@ fun AppleEmojiPickerPanel( ) } } + + // ΠžΡ‚ΡΡ‚ΡƒΠΏ снизу для navigation bar + Spacer(modifier = Modifier.height(8.dp)) } }