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.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
)
}
}
}
}
},

View File

@@ -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,

View File

@@ -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))

View File

@@ -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 = {

View File

@@ -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)
)
}

View File

@@ -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]

View File

@@ -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()