From 3a810d6d617a08e58e92c0a35de89f916b6cacd5 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 6 Feb 2026 02:01:03 +0500 Subject: [PATCH] fix: update message logging format for consistency and clarity --- .../messenger/ui/chats/ChatsListScreen.kt | 63 +++++--- .../messenger/ui/chats/ChatsListViewModel.kt | 26 +++- .../chats/components/AttachmentComponents.kt | 139 ++++++++++++++---- .../chats/components/ChatDetailComponents.kt | 12 +- .../ui/chats/components/ImageEditorScreen.kt | 31 ++-- .../ui/chats/components/ImageViewerScreen.kt | 114 ++++++++++++-- .../components/MediaPickerBottomSheet.kt | 42 +++++- .../ui/components/AppleEmojiEditText.kt | 45 +++++- .../messenger/ui/components/AvatarImage.kt | 9 +- .../rosetta/messenger/utils/MessageLogger.kt | 37 ++--- 10 files changed, 393 insertions(+), 125 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index a691be3..612be13 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -43,6 +43,7 @@ import com.rosetta.messenger.R import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolState +import com.rosetta.messenger.ui.chats.components.AnimatedDotsText import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.BlurredAvatarBackground @@ -351,26 +352,48 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren ) ) Spacer(modifier = Modifier.width(12.dp)) - Text( - text = - when (protocolState) { - ProtocolState - .DISCONNECTED -> - "Disconnected" - ProtocolState.CONNECTING -> - "Connecting..." - ProtocolState.CONNECTED -> - "Connected" - ProtocolState.HANDSHAKING -> - "Authenticating..." - ProtocolState - .AUTHENTICATED -> - "Authenticated" - }, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = textColor - ) + when (protocolState) { + ProtocolState.DISCONNECTED -> { + Text( + text = "Disconnected", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = textColor + ) + } + ProtocolState.CONNECTING -> { + AnimatedDotsText( + baseText = "Connecting", + color = textColor, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + ProtocolState.CONNECTED -> { + Text( + text = "Connected", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = textColor + ) + } + ProtocolState.HANDSHAKING -> { + AnimatedDotsText( + baseText = "Authenticating", + color = textColor, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + ProtocolState.AUTHENTICATED -> { + Text( + text = "Authenticated", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = textColor + ) + } + } } } }, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index 994cd9c..1e9ba44 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -156,8 +156,10 @@ if (currentAccount == publicKey) { val actualFromMe = lastMsgStatus?.fromMe ?: 0 val actualDelivered = if (actualFromMe == 1) (lastMsgStatus?.delivered ?: 0) else 0 val actualRead = if (actualFromMe == 1) (lastMsgStatus?.read ?: 0) else 0 - + // 📎 Определяем тип attachment последнего сообщения + // 🔥 Reply vs Forward: если есть текст - это Reply (показываем текст), + // если текст пустой - это Forward (показываем "Forwarded message") val attachmentType = try { val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey) if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") { @@ -167,7 +169,12 @@ if (currentAccount == publicKey) { val type = firstAttachment.optInt("type", -1) when (type) { 0 -> "Photo" // AttachmentType.IMAGE = 0 - 1 -> "Forwarded" // AttachmentType.MESSAGES = 1 + 1 -> { + // AttachmentType.MESSAGES = 1 (Reply или Forward) + // Reply: есть текст сообщения -> показываем текст (null) + // Forward: текст пустой -> показываем "Forwarded" + if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" + } 2 -> "File" // AttachmentType.FILE = 2 3 -> "Avatar" // AttachmentType.AVATAR = 3 else -> null @@ -177,7 +184,7 @@ if (currentAccount == publicKey) { } catch (e: Exception) { null } - + DialogUiModel( id = dialog.id, account = dialog.account, @@ -242,8 +249,10 @@ if (currentAccount == publicKey) { } catch (e: Exception) { dialog.lastMessage } - + // 📎 Определяем тип attachment последнего сообщения + // 🔥 Reply vs Forward: если есть текст - это Reply (показываем текст), + // если текст пустой - это Forward (показываем "Forwarded message") val attachmentType = try { val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey) if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") { @@ -253,7 +262,12 @@ if (currentAccount == publicKey) { val type = firstAttachment.optInt("type", -1) when (type) { 0 -> "Photo" // AttachmentType.IMAGE = 0 - 1 -> "Forwarded" // AttachmentType.MESSAGES = 1 + 1 -> { + // AttachmentType.MESSAGES = 1 (Reply или Forward) + // Reply: есть текст сообщения -> показываем текст (null) + // Forward: текст пустой -> показываем "Forwarded" + if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" + } 2 -> "File" // AttachmentType.FILE = 2 3 -> "Avatar" // AttachmentType.AVATAR = 3 else -> null @@ -263,7 +277,7 @@ if (currentAccount == publicKey) { } catch (e: Exception) { null } - + DialogUiModel( id = dialog.id, account = dialog.account, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 2e35f5b..6d1b99d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -49,6 +49,7 @@ import com.vanniktech.blurhash.BlurHash import compose.icons.TablerIcons import compose.icons.tablericons.* import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import androidx.compose.ui.platform.LocalConfiguration @@ -56,6 +57,48 @@ import kotlin.math.min private const val TAG = "AttachmentComponents" +/** + * 🔄 Анимированный текст с точками (Downloading... → Downloading. → Downloading.. → Downloading...) + * Как в Telegram - точки плавно появляются и исчезают + */ +@Composable +fun AnimatedDotsText( + baseText: String, + color: Color, + fontSize: androidx.compose.ui.unit.TextUnit = 12.sp, + fontWeight: FontWeight = FontWeight.Normal +) { + var dotCount by remember { mutableIntStateOf(0) } + + // Анимация точек: 0 → 1 → 2 → 3 → 0 → ... + LaunchedEffect(Unit) { + while (true) { + delay(400) // Интервал между изменениями + dotCount = (dotCount + 1) % 4 + } + } + + val dots = ".".repeat(dotCount) + // Добавляем невидимые точки для фиксированной ширины текста + val invisibleDots = ".".repeat(3 - dotCount) + + Row { + Text( + text = "$baseText$dots", + fontSize = fontSize, + fontWeight = fontWeight, + color = color + ) + // Невидимые точки для сохранения ширины + Text( + text = invisibleDots, + fontSize = fontSize, + fontWeight = fontWeight, + color = Color.Transparent + ) + } +} + /** * 🖼️ Глобальный LRU кэш для bitmap'ов изображений * Это предотвращает мигание при перезаходе в диалог, @@ -1315,24 +1358,40 @@ fun FileAttachment( // Размер файла и тип val fileExtension = fileName.substringAfterLast('.', "").uppercase() - Text( - text = - when (downloadStatus) { - DownloadStatus.DOWNLOADING -> "Downloading..." - DownloadStatus.DECRYPTING -> "Decrypting..." - DownloadStatus.ERROR -> "File expired" - else -> "${formatFileSize(fileSize)} $fileExtension" - }, - fontSize = 12.sp, - color = - if (downloadStatus == DownloadStatus.ERROR) { - Color(0xFFE53935) - } else if (isOutgoing) { - Color.White.copy(alpha = 0.7f) - } else { - PrimaryBlue - } - ) + val statusColor = if (downloadStatus == DownloadStatus.ERROR) { + Color(0xFFE53935) + } else if (isOutgoing) { + Color.White.copy(alpha = 0.7f) + } else { + PrimaryBlue + } + + when (downloadStatus) { + DownloadStatus.DOWNLOADING -> { + AnimatedDotsText( + baseText = "Downloading", + color = statusColor, + fontSize = 12.sp + ) + } + DownloadStatus.DECRYPTING -> { + AnimatedDotsText( + baseText = "Decrypting", + color = statusColor, + fontSize = 12.sp + ) + } + else -> { + Text( + text = when (downloadStatus) { + DownloadStatus.ERROR -> "File expired" + else -> "${formatFileSize(fileSize)} $fileExtension" + }, + fontSize = 12.sp, + color = statusColor + ) + } + } } } @@ -1692,20 +1751,36 @@ fun AvatarAttachment( Spacer(modifier = Modifier.height(2.dp)) // Описание статуса - Text( - text = - when (downloadStatus) { - DownloadStatus.DOWNLOADING -> "Downloading..." - DownloadStatus.DECRYPTING -> "Decrypting..." - DownloadStatus.ERROR -> "Tap to retry" - DownloadStatus.DOWNLOADED -> "Shared profile photo" - else -> "Tap to download" - }, - fontSize = 13.sp, - color = - if (isOutgoing) Color.White.copy(alpha = 0.7f) - else (if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray) - ) + val avatarStatusColor = if (isOutgoing) Color.White.copy(alpha = 0.7f) + else (if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray) + + when (downloadStatus) { + DownloadStatus.DOWNLOADING -> { + AnimatedDotsText( + baseText = "Downloading", + color = avatarStatusColor, + fontSize = 13.sp + ) + } + DownloadStatus.DECRYPTING -> { + AnimatedDotsText( + baseText = "Decrypting", + color = avatarStatusColor, + fontSize = 13.sp + ) + } + else -> { + Text( + text = when (downloadStatus) { + DownloadStatus.ERROR -> "Tap to retry" + DownloadStatus.DOWNLOADED -> "Shared profile photo" + else -> "Tap to download" + }, + fontSize = 13.sp, + color = avatarStatusColor + ) + } + } Spacer(modifier = Modifier.height(4.dp)) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 6e00e3a..827314b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -626,7 +626,8 @@ fun MessageBubble( text = message.text, color = textColor, fontSize = 16.sp, - linkColor = linkColor + linkColor = linkColor, + onLongClick = onLongClick // 🔥 Long press для selection ) }, timeContent = { @@ -673,7 +674,8 @@ fun MessageBubble( text = message.text, color = textColor, fontSize = 17.sp, - linkColor = linkColor + linkColor = linkColor, + onLongClick = onLongClick // 🔥 Long press для selection ) }, timeContent = { @@ -701,7 +703,8 @@ fun MessageBubble( ) } } - } + }, + fillWidth = true // 🔥 Время справа с учётом ширины ReplyBubble ) } else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) { // Telegram-style: текст + время с автоматическим переносом @@ -711,7 +714,8 @@ fun MessageBubble( text = message.text, color = textColor, fontSize = 17.sp, - linkColor = linkColor + linkColor = linkColor, + onLongClick = onLongClick // 🔥 Long press для selection ) }, timeContent = { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt index 56593b7..8bbc17f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt @@ -36,6 +36,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap @@ -387,6 +390,13 @@ fun ImageEditorScreen( Box( modifier = Modifier .fillMaxSize() + // 🔥 Блокируем свайпы от SwipeBackContainer - на ImageEditor свайпы не должны работать + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + down.consume() // Поглощаем все touch события + } + } .graphicsLayer { alpha = animationProgress.value } // ⚡ Всё плавно fade .background(Color.Black) ) { @@ -1067,33 +1077,20 @@ private fun TelegramCaptionBar( horizontalArrangement = Arrangement.spacedBy(10.dp) ) { // 🎬 Левая иконка: плавная анимация через alpha - // Камера (compact) ↔ Emoji/Keyboard (expanded) + // Emoji/Keyboard toggle - всегда виден Box( modifier = Modifier.size(32.dp), contentAlignment = Alignment.Center ) { - // Камера - видна когда progress близок к 0 - Icon( - TablerIcons.CameraPlus, - contentDescription = "Camera", - tint = Color.White.copy(alpha = 0.7f * (1f - progress)), - modifier = Modifier - .size(26.dp) - .graphicsLayer { alpha = 1f - progress } - ) - - // Emoji/Keyboard toggle - виден когда progress близок к 1 + // Emoji/Keyboard toggle IconButton( onClick = onToggleEmojiPicker, - modifier = Modifier - .size(32.dp) - .graphicsLayer { alpha = progress }, - enabled = progress > 0.5f // Кликабелен только когда достаточно виден + modifier = Modifier.size(32.dp) ) { Icon( if (showEmojiPicker) TablerIcons.Keyboard else TablerIcons.MoodSmile, contentDescription = if (showEmojiPicker) "Keyboard" else "Emoji", - tint = Color.White.copy(alpha = 0.7f * progress), + tint = Color.White.copy(alpha = 0.7f), modifier = Modifier.size(26.dp) ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt index 2e72645..b8c5e82 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt @@ -69,6 +69,24 @@ private const val TAG = "ImageViewerScreen" */ private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) +/** + * Linear interpolation helper + */ +private fun lerp(start: Float, stop: Float, fraction: Float): Float { + return start + (stop - start) * fraction +} + +/** + * Transform data for shared element animation + */ +private data class ImageTransform( + val scaleX: Float, + val scaleY: Float, + val translationX: Float, + val translationY: Float, + val cornerRadius: Float +) + /** * Данные об источнике изображения для shared element transition */ @@ -196,8 +214,47 @@ fun ImageViewerScreen( var showControls by remember { mutableStateOf(true) } // ═══════════════════════════════════════════════════════════ + // 🎬 SHARED ELEMENT TRANSFORM - вычисляем трансформацию // ═══════════════════════════════════════════════════════════ - // 🎬 ПРОСТОЙ FADE-OUT при свайпе + val imageTransform by remember(sourceBounds, screenSize, animationProgress.value) { + derivedStateOf { + if (sourceBounds != null && screenSize != IntSize.Zero) { + val progress = animationProgress.value + + // Вычисляем scale для перехода от источника к fullscreen + val sourceScaleX = sourceBounds.width / screenSize.width.toFloat() + val sourceScaleY = sourceBounds.height / screenSize.height.toFloat() + + val currentScaleX = lerp(sourceScaleX, 1f, progress) + val currentScaleY = lerp(sourceScaleY, 1f, progress) + + // Вычисляем translation для центрирования + val targetCenterX = screenSize.width / 2f + val targetCenterY = screenSize.height / 2f + val sourceCenterX = sourceBounds.left + sourceBounds.width / 2f + val sourceCenterY = sourceBounds.top + sourceBounds.height / 2f + + val currentTranslationX = lerp(sourceCenterX - targetCenterX, 0f, progress) + val currentTranslationY = lerp(sourceCenterY - targetCenterY, 0f, progress) + + // Скругление углов: от sourceBounds.cornerRadius до 0 + val currentCornerRadius = lerp(sourceBounds.cornerRadius, 0f, progress) + + ImageTransform( + scaleX = currentScaleX, + scaleY = currentScaleY, + translationX = currentTranslationX, + translationY = currentTranslationY, + cornerRadius = currentCornerRadius + ) + } else { + ImageTransform(1f, 1f, 0f, 0f, 0f) + } + } + } + + // ═══════════════════════════════════════════════════════════ + // 🎬 SWIPE TO DISMISS // ═══════════════════════════════════════════════════════════ var dragOffsetY by remember { mutableFloatStateOf(0f) } var isDragging by remember { mutableStateOf(false) } @@ -216,21 +273,35 @@ fun ImageViewerScreen( } } - // 🎬 Простой fade-out dismiss + // 🎬 Shared element dismiss - возврат к исходному положению fun smoothDismiss() { if (isClosing) return isClosing = true onClosingStart() // Сразу сбрасываем status bar scope.launch { - // Плавно исчезаем - более длинная анимация - dismissAlpha.animateTo( - targetValue = 0f, - animationSpec = tween( - durationMillis = 350, - easing = LinearEasing + // Сначала сбрасываем offset от drag + animatedOffsetY.snapTo(0f) + + if (sourceBounds != null && pagerState.currentPage == initialIndex) { + // 🔥 Telegram-style: возвращаемся к исходному положению с shared element + animationProgress.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = 280, + easing = FastOutSlowInEasing + ) ) - ) + } else { + // Fallback: простой fade-out если пролистнули на другое фото + dismissAlpha.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = 250, + easing = LinearEasing + ) + ) + } onDismiss() } } @@ -269,6 +340,11 @@ fun ImageViewerScreen( // ═══════════════════════════════════════════════════════════ // 📸 HORIZONTAL PAGER с Telegram-style эффектом наслаивания // ═══════════════════════════════════════════════════════════ + + // 🔥 Применяем shared element transform только для текущей страницы при анимации + val isAnimating = animationProgress.value < 1f + val shouldApplyTransform = isAnimating && pagerState.currentPage == initialIndex + HorizontalPager( state = pagerState, modifier = Modifier @@ -276,9 +352,25 @@ fun ImageViewerScreen( .graphicsLayer { alpha = dismissAlpha.value translationY = animatedOffsetY.value - }, + + // 🔥 Shared element transform при входе/выходе + if (shouldApplyTransform) { + scaleX = imageTransform.scaleX + scaleY = imageTransform.scaleY + translationX = imageTransform.translationX + translationY = imageTransform.translationY + } + } + .then( + if (shouldApplyTransform && imageTransform.cornerRadius > 0f) { + Modifier.clip(RoundedCornerShape(imageTransform.cornerRadius.dp)) + } else { + Modifier + } + ), pageSpacing = 30.dp, // Telegram: dp(30) между фото - key = { images[it].attachmentId } + key = { images[it].attachmentId }, + userScrollEnabled = !isAnimating // Отключаем скролл во время анимации ) { page -> val image = images[page] diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt index 417e237..47f32c9 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt @@ -59,6 +59,7 @@ import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat import coil.compose.AsyncImage import coil.request.ImageRequest import compose.icons.TablerIcons @@ -364,23 +365,38 @@ fun MediaPickerBottomSheet( } } - // 🎨 Затемнение статус бара когда галерея открыта + // 🎨 Затемнение статус бара и навигейшн бара когда галерея открыта val view = LocalView.current + + // 🔥 Контроллер для управления цветом иконок navigation bar + val insetsController = remember(view) { + val window = (view.context as? android.app.Activity)?.window + window?.let { WindowCompat.getInsetsController(it, view) } + } + DisposableEffect(shouldShow, scrimAlpha) { if (shouldShow && !view.isInEditMode) { val window = (view.context as? android.app.Activity)?.window val originalStatusBarColor = window?.statusBarColor ?: 0 - - // Затемняем статус бар + val originalNavigationBarColor = window?.navigationBarColor ?: 0 + val originalLightNavigationBars = insetsController?.isAppearanceLightNavigationBars ?: true + + // Затемняем статус бар и навигейшн бар val scrimColor = android.graphics.Color.argb( (scrimAlpha * 255).toInt().coerceIn(0, 255), 0, 0, 0 ) window?.statusBarColor = scrimColor - + window?.navigationBarColor = scrimColor + + // 🔥 Иконки навигейшн бара светлые (белые) когда scrim виден + insetsController?.isAppearanceLightNavigationBars = scrimAlpha < 0.15f && originalLightNavigationBars + onDispose { - // Восстанавливаем оригинальный цвет + // Восстанавливаем оригинальные цвета window?.statusBarColor = originalStatusBarColor + window?.navigationBarColor = originalNavigationBarColor + insetsController?.isAppearanceLightNavigationBars = originalLightNavigationBars } } else { onDispose { } @@ -413,6 +429,15 @@ fun MediaPickerBottomSheet( dismissOnClickOutside = false ) ) { + // 🔥 Получаем высоту navigation bar и IME (клавиатуры) + val navigationBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val imeHeight = WindowInsets.ime.asPaddingValues().calculateBottomPadding() + val navigationBarHeightPx = with(density) { navigationBarHeight.toPx().toInt() } + val imeHeightPx = with(density) { imeHeight.toPx().toInt() } + + // 🔥 Если клавиатура открыта - не добавляем navigationBar offset (клавиатура его перекрывает) + val bottomOffset = if (imeHeightPx > 0) 0 else navigationBarHeightPx + // Полноэкранный контейнер с мягким затемнением Box( modifier = Modifier @@ -426,11 +451,12 @@ fun MediaPickerBottomSheet( ) { // Sheet content val currentHeightDp = with(density) { sheetHeightPx.value.toDp() } - val slideOffset = (sheetHeightPx.value * animatedOffset).toInt() - + // 🔥 Добавляем смещение вниз только если клавиатура закрыта + val slideOffset = (sheetHeightPx.value * animatedOffset).toInt() + bottomOffset + // Отслеживаем velocity для плавного snap var lastDragVelocity by remember { mutableFloatStateOf(0f) } - + Column( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt index 5af3d67..5b845f1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt @@ -17,7 +17,9 @@ import android.text.style.ImageSpan import android.util.AttributeSet import android.util.LruCache import android.util.Patterns +import android.view.GestureDetector import android.view.Gravity +import android.view.MotionEvent import android.view.View import android.view.inputmethod.EditorInfo import android.widget.EditText @@ -325,7 +327,8 @@ fun AppleEmojiText( maxLines: Int = Int.MAX_VALUE, overflow: android.text.TextUtils.TruncateAt? = null, linkColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color(0xFF54A9EB), // Telegram-style blue - enableLinks: Boolean = true // 🔥 Включить кликабельные ссылки + enableLinks: Boolean = true, // 🔥 Включить кликабельные ссылки + onLongClick: (() -> Unit)? = null // 🔥 Callback для long press (selection в MessageBubble) ) { val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f else fontSize.value @@ -355,10 +358,13 @@ fun AppleEmojiText( if (overflow != null) { ellipsize = overflow } - // 🔥 Включаем кликабельные ссылки + // 🔥 Включаем кликабельные ссылки с поддержкой long press if (enableLinks) { setLinkColor(linkColor.toArgb()) - enableClickableLinks(true) + enableClickableLinks(true, onLongClick) + } else { + // 🔥 Даже без ссылок поддерживаем long press + onLongClickCallback = onLongClick } } }, @@ -371,10 +377,13 @@ fun AppleEmojiText( if (overflow != null) { view.ellipsize = overflow } - // 🔥 Обновляем настройки ссылок + // 🔥 Обновляем настройки ссылок с поддержкой long press if (enableLinks) { view.setLinkColor(linkColor.toArgb()) - view.enableClickableLinks(true) + view.enableClickableLinks(true, onLongClick) + } else { + // 🔥 Даже без ссылок поддерживаем long press + view.onLongClickCallback = onLongClick } }, modifier = modifier @@ -415,11 +424,32 @@ class AppleEmojiTextView @JvmOverloads constructor( private var linkColorValue: Int = 0xFF54A9EB.toInt() // Default Telegram blue private var linksEnabled: Boolean = false + // 🔥 Long press callback для selection в MessageBubble + var onLongClickCallback: (() -> Unit)? = null + + // 🔥 GestureDetector для обработки long press поверх LinkMovementMethod + private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { + override fun onLongPress(e: MotionEvent) { + onLongClickCallback?.invoke() + } + }) + init { // Отключаем лишние отступы шрифта для корректного отображения emoji includeFontPadding = false } + /** + * 🔥 Перехватываем touch события для обработки long press + * GestureDetector обрабатывает long press, затем передаем событие parent для ссылок + */ + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + // Позволяем GestureDetector обработать событие (для long press) + gestureDetector.onTouchEvent(event) + // Передаем событие дальше для обработки ссылок + return super.dispatchTouchEvent(event) + } + /** * 🔥 Установить цвет для ссылок */ @@ -429,9 +459,12 @@ class AppleEmojiTextView @JvmOverloads constructor( /** * 🔥 Включить/выключить кликабельные ссылки + * @param enable - включить ссылки + * @param onLongClick - callback для long press (для selection в MessageBubble) */ - fun enableClickableLinks(enable: Boolean) { + fun enableClickableLinks(enable: Boolean, onLongClick: (() -> Unit)? = null) { linksEnabled = enable + onLongClickCallback = onLongClick if (enable) { movementMethod = LinkMovementMethod.getInstance() // Убираем highlight при клике diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt index 38816d3..b3ee930 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt @@ -76,8 +76,8 @@ fun AvatarImage( var bitmap by remember { mutableStateOf(null) } // 🔥 FIX: Декодируем только когда avatarKey (timestamp) реально изменился - // НЕ сбрасываем bitmap в null - показываем старый пока грузится новый - LaunchedEffect(avatarKey) { + // Сбрасываем bitmap в null ТОЛЬКО когда аватар удалён (avatars пустой) + LaunchedEffect(avatarKey, avatars.isEmpty()) { val currentAvatars = avatars if (currentAvatars.isNotEmpty()) { val newBitmap = withContext(Dispatchers.IO) { @@ -87,9 +87,10 @@ fun AvatarImage( if (newBitmap != null) { bitmap = newBitmap } + } else { + // 🔥 FIX: Если аватары удалены - сбрасываем bitmap чтобы показался placeholder + bitmap = null } - // Если avatars пустой - НЕ сбрасываем bitmap в null - // Placeholder покажется через условие bitmap == null ниже } Box( diff --git a/app/src/main/java/com/rosetta/messenger/utils/MessageLogger.kt b/app/src/main/java/com/rosetta/messenger/utils/MessageLogger.kt index 6efe67e..09fad5e 100644 --- a/app/src/main/java/com/rosetta/messenger/utils/MessageLogger.kt +++ b/app/src/main/java/com/rosetta/messenger/utils/MessageLogger.kt @@ -38,7 +38,8 @@ object MessageLogger { if (!isEnabled) return val shortId = messageId.take(8) val shortKey = toPublicKey.take(12) - val msg = "📤 SEND | id:\$shortId to:\$shortKey len:\$textLength att:\$attachmentsCount saved:\$isSavedMessages reply:\${replyToMessageId?.take(8) ?: \"-\"}" + val replyStr = replyToMessageId?.take(8) ?: "-" + val msg = "📤 SEND | id:$shortId to:$shortKey len:$textLength att:$attachmentsCount saved:$isSavedMessages reply:$replyStr" Log.d(TAG, msg) addToUI(msg) } @@ -53,7 +54,7 @@ object MessageLogger { ) { if (!isEnabled) return val shortId = messageId.take(8) - val msg = "🔐 ENCRYPT | id:\$shortId content:\${encryptedContentLength}b key:\${encryptedKeyLength}b" + val msg = "🔐 ENCRYPT | id:$shortId content:${encryptedContentLength}b key:${encryptedKeyLength}b" Log.d(TAG, msg) addToUI(msg) } @@ -69,7 +70,7 @@ object MessageLogger { if (!isEnabled) return val shortId = messageId.take(8) val shortDialog = dialogKey.take(12) - val msg = "💾 DB | id:\$shortId dialog:\$shortDialog new:\$isNew" + val msg = "💾 DB | id:$shortId dialog:$shortDialog new:$isNew" Log.d(TAG, msg) addToUI(msg) } @@ -85,7 +86,7 @@ object MessageLogger { if (!isEnabled) return val shortId = messageId.take(8) val shortKey = toPublicKey.take(12) - val msg = "📡 PACKET→ | id:\$shortId to:\$shortKey ts:\$timestamp" + val msg = "📡 PACKET→ | id:$shortId to:$shortKey ts:$timestamp" Log.d(TAG, msg) addToUI(msg) } @@ -96,7 +97,7 @@ object MessageLogger { fun logSendSuccess(messageId: String, duration: Long) { if (!isEnabled) return val shortId = messageId.take(8) - val msg = "✅ SENT | id:\$shortId time:\${duration}ms" + val msg = "✅ SENT | id:$shortId time:${duration}ms" Log.d(TAG, msg) addToUI(msg) } @@ -107,7 +108,8 @@ object MessageLogger { fun logSendError(messageId: String, error: Throwable) { if (!isEnabled) return val shortId = messageId.take(8) - val msg = "❌ SEND ERR | id:\$shortId err:\${error.message?.take(50)}" + val errMsg = error.message?.take(50) ?: "unknown" + val msg = "❌ SEND ERR | id:$shortId err:$errMsg" Log.e(TAG, msg, error) addToUI(msg) } @@ -127,7 +129,7 @@ object MessageLogger { val shortId = messageId.take(8) val shortFrom = fromPublicKey.take(12) val shortTo = toPublicKey.take(12) - val msg = "📥 RECV | id:\$shortId from:\$shortFrom to:\$shortTo len:\${contentLength}b att:\$attachmentsCount ts:\$timestamp" + val msg = "📥 RECV | id:$shortId from:$shortFrom to:$shortTo len:${contentLength}b att:$attachmentsCount ts:$timestamp" Log.d(TAG, msg) addToUI(msg) } @@ -139,7 +141,7 @@ object MessageLogger { if (!isEnabled) return val shortId = messageId.take(8) val status = if (isDuplicate) "⚠️DUP" else "✓NEW" - val msg = "🔍 CHECK | id:\$shortId \$status" + val msg = "🔍 CHECK | id:$shortId $status" Log.d(TAG, msg) addToUI(msg) } @@ -150,7 +152,7 @@ object MessageLogger { fun logBlockedSender(fromPublicKey: String) { if (!isEnabled) return val shortKey = fromPublicKey.take(12) - val msg = "🚫 BLOCKED | from:\$shortKey" + val msg = "🚫 BLOCKED | from:$shortKey" Log.d(TAG, msg) addToUI(msg) } @@ -165,7 +167,7 @@ object MessageLogger { ) { if (!isEnabled) return val shortId = messageId.take(8) - val msg = "🔓 DECRYPT | id:\$shortId text:\${plainTextLength}c att:\$attachmentsCount" + val msg = "🔓 DECRYPT | id:$shortId text:${plainTextLength}c att:$attachmentsCount" Log.d(TAG, msg) addToUI(msg) } @@ -176,7 +178,8 @@ object MessageLogger { fun logDecryptionError(messageId: String, error: Throwable) { if (!isEnabled) return val shortId = messageId.take(8) - val msg = "❌ DECRYPT ERR | id:\$shortId err:\${error.message?.take(50)}" + val errMsg = error.message?.take(50) ?: "unknown" + val msg = "❌ DECRYPT ERR | id:$shortId err:$errMsg" Log.e(TAG, msg, error) addToUI(msg) } @@ -187,7 +190,7 @@ object MessageLogger { fun logReceiveSuccess(messageId: String, duration: Long) { if (!isEnabled) return val shortId = messageId.take(8) - val msg = "✅ RECEIVED | id:\$shortId time:\${duration}ms" + val msg = "✅ RECEIVED | id:$shortId time:${duration}ms" Log.d(TAG, msg) addToUI(msg) } @@ -203,7 +206,7 @@ object MessageLogger { if (!isEnabled) return val shortId = messageId.take(8) val shortKey = toPublicKey.take(12) - val msg = "📬 DELIVERY | id:\$shortId to:\$shortKey status:\$status" + val msg = "📬 DELIVERY | id:$shortId to:$shortKey status:$status" Log.d(TAG, msg) addToUI(msg) } @@ -217,7 +220,7 @@ object MessageLogger { ) { if (!isEnabled) return val shortKey = fromPublicKey.take(12) - val msg = "👁 READ | from:\$shortKey count:\$messagesCount" + val msg = "👁 READ | from:$shortKey count:$messagesCount" Log.d(TAG, msg) addToUI(msg) } @@ -233,7 +236,7 @@ object MessageLogger { if (!isEnabled) return val shortDialog = dialogKey.take(12) val shortMsg = lastMessage?.take(20) ?: "-" - val msg = "📋 DIALOG | key:\$shortDialog last:\"\$shortMsg\" unread:\$unreadCount" + val msg = "📋 DIALOG | key:$shortDialog last:\"$shortMsg\" unread:$unreadCount" Log.d(TAG, msg) addToUI(msg) } @@ -244,7 +247,7 @@ object MessageLogger { fun logCacheUpdate(dialogKey: String, totalMessages: Int) { if (!isEnabled) return val shortDialog = dialogKey.take(12) - val msg = "🗃 CACHE | dialog:\$shortDialog total:\$totalMessages" + val msg = "🗃 CACHE | dialog:$shortDialog total:$totalMessages" Log.d(TAG, msg) addToUI(msg) } @@ -268,6 +271,6 @@ object MessageLogger { } else { Log.e(TAG, message) } - addToUI("❌ \$message") + addToUI("❌ $message") } }