diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 3da3783..4dd5c36 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -1590,8 +1590,7 @@ fun MainScreen( isVisible = isSearchVisible, onBack = { navStack = navStack.filterNot { it is Screen.Search } }, isDarkTheme = isDarkTheme, - layer = 1, - deferToChildren = true + layer = 1 ) { // Экран поиска SearchScreen( diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index a191bb3..c1a6ae9 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -508,6 +508,7 @@ interface MessageDao { WHERE account = :account AND from_me = 1 AND delivered = 0 + AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]') AND timestamp >= :minTimestamp ORDER BY timestamp ASC """ @@ -524,6 +525,7 @@ interface MessageDao { WHERE account = :account AND from_me = 1 AND delivered = 0 + AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]') AND timestamp < :maxTimestamp """ ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt index 9059251..dac0590 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -35,19 +34,18 @@ fun SeedPhraseScreen( val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - val cardBackground = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5) var seedPhrase by remember { mutableStateOf>(emptyList()) } - var isGenerating by remember { mutableStateOf(true) } var hasCopied by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val clipboardManager = LocalClipboardManager.current LaunchedEffect(Unit) { - delay(100) + // Генерируем фразу сразу, без задержек seedPhrase = CryptoManager.generateSeedPhrase() - isGenerating = false + // Даем микро-паузу, чтобы верстка отрисовалась, и запускаем анимацию + delay(50) visible = true } @@ -59,7 +57,7 @@ fun SeedPhraseScreen( .navigationBarsPadding() ) { Column(modifier = Modifier.fillMaxSize()) { - // Simple top bar + // Top bar Row( modifier = Modifier .fillMaxWidth() @@ -108,126 +106,106 @@ fun SeedPhraseScreen( Spacer(modifier = Modifier.height(32.dp)) - // Two column layout - if (isGenerating) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(300.dp), - contentAlignment = Alignment.Center + // Сетка со словами (без Crossfade и лоадера) + if (seedPhrase.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - CircularProgressIndicator( - color = PrimaryBlue, - strokeWidth = 2.dp, - modifier = Modifier.size(40.dp) - ) - } - } else { - AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(500, delayMillis = 200)) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + // Левая колонка (1-6) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - // Left column (words 1-6) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - for (i in 0..5) { - AnimatedWordItem( - number = i + 1, - word = seedPhrase[i], - isDarkTheme = isDarkTheme, - visible = visible, - delay = 300 + (i * 50) - ) - } + for (i in 0..5) { + AnimatedWordItem( + number = i + 1, + word = seedPhrase[i], + isDarkTheme = isDarkTheme, + visible = visible, + delay = i * 60 + ) } - - // Right column (words 7-12) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - for (i in 6..11) { - AnimatedWordItem( - number = i + 1, - word = seedPhrase[i], - isDarkTheme = isDarkTheme, - visible = visible, - delay = 300 + (i * 50) - ) - } + } + + // Правая колонка (7-12) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + for (i in 6..11) { + AnimatedWordItem( + number = i + 1, + word = seedPhrase[i], + isDarkTheme = isDarkTheme, + visible = visible, + delay = i * 60 + ) } } } } - Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.height(24.dp)) - // Copy button - if (!isGenerating) { - AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(500, delayMillis = 600)) + scaleIn( - initialScale = 0.8f, - animationSpec = tween(500, delayMillis = 600) - ) - ) { - TextButton( - onClick = { - clipboardManager.setText(AnnotatedString(seedPhrase.joinToString(" "))) - hasCopied = true - scope.launch { - delay(2000) - hasCopied = false - } + // Кнопка Copy + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(400, delayMillis = 800)) + scaleIn( + initialScale = 0.8f, + animationSpec = tween(400, delayMillis = 800, easing = LinearOutSlowInEasing) + ) + ) { + TextButton( + onClick = { + clipboardManager.setText(AnnotatedString(seedPhrase.joinToString(" "))) + hasCopied = true + scope.launch { + delay(2000) + 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)) - // Continue button + // Кнопка Continue AnimatedVisibility( visible = visible, - enter = fadeIn(tween(400, delayMillis = 700)) + enter = fadeIn(tween(400, delayMillis = 900)) + slideInVertically( + initialOffsetY = { 20 }, + animationSpec = tween(400, delayMillis = 900) + ) ) { Button( onClick = { onConfirm(seedPhrase) }, - enabled = !isGenerating, modifier = Modifier .fillMaxWidth() - .height(50.dp), + .height(52.dp), colors = ButtonDefaults.buttonColors( containerColor = PrimaryBlue, - contentColor = Color.White, - disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f), - disabledContentColor = Color.White.copy(alpha = 0.5f) + contentColor = Color.White ), - shape = RoundedCornerShape(12.dp) + shape = RoundedCornerShape(14.dp) ) { Text( text = "Continue", fontSize = 17.sp, - fontWeight = FontWeight.Medium + fontWeight = FontWeight.SemiBold ) } } @@ -246,21 +224,11 @@ private fun WordItem( modifier: Modifier = Modifier ) { val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999) - - // Beautiful solid colors that fit the theme val wordColors = listOf( - Color(0xFF5E9FFF), // Soft blue - Color(0xFFFF7EB3), // Soft pink - Color(0xFF7B68EE), // Medium purple - Color(0xFF50C878), // Emerald green - 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 + Color(0xFF5E9FFF), Color(0xFFFF7EB3), Color(0xFF7B68EE), + Color(0xFF50C878), Color(0xFFFF6B6B), Color(0xFF4ECDC4), + Color(0xFFFFB347), Color(0xFFBA55D3), Color(0xFF87CEEB), + Color(0xFFDDA0DD), Color(0xFF98D8C8), Color(0xFFF7DC6F) ) val wordColor = wordColors[(number - 1) % wordColors.size] @@ -271,21 +239,18 @@ private fun WordItem( .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .background(bgColor) - .padding(horizontal = 16.dp, vertical = 14.dp) + .padding(horizontal = 14.dp, vertical = 12.dp) ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { + Row(verticalAlignment = Alignment.CenterVertically) { Text( text = "$number.", - fontSize = 15.sp, + fontSize = 13.sp, color = numberColor, - modifier = Modifier.width(28.dp) + modifier = Modifier.width(26.dp) ) Text( text = word, - fontSize = 17.sp, + fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = wordColor, fontFamily = FontFamily.Monospace @@ -303,15 +268,21 @@ private fun AnimatedWordItem( delay: Int, modifier: Modifier = Modifier ) { + val overshootEasing = remember { CubicBezierEasing(0.175f, 0.885f, 0.32f, 1.275f) } + AnimatedVisibility( 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( - number = number, - word = word, - isDarkTheme = isDarkTheme, - modifier = modifier - ) + WordItem(number, word, isDarkTheme) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 7da3eaf..e0096cf 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -620,6 +620,50 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { 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 { val trimmed = value.trim() if (trimmed.isEmpty()) return "unknown" @@ -1045,14 +1089,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { currentMessages.map { message -> val entity = entitiesById[message.id] ?: return@map message - val dbStatus = - 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 - } + val dbStatus = mapEntityDeliveryStatus(entity) var updatedMessage = message if (updatedMessage.status != dbStatus) { @@ -1371,14 +1408,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { text = displayText, isOutgoing = entity.fromMe == 1, timestamp = Date(entity.timestamp), - status = - 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 - }, + status = mapEntityDeliveryStatus(entity), replyData = if (forwardedMessages.isNotEmpty()) null else replyData, forwardedMessages = forwardedMessages, attachments = finalAttachments, @@ -3813,17 +3843,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { logPhotoPipeline(messageId, "db status+attachments updated") withContext(Dispatchers.Main) { - if (isSavedMessages) { - updateMessageStatus(messageId, MessageStatus.SENT) - } + updateMessageStatus(messageId, MessageStatus.SENT) // Также очищаем localUri в UI updateMessageAttachments(messageId, null) } - logPhotoPipeline( - messageId, - if (isSavedMessages) "ui status switched to SENT" - else "ui status kept at SENDING until delivery ACK" - ) + logPhotoPipeline(messageId, "ui status switched to SENT") saveDialog( lastMessage = if (caption.isNotEmpty()) caption else "photo", @@ -4023,11 +4047,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { opponentPublicKey = recipient ) - // Для обычных диалогов остаёмся в SENDING до PacketDelivery(messageId). + // После успешной отправки пакета фиксируем SENT (без ложного timeout->ERROR). withContext(Dispatchers.Main) { - if (isSavedMessages) { - updateMessageStatus(messageId, MessageStatus.SENT) - } + updateMessageStatus(messageId, MessageStatus.SENT) } saveDialog( @@ -4311,9 +4333,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { _messages.value.map { msg -> if (msg.id != messageId) return@map msg msg.copy( - status = - if (isSavedMessages) MessageStatus.SENT - else MessageStatus.SENDING, + status = MessageStatus.SENT, attachments = msg.attachments.map { current -> val final = finalAttachmentsById[current.id] @@ -4544,11 +4564,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { opponentPublicKey = recipient ) - // Обновляем UI: для обычных чатов оставляем SENDING до PacketDelivery(messageId). + // После успешной отправки медиа переводим в SENT. withContext(Dispatchers.Main) { - if (isSavedMessages) { - updateMessageStatus(messageId, MessageStatus.SENT) - } + updateMessageStatus(messageId, MessageStatus.SENT) } saveDialog( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index cb680f0..991ad0e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -610,6 +610,7 @@ fun ChatsListScreen( val topLevelChatsState by chatsViewModel.chatsState.collectAsState() val topLevelIsLoading by chatsViewModel.isLoading.collectAsState() val topLevelRequestsCount = topLevelChatsState.requestsCount + val visibleTopLevelRequestsCount = if (syncInProgress) 0 else topLevelRequestsCount // Dev console dialog - commented out for now /* @@ -1163,7 +1164,7 @@ fun ChatsListScreen( text = "Requests", iconColor = menuIconColor, textColor = menuTextColor, - badge = if (topLevelRequestsCount > 0) topLevelRequestsCount.toString() else null, + badge = if (visibleTopLevelRequestsCount > 0) visibleTopLevelRequestsCount.toString() else null, badgeColor = accentColor, onClick = { scope.launch { @@ -1598,7 +1599,7 @@ fun ChatsListScreen( ) // Badge с числом запросов androidx.compose.animation.AnimatedVisibility( - visible = topLevelRequestsCount > 0, + visible = visibleTopLevelRequestsCount > 0, enter = scaleIn( animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, @@ -1608,10 +1609,10 @@ fun ChatsListScreen( exit = scaleOut() + fadeOut(), modifier = Modifier.align(Alignment.TopEnd) ) { - val badgeText = remember(topLevelRequestsCount) { + val badgeText = remember(visibleTopLevelRequestsCount) { when { - topLevelRequestsCount > 99 -> "99+" - else -> topLevelRequestsCount.toString() + visibleTopLevelRequestsCount > 99 -> "99+" + else -> visibleTopLevelRequestsCount.toString() } } val badgeBg = Color.White @@ -1679,7 +1680,7 @@ fun ChatsListScreen( ) } else if (syncInProgress) { AnimatedDotsText( - baseText = "Synchronizing", + baseText = "Updating", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold @@ -1858,8 +1859,8 @@ fun ChatsListScreen( // независимо val chatsState = topLevelChatsState val isLoading = topLevelIsLoading - val requests = chatsState.requests - val requestsCount = chatsState.requestsCount + val requests = if (syncInProgress) emptyList() else chatsState.requests + val requestsCount = if (syncInProgress) 0 else chatsState.requestsCount val showSkeleton by produceState( @@ -4118,6 +4119,7 @@ fun DialogItemContent( val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } val secondaryTextColor = 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) } @@ -4450,7 +4452,7 @@ fun DialogItemContent( text = formattedTime, fontSize = 13.sp, color = - if (dialog.unreadCount > 0) PrimaryBlue + if (visibleUnreadCount > 0) PrimaryBlue else secondaryTextColor ) } @@ -4590,7 +4592,7 @@ fun DialogItemContent( baseDisplayText, fontSize = 14.sp, color = - if (dialog.unreadCount > + if (visibleUnreadCount > 0 ) textColor.copy( @@ -4600,7 +4602,7 @@ fun DialogItemContent( else secondaryTextColor, fontWeight = - if (dialog.unreadCount > + if (visibleUnreadCount > 0 ) FontWeight.Medium @@ -4621,11 +4623,11 @@ fun DialogItemContent( text = baseDisplayText, fontSize = 14.sp, color = - if (dialog.unreadCount > 0) + if (visibleUnreadCount > 0) textColor.copy(alpha = 0.85f) else secondaryTextColor, fontWeight = - if (dialog.unreadCount > 0) + if (visibleUnreadCount > 0) FontWeight.Medium else FontWeight.Normal, maxLines = 1, @@ -4660,15 +4662,15 @@ fun DialogItemContent( } // Unread badge - if (dialog.unreadCount > 0) { + if (visibleUnreadCount > 0) { Spacer(modifier = Modifier.width(8.dp)) val unreadText = - remember(dialog.unreadCount) { + remember(visibleUnreadCount) { when { - dialog.unreadCount > 999 -> "999+" - dialog.unreadCount > 99 -> "99+" + visibleUnreadCount > 999 -> "999+" + visibleUnreadCount > 99 -> "99+" else -> - dialog.unreadCount + visibleUnreadCount .toString() } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index c222dba..7f9ee46 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -16,6 +16,7 @@ import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser import java.util.Locale import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* @@ -391,20 +392,31 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio } // Подписываемся на обычные диалоги - @OptIn(FlowPreview::class) + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) launch { dialogDao .getDialogsFlow(publicKey) .flowOn(Dispatchers.IO) // 🚀 Flow работает на IO .debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка) - .map { dialogsList -> - mapDialogListIncremental( - dialogsList = dialogsList, - privateKey = privateKey, - cache = dialogsUiCache, - isRequestsFlow = false - ) + .combine(ProtocolManager.syncInProgress) { dialogsList, syncing -> + dialogsList to syncing } + .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() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .collect { decryptedDialogs -> _dialogs.value = decryptedDialogs @@ -423,19 +435,26 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio } // 📬 Подписываемся на requests (запросы от новых пользователей) - @OptIn(FlowPreview::class) + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) launch { dialogDao .getRequestsFlow(publicKey) .flowOn(Dispatchers.IO) .debounce(100) // 🚀 Батчим быстрые обновления - .map { requestsList -> - mapDialogListIncremental( - dialogsList = requestsList, - privateKey = privateKey, - cache = requestsUiCache, - isRequestsFlow = true - ) + .combine(ProtocolManager.syncInProgress) { requestsList, syncing -> + requestsList to syncing + } + .mapLatest { (requestsList, syncing) -> + if (syncing) { + emptyList() + } else { + mapDialogListIncremental( + dialogsList = requestsList, + privateKey = privateKey, + cache = requestsUiCache, + isRequestsFlow = true + ) + } } .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .collect { decryptedRequests -> _requests.value = decryptedRequests } @@ -446,6 +465,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio dialogDao .getRequestsCountFlow(publicKey) .flowOn(Dispatchers.IO) + .combine(ProtocolManager.syncInProgress) { count, syncing -> + if (syncing) 0 else count + } .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения .collect { count -> _requestsCount.value = count } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt index c103ec8..76f07d4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp +import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.onboarding.PrimaryBlue @@ -39,12 +40,12 @@ fun RequestsListScreen( avatarRepository: AvatarRepository? = null ) { 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 scope = rememberCoroutineScope() val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) val headerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4) - val textColor = if (isDarkTheme) Color.White else Color.Black Scaffold( topBar = { @@ -60,7 +61,7 @@ fun RequestsListScreen( }, title = { Text( - text = "Requests", + text = if (syncInProgress) "Updating..." else "Requests", fontWeight = FontWeight.Bold, fontSize = 20.sp, color = Color.White diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index fc04dae..cce6809 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -238,14 +239,10 @@ fun SearchScreen( } } - Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) { - detectHorizontalDragGestures { _, dragAmount -> - if (dragAmount > 10f) { - hideKeyboardInstantly() - } - } - }) { + Box(modifier = Modifier.fillMaxSize()) { + val hideKbScrollConnection = remember { HideKeyboardNestedScroll(view, focusManager) } Scaffold( + modifier = Modifier.nestedScroll(hideKbScrollConnection), topBar = { // Хедер как в Telegram: стрелка назад + поле ввода Surface( @@ -538,7 +535,7 @@ private fun ChatsTabContent( if (searchQuery.isEmpty()) { // ═══ Idle state: frequent contacts + recent searches ═══ LazyColumn( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize().imePadding() ) { // ─── Горизонтальный ряд частых контактов (как в Telegram) ─── if (visibleFrequentContacts.isNotEmpty()) { @@ -1183,7 +1180,7 @@ private fun MessagesTabContent( } else -> { LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().imePadding(), contentPadding = PaddingValues(vertical = 4.dp) ) { items(results, key = { it.messageId }) { result -> @@ -1757,7 +1754,7 @@ private fun DownloadsTabContent( ) } else { LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().imePadding(), contentPadding = PaddingValues(vertical = 4.dp) ) { items(files, key = { it.name }) { downloadedFile -> @@ -1909,7 +1906,7 @@ private fun FilesTabContent( } else { val dateFormat = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) } LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().imePadding(), contentPadding = PaddingValues(vertical = 4.dp) ) { 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 + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index c751393..122ef5b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -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 -> { - /* MESSAGES обрабатываются отдельно */ + // Desktop parity: unsupported/legacy attachment gets explicit compatibility card. + LegacyAttachmentErrorCard(isDarkTheme = isDarkTheme) } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 9231e17..fabb405 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -573,6 +573,7 @@ fun MessageBubble( val telegramIncomingAvatarSize = 42.dp val telegramIncomingAvatarLane = 48.dp val telegramIncomingAvatarInset = 6.dp + val telegramIncomingBubbleGap = 6.dp val shouldShowIncomingGroupAvatar = showIncomingGroupAvatar ?: (isGroupChat && @@ -813,7 +814,13 @@ fun MessageBubble( Box( modifier = Modifier.align(Alignment.Bottom) - .padding(end = 12.dp) + .padding( + start = + if (!message.isOutgoing && isGroupChat) + telegramIncomingBubbleGap + else 0.dp, + end = 12.dp + ) .then(bubbleWidthModifier) .graphicsLayer { this.alpha = selectionAlpha diff --git a/app/src/main/res/drawable-nodpi/wallpaper_light_03.png b/app/src/main/res/drawable-nodpi/wallpaper_light_03.png index c69cdca..7abdd15 100644 Binary files a/app/src/main/res/drawable-nodpi/wallpaper_light_03.png and b/app/src/main/res/drawable-nodpi/wallpaper_light_03.png differ