Compare commits

...

11 Commits

Author SHA1 Message Date
d5b6ca3a7e Белый цвет даты и пустого стейта при обоях, фикс обрезки имени в группах
- dateHeader и empty state текст белые при тёмной теме или обоях
- Увеличена минимальная ширина бабла для групповых сообщений
- Медиа-бабл расширяется для имени отправителя в группе

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:32:41 +07:00
9e7a2e4998 Фикс цвет галочек 2026-03-16 21:32:41 +07:00
822f982332 Скрытие клавиатуры при свайпе назад на экране поиска
Добавлена обработка горизонтального свайпа вправо для автоматического скрытия клавиатуры на SearchScreen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 21:32:41 +07:00
64c767823c Обновлены release notes: добавлены новые функции и исправления интерфейса 2026-03-16 21:32:41 +07:00
dc16ada30b Добавлены release notes для версии 1.2.0 (с 1.1.9) 2026-03-16 21:32:41 +07:00
297309db1f Релиз 1.2.0: синхронизированы статусы, скролл и UI-выравнивание
- Поднята версия приложения до 1.2.0 (versionCode 22)\n- Синхронизированы статусы отправки между чат-листом и диалогом: SENT отображается как часы до delivery, ERROR теперь стабильно приходит в открытый диалог\n- Доработан Telegram-подобный skeleton в диалоге: shimmer, геометрия пузырей, поддержка групповых аватаров\n- Добавлен плавный автоскролл к баннеру подтверждения нового устройства в чат-листе\n- Выровнены verified-галочки с именами в профилях и в сайдбаре\n- Кнопка Copy Seed Phrase в светлой теме приведена к белому тексту\n- Мелкие UI-правки в чате и компонентах ввода/эмодзи
2026-03-16 21:32:41 +07:00
b01b2902b3 Синхронизирована логика read-индикаторов в диалоге с чат-листом 2026-03-16 21:32:41 +07:00
398f460a60 Скрыт инпут и оверлеи при рисовании на фото 2026-03-16 21:32:41 +07:00
89f3561358 Исправлен черный gesture navigation bar при fullscreen фото 2026-03-16 21:32:41 +07:00
9c5c92eab6 Исправлен цвет галочки верификации в профилях и попапах по теме 2026-03-16 21:32:41 +07:00
4fd73f23ea Исправлен цвет галочки верификации в сайдбаре по теме 2026-03-16 21:32:41 +07:00
15 changed files with 490 additions and 272 deletions

19
RELEASE_NOTES.md Normal file
View File

@@ -0,0 +1,19 @@
# Release Notes
## 1.2.0 (обновление с 1.1.9)
- Синхронизированы индикаторы отправки между чат-листом и диалогом:
- до фактической доставки показываются часы;
- ошибки отправки корректно отображаются внутри диалога.
- Доработан skeleton загрузки сообщений в диалоге в стиле Telegram:
- shimmer-анимация;
- более реалистичные размеры и форма пузырей;
- поддержка групповых аватаров в skeleton.
- Добавлен плавный автоскролл вверх в чат-листе при появлении плашки подтверждения нового устройства.
- Выровнены verified-галочки по имени:
- в моем профиле;
- в чужом профиле;
- в боковом меню (sidebar).
- Улучшено выравнивание Apple-like emoji в тексте сообщений.
- В светлой теме кнопка `Copy Seed Phrase` отображается с белым текстом.
- Выполнены дополнительные UI-правки и полировка отображения в чате и профилях.

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.1.9"
val rosettaVersionCode = 21 // Increment on each release
val rosettaVersionName = "1.2.0"
val rosettaVersionCode = 22 // Increment on each release
android {
namespace = "com.rosetta.messenger"

View File

@@ -600,6 +600,9 @@ class MessageRepository private constructor(private val context: Context) {
// При ошибке обновляем статус
messageDao.updateDeliveryStatus(account, messageId, DeliveryStatus.ERROR.value)
updateMessageStatus(dialogKey, messageId, DeliveryStatus.ERROR)
_deliveryStatusEvents.tryEmit(
DeliveryStatusUpdate(dialogKey, messageId, DeliveryStatus.ERROR)
)
}
}
@@ -1140,6 +1143,9 @@ class MessageRepository private constructor(private val context: Context) {
messageDao.updateDeliveryStatus(account, entity.messageId, DeliveryStatus.ERROR.value)
val dialogKey = getDialogKey(entity.toPublicKey)
updateMessageStatus(dialogKey, entity.messageId, DeliveryStatus.ERROR)
_deliveryStatusEvents.tryEmit(
DeliveryStatusUpdate(dialogKey, entity.messageId, DeliveryStatus.ERROR)
)
}
}
}

View File

@@ -17,23 +17,18 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
Отправка сообщений
- Синхронизированы индикаторы отправки между чат-листом и диалогом: до фактической доставки показываются часы; ошибки отправки корректно отображаются внутри диалога
Загрузка сообщений
- Доработан skeleton загрузки сообщений в диалоге в стиле Telegram: shimmer-анимация, более реалистичные размеры и форма пузырей, поддержка групповых аватаров в skeleton
Интерфейс
- Исправлен цвет галочки верификации в сайдбаре в зависимости от темы
- Исправлен цвет галочки верификации в профилях и попапах в светлой теме
- Исправлен черный gesture navigation bar при fullscreen фото
Фото и редактор
- При рисовании на фото теперь скрываются инпут и лишние оверлеи
- Синхронизировано поведение системных баров в полноэкранном фото-режиме
Сообщения
- Логика read-индикаторов внутри диалога приведена к логике чат-листа
- Убраны неверные переходы статусов сообщений при read/delivered событиях
Обновления приложения
- Загрузка апдейта переведена на системный DownloadManager
- Скачивание обновления продолжается после сворачивания/выхода из приложения
- Прогресс и состояние установки апдейта восстанавливаются после повторного запуска
- Добавлен плавный автоскролл вверх в чат-листе при появлении плашки подтверждения нового устройства
- Выровнены verified-галочки по имени: в моем профиле, в чужом профиле, в боковом меню (sidebar)
- Улучшено выравнивание Apple-like emoji в тексте сообщений
- В светлой теме кнопка Copy Seed Phrase отображается с белым текстом
- Дополнительные UI-правки и полировка отображения в чате и профилях
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -106,6 +106,7 @@ import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.settings.ThemeWallpapers
import com.rosetta.messenger.ui.utils.NavigationModeUtils
import com.rosetta.messenger.ui.utils.SystemBarsStyleUtils
import com.rosetta.messenger.utils.MediaUtils
import java.text.SimpleDateFormat
@@ -209,7 +210,7 @@ fun ChatDetailScreen(
val chatWallpaperResId = remember(chatWallpaperId) { ThemeWallpapers.drawableResOrNull(chatWallpaperId) }
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
val dateHeaderTextColor = if (isDarkTheme) Color.White else secondaryTextColor
val dateHeaderTextColor = if (isDarkTheme || chatWallpaperResId != null) Color.White else secondaryTextColor
val headerIconColor = Color.White
// 🔥 Keyboard & Emoji Coordinator
@@ -332,6 +333,12 @@ fun ChatDetailScreen(
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
window.statusBarColor = android.graphics.Color.TRANSPARENT
ic.isAppearanceLightStatusBars = false
NavigationModeUtils.applyNavigationBarVisibility(
window = window,
insetsController = ic,
context = context,
isDarkTheme = isDarkTheme
)
}
}
}
@@ -344,6 +351,12 @@ fun ChatDetailScreen(
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
window.statusBarColor = android.graphics.Color.TRANSPARENT
ic.isAppearanceLightStatusBars = false
NavigationModeUtils.applyNavigationBarVisibility(
window = window,
insetsController = ic,
context = context,
isDarkTheme = isDarkTheme
)
}
}
}
@@ -1151,7 +1164,7 @@ fun ChatDetailScreen(
Row(
modifier =
Modifier.fillMaxWidth()
.height(56.dp)
.height(64.dp)
.padding(
horizontal =
4.dp
@@ -1323,7 +1336,7 @@ fun ChatDetailScreen(
Row(
modifier =
Modifier.fillMaxWidth()
.height(56.dp)
.height(64.dp)
.padding(
horizontal =
4.dp
@@ -2297,6 +2310,7 @@ fun ChatDetailScreen(
isLoading -> {
MessageSkeletonList(
isDarkTheme = isDarkTheme,
isGroupChat = isGroupChat,
modifier =
Modifier.fillMaxSize()
)
@@ -2385,7 +2399,7 @@ fun ChatDetailScreen(
"No messages yet",
fontSize = 16.sp,
color =
secondaryTextColor,
dateHeaderTextColor,
fontWeight =
FontWeight
.Medium
@@ -2405,7 +2419,7 @@ fun ChatDetailScreen(
"Send a message to start the conversation",
fontSize = 14.sp,
color =
secondaryTextColor
dateHeaderTextColor
.copy(
alpha =
0.7f

View File

@@ -425,6 +425,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Обновляем конкретное сообщение
updateMessageStatus(update.messageId, MessageStatus.DELIVERED)
}
DeliveryStatus.ERROR -> {
// Синхронизируем ошибку отправки с открытым диалогом
updateMessageStatus(update.messageId, MessageStatus.ERROR)
}
DeliveryStatus.READ -> {
// Помечаем все исходящие как прочитанные
markAllOutgoingAsRead()
@@ -440,9 +444,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private fun markAllOutgoingAsRead() {
_messages.value =
_messages.value.map { msg ->
if (msg.isOutgoing && msg.status != MessageStatus.READ) {
msg.copy(status = MessageStatus.READ)
} else msg
if (!msg.isOutgoing) return@map msg
val nextStatus =
when (msg.status) {
// Read event can promote only already-sent/delivered messages.
MessageStatus.SENT,
MessageStatus.DELIVERED,
MessageStatus.READ -> MessageStatus.READ
MessageStatus.SENDING,
MessageStatus.ERROR -> msg.status
}
if (nextStatus != msg.status) msg.copy(status = nextStatus) else msg
}
updateCacheFromCurrentMessages()
}
@@ -479,7 +493,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private fun updateMessageStatus(messageId: String, status: MessageStatus) {
_messages.value =
_messages.value.map { msg ->
if (msg.id == messageId) msg.copy(status = status) else msg
if (msg.id != messageId) return@map msg
// Keep read status monotonic: late DELIVERED must not downgrade READ.
val mergedStatus =
when (status) {
MessageStatus.DELIVERED ->
if (msg.status == MessageStatus.READ) MessageStatus.READ
else MessageStatus.DELIVERED
else -> status
}
if (mergedStatus != msg.status) msg.copy(status = mergedStatus) else msg
}
// 🔥 Также обновляем кэш!

View File

@@ -928,26 +928,27 @@ fun ChatsListScreen(
// Display name
if (accountName.isNotEmpty()) {
Row(
verticalAlignment = Alignment.CenterVertically
horizontalArrangement = Arrangement.Start
) {
Text(
text = accountName,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = Color.White
color = Color.White,
modifier = Modifier.alignByBaseline()
)
if (accountVerified > 0 || isRosettaOfficial || isFreddyOfficial) {
Spacer(
Box(
modifier =
Modifier.width(
6.dp
)
)
VerifiedBadge(
verified = if (accountVerified > 0) accountVerified else 1,
size = 15,
badgeTint = if (isDarkTheme) Color.White else PrimaryBlue
)
Modifier.padding(start = 2.dp)
.alignBy { (it.measuredHeight * 0.78f).toInt() }
) {
VerifiedBadge(
verified = if (accountVerified > 0) accountVerified else 1,
size = 15,
badgeTint = if (isDarkTheme) PrimaryBlue else Color.White
)
}
}
}
}
@@ -2066,8 +2067,34 @@ fun ChatsListScreen(
// Track scroll direction to hide/show Requests
val chatListState = rememberLazyListState()
var isRequestsVisible by remember { mutableStateOf(true) }
var lastAutoScrolledVerificationId by remember {
mutableStateOf<String?>(null)
}
val hapticFeedback = LocalHapticFeedback.current
// When a new device confirmation banner appears at the top,
// smoothly bring the list to top so the banner is visible.
LaunchedEffect(pendingDeviceVerification?.deviceId) {
val verificationId =
pendingDeviceVerification?.deviceId
if (verificationId.isNullOrBlank()) {
lastAutoScrolledVerificationId = null
return@LaunchedEffect
}
if (verificationId == lastAutoScrolledVerificationId) {
return@LaunchedEffect
}
val alreadyAtTop =
chatListState.firstVisibleItemIndex == 0 &&
chatListState.firstVisibleItemScrollOffset <= 2
if (!alreadyAtTop) {
chatListState.animateScrollToItem(0)
}
lastAutoScrolledVerificationId = verificationId
}
// NestedScroll — ловим направление свайпа даже без скролла
// Для появления: накапливаем pull down дельту, нужен сильный жест
val requestsNestedScroll = remember(hapticFeedback) {

View File

@@ -37,6 +37,8 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import com.rosetta.messenger.repository.AvatarRepository
@@ -224,7 +226,13 @@ fun SearchScreen(
}
}
Box(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) {
detectHorizontalDragGestures { _, dragAmount ->
if (dragAmount > 10f) {
hideKeyboardInstantly()
}
}
}) {
Scaffold(
topBar = {
// Хедер как в Telegram: стрелка назад + поле ввода

View File

@@ -752,18 +752,23 @@ fun MessageBubble(
280.dp
}
val hasGroupSenderName = showGroupSenderLabel && !message.isOutgoing && senderName.isNotBlank()
val bubbleWidthModifier =
if (isSafeSystemMessage) {
Modifier.widthIn(min = 220.dp, max = 320.dp)
} else if (isStandaloneGroupInvite) {
Modifier.widthIn(min = 180.dp, max = 260.dp)
} else if (hasImageWithCaption || hasOnlyMedia) {
Modifier.width(
photoWidth
) // 🔥 Фиксированная ширина = размер фото (убирает лишний
// отступ)
if (hasGroupSenderName) {
Modifier.widthIn(min = photoWidth)
} else {
Modifier.width(
photoWidth
) // 🔥 Фиксированная ширина = размер фото (убирает лишний
// отступ)
}
} else {
Modifier.widthIn(min = 60.dp, max = 280.dp)
Modifier.widthIn(min = if (hasGroupSenderName) 120.dp else 60.dp, max = 280.dp)
}
Box(
@@ -1898,12 +1903,12 @@ fun AnimatedMessageStatus(
}
} else {
Icon(
painter =
painter =
when (currentStatus) {
MessageStatus.SENDING ->
TelegramIcons.Clock
MessageStatus.SENT ->
TelegramIcons.Done
TelegramIcons.Clock
MessageStatus.DELIVERED ->
TelegramIcons.Done
else -> TelegramIcons.Clock
@@ -2573,68 +2578,130 @@ private fun ForwardedImagePreview(
/** Message skeleton loader with shimmer animation */
@Composable
fun MessageSkeletonList(isDarkTheme: Boolean, modifier: Modifier = Modifier) {
val skeletonColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE0E0E0)
val infiniteTransition = rememberInfiniteTransition(label = "shimmer")
val shimmerAlpha by
infiniteTransition.animateFloat(
initialValue = 0.4f,
targetValue = 0.8f,
fun MessageSkeletonList(
isDarkTheme: Boolean,
isGroupChat: Boolean,
modifier: Modifier = Modifier
) {
val transition = rememberInfiniteTransition(label = "telegramSkeleton")
val shimmerProgress by
transition.animateFloat(
initialValue = -1f,
targetValue = 2f,
animationSpec =
infiniteRepeatable(
animation = tween(800, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
animation = tween(1300, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "shimmerAlpha"
label = "telegramSkeletonProgress"
)
Box(modifier = modifier) {
// Telegram-style deterministic pseudo-randomness.
val widthRandom = remember { floatArrayOf(0.18f, 0.74f, 0.32f, 0.61f, 0.27f, 0.84f, 0.49f, 0.12f) }
val heightRandom = remember { floatArrayOf(0.25f, 0.68f, 0.14f, 0.52f, 0.37f, 0.79f, 0.29f, 0.57f) }
val bubbleShape =
remember {
RoundedCornerShape(
topStart = TelegramBubbleSpec.bubbleRadius,
topEnd = TelegramBubbleSpec.bubbleRadius,
bottomStart = TelegramBubbleSpec.nearRadius,
bottomEnd = TelegramBubbleSpec.bubbleRadius
)
}
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val density = LocalDensity.current
val maxWidthPx = with(density) { maxWidth.toPx() }.coerceAtLeast(1f)
val gradientWidthPx = with(density) { 200.dp.toPx() }
val shimmerX = shimmerProgress * maxWidthPx
val color0 =
if (isDarkTheme) Color(0x36FFFFFF) else Color(0x1F000000)
val color1 =
if (isDarkTheme) Color(0x1CFFFFFF) else Color(0x12000000)
val outlineColor =
if (isDarkTheme) Color(0x40FFFFFF) else Color(0x24FFFFFF)
val shimmerBrush =
Brush.linearGradient(
colorStops =
arrayOf(
0f to color1,
0.4f to color0,
0.6f to color0,
1f to color1
),
start = Offset(shimmerX - gradientWidthPx, 0f),
end = Offset(shimmerX, 0f)
)
val bottomInset = 58.dp
val containerWidth = this@BoxWithConstraints.maxWidth
val bubbleMaxWidth = (containerWidth * 0.8f) - if (isGroupChat) 42.dp else 0.dp
val avatarGap = 6.dp
val availableHeight = (maxHeight - bottomInset).coerceAtLeast(0.dp)
var usedHeight = 0.dp
var rowCount = 0
while (rowCount < 24 && usedHeight < availableHeight) {
val randomHeight = heightRandom[rowCount % heightRandom.size]
usedHeight += (64.dp + (randomHeight * 64f).dp + 3.dp)
rowCount++
}
if (rowCount < 6) rowCount = 6
Column(
modifier =
Modifier.align(Alignment.BottomCenter)
Modifier.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(horizontal = 8.dp)
.padding(bottom = 80.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
.padding(start = 3.dp, end = 8.dp, bottom = bottomInset),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
SkeletonBubble(true, 0.45f, skeletonColor, shimmerAlpha)
SkeletonBubble(false, 0.55f, skeletonColor, shimmerAlpha)
SkeletonBubble(true, 0.35f, skeletonColor, shimmerAlpha)
SkeletonBubble(false, 0.50f, skeletonColor, shimmerAlpha)
SkeletonBubble(true, 0.60f, skeletonColor, shimmerAlpha)
SkeletonBubble(false, 0.40f, skeletonColor, shimmerAlpha)
}
}
}
@Composable
private fun SkeletonBubble(
isOutgoing: Boolean,
widthFraction: Float,
bubbleColor: Color,
alpha: Float
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start
) {
Box(
modifier =
Modifier.fillMaxWidth(widthFraction)
.defaultMinSize(minHeight = 44.dp)
.clip(
RoundedCornerShape(
topStart = 18.dp,
topEnd = 18.dp,
bottomStart =
if (isOutgoing) 18.dp else 6.dp,
bottomEnd = if (isOutgoing) 6.dp else 18.dp
)
repeat(rowCount) { index ->
val lineWidth =
minOf(
bubbleMaxWidth,
42.dp +
containerWidth *
(0.4f +
widthRandom[index % widthRandom.size] *
0.35f)
)
.background(bubbleColor.copy(alpha = alpha))
.padding(horizontal = 14.dp, vertical = 10.dp)
)
val lineHeight =
64.dp +
(heightRandom[index % heightRandom.size] * 64f).dp
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom
) {
if (isGroupChat) {
Box(
modifier =
Modifier.size(42.dp)
.clip(CircleShape)
.background(shimmerBrush)
.border(1.dp, outlineColor, CircleShape)
)
Spacer(modifier = Modifier.width(avatarGap))
}
Box(
modifier =
Modifier.width(lineWidth)
.height(lineHeight)
.clip(bubbleShape)
.background(shimmerBrush)
.border(
width = 1.dp,
color = outlineColor,
shape = bubbleShape
)
)
}
}
}
}
}

View File

@@ -343,6 +343,7 @@ private fun SimpleFullscreenPhotoContent(
localCaption = value
}
}
val isDrawingModeActive = currentTool == EditorTool.DRAW
LaunchedEffect(imageUri, sourceThumbnail) {
localCaption = caption
@@ -394,6 +395,15 @@ private fun SimpleFullscreenPhotoContent(
}
}
LaunchedEffect(isDrawingModeActive) {
if (isDrawingModeActive) {
showEmojiPicker = false
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus(force = true)
}
}
fun hideKeyboard() {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
@@ -851,16 +861,18 @@ private fun SimpleFullscreenPhotoContent(
.statusBarsPadding()
.padding(horizontal = 4.dp, vertical = 8.dp)
) {
IconButton(
onClick = { closeViewer() },
modifier = Modifier.align(Alignment.CenterStart)
) {
Icon(
painter = TelegramIcons.Close,
contentDescription = "Close",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
if (!isDrawingModeActive) {
IconButton(
onClick = { closeViewer() },
modifier = Modifier.align(Alignment.CenterStart)
) {
Icon(
painter = TelegramIcons.Close,
contentDescription = "Close",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
}
}
@@ -990,136 +1002,144 @@ private fun SimpleFullscreenPhotoContent(
}
}
Box(
modifier =
Modifier.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.75f))
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp,
bottom =
if (isKeyboardVisible || coordinator.isEmojiBoxVisible) 10.dp
else 16.dp
)
.then(
if (shouldAddNavBarPadding) Modifier.navigationBarsPadding()
else Modifier
)
AnimatedVisibility(
visible = !isDrawingModeActive,
enter = fadeIn() + slideInVertically { it / 2 },
exit = fadeOut() + slideOutVertically { it / 2 }
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
Box(
modifier =
Modifier.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.75f))
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp,
bottom =
if (isKeyboardVisible || coordinator.isEmojiBoxVisible) 10.dp
else 16.dp
)
.then(
if (shouldAddNavBarPadding) Modifier.navigationBarsPadding()
else Modifier
)
) {
IconButton(
onClick = { toggleEmojiPicker() },
modifier = Modifier.size(32.dp)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Crossfade(
targetState = showEmojiPicker,
animationSpec = tween(150),
label = "simpleViewerEmojiToggle"
) { isEmoji ->
Icon(
painter = if (isEmoji) TelegramIcons.Keyboard else TelegramIcons.Smile,
contentDescription = if (isEmoji) "Keyboard" else "Emoji",
tint = Color.White.copy(alpha = 0.72f),
modifier = Modifier.size(26.dp)
IconButton(
onClick = { toggleEmojiPicker() },
modifier = Modifier.size(32.dp)
) {
Crossfade(
targetState = showEmojiPicker,
animationSpec = tween(150),
label = "simpleViewerEmojiToggle"
) { isEmoji ->
Icon(
painter = if (isEmoji) TelegramIcons.Keyboard else TelegramIcons.Smile,
contentDescription = if (isEmoji) "Keyboard" else "Emoji",
tint = Color.White.copy(alpha = 0.72f),
modifier = Modifier.size(26.dp)
)
}
}
Box(
modifier =
Modifier.weight(1f)
.heightIn(min = 24.dp, max = 100.dp)
) {
AppleEmojiTextField(
value = captionText,
onValueChange = updateCaption,
textColor = Color.White,
textSize = 16f,
hint = "Add a caption...",
hintColor = Color.White.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth(),
requestFocus = false,
onViewCreated = { textView -> editTextView = textView },
onFocusChanged = { hasFocus ->
if (hasFocus && showEmojiPicker) {
toggleEmojiPicker()
}
}
)
}
}
Box(
modifier =
Modifier.weight(1f)
.heightIn(min = 24.dp, max = 100.dp)
) {
AppleEmojiTextField(
value = captionText,
onValueChange = updateCaption,
textColor = Color.White,
textSize = 16f,
hint = "Add a caption...",
hintColor = Color.White.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth(),
requestFocus = false,
onViewCreated = { textView -> editTextView = textView },
onFocusChanged = { hasFocus ->
if (hasFocus && showEmojiPicker) {
toggleEmojiPicker()
}
}
)
}
Box(
modifier =
Modifier.size(44.dp)
.shadow(
elevation = 4.dp,
shape = CircleShape,
clip = false
)
.clip(CircleShape)
.background(PrimaryBlue)
.clickable(enabled = !isSaving) {
if (isSaving || isClosing) return@clickable
showEmojiPicker = false
hideKeyboard()
focusManager.clearFocus(force = true)
scope.launch {
isSaving = true
val savedUri =
saveEditedImageSync(
context = context,
photoEditor = photoEditor,
photoEditorView = photoEditorView,
imageUri = currentImageUri,
hasDrawingEdits = hasDrawingEdits
)
isSaving = false
val finalUri = savedUri ?: currentImageUri
if (onSend != null) {
onSend(finalUri, captionText)
} else {
closeViewer()
Box(
modifier =
Modifier.size(44.dp)
.shadow(
elevation = 4.dp,
shape = CircleShape,
clip = false
)
.clip(CircleShape)
.background(PrimaryBlue)
.clickable(enabled = !isSaving) {
if (isSaving || isClosing) return@clickable
showEmojiPicker = false
hideKeyboard()
focusManager.clearFocus(force = true)
scope.launch {
isSaving = true
val savedUri =
saveEditedImageSync(
context = context,
photoEditor = photoEditor,
photoEditorView = photoEditorView,
imageUri = currentImageUri,
hasDrawingEdits = hasDrawingEdits
)
isSaving = false
val finalUri = savedUri ?: currentImageUri
if (onSend != null) {
onSend(finalUri, captionText)
} else {
closeViewer()
}
}
}
},
contentAlignment = Alignment.Center
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Icon(
painter = TelegramIcons.Send,
contentDescription = "Send",
tint = Color.White,
modifier =
Modifier.size(24.dp)
.offset(x = 1.dp)
)
},
contentAlignment = Alignment.Center
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Icon(
painter = TelegramIcons.Send,
contentDescription = "Send",
tint = Color.White,
modifier =
Modifier.size(24.dp)
.offset(x = 1.dp)
)
}
}
}
}
}
AnimatedKeyboardTransition(
coordinator = coordinator,
showEmojiPicker = showEmojiPicker
) {
OptimizedEmojiPicker(
isVisible = true,
isDarkTheme = isDarkTheme,
onEmojiSelected = { emoji -> updateCaption(captionText + emoji) },
onClose = { toggleEmojiPicker() },
modifier = Modifier.fillMaxWidth()
)
if (!isDrawingModeActive) {
AnimatedKeyboardTransition(
coordinator = coordinator,
showEmojiPicker = showEmojiPicker
) {
OptimizedEmojiPicker(
isVisible = true,
isDarkTheme = isDarkTheme,
onEmojiSelected = { emoji -> updateCaption(captionText + emoji) },
onClose = { toggleEmojiPicker() },
modifier = Modifier.fillMaxWidth()
)
}
}
}
}

View File

@@ -4,7 +4,9 @@ import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Paint
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.text.Editable
import android.text.SpannableStringBuilder
@@ -12,6 +14,7 @@ import android.text.TextPaint
import android.text.TextWatcher
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.DynamicDrawableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.ImageSpan
import android.util.AttributeSet
@@ -32,6 +35,43 @@ import androidx.compose.foundation.background
import androidx.compose.ui.viewinterop.AndroidView
import java.util.regex.Pattern
private class TelegramLikeEmojiSpan(
emojiDrawable: Drawable,
private var sourceFontMetrics: Paint.FontMetricsInt?
) : ImageSpan(emojiDrawable, DynamicDrawableSpan.ALIGN_BOTTOM) {
private var sizePx: Int = resolveSize(sourceFontMetrics)
private fun resolveSize(metrics: Paint.FontMetricsInt?): Int {
val metricsSize = metrics?.let { kotlin.math.abs(it.descent) + kotlin.math.abs(it.ascent) } ?: 0
if (metricsSize > 0) return metricsSize
val intrinsic = drawable.intrinsicHeight
return if (intrinsic > 0) intrinsic else 20
}
override fun getSize(
paint: Paint,
text: CharSequence,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
val metrics = sourceFontMetrics ?: paint.fontMetricsInt
sourceFontMetrics = metrics
sizePx = resolveSize(metrics)
val scaledSize = sizePx.coerceAtLeast(1)
drawable.setBounds(0, 0, scaledSize, scaledSize)
fm?.let {
it.ascent = metrics.ascent
it.descent = metrics.descent
it.top = metrics.top
it.bottom = metrics.bottom
it.leading = metrics.leading
}
return scaledSize
}
}
/**
* Apple Emoji EditText - кастомный EditText с PNG эмодзи
* Заменяет системные эмодзи на Apple PNG изображения из assets
@@ -98,9 +138,8 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
// 🔥 Паттерн для :emoji_XXXX: формата (как в десктопе)
val EMOJI_CODE_PATTERN: Pattern = Pattern.compile(":emoji_([a-fA-F0-9_-]+):")
// Кэш для bitmap и drawable
// Кэш bitmap
private val bitmapCache = LruCache<String, Bitmap>(500)
private val drawableCache = LruCache<String, BitmapDrawable>(500)
}
init {
@@ -195,27 +234,17 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
if (existingSpans.isNotEmpty()) continue
val unified = match.unified
var drawable = drawableCache.get(unified)
if (drawable == null) {
var bitmap = bitmapCache.get(unified)
if (bitmap == null) {
bitmap = loadFromAssets(unified)
if (bitmap != null) {
bitmapCache.put(unified, bitmap)
}
}
var bitmap = bitmapCache.get(unified)
if (bitmap == null) {
bitmap = loadFromAssets(unified)
if (bitmap != null) {
drawable = BitmapDrawable(getContext().resources, bitmap)
val size = (textSize * 1.15).toInt()
drawable.setBounds(0, 0, size, size)
drawableCache.put(unified, drawable)
bitmapCache.put(unified, bitmap)
}
}
if (drawable != null && start < editable.length && end <= editable.length) {
val imageSpan = ImageSpan(drawable, ImageSpan.ALIGN_CENTER)
if (bitmap != null && start < editable.length && end <= editable.length) {
val drawable = BitmapDrawable(resources, bitmap)
val imageSpan = TelegramLikeEmojiSpan(drawable, paint.fontMetricsInt)
editable.setSpan(imageSpan, start, end, SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
@@ -577,12 +606,8 @@ class AppleEmojiTextView @JvmOverloads constructor(
for (match in emojiMatches) {
val bitmap = loadEmojiBitmap(match.unified)
if (bitmap != null) {
val size = (textSize * 1.3).toInt()
val scaledBitmap = Bitmap.createScaledBitmap(bitmap, size, size, true)
val drawable = BitmapDrawable(resources, scaledBitmap)
drawable.setBounds(0, 0, size, size)
val span = ImageSpan(drawable, ImageSpan.ALIGN_BOTTOM)
val drawable = BitmapDrawable(resources, bitmap)
val span = TelegramLikeEmojiSpan(drawable, paint.fontMetricsInt)
// Для :emoji_XXXX: заменяем весь текст на пробел + span
// Для Unicode эмодзи оставляем символ как есть

View File

@@ -44,7 +44,7 @@ fun VerifiedBadge(
// Цвет верификации: в тёмной теме — как индикаторы прочтения (PrimaryBlue), в светлой — #ACD2F9
val badgeColor =
badgeTint ?: if (isDarkTheme) PrimaryBlue else Color(0xFFACD2F9)
badgeTint ?: PrimaryBlue
// Текст аннотации
val annotationText = when (verified) {

View File

@@ -253,7 +253,8 @@ fun BackupScreen(
.fillMaxWidth()
.height(52.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF248AE6)
containerColor = Color(0xFF248AE6),
contentColor = Color.White
),
shape = RoundedCornerShape(10.dp)
) {
@@ -266,7 +267,8 @@ fun BackupScreen(
Text(
text = "Copy Seed Phrase",
fontSize = 15.sp,
fontWeight = FontWeight.Medium
fontWeight = FontWeight.Medium,
color = Color.White
)
}

View File

@@ -2138,9 +2138,9 @@ private fun CollapsingOtherProfileHeader(
Modifier.align(Alignment.TopCenter).offset(y = textY),
horizontalAlignment = Alignment.CenterHorizontally
) {
val verifiedBadgeSize = (nameFontSize.value * 0.8f).toInt()
// Name + Verified Badge
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
@@ -2150,18 +2150,23 @@ private fun CollapsingOtherProfileHeader(
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 220.dp),
modifier = Modifier.widthIn(max = 220.dp).alignByBaseline(),
textAlign = TextAlign.Center
)
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(
verified = if (verified > 0) verified else 1,
size = (nameFontSize.value * 0.8f).toInt(),
isDarkTheme = isDarkTheme,
badgeTint = Color.White
)
Box(
modifier =
Modifier.padding(start = 4.dp)
.alignBy { (it.measuredHeight * 0.78f).toInt() }
) {
VerifiedBadge(
verified = if (verified > 0) verified else 1,
size = verifiedBadgeSize,
isDarkTheme = isDarkTheme,
badgeTint = Color.White
)
}
}
}

View File

@@ -1389,8 +1389,8 @@ private fun CollapsingProfileHeader(
Modifier.align(Alignment.TopCenter).offset(y = textY),
horizontalAlignment = Alignment.CenterHorizontally
) {
val verifiedBadgeSize = (nameFontSize.value * 0.8f).toInt()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
@@ -1400,16 +1400,22 @@ private fun CollapsingProfileHeader(
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 220.dp),
modifier = Modifier.widthIn(max = 220.dp).alignByBaseline(),
textAlign = TextAlign.Center
)
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(
verified = if (verified > 0) verified else 2,
size = (nameFontSize.value * 0.8f).toInt(),
isDarkTheme = isDarkTheme
)
Box(
modifier =
Modifier.padding(start = 4.dp)
.alignBy { (it.measuredHeight * 0.78f).toInt() }
) {
VerifiedBadge(
verified = if (verified > 0) verified else 2,
size = verifiedBadgeSize,
isDarkTheme = isDarkTheme,
badgeTint = Color.White
)
}
}
}