Релиз 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-правки в чате и компонентах ввода/эмодзи
This commit is contained in:
2026-03-16 00:02:27 +07:00
parent b01b2902b3
commit 297309db1f
10 changed files with 252 additions and 115 deletions

View File

@@ -1164,7 +1164,7 @@ fun ChatDetailScreen(
Row(
modifier =
Modifier.fillMaxWidth()
.height(56.dp)
.height(64.dp)
.padding(
horizontal =
4.dp
@@ -1336,7 +1336,7 @@ fun ChatDetailScreen(
Row(
modifier =
Modifier.fillMaxWidth()
.height(56.dp)
.height(64.dp)
.padding(
horizontal =
4.dp
@@ -2310,6 +2310,7 @@ fun ChatDetailScreen(
isLoading -> {
MessageSkeletonList(
isDarkTheme = isDarkTheme,
isGroupChat = isGroupChat,
modifier =
Modifier.fillMaxSize()
)

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

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) PrimaryBlue else Color.White
)
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

@@ -1898,12 +1898,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 +2573,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
)
)
}
}
}
}
}