Фикс: поведение синхронизации, проработка UX, проработка UI в групповых чатах, проработка анимаций AuthFlow

This commit is contained in:
2026-04-07 03:00:54 +05:00
parent 081bdb6d30
commit 6d14881fa2
11 changed files with 287 additions and 213 deletions

View File

@@ -1590,8 +1590,7 @@ fun MainScreen(
isVisible = isSearchVisible, isVisible = isSearchVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Search } }, onBack = { navStack = navStack.filterNot { it is Screen.Search } },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
layer = 1, layer = 1
deferToChildren = true
) { ) {
// Экран поиска // Экран поиска
SearchScreen( SearchScreen(

View File

@@ -508,6 +508,7 @@ interface MessageDao {
WHERE account = :account WHERE account = :account
AND from_me = 1 AND from_me = 1
AND delivered = 0 AND delivered = 0
AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]')
AND timestamp >= :minTimestamp AND timestamp >= :minTimestamp
ORDER BY timestamp ASC ORDER BY timestamp ASC
""" """
@@ -524,6 +525,7 @@ interface MessageDao {
WHERE account = :account WHERE account = :account
AND from_me = 1 AND from_me = 1
AND delivered = 0 AND delivered = 0
AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]')
AND timestamp < :maxTimestamp AND timestamp < :maxTimestamp
""" """
) )

View File

@@ -12,7 +12,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -35,19 +34,18 @@ fun SeedPhraseScreen(
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
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(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val cardBackground = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)
var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) } var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) }
var isGenerating by remember { mutableStateOf(true) }
var hasCopied by remember { mutableStateOf(false) } var hasCopied by remember { mutableStateOf(false) }
var visible by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
delay(100) // Генерируем фразу сразу, без задержек
seedPhrase = CryptoManager.generateSeedPhrase() seedPhrase = CryptoManager.generateSeedPhrase()
isGenerating = false // Даем микро-паузу, чтобы верстка отрисовалась, и запускаем анимацию
delay(50)
visible = true visible = true
} }
@@ -59,7 +57,7 @@ fun SeedPhraseScreen(
.navigationBarsPadding() .navigationBarsPadding()
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Simple top bar // Top bar
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -108,126 +106,106 @@ fun SeedPhraseScreen(
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
// Two column layout // Сетка со словами (без Crossfade и лоадера)
if (isGenerating) { if (seedPhrase.isNotEmpty()) {
Box( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() horizontalArrangement = Arrangement.spacedBy(12.dp)
.height(300.dp),
contentAlignment = Alignment.Center
) { ) {
CircularProgressIndicator( // Левая колонка (1-6)
color = PrimaryBlue, Column(
strokeWidth = 2.dp, modifier = Modifier.weight(1f),
modifier = Modifier.size(40.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
)
}
} else {
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500, delayMillis = 200))
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// Left column (words 1-6) for (i in 0..5) {
Column( AnimatedWordItem(
modifier = Modifier.weight(1f), number = i + 1,
verticalArrangement = Arrangement.spacedBy(12.dp) word = seedPhrase[i],
) { isDarkTheme = isDarkTheme,
for (i in 0..5) { visible = visible,
AnimatedWordItem( delay = i * 60
number = i + 1, )
word = seedPhrase[i],
isDarkTheme = isDarkTheme,
visible = visible,
delay = 300 + (i * 50)
)
}
} }
}
// Right column (words 7-12)
Column( // Правая колонка (7-12)
modifier = Modifier.weight(1f), Column(
verticalArrangement = Arrangement.spacedBy(12.dp) modifier = Modifier.weight(1f),
) { verticalArrangement = Arrangement.spacedBy(12.dp)
for (i in 6..11) { ) {
AnimatedWordItem( for (i in 6..11) {
number = i + 1, AnimatedWordItem(
word = seedPhrase[i], number = i + 1,
isDarkTheme = isDarkTheme, word = seedPhrase[i],
visible = visible, isDarkTheme = isDarkTheme,
delay = 300 + (i * 50) visible = visible,
) delay = i * 60
} )
} }
} }
} }
} }
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(24.dp))
// Copy button // Кнопка Copy
if (!isGenerating) { AnimatedVisibility(
AnimatedVisibility( visible = visible,
visible = visible, enter = fadeIn(tween(400, delayMillis = 800)) + scaleIn(
enter = fadeIn(tween(500, delayMillis = 600)) + scaleIn( initialScale = 0.8f,
initialScale = 0.8f, animationSpec = tween(400, delayMillis = 800, easing = LinearOutSlowInEasing)
animationSpec = tween(500, delayMillis = 600) )
) ) {
) { TextButton(
TextButton( onClick = {
onClick = { clipboardManager.setText(AnnotatedString(seedPhrase.joinToString(" ")))
clipboardManager.setText(AnnotatedString(seedPhrase.joinToString(" "))) hasCopied = true
hasCopied = true scope.launch {
scope.launch { delay(2000)
delay(2000) hasCopied = false
hasCopied = false
}
} }
) {
Icon(
imageVector = if (hasCopied) Icons.Default.Check else Icons.Default.ContentCopy,
contentDescription = null,
tint = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (hasCopied) "Copied" else "Copy to clipboard",
color = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue,
fontSize = 15.sp
)
} }
) {
Icon(
imageVector = if (hasCopied) Icons.Default.Check else Icons.Default.ContentCopy,
contentDescription = null,
tint = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (hasCopied) "Copied" else "Copy to clipboard",
color = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue,
fontSize = 15.sp
)
} }
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
// Continue button // Кнопка Continue
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(400, delayMillis = 700)) enter = fadeIn(tween(400, delayMillis = 900)) + slideInVertically(
initialOffsetY = { 20 },
animationSpec = tween(400, delayMillis = 900)
)
) { ) {
Button( Button(
onClick = { onConfirm(seedPhrase) }, onClick = { onConfirm(seedPhrase) },
enabled = !isGenerating,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(50.dp), .height(52.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue, containerColor = PrimaryBlue,
contentColor = Color.White, contentColor = Color.White
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f),
disabledContentColor = Color.White.copy(alpha = 0.5f)
), ),
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(14.dp)
) { ) {
Text( Text(
text = "Continue", text = "Continue",
fontSize = 17.sp, fontSize = 17.sp,
fontWeight = FontWeight.Medium fontWeight = FontWeight.SemiBold
) )
} }
} }
@@ -246,21 +224,11 @@ private fun WordItem(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999) val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999)
// Beautiful solid colors that fit the theme
val wordColors = listOf( val wordColors = listOf(
Color(0xFF5E9FFF), // Soft blue Color(0xFF5E9FFF), Color(0xFFFF7EB3), Color(0xFF7B68EE),
Color(0xFFFF7EB3), // Soft pink Color(0xFF50C878), Color(0xFFFF6B6B), Color(0xFF4ECDC4),
Color(0xFF7B68EE), // Medium purple Color(0xFFFFB347), Color(0xFFBA55D3), Color(0xFF87CEEB),
Color(0xFF50C878), // Emerald green Color(0xFFDDA0DD), Color(0xFF98D8C8), Color(0xFFF7DC6F)
Color(0xFFFF6B6B), // Coral red
Color(0xFF4ECDC4), // Teal
Color(0xFFFFB347), // Pastel orange
Color(0xFFBA55D3), // Medium orchid
Color(0xFF87CEEB), // Sky blue
Color(0xFFDDA0DD), // Plum
Color(0xFF98D8C8), // Mint
Color(0xFFF7DC6F) // Soft yellow
) )
val wordColor = wordColors[(number - 1) % wordColors.size] val wordColor = wordColors[(number - 1) % wordColors.size]
@@ -271,21 +239,18 @@ private fun WordItem(
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(bgColor) .background(bgColor)
.padding(horizontal = 16.dp, vertical = 14.dp) .padding(horizontal = 14.dp, vertical = 12.dp)
) { ) {
Row( Row(verticalAlignment = Alignment.CenterVertically) {
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text( Text(
text = "$number.", text = "$number.",
fontSize = 15.sp, fontSize = 13.sp,
color = numberColor, color = numberColor,
modifier = Modifier.width(28.dp) modifier = Modifier.width(26.dp)
) )
Text( Text(
text = word, text = word,
fontSize = 17.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = wordColor, color = wordColor,
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace
@@ -303,15 +268,21 @@ private fun AnimatedWordItem(
delay: Int, delay: Int,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val overshootEasing = remember { CubicBezierEasing(0.175f, 0.885f, 0.32f, 1.275f) }
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(400, delayMillis = delay)) enter = fadeIn(animationSpec = tween(300, delayMillis = delay)) +
slideInVertically(
initialOffsetY = { 30 },
animationSpec = tween(400, delayMillis = delay, easing = FastOutSlowInEasing)
) +
scaleIn(
initialScale = 0.85f,
animationSpec = tween(400, delayMillis = delay, easing = overshootEasing)
),
modifier = modifier
) { ) {
WordItem( WordItem(number, word, isDarkTheme)
number = number,
word = word,
isDarkTheme = isDarkTheme,
modifier = modifier
)
} }
} }

View File

@@ -620,6 +620,50 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
updateCacheFromCurrentMessages() updateCacheFromCurrentMessages()
} }
/**
* Для исходящих media-сообщений (фото/файл/аватар) не держим "часики" после фактической отправки:
* optimistic WAITING в БД должен отображаться как SENT, если localUri уже очищен.
*/
private fun shouldTreatWaitingAsSent(entity: MessageEntity): Boolean {
if (entity.fromMe != 1 || entity.primaryAttachmentType < 0) return false
val attachments = parseAttachmentsJsonArray(entity.attachments) ?: return false
if (attachments.length() == 0) return false
var hasMediaAttachment = false
for (index in 0 until attachments.length()) {
val attachment = attachments.optJSONObject(index) ?: continue
when (parseAttachmentType(attachment)) {
AttachmentType.IMAGE,
AttachmentType.FILE,
AttachmentType.AVATAR -> {
hasMediaAttachment = true
if (attachment.optString("localUri", "").isNotBlank()) {
// Локальный URI ещё есть => загрузка/подготовка не завершена.
return false
}
}
AttachmentType.UNKNOWN -> continue
else -> return false
}
}
return hasMediaAttachment
}
private fun mapEntityDeliveryStatus(entity: MessageEntity): MessageStatus {
return when (entity.delivered) {
DeliveryStatus.WAITING.value ->
if (shouldTreatWaitingAsSent(entity)) MessageStatus.SENT
else MessageStatus.SENDING
DeliveryStatus.DELIVERED.value ->
if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
DeliveryStatus.ERROR.value -> MessageStatus.ERROR
DeliveryStatus.READ.value -> MessageStatus.READ
else -> MessageStatus.SENT
}
}
private fun shortPhotoId(value: String, limit: Int = 8): String { private fun shortPhotoId(value: String, limit: Int = 8): String {
val trimmed = value.trim() val trimmed = value.trim()
if (trimmed.isEmpty()) return "unknown" if (trimmed.isEmpty()) return "unknown"
@@ -1045,14 +1089,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
currentMessages.map { message -> currentMessages.map { message ->
val entity = entitiesById[message.id] ?: return@map message val entity = entitiesById[message.id] ?: return@map message
val dbStatus = val dbStatus = mapEntityDeliveryStatus(entity)
when (entity.delivered) {
0 -> MessageStatus.SENDING
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
2 -> MessageStatus.ERROR
3 -> MessageStatus.READ
else -> MessageStatus.SENT
}
var updatedMessage = message var updatedMessage = message
if (updatedMessage.status != dbStatus) { if (updatedMessage.status != dbStatus) {
@@ -1371,14 +1408,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
text = displayText, text = displayText,
isOutgoing = entity.fromMe == 1, isOutgoing = entity.fromMe == 1,
timestamp = Date(entity.timestamp), timestamp = Date(entity.timestamp),
status = status = mapEntityDeliveryStatus(entity),
when (entity.delivered) {
0 -> MessageStatus.SENDING
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
2 -> MessageStatus.ERROR
3 -> MessageStatus.READ
else -> MessageStatus.SENT
},
replyData = if (forwardedMessages.isNotEmpty()) null else replyData, replyData = if (forwardedMessages.isNotEmpty()) null else replyData,
forwardedMessages = forwardedMessages, forwardedMessages = forwardedMessages,
attachments = finalAttachments, attachments = finalAttachments,
@@ -3813,17 +3843,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
logPhotoPipeline(messageId, "db status+attachments updated") logPhotoPipeline(messageId, "db status+attachments updated")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (isSavedMessages) { updateMessageStatus(messageId, MessageStatus.SENT)
updateMessageStatus(messageId, MessageStatus.SENT)
}
// Также очищаем localUri в UI // Также очищаем localUri в UI
updateMessageAttachments(messageId, null) updateMessageAttachments(messageId, null)
} }
logPhotoPipeline( logPhotoPipeline(messageId, "ui status switched to SENT")
messageId,
if (isSavedMessages) "ui status switched to SENT"
else "ui status kept at SENDING until delivery ACK"
)
saveDialog( saveDialog(
lastMessage = if (caption.isNotEmpty()) caption else "photo", lastMessage = if (caption.isNotEmpty()) caption else "photo",
@@ -4023,11 +4047,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
opponentPublicKey = recipient opponentPublicKey = recipient
) )
// Для обычных диалогов остаёмся в SENDING до PacketDelivery(messageId). // После успешной отправки пакета фиксируем SENT (без ложного timeout->ERROR).
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (isSavedMessages) { updateMessageStatus(messageId, MessageStatus.SENT)
updateMessageStatus(messageId, MessageStatus.SENT)
}
} }
saveDialog( saveDialog(
@@ -4311,9 +4333,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_messages.value.map { msg -> _messages.value.map { msg ->
if (msg.id != messageId) return@map msg if (msg.id != messageId) return@map msg
msg.copy( msg.copy(
status = status = MessageStatus.SENT,
if (isSavedMessages) MessageStatus.SENT
else MessageStatus.SENDING,
attachments = attachments =
msg.attachments.map { current -> msg.attachments.map { current ->
val final = finalAttachmentsById[current.id] val final = finalAttachmentsById[current.id]
@@ -4544,11 +4564,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
opponentPublicKey = recipient opponentPublicKey = recipient
) )
// Обновляем UI: для обычных чатов оставляем SENDING до PacketDelivery(messageId). // После успешной отправки медиа переводим в SENT.
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (isSavedMessages) { updateMessageStatus(messageId, MessageStatus.SENT)
updateMessageStatus(messageId, MessageStatus.SENT)
}
} }
saveDialog( saveDialog(

View File

@@ -610,6 +610,7 @@ fun ChatsListScreen(
val topLevelChatsState by chatsViewModel.chatsState.collectAsState() val topLevelChatsState by chatsViewModel.chatsState.collectAsState()
val topLevelIsLoading by chatsViewModel.isLoading.collectAsState() val topLevelIsLoading by chatsViewModel.isLoading.collectAsState()
val topLevelRequestsCount = topLevelChatsState.requestsCount val topLevelRequestsCount = topLevelChatsState.requestsCount
val visibleTopLevelRequestsCount = if (syncInProgress) 0 else topLevelRequestsCount
// Dev console dialog - commented out for now // Dev console dialog - commented out for now
/* /*
@@ -1163,7 +1164,7 @@ fun ChatsListScreen(
text = "Requests", text = "Requests",
iconColor = menuIconColor, iconColor = menuIconColor,
textColor = menuTextColor, textColor = menuTextColor,
badge = if (topLevelRequestsCount > 0) topLevelRequestsCount.toString() else null, badge = if (visibleTopLevelRequestsCount > 0) visibleTopLevelRequestsCount.toString() else null,
badgeColor = accentColor, badgeColor = accentColor,
onClick = { onClick = {
scope.launch { scope.launch {
@@ -1598,7 +1599,7 @@ fun ChatsListScreen(
) )
// Badge с числом запросов // Badge с числом запросов
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
visible = topLevelRequestsCount > 0, visible = visibleTopLevelRequestsCount > 0,
enter = scaleIn( enter = scaleIn(
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy, dampingRatio = Spring.DampingRatioMediumBouncy,
@@ -1608,10 +1609,10 @@ fun ChatsListScreen(
exit = scaleOut() + fadeOut(), exit = scaleOut() + fadeOut(),
modifier = Modifier.align(Alignment.TopEnd) modifier = Modifier.align(Alignment.TopEnd)
) { ) {
val badgeText = remember(topLevelRequestsCount) { val badgeText = remember(visibleTopLevelRequestsCount) {
when { when {
topLevelRequestsCount > 99 -> "99+" visibleTopLevelRequestsCount > 99 -> "99+"
else -> topLevelRequestsCount.toString() else -> visibleTopLevelRequestsCount.toString()
} }
} }
val badgeBg = Color.White val badgeBg = Color.White
@@ -1679,7 +1680,7 @@ fun ChatsListScreen(
) )
} else if (syncInProgress) { } else if (syncInProgress) {
AnimatedDotsText( AnimatedDotsText(
baseText = "Synchronizing", baseText = "Updating",
color = Color.White, color = Color.White,
fontSize = 20.sp, fontSize = 20.sp,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
@@ -1858,8 +1859,8 @@ fun ChatsListScreen(
// независимо // независимо
val chatsState = topLevelChatsState val chatsState = topLevelChatsState
val isLoading = topLevelIsLoading val isLoading = topLevelIsLoading
val requests = chatsState.requests val requests = if (syncInProgress) emptyList() else chatsState.requests
val requestsCount = chatsState.requestsCount val requestsCount = if (syncInProgress) 0 else chatsState.requestsCount
val showSkeleton by val showSkeleton by
produceState( produceState(
@@ -4118,6 +4119,7 @@ fun DialogItemContent(
val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black }
val secondaryTextColor = val secondaryTextColor =
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) } remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
val visibleUnreadCount = if (syncInProgress) 0 else dialog.unreadCount
val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) } val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) }
@@ -4450,7 +4452,7 @@ fun DialogItemContent(
text = formattedTime, text = formattedTime,
fontSize = 13.sp, fontSize = 13.sp,
color = color =
if (dialog.unreadCount > 0) PrimaryBlue if (visibleUnreadCount > 0) PrimaryBlue
else secondaryTextColor else secondaryTextColor
) )
} }
@@ -4590,7 +4592,7 @@ fun DialogItemContent(
baseDisplayText, baseDisplayText,
fontSize = 14.sp, fontSize = 14.sp,
color = color =
if (dialog.unreadCount > if (visibleUnreadCount >
0 0
) )
textColor.copy( textColor.copy(
@@ -4600,7 +4602,7 @@ fun DialogItemContent(
else else
secondaryTextColor, secondaryTextColor,
fontWeight = fontWeight =
if (dialog.unreadCount > if (visibleUnreadCount >
0 0
) )
FontWeight.Medium FontWeight.Medium
@@ -4621,11 +4623,11 @@ fun DialogItemContent(
text = baseDisplayText, text = baseDisplayText,
fontSize = 14.sp, fontSize = 14.sp,
color = color =
if (dialog.unreadCount > 0) if (visibleUnreadCount > 0)
textColor.copy(alpha = 0.85f) textColor.copy(alpha = 0.85f)
else secondaryTextColor, else secondaryTextColor,
fontWeight = fontWeight =
if (dialog.unreadCount > 0) if (visibleUnreadCount > 0)
FontWeight.Medium FontWeight.Medium
else FontWeight.Normal, else FontWeight.Normal,
maxLines = 1, maxLines = 1,
@@ -4660,15 +4662,15 @@ fun DialogItemContent(
} }
// Unread badge // Unread badge
if (dialog.unreadCount > 0) { if (visibleUnreadCount > 0) {
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
val unreadText = val unreadText =
remember(dialog.unreadCount) { remember(visibleUnreadCount) {
when { when {
dialog.unreadCount > 999 -> "999+" visibleUnreadCount > 999 -> "999+"
dialog.unreadCount > 99 -> "99+" visibleUnreadCount > 99 -> "99+"
else -> else ->
dialog.unreadCount visibleUnreadCount
.toString() .toString()
} }
} }

View File

@@ -16,6 +16,7 @@ import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@@ -391,20 +392,31 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} }
// Подписываемся на обычные диалоги // Подписываемся на обычные диалоги
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
launch { launch {
dialogDao dialogDao
.getDialogsFlow(publicKey) .getDialogsFlow(publicKey)
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO .flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка) .debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
.map { dialogsList -> .combine(ProtocolManager.syncInProgress) { dialogsList, syncing ->
mapDialogListIncremental( dialogsList to syncing
dialogsList = dialogsList,
privateKey = privateKey,
cache = dialogsUiCache,
isRequestsFlow = false
)
} }
.mapLatest { (dialogsList, syncing) ->
// Desktop behavior parity:
// while sync is active we keep current chats list stable (no per-message UI churn),
// then apply one consolidated update when sync finishes.
if (syncing && _dialogs.value.isNotEmpty()) {
null
} else {
mapDialogListIncremental(
dialogsList = dialogsList,
privateKey = privateKey,
cache = dialogsUiCache,
isRequestsFlow = false
)
}
}
.filterNotNull()
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedDialogs -> .collect { decryptedDialogs ->
_dialogs.value = decryptedDialogs _dialogs.value = decryptedDialogs
@@ -423,19 +435,26 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} }
// 📬 Подписываемся на requests (запросы от новых пользователей) // 📬 Подписываемся на requests (запросы от новых пользователей)
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
launch { launch {
dialogDao dialogDao
.getRequestsFlow(publicKey) .getRequestsFlow(publicKey)
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.debounce(100) // 🚀 Батчим быстрые обновления .debounce(100) // 🚀 Батчим быстрые обновления
.map { requestsList -> .combine(ProtocolManager.syncInProgress) { requestsList, syncing ->
mapDialogListIncremental( requestsList to syncing
dialogsList = requestsList, }
privateKey = privateKey, .mapLatest { (requestsList, syncing) ->
cache = requestsUiCache, if (syncing) {
isRequestsFlow = true emptyList()
) } else {
mapDialogListIncremental(
dialogsList = requestsList,
privateKey = privateKey,
cache = requestsUiCache,
isRequestsFlow = true
)
}
} }
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedRequests -> _requests.value = decryptedRequests } .collect { decryptedRequests -> _requests.value = decryptedRequests }
@@ -446,6 +465,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
dialogDao dialogDao
.getRequestsCountFlow(publicKey) .getRequestsCountFlow(publicKey)
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.combine(ProtocolManager.syncInProgress) { count, syncing ->
if (syncing) 0 else count
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
.collect { count -> _requestsCount.value = count } .collect { count -> _requestsCount.value = count }
} }

View File

@@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@@ -39,12 +40,12 @@ fun RequestsListScreen(
avatarRepository: AvatarRepository? = null avatarRepository: AvatarRepository? = null
) { ) {
val chatsState by chatsViewModel.chatsState.collectAsState() val chatsState by chatsViewModel.chatsState.collectAsState()
val requests = chatsState.requests val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
val requests = if (syncInProgress) emptyList() else chatsState.requests
val blockedUsers by chatsViewModel.blockedUsers.collectAsState() val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val headerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4) val headerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
val textColor = if (isDarkTheme) Color.White else Color.Black
Scaffold( Scaffold(
topBar = { topBar = {
@@ -60,7 +61,7 @@ fun RequestsListScreen(
}, },
title = { title = {
Text( Text(
text = "Requests", text = if (syncInProgress) "Updating..." else "Requests",
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 20.sp, fontSize = 20.sp,
color = Color.White color = Color.White

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -238,14 +239,10 @@ fun SearchScreen(
} }
} }
Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) { Box(modifier = Modifier.fillMaxSize()) {
detectHorizontalDragGestures { _, dragAmount -> val hideKbScrollConnection = remember { HideKeyboardNestedScroll(view, focusManager) }
if (dragAmount > 10f) {
hideKeyboardInstantly()
}
}
}) {
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(hideKbScrollConnection),
topBar = { topBar = {
// Хедер как в Telegram: стрелка назад + поле ввода // Хедер как в Telegram: стрелка назад + поле ввода
Surface( Surface(
@@ -538,7 +535,7 @@ private fun ChatsTabContent(
if (searchQuery.isEmpty()) { if (searchQuery.isEmpty()) {
// ═══ Idle state: frequent contacts + recent searches ═══ // ═══ Idle state: frequent contacts + recent searches ═══
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize().imePadding()
) { ) {
// ─── Горизонтальный ряд частых контактов (как в Telegram) ─── // ─── Горизонтальный ряд частых контактов (как в Telegram) ───
if (visibleFrequentContacts.isNotEmpty()) { if (visibleFrequentContacts.isNotEmpty()) {
@@ -1183,7 +1180,7 @@ private fun MessagesTabContent(
} }
else -> { else -> {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize().imePadding(),
contentPadding = PaddingValues(vertical = 4.dp) contentPadding = PaddingValues(vertical = 4.dp)
) { ) {
items(results, key = { it.messageId }) { result -> items(results, key = { it.messageId }) { result ->
@@ -1757,7 +1754,7 @@ private fun DownloadsTabContent(
) )
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize().imePadding(),
contentPadding = PaddingValues(vertical = 4.dp) contentPadding = PaddingValues(vertical = 4.dp)
) { ) {
items(files, key = { it.name }) { downloadedFile -> items(files, key = { it.name }) { downloadedFile ->
@@ -1909,7 +1906,7 @@ private fun FilesTabContent(
} else { } else {
val dateFormat = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) } val dateFormat = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) }
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize().imePadding(),
contentPadding = PaddingValues(vertical = 4.dp) contentPadding = PaddingValues(vertical = 4.dp)
) { ) {
items(fileItems, key = { "${it.messageId}_${it.attachmentId}" }) { item -> items(fileItems, key = { "${it.messageId}_${it.attachmentId}" }) { item ->
@@ -2163,3 +2160,21 @@ private fun RecentUserItem(
} }
} }
} }
/** NestedScrollConnection который скрывает клавиатуру при любом вертикальном скролле */
private class HideKeyboardNestedScroll(
private val view: android.view.View,
private val focusManager: androidx.compose.ui.focus.FocusManager
) : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
override fun onPreScroll(
available: androidx.compose.ui.geometry.Offset,
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
): androidx.compose.ui.geometry.Offset {
if (kotlin.math.abs(available.y) > 0.5f) {
val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
}
return androidx.compose.ui.geometry.Offset.Zero
}
}

View File

@@ -145,6 +145,42 @@ private fun shortDebugHash(bytes: ByteArray): String {
} }
} }
private const val LEGACY_ATTACHMENT_ERROR_TEXT =
"This attachment is no longer available because it was sent for a previous version of the app."
@Composable
private fun LegacyAttachmentErrorCard(
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val borderColor = if (isDarkTheme) Color(0xFF2A2F38) else Color(0xFFE3E7EF)
val backgroundColor = if (isDarkTheme) Color(0xFF1E232B) else Color(0xFFF7F9FC)
val textColor = if (isDarkTheme) Color(0xFFE9EDF5) else Color(0xFF2B3340)
Row(
modifier =
modifier.fillMaxWidth()
.border(1.dp, borderColor, RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(8.dp))
.background(backgroundColor)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
tint = Color(0xFFE55A5A),
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = LEGACY_ATTACHMENT_ERROR_TEXT,
color = textColor,
fontSize = 12.sp
)
}
}
/** /**
* Анимированный текст с волнообразными точками. * Анимированный текст с волнообразными точками.
* Три точки плавно подпрыгивают каскадом с изменением прозрачности. * Три точки плавно подпрыгивают каскадом с изменением прозрачности.
@@ -538,7 +574,8 @@ fun MessageAttachments(
) )
} }
else -> { else -> {
/* MESSAGES обрабатываются отдельно */ // Desktop parity: unsupported/legacy attachment gets explicit compatibility card.
LegacyAttachmentErrorCard(isDarkTheme = isDarkTheme)
} }
} }
} }

View File

@@ -573,6 +573,7 @@ fun MessageBubble(
val telegramIncomingAvatarSize = 42.dp val telegramIncomingAvatarSize = 42.dp
val telegramIncomingAvatarLane = 48.dp val telegramIncomingAvatarLane = 48.dp
val telegramIncomingAvatarInset = 6.dp val telegramIncomingAvatarInset = 6.dp
val telegramIncomingBubbleGap = 6.dp
val shouldShowIncomingGroupAvatar = val shouldShowIncomingGroupAvatar =
showIncomingGroupAvatar showIncomingGroupAvatar
?: (isGroupChat && ?: (isGroupChat &&
@@ -813,7 +814,13 @@ fun MessageBubble(
Box( Box(
modifier = modifier =
Modifier.align(Alignment.Bottom) Modifier.align(Alignment.Bottom)
.padding(end = 12.dp) .padding(
start =
if (!message.isOutgoing && isGroupChat)
telegramIncomingBubbleGap
else 0.dp,
end = 12.dp
)
.then(bubbleWidthModifier) .then(bubbleWidthModifier)
.graphicsLayer { .graphicsLayer {
this.alpha = selectionAlpha this.alpha = selectionAlpha

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB