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 // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.1.9" val rosettaVersionName = "1.2.0"
val rosettaVersionCode = 21 // Increment on each release val rosettaVersionCode = 22 // Increment on each release
android { android {
namespace = "com.rosetta.messenger" 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) messageDao.updateDeliveryStatus(account, messageId, DeliveryStatus.ERROR.value)
updateMessageStatus(dialogKey, messageId, DeliveryStatus.ERROR) 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) messageDao.updateDeliveryStatus(account, entity.messageId, DeliveryStatus.ERROR.value)
val dialogKey = getDialogKey(entity.toPublicKey) val dialogKey = getDialogKey(entity.toPublicKey)
updateMessageStatus(dialogKey, entity.messageId, DeliveryStatus.ERROR) 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 = """ val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER Update v$VERSION_PLACEHOLDER
Отправка сообщений
- Синхронизированы индикаторы отправки между чат-листом и диалогом: до фактической доставки показываются часы; ошибки отправки корректно отображаются внутри диалога
Загрузка сообщений
- Доработан skeleton загрузки сообщений в диалоге в стиле Telegram: shimmer-анимация, более реалистичные размеры и форма пузырей, поддержка групповых аватаров в skeleton
Интерфейс Интерфейс
- Исправлен цвет галочки верификации в сайдбаре в зависимости от темы - Добавлен плавный автоскролл вверх в чат-листе при появлении плашки подтверждения нового устройства
- Исправлен цвет галочки верификации в профилях и попапах в светлой теме - Выровнены verified-галочки по имени: в моем профиле, в чужом профиле, в боковом меню (sidebar)
- Исправлен черный gesture navigation bar при fullscreen фото - Улучшено выравнивание Apple-like emoji в тексте сообщений
- В светлой теме кнопка Copy Seed Phrase отображается с белым текстом
Фото и редактор - Дополнительные UI-правки и полировка отображения в чате и профилях
- При рисовании на фото теперь скрываются инпут и лишние оверлеи
- Синхронизировано поведение системных баров в полноэкранном фото-режиме
Сообщения
- Логика read-индикаторов внутри диалога приведена к логике чат-листа
- Убраны неверные переходы статусов сообщений при read/delivered событиях
Обновления приложения
- Загрузка апдейта переведена на системный DownloadManager
- Скачивание обновления продолжается после сворачивания/выхода из приложения
- Прогресс и состояние установки апдейта восстанавливаются после повторного запуска
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = 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.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.settings.ThemeWallpapers 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.ui.utils.SystemBarsStyleUtils
import com.rosetta.messenger.utils.MediaUtils import com.rosetta.messenger.utils.MediaUtils
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -209,7 +210,7 @@ fun ChatDetailScreen(
val chatWallpaperResId = remember(chatWallpaperId) { ThemeWallpapers.drawableResOrNull(chatWallpaperId) } val chatWallpaperResId = remember(chatWallpaperId) { ThemeWallpapers.drawableResOrNull(chatWallpaperId) }
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) 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 val headerIconColor = Color.White
// 🔥 Keyboard & Emoji Coordinator // 🔥 Keyboard & Emoji Coordinator
@@ -332,6 +333,12 @@ fun ChatDetailScreen(
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view) val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
window.statusBarColor = android.graphics.Color.TRANSPARENT window.statusBarColor = android.graphics.Color.TRANSPARENT
ic.isAppearanceLightStatusBars = false 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) val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
window.statusBarColor = android.graphics.Color.TRANSPARENT window.statusBarColor = android.graphics.Color.TRANSPARENT
ic.isAppearanceLightStatusBars = false ic.isAppearanceLightStatusBars = false
NavigationModeUtils.applyNavigationBarVisibility(
window = window,
insetsController = ic,
context = context,
isDarkTheme = isDarkTheme
)
} }
} }
} }
@@ -1151,7 +1164,7 @@ fun ChatDetailScreen(
Row( Row(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.height(56.dp) .height(64.dp)
.padding( .padding(
horizontal = horizontal =
4.dp 4.dp
@@ -1323,7 +1336,7 @@ fun ChatDetailScreen(
Row( Row(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.height(56.dp) .height(64.dp)
.padding( .padding(
horizontal = horizontal =
4.dp 4.dp
@@ -2297,6 +2310,7 @@ fun ChatDetailScreen(
isLoading -> { isLoading -> {
MessageSkeletonList( MessageSkeletonList(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
isGroupChat = isGroupChat,
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize()
) )
@@ -2385,7 +2399,7 @@ fun ChatDetailScreen(
"No messages yet", "No messages yet",
fontSize = 16.sp, fontSize = 16.sp,
color = color =
secondaryTextColor, dateHeaderTextColor,
fontWeight = fontWeight =
FontWeight FontWeight
.Medium .Medium
@@ -2405,7 +2419,7 @@ fun ChatDetailScreen(
"Send a message to start the conversation", "Send a message to start the conversation",
fontSize = 14.sp, fontSize = 14.sp,
color = color =
secondaryTextColor dateHeaderTextColor
.copy( .copy(
alpha = alpha =
0.7f 0.7f

View File

@@ -425,6 +425,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Обновляем конкретное сообщение // Обновляем конкретное сообщение
updateMessageStatus(update.messageId, MessageStatus.DELIVERED) updateMessageStatus(update.messageId, MessageStatus.DELIVERED)
} }
DeliveryStatus.ERROR -> {
// Синхронизируем ошибку отправки с открытым диалогом
updateMessageStatus(update.messageId, MessageStatus.ERROR)
}
DeliveryStatus.READ -> { DeliveryStatus.READ -> {
// Помечаем все исходящие как прочитанные // Помечаем все исходящие как прочитанные
markAllOutgoingAsRead() markAllOutgoingAsRead()
@@ -440,9 +444,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private fun markAllOutgoingAsRead() { private fun markAllOutgoingAsRead() {
_messages.value = _messages.value =
_messages.value.map { msg -> _messages.value.map { msg ->
if (msg.isOutgoing && msg.status != MessageStatus.READ) { if (!msg.isOutgoing) return@map msg
msg.copy(status = MessageStatus.READ)
} else 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() updateCacheFromCurrentMessages()
} }
@@ -479,7 +493,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private fun updateMessageStatus(messageId: String, status: MessageStatus) { private fun updateMessageStatus(messageId: String, status: MessageStatus) {
_messages.value = _messages.value =
_messages.value.map { msg -> _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,29 +928,30 @@ fun ChatsListScreen(
// Display name // Display name
if (accountName.isNotEmpty()) { if (accountName.isNotEmpty()) {
Row( Row(
verticalAlignment = Alignment.CenterVertically horizontalArrangement = Arrangement.Start
) { ) {
Text( Text(
text = accountName, text = accountName,
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.White color = Color.White,
modifier = Modifier.alignByBaseline()
) )
if (accountVerified > 0 || isRosettaOfficial || isFreddyOfficial) { if (accountVerified > 0 || isRosettaOfficial || isFreddyOfficial) {
Spacer( Box(
modifier = modifier =
Modifier.width( Modifier.padding(start = 2.dp)
6.dp .alignBy { (it.measuredHeight * 0.78f).toInt() }
) ) {
)
VerifiedBadge( VerifiedBadge(
verified = if (accountVerified > 0) accountVerified else 1, verified = if (accountVerified > 0) accountVerified else 1,
size = 15, size = 15,
badgeTint = if (isDarkTheme) Color.White else PrimaryBlue badgeTint = if (isDarkTheme) PrimaryBlue else Color.White
) )
} }
} }
} }
}
// Username // Username
if (accountUsername.isNotEmpty()) { if (accountUsername.isNotEmpty()) {
@@ -2066,8 +2067,34 @@ fun ChatsListScreen(
// Track scroll direction to hide/show Requests // Track scroll direction to hide/show Requests
val chatListState = rememberLazyListState() val chatListState = rememberLazyListState()
var isRequestsVisible by remember { mutableStateOf(true) } var isRequestsVisible by remember { mutableStateOf(true) }
var lastAutoScrolledVerificationId by remember {
mutableStateOf<String?>(null)
}
val hapticFeedback = LocalHapticFeedback.current 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 — ловим направление свайпа даже без скролла // NestedScroll — ловим направление свайпа даже без скролла
// Для появления: накапливаем pull down дельту, нужен сильный жест // Для появления: накапливаем pull down дельту, нужен сильный жест
val requestsNestedScroll = remember(hapticFeedback) { 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.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback 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.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import com.rosetta.messenger.repository.AvatarRepository 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( Scaffold(
topBar = { topBar = {
// Хедер как в Telegram: стрелка назад + поле ввода // Хедер как в Telegram: стрелка назад + поле ввода

View File

@@ -752,18 +752,23 @@ fun MessageBubble(
280.dp 280.dp
} }
val hasGroupSenderName = showGroupSenderLabel && !message.isOutgoing && senderName.isNotBlank()
val bubbleWidthModifier = val bubbleWidthModifier =
if (isSafeSystemMessage) { if (isSafeSystemMessage) {
Modifier.widthIn(min = 220.dp, max = 320.dp) Modifier.widthIn(min = 220.dp, max = 320.dp)
} else if (isStandaloneGroupInvite) { } else if (isStandaloneGroupInvite) {
Modifier.widthIn(min = 180.dp, max = 260.dp) Modifier.widthIn(min = 180.dp, max = 260.dp)
} else if (hasImageWithCaption || hasOnlyMedia) { } else if (hasImageWithCaption || hasOnlyMedia) {
if (hasGroupSenderName) {
Modifier.widthIn(min = photoWidth)
} else {
Modifier.width( Modifier.width(
photoWidth photoWidth
) // 🔥 Фиксированная ширина = размер фото (убирает лишний ) // 🔥 Фиксированная ширина = размер фото (убирает лишний
// отступ) // отступ)
}
} else { } else {
Modifier.widthIn(min = 60.dp, max = 280.dp) Modifier.widthIn(min = if (hasGroupSenderName) 120.dp else 60.dp, max = 280.dp)
} }
Box( Box(
@@ -1903,7 +1908,7 @@ fun AnimatedMessageStatus(
MessageStatus.SENDING -> MessageStatus.SENDING ->
TelegramIcons.Clock TelegramIcons.Clock
MessageStatus.SENT -> MessageStatus.SENT ->
TelegramIcons.Done TelegramIcons.Clock
MessageStatus.DELIVERED -> MessageStatus.DELIVERED ->
TelegramIcons.Done TelegramIcons.Done
else -> TelegramIcons.Clock else -> TelegramIcons.Clock
@@ -2573,68 +2578,130 @@ private fun ForwardedImagePreview(
/** Message skeleton loader with shimmer animation */ /** Message skeleton loader with shimmer animation */
@Composable @Composable
fun MessageSkeletonList(isDarkTheme: Boolean, modifier: Modifier = Modifier) { fun MessageSkeletonList(
val skeletonColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE0E0E0) isDarkTheme: Boolean,
isGroupChat: Boolean,
val infiniteTransition = rememberInfiniteTransition(label = "shimmer") modifier: Modifier = Modifier
val shimmerAlpha by ) {
infiniteTransition.animateFloat( val transition = rememberInfiniteTransition(label = "telegramSkeleton")
initialValue = 0.4f, val shimmerProgress by
targetValue = 0.8f, transition.animateFloat(
initialValue = -1f,
targetValue = 2f,
animationSpec = animationSpec =
infiniteRepeatable( infiniteRepeatable(
animation = tween(800, easing = FastOutSlowInEasing), animation = tween(1300, easing = LinearEasing),
repeatMode = RepeatMode.Reverse 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( Column(
modifier = modifier =
Modifier.align(Alignment.BottomCenter) Modifier.align(Alignment.BottomStart)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp) .padding(start = 3.dp, end = 8.dp, bottom = bottomInset),
.padding(bottom = 80.dp), verticalArrangement = Arrangement.spacedBy(3.dp)
verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
SkeletonBubble(true, 0.45f, skeletonColor, shimmerAlpha) repeat(rowCount) { index ->
SkeletonBubble(false, 0.55f, skeletonColor, shimmerAlpha) val lineWidth =
SkeletonBubble(true, 0.35f, skeletonColor, shimmerAlpha) minOf(
SkeletonBubble(false, 0.50f, skeletonColor, shimmerAlpha) bubbleMaxWidth,
SkeletonBubble(true, 0.60f, skeletonColor, shimmerAlpha) 42.dp +
SkeletonBubble(false, 0.40f, skeletonColor, shimmerAlpha) containerWidth *
} (0.4f +
} widthRandom[index % widthRandom.size] *
} 0.35f)
)
val lineHeight =
64.dp +
(heightRandom[index % heightRandom.size] * 64f).dp
@Composable
private fun SkeletonBubble(
isOutgoing: Boolean,
widthFraction: Float,
bubbleColor: Color,
alpha: Float
) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start verticalAlignment = Alignment.Bottom
) { ) {
if (isGroupChat) {
Box( Box(
modifier = modifier =
Modifier.fillMaxWidth(widthFraction) Modifier.size(42.dp)
.defaultMinSize(minHeight = 44.dp) .clip(CircleShape)
.clip( .background(shimmerBrush)
RoundedCornerShape( .border(1.dp, outlineColor, CircleShape)
topStart = 18.dp, )
topEnd = 18.dp, Spacer(modifier = Modifier.width(avatarGap))
bottomStart = }
if (isOutgoing) 18.dp else 6.dp,
bottomEnd = if (isOutgoing) 6.dp else 18.dp Box(
modifier =
Modifier.width(lineWidth)
.height(lineHeight)
.clip(bubbleShape)
.background(shimmerBrush)
.border(
width = 1.dp,
color = outlineColor,
shape = bubbleShape
) )
) )
.background(bubbleColor.copy(alpha = alpha)) }
.padding(horizontal = 14.dp, vertical = 10.dp) }
) }
} }
} }

View File

@@ -343,6 +343,7 @@ private fun SimpleFullscreenPhotoContent(
localCaption = value localCaption = value
} }
} }
val isDrawingModeActive = currentTool == EditorTool.DRAW
LaunchedEffect(imageUri, sourceThumbnail) { LaunchedEffect(imageUri, sourceThumbnail) {
localCaption = caption 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() { fun hideKeyboard() {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0) imm.hideSoftInputFromWindow(view.windowToken, 0)
@@ -851,6 +861,7 @@ private fun SimpleFullscreenPhotoContent(
.statusBarsPadding() .statusBarsPadding()
.padding(horizontal = 4.dp, vertical = 8.dp) .padding(horizontal = 4.dp, vertical = 8.dp)
) { ) {
if (!isDrawingModeActive) {
IconButton( IconButton(
onClick = { closeViewer() }, onClick = { closeViewer() },
modifier = Modifier.align(Alignment.CenterStart) modifier = Modifier.align(Alignment.CenterStart)
@@ -863,6 +874,7 @@ private fun SimpleFullscreenPhotoContent(
) )
} }
} }
}
AnimatedVisibility( AnimatedVisibility(
visible = currentTool == EditorTool.DRAW && showColorPicker, visible = currentTool == EditorTool.DRAW && showColorPicker,
@@ -990,6 +1002,11 @@ private fun SimpleFullscreenPhotoContent(
} }
} }
AnimatedVisibility(
visible = !isDrawingModeActive,
enter = fadeIn() + slideInVertically { it / 2 },
exit = fadeOut() + slideOutVertically { it / 2 }
) {
Box( Box(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
@@ -1108,7 +1125,9 @@ private fun SimpleFullscreenPhotoContent(
} }
} }
} }
}
if (!isDrawingModeActive) {
AnimatedKeyboardTransition( AnimatedKeyboardTransition(
coordinator = coordinator, coordinator = coordinator,
showEmojiPicker = showEmojiPicker showEmojiPicker = showEmojiPicker
@@ -1124,4 +1143,5 @@ private fun SimpleFullscreenPhotoContent(
} }
} }
} }
}
} }

View File

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

View File

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

View File

@@ -253,7 +253,8 @@ fun BackupScreen(
.fillMaxWidth() .fillMaxWidth()
.height(52.dp), .height(52.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF248AE6) containerColor = Color(0xFF248AE6),
contentColor = Color.White
), ),
shape = RoundedCornerShape(10.dp) shape = RoundedCornerShape(10.dp)
) { ) {
@@ -266,7 +267,8 @@ fun BackupScreen(
Text( Text(
text = "Copy Seed Phrase", text = "Copy Seed Phrase",
fontSize = 15.sp, 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), Modifier.align(Alignment.TopCenter).offset(y = textY),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
val verifiedBadgeSize = (nameFontSize.value * 0.8f).toInt()
// Name + Verified Badge // Name + Verified Badge
Row( Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
Text( Text(
@@ -2150,20 +2150,25 @@ private fun CollapsingOtherProfileHeader(
color = textColor, color = textColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 220.dp), modifier = Modifier.widthIn(max = 220.dp).alignByBaseline(),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) { if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
Spacer(modifier = Modifier.width(4.dp)) Box(
modifier =
Modifier.padding(start = 4.dp)
.alignBy { (it.measuredHeight * 0.78f).toInt() }
) {
VerifiedBadge( VerifiedBadge(
verified = if (verified > 0) verified else 1, verified = if (verified > 0) verified else 1,
size = (nameFontSize.value * 0.8f).toInt(), size = verifiedBadgeSize,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
badgeTint = Color.White badgeTint = Color.White
) )
} }
} }
}
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))

View File

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