fix: update message logging format for consistency and clarity

This commit is contained in:
2026-02-06 02:01:03 +05:00
parent c455994224
commit 3a810d6d61
10 changed files with 393 additions and 125 deletions

View File

@@ -43,6 +43,7 @@ import com.rosetta.messenger.R
import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState 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.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.BlurredAvatarBackground import com.rosetta.messenger.ui.components.BlurredAvatarBackground
@@ -351,27 +352,49 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
) )
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Text(
text =
when (protocolState) { when (protocolState) {
ProtocolState ProtocolState.DISCONNECTED -> {
.DISCONNECTED -> Text(
"Disconnected" text = "Disconnected",
ProtocolState.CONNECTING ->
"Connecting..."
ProtocolState.CONNECTED ->
"Connected"
ProtocolState.HANDSHAKING ->
"Authenticating..."
ProtocolState
.AUTHENTICATED ->
"Authenticated"
},
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = textColor 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
)
}
}
}
} }
}, },
confirmButton = { confirmButton = {

View File

@@ -158,6 +158,8 @@ if (currentAccount == publicKey) {
val actualRead = if (actualFromMe == 1) (lastMsgStatus?.read ?: 0) else 0 val actualRead = if (actualFromMe == 1) (lastMsgStatus?.read ?: 0) else 0
// 📎 Определяем тип attachment последнего сообщения // 📎 Определяем тип attachment последнего сообщения
// 🔥 Reply vs Forward: если есть текст - это Reply (показываем текст),
// если текст пустой - это Forward (показываем "Forwarded message")
val attachmentType = try { val attachmentType = try {
val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey) val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey)
if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") { if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") {
@@ -167,7 +169,12 @@ if (currentAccount == publicKey) {
val type = firstAttachment.optInt("type", -1) val type = firstAttachment.optInt("type", -1)
when (type) { when (type) {
0 -> "Photo" // AttachmentType.IMAGE = 0 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 2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3 3 -> "Avatar" // AttachmentType.AVATAR = 3
else -> null else -> null
@@ -244,6 +251,8 @@ if (currentAccount == publicKey) {
} }
// 📎 Определяем тип attachment последнего сообщения // 📎 Определяем тип attachment последнего сообщения
// 🔥 Reply vs Forward: если есть текст - это Reply (показываем текст),
// если текст пустой - это Forward (показываем "Forwarded message")
val attachmentType = try { val attachmentType = try {
val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey) val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey)
if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") { if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") {
@@ -253,7 +262,12 @@ if (currentAccount == publicKey) {
val type = firstAttachment.optInt("type", -1) val type = firstAttachment.optInt("type", -1)
when (type) { when (type) {
0 -> "Photo" // AttachmentType.IMAGE = 0 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 2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3 3 -> "Avatar" // AttachmentType.AVATAR = 3
else -> null else -> null

View File

@@ -49,6 +49,7 @@ import com.vanniktech.blurhash.BlurHash
import compose.icons.TablerIcons import compose.icons.TablerIcons
import compose.icons.tablericons.* import compose.icons.tablericons.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
@@ -56,6 +57,48 @@ import kotlin.math.min
private const val TAG = "AttachmentComponents" 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'ов изображений * 🖼️ Глобальный LRU кэш для bitmap'ов изображений
* Это предотвращает мигание при перезаходе в диалог, * Это предотвращает мигание при перезаходе в диалог,
@@ -1315,25 +1358,41 @@ fun FileAttachment(
// Размер файла и тип // Размер файла и тип
val fileExtension = fileName.substringAfterLast('.', "").uppercase() val fileExtension = fileName.substringAfterLast('.', "").uppercase()
Text( val statusColor = if (downloadStatus == DownloadStatus.ERROR) {
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) Color(0xFFE53935)
} else if (isOutgoing) { } else if (isOutgoing) {
Color.White.copy(alpha = 0.7f) Color.White.copy(alpha = 0.7f)
} else { } else {
PrimaryBlue 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
)
}
}
}
} }
// Time and checkmarks (bottom-right overlay) for outgoing files // Time and checkmarks (bottom-right overlay) for outgoing files
@@ -1692,20 +1751,36 @@ fun AvatarAttachment(
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
// Описание статуса // Описание статуса
Text( val avatarStatusColor = if (isOutgoing) Color.White.copy(alpha = 0.7f)
text = else (if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray)
when (downloadStatus) { when (downloadStatus) {
DownloadStatus.DOWNLOADING -> "Downloading..." DownloadStatus.DOWNLOADING -> {
DownloadStatus.DECRYPTING -> "Decrypting..." 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.ERROR -> "Tap to retry"
DownloadStatus.DOWNLOADED -> "Shared profile photo" DownloadStatus.DOWNLOADED -> "Shared profile photo"
else -> "Tap to download" else -> "Tap to download"
}, },
fontSize = 13.sp, fontSize = 13.sp,
color = color = avatarStatusColor
if (isOutgoing) Color.White.copy(alpha = 0.7f)
else (if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray)
) )
}
}
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))

View File

@@ -626,7 +626,8 @@ fun MessageBubble(
text = message.text, text = message.text,
color = textColor, color = textColor,
fontSize = 16.sp, fontSize = 16.sp,
linkColor = linkColor linkColor = linkColor,
onLongClick = onLongClick // 🔥 Long press для selection
) )
}, },
timeContent = { timeContent = {
@@ -673,7 +674,8 @@ fun MessageBubble(
text = message.text, text = message.text,
color = textColor, color = textColor,
fontSize = 17.sp, fontSize = 17.sp,
linkColor = linkColor linkColor = linkColor,
onLongClick = onLongClick // 🔥 Long press для selection
) )
}, },
timeContent = { timeContent = {
@@ -701,7 +703,8 @@ fun MessageBubble(
) )
} }
} }
} },
fillWidth = true // 🔥 Время справа с учётом ширины ReplyBubble
) )
} else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) { } else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) {
// Telegram-style: текст + время с автоматическим переносом // Telegram-style: текст + время с автоматическим переносом
@@ -711,7 +714,8 @@ fun MessageBubble(
text = message.text, text = message.text,
color = textColor, color = textColor,
fontSize = 17.sp, fontSize = 17.sp,
linkColor = linkColor linkColor = linkColor,
onLongClick = onLongClick // 🔥 Long press для selection
) )
}, },
timeContent = { timeContent = {

View File

@@ -36,6 +36,9 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
@@ -387,6 +390,13 @@ fun ImageEditorScreen(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
// 🔥 Блокируем свайпы от SwipeBackContainer - на ImageEditor свайпы не должны работать
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
down.consume() // Поглощаем все touch события
}
}
.graphicsLayer { alpha = animationProgress.value } // ⚡ Всё плавно fade .graphicsLayer { alpha = animationProgress.value } // ⚡ Всё плавно fade
.background(Color.Black) .background(Color.Black)
) { ) {
@@ -1067,33 +1077,20 @@ private fun TelegramCaptionBar(
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
// 🎬 Левая иконка: плавная анимация через alpha // 🎬 Левая иконка: плавная анимация через alpha
// Камера (compact) ↔ Emoji/Keyboard (expanded) // Emoji/Keyboard toggle - всегда виден
Box( Box(
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// Камера - видна когда progress близок к 0 // Emoji/Keyboard toggle
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
IconButton( IconButton(
onClick = onToggleEmojiPicker, onClick = onToggleEmojiPicker,
modifier = Modifier modifier = Modifier.size(32.dp)
.size(32.dp)
.graphicsLayer { alpha = progress },
enabled = progress > 0.5f // Кликабелен только когда достаточно виден
) { ) {
Icon( Icon(
if (showEmojiPicker) TablerIcons.Keyboard else TablerIcons.MoodSmile, if (showEmojiPicker) TablerIcons.Keyboard else TablerIcons.MoodSmile,
contentDescription = if (showEmojiPicker) "Keyboard" else "Emoji", 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) modifier = Modifier.size(26.dp)
) )
} }

View File

@@ -69,6 +69,24 @@ private const val TAG = "ImageViewerScreen"
*/ */
private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) 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 * Данные об источнике изображения для shared element transition
*/ */
@@ -196,8 +214,47 @@ fun ImageViewerScreen(
var showControls by remember { mutableStateOf(true) } 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 dragOffsetY by remember { mutableFloatStateOf(0f) }
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }
@@ -216,21 +273,35 @@ fun ImageViewerScreen(
} }
} }
// 🎬 Простой fade-out dismiss // 🎬 Shared element dismiss - возврат к исходному положению
fun smoothDismiss() { fun smoothDismiss() {
if (isClosing) return if (isClosing) return
isClosing = true isClosing = true
onClosingStart() // Сразу сбрасываем status bar onClosingStart() // Сразу сбрасываем status bar
scope.launch { scope.launch {
// Плавно исчезаем - более длинная анимация // Сначала сбрасываем 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( dismissAlpha.animateTo(
targetValue = 0f, targetValue = 0f,
animationSpec = tween( animationSpec = tween(
durationMillis = 350, durationMillis = 250,
easing = LinearEasing easing = LinearEasing
) )
) )
}
onDismiss() onDismiss()
} }
} }
@@ -269,6 +340,11 @@ fun ImageViewerScreen(
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 📸 HORIZONTAL PAGER с Telegram-style эффектом наслаивания // 📸 HORIZONTAL PAGER с Telegram-style эффектом наслаивания
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🔥 Применяем shared element transform только для текущей страницы при анимации
val isAnimating = animationProgress.value < 1f
val shouldApplyTransform = isAnimating && pagerState.currentPage == initialIndex
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier modifier = Modifier
@@ -276,9 +352,25 @@ fun ImageViewerScreen(
.graphicsLayer { .graphicsLayer {
alpha = dismissAlpha.value alpha = dismissAlpha.value
translationY = animatedOffsetY.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) между фото pageSpacing = 30.dp, // Telegram: dp(30) между фото
key = { images[it].attachmentId } key = { images[it].attachmentId },
userScrollEnabled = !isAnimating // Отключаем скролл во время анимации
) { page -> ) { page ->
val image = images[page] val image = images[page]

View File

@@ -59,6 +59,7 @@ import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import compose.icons.TablerIcons import compose.icons.TablerIcons
@@ -364,23 +365,38 @@ fun MediaPickerBottomSheet(
} }
} }
// 🎨 Затемнение статус бара когда галерея открыта // 🎨 Затемнение статус бара и навигейшн бара когда галерея открыта
val view = LocalView.current 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) { DisposableEffect(shouldShow, scrimAlpha) {
if (shouldShow && !view.isInEditMode) { if (shouldShow && !view.isInEditMode) {
val window = (view.context as? android.app.Activity)?.window val window = (view.context as? android.app.Activity)?.window
val originalStatusBarColor = window?.statusBarColor ?: 0 val originalStatusBarColor = window?.statusBarColor ?: 0
val originalNavigationBarColor = window?.navigationBarColor ?: 0
val originalLightNavigationBars = insetsController?.isAppearanceLightNavigationBars ?: true
// Затемняем статус бар // Затемняем статус бар и навигейшн бар
val scrimColor = android.graphics.Color.argb( val scrimColor = android.graphics.Color.argb(
(scrimAlpha * 255).toInt().coerceIn(0, 255), (scrimAlpha * 255).toInt().coerceIn(0, 255),
0, 0, 0 0, 0, 0
) )
window?.statusBarColor = scrimColor window?.statusBarColor = scrimColor
window?.navigationBarColor = scrimColor
// 🔥 Иконки навигейшн бара светлые (белые) когда scrim виден
insetsController?.isAppearanceLightNavigationBars = scrimAlpha < 0.15f && originalLightNavigationBars
onDispose { onDispose {
// Восстанавливаем оригинальный цвет // Восстанавливаем оригинальные цвета
window?.statusBarColor = originalStatusBarColor window?.statusBarColor = originalStatusBarColor
window?.navigationBarColor = originalNavigationBarColor
insetsController?.isAppearanceLightNavigationBars = originalLightNavigationBars
} }
} else { } else {
onDispose { } onDispose { }
@@ -413,6 +429,15 @@ fun MediaPickerBottomSheet(
dismissOnClickOutside = false 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( Box(
modifier = Modifier modifier = Modifier
@@ -426,7 +451,8 @@ fun MediaPickerBottomSheet(
) { ) {
// Sheet content // Sheet content
val currentHeightDp = with(density) { sheetHeightPx.value.toDp() } val currentHeightDp = with(density) { sheetHeightPx.value.toDp() }
val slideOffset = (sheetHeightPx.value * animatedOffset).toInt() // 🔥 Добавляем смещение вниз только если клавиатура закрыта
val slideOffset = (sheetHeightPx.value * animatedOffset).toInt() + bottomOffset
// Отслеживаем velocity для плавного snap // Отслеживаем velocity для плавного snap
var lastDragVelocity by remember { mutableFloatStateOf(0f) } var lastDragVelocity by remember { mutableFloatStateOf(0f) }

View File

@@ -17,7 +17,9 @@ import android.text.style.ImageSpan
import android.util.AttributeSet import android.util.AttributeSet
import android.util.LruCache import android.util.LruCache
import android.util.Patterns import android.util.Patterns
import android.view.GestureDetector
import android.view.Gravity import android.view.Gravity
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.widget.EditText import android.widget.EditText
@@ -325,7 +327,8 @@ fun AppleEmojiText(
maxLines: Int = Int.MAX_VALUE, maxLines: Int = Int.MAX_VALUE,
overflow: android.text.TextUtils.TruncateAt? = null, overflow: android.text.TextUtils.TruncateAt? = null,
linkColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color(0xFF54A9EB), // Telegram-style blue 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 val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f
else fontSize.value else fontSize.value
@@ -355,10 +358,13 @@ fun AppleEmojiText(
if (overflow != null) { if (overflow != null) {
ellipsize = overflow ellipsize = overflow
} }
// 🔥 Включаем кликабельные ссылки // 🔥 Включаем кликабельные ссылки с поддержкой long press
if (enableLinks) { if (enableLinks) {
setLinkColor(linkColor.toArgb()) setLinkColor(linkColor.toArgb())
enableClickableLinks(true) enableClickableLinks(true, onLongClick)
} else {
// 🔥 Даже без ссылок поддерживаем long press
onLongClickCallback = onLongClick
} }
} }
}, },
@@ -371,10 +377,13 @@ fun AppleEmojiText(
if (overflow != null) { if (overflow != null) {
view.ellipsize = overflow view.ellipsize = overflow
} }
// 🔥 Обновляем настройки ссылок // 🔥 Обновляем настройки ссылок с поддержкой long press
if (enableLinks) { if (enableLinks) {
view.setLinkColor(linkColor.toArgb()) view.setLinkColor(linkColor.toArgb())
view.enableClickableLinks(true) view.enableClickableLinks(true, onLongClick)
} else {
// 🔥 Даже без ссылок поддерживаем long press
view.onLongClickCallback = onLongClick
} }
}, },
modifier = modifier modifier = modifier
@@ -415,11 +424,32 @@ class AppleEmojiTextView @JvmOverloads constructor(
private var linkColorValue: Int = 0xFF54A9EB.toInt() // Default Telegram blue private var linkColorValue: Int = 0xFF54A9EB.toInt() // Default Telegram blue
private var linksEnabled: Boolean = false 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 { init {
// Отключаем лишние отступы шрифта для корректного отображения emoji // Отключаем лишние отступы шрифта для корректного отображения emoji
includeFontPadding = false 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 linksEnabled = enable
onLongClickCallback = onLongClick
if (enable) { if (enable) {
movementMethod = LinkMovementMethod.getInstance() movementMethod = LinkMovementMethod.getInstance()
// Убираем highlight при клике // Убираем highlight при клике

View File

@@ -76,8 +76,8 @@ fun AvatarImage(
var bitmap by remember { mutableStateOf<Bitmap?>(null) } var bitmap by remember { mutableStateOf<Bitmap?>(null) }
// 🔥 FIX: Декодируем только когда avatarKey (timestamp) реально изменился // 🔥 FIX: Декодируем только когда avatarKey (timestamp) реально изменился
// НЕ сбрасываем bitmap в null - показываем старый пока грузится новый // Сбрасываем bitmap в null ТОЛЬКО когда аватар удалён (avatars пустой)
LaunchedEffect(avatarKey) { LaunchedEffect(avatarKey, avatars.isEmpty()) {
val currentAvatars = avatars val currentAvatars = avatars
if (currentAvatars.isNotEmpty()) { if (currentAvatars.isNotEmpty()) {
val newBitmap = withContext(Dispatchers.IO) { val newBitmap = withContext(Dispatchers.IO) {
@@ -87,9 +87,10 @@ fun AvatarImage(
if (newBitmap != null) { if (newBitmap != null) {
bitmap = newBitmap bitmap = newBitmap
} }
} else {
// 🔥 FIX: Если аватары удалены - сбрасываем bitmap чтобы показался placeholder
bitmap = null
} }
// Если avatars пустой - НЕ сбрасываем bitmap в null
// Placeholder покажется через условие bitmap == null ниже
} }
Box( Box(

View File

@@ -38,7 +38,8 @@ object MessageLogger {
if (!isEnabled) return if (!isEnabled) return
val shortId = messageId.take(8) val shortId = messageId.take(8)
val shortKey = toPublicKey.take(12) 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) Log.d(TAG, msg)
addToUI(msg) addToUI(msg)
} }
@@ -53,7 +54,7 @@ object MessageLogger {
) { ) {
if (!isEnabled) return if (!isEnabled) return
val shortId = messageId.take(8) 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) Log.d(TAG, msg)
addToUI(msg) addToUI(msg)
} }
@@ -69,7 +70,7 @@ object MessageLogger {
if (!isEnabled) return if (!isEnabled) return
val shortId = messageId.take(8) val shortId = messageId.take(8)
val shortDialog = dialogKey.take(12) 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) Log.d(TAG, msg)
addToUI(msg) addToUI(msg)
} }
@@ -85,7 +86,7 @@ object MessageLogger {
if (!isEnabled) return if (!isEnabled) return
val shortId = messageId.take(8) val shortId = messageId.take(8)
val shortKey = toPublicKey.take(12) 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) Log.d(TAG, msg)
addToUI(msg) addToUI(msg)
} }
@@ -96,7 +97,7 @@ object MessageLogger {
fun logSendSuccess(messageId: String, duration: Long) { fun logSendSuccess(messageId: String, duration: Long) {
if (!isEnabled) return if (!isEnabled) return
val shortId = messageId.take(8) 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) Log.d(TAG, msg)
addToUI(msg) addToUI(msg)
} }
@@ -107,7 +108,8 @@ object MessageLogger {
fun logSendError(messageId: String, error: Throwable) { fun logSendError(messageId: String, error: Throwable) {
if (!isEnabled) return if (!isEnabled) return
val shortId = messageId.take(8) 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) Log.e(TAG, msg, error)
addToUI(msg) addToUI(msg)
} }
@@ -127,7 +129,7 @@ object MessageLogger {
val shortId = messageId.take(8) val shortId = messageId.take(8)
val shortFrom = fromPublicKey.take(12) val shortFrom = fromPublicKey.take(12)
val shortTo = toPublicKey.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) Log.d(TAG, msg)
addToUI(msg) addToUI(msg)
} }
@@ -139,7 +141,7 @@ object MessageLogger {
if (!isEnabled) return if (!isEnabled) return
val shortId = messageId.take(8) val shortId = messageId.take(8)
val status = if (isDuplicate) "DUP" else "✓NEW" val status = if (isDuplicate) "DUP" else "✓NEW"
val msg = "🔍 CHECK | id:\$shortId \$status" val msg = "🔍 CHECK | id:$shortId $status"
Log.d(TAG, msg) Log.d(TAG, msg)
addToUI(msg) addToUI(msg)
} }
@@ -150,7 +152,7 @@ object MessageLogger {
fun logBlockedSender(fromPublicKey: String) { fun logBlockedSender(fromPublicKey: String) {
if (!isEnabled) return if (!isEnabled) return
val shortKey = fromPublicKey.take(12) val shortKey = fromPublicKey.take(12)
val msg = "🚫 BLOCKED | from:\$shortKey" val msg = "🚫 BLOCKED | from:$shortKey"
Log.d(TAG, msg) Log.d(TAG, msg)
addToUI(msg) addToUI(msg)
} }
@@ -165,7 +167,7 @@ object MessageLogger {
) { ) {
if (!isEnabled) return if (!isEnabled) return
val shortId = messageId.take(8) 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) Log.d(TAG, msg)
addToUI(msg) addToUI(msg)
} }
@@ -176,7 +178,8 @@ object MessageLogger {
fun logDecryptionError(messageId: String, error: Throwable) { fun logDecryptionError(messageId: String, error: Throwable) {
if (!isEnabled) return if (!isEnabled) return
val shortId = messageId.take(8) 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) Log.e(TAG, msg, error)
addToUI(msg) addToUI(msg)
} }
@@ -187,7 +190,7 @@ object MessageLogger {
fun logReceiveSuccess(messageId: String, duration: Long) { fun logReceiveSuccess(messageId: String, duration: Long) {
if (!isEnabled) return if (!isEnabled) return
val shortId = messageId.take(8) 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) Log.d(TAG, msg)
addToUI(msg) addToUI(msg)
} }
@@ -203,7 +206,7 @@ object MessageLogger {
if (!isEnabled) return if (!isEnabled) return
val shortId = messageId.take(8) val shortId = messageId.take(8)
val shortKey = toPublicKey.take(12) 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) Log.d(TAG, msg)
addToUI(msg) addToUI(msg)
} }
@@ -217,7 +220,7 @@ object MessageLogger {
) { ) {
if (!isEnabled) return if (!isEnabled) return
val shortKey = fromPublicKey.take(12) val shortKey = fromPublicKey.take(12)
val msg = "👁 READ | from:\$shortKey count:\$messagesCount" val msg = "👁 READ | from:$shortKey count:$messagesCount"
Log.d(TAG, msg) Log.d(TAG, msg)
addToUI(msg) addToUI(msg)
} }
@@ -233,7 +236,7 @@ object MessageLogger {
if (!isEnabled) return if (!isEnabled) return
val shortDialog = dialogKey.take(12) val shortDialog = dialogKey.take(12)
val shortMsg = lastMessage?.take(20) ?: "-" 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) Log.d(TAG, msg)
addToUI(msg) addToUI(msg)
} }
@@ -244,7 +247,7 @@ object MessageLogger {
fun logCacheUpdate(dialogKey: String, totalMessages: Int) { fun logCacheUpdate(dialogKey: String, totalMessages: Int) {
if (!isEnabled) return if (!isEnabled) return
val shortDialog = dialogKey.take(12) val shortDialog = dialogKey.take(12)
val msg = "🗃 CACHE | dialog:\$shortDialog total:\$totalMessages" val msg = "🗃 CACHE | dialog:$shortDialog total:$totalMessages"
Log.d(TAG, msg) Log.d(TAG, msg)
addToUI(msg) addToUI(msg)
} }
@@ -268,6 +271,6 @@ object MessageLogger {
} else { } else {
Log.e(TAG, message) Log.e(TAG, message)
} }
addToUI("\$message") addToUI("$message")
} }
} }