fix: update message logging format for consistency and clarity
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 при клике
|
||||
|
||||
@@ -76,8 +76,8 @@ fun AvatarImage(
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(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(
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user