diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 07ff4cd..32b7ab0 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -49,6 +49,7 @@ import com.rosetta.messenger.ui.chats.SearchScreen import com.rosetta.messenger.ui.components.OptimizedEmojiCache import com.rosetta.messenger.ui.components.SwipeBackBackgroundEffect import com.rosetta.messenger.ui.components.SwipeBackContainer +import com.rosetta.messenger.ui.components.SwipeBackEnterAnimation import com.rosetta.messenger.ui.crashlogs.CrashLogsScreen import com.rosetta.messenger.ui.onboarding.OnboardingScreen import com.rosetta.messenger.ui.settings.BackupScreen @@ -423,6 +424,8 @@ class MainActivity : FragmentActivity() { super.onResume() // πŸ”₯ ΠŸΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ стало Π²ΠΈΠ΄ΠΈΠΌΡ‹ΠΌ - ΠΎΡ‚ΠΊΠ»ΡŽΡ‡Π°Π΅ΠΌ увСдомлСния com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = true + // πŸ”” БбрасываСм всС увСдомлСния ΠΈΠ· ΡˆΡ‚ΠΎΡ€ΠΊΠΈ ΠΏΡ€ΠΈ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΠΈΠΈ прилоТСния + (getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager).cancelAll() // ⚑ На Π²ΠΎΠ·Π²Ρ€Π°Ρ‚Π΅ Π² ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΠΏΡ€ΠΎΠ±ΡƒΠ΅ΠΌ ΠΌΠ³Π½ΠΎΠ²Π΅Π½Π½Ρ‹ΠΉ reconnect Π±Π΅Π· оТидания backoff. ProtocolManager.reconnectNowIfNeeded("activity_onResume") } @@ -1027,6 +1030,7 @@ fun MainScreen( isDarkTheme = isDarkTheme, layer = 1, swipeEnabled = !isChatSwipeLocked, + enterAnimation = SwipeBackEnterAnimation.SlideFromRight, propagateBackgroundProgress = false ) { selectedUser?.let { currentChatUser -> diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index 8c1b244..5c1a32f 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -19,11 +19,24 @@ object ReleaseNotes { Π§Ρ‚ΠΎ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΎ послС вСрсии 1.2.1 + УвСдомлСния ΠΈ обновлСния + - Π˜ΡΠΏΡ€Π°Π²Π»Π΅Π½Π° ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° push-ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠΉ ΠΏΡ€ΠΈ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΠΈΠΈ прилоТСния + - Π˜ΡΠΏΡ€Π°Π²Π»Π΅Π½ΠΎ ΠΏΠΎΠ²Π΅Π΄Π΅Π½ΠΈΠ΅ ΠΊΠ½ΠΎΠΏΠΊΠΈ установки обновлСния (Π±Π΅Π· растягивания/залипания) + + Анимации ΠΈ скСлСтоны + - Π‘ΠΈΠ½Ρ…Ρ€ΠΎΠ½ΠΈΠ·ΠΈΡ€ΠΎΠ²Π°Π½Ρ‹ Π°Π½ΠΈΠΌΠ°Ρ†ΠΈΠΈ открытия Ρ‡Π°Ρ‚Π° ΠΈ Π±Π»ΠΎΠΊΠ° Requests Π² стилС Telegram + - Π”ΠΎΠ±Π°Π²Π»Π΅Π½ ΠΈ Π΄ΠΎΡ€Π°Π±ΠΎΡ‚Π°Π½ shimmer skeleton для свСтлой ΠΈ Ρ‚Ρ‘ΠΌΠ½ΠΎΠΉ Ρ‚Π΅ΠΌ + МСдиа-ΠΏΠΈΠΊΠ΅Ρ€Ρ‹ (ΠΏΠΎΠ²Π΅Π΄Π΅Π½ΠΈΠ΅ ΠΊΠ°ΠΊ Π² Telegram) - Π”ΠΎΠ±Π°Π²Π»Π΅Π½ΠΎ ΠΈΠ½Ρ‚Π΅Ρ€Π°ΠΊΡ‚ΠΈΠ²Π½ΠΎΠ΅ ΠΏΠΎΠ²Π΅Π΄Π΅Π½ΠΈΠ΅: панСль слСдуСт Π·Π° ΠΏΠ°Π»ΡŒΡ†Π΅ΠΌ Π²ΠΎ врСмя drag, Π° Π½Π΅ просто раскрываСтся ΠΏΠΎ шагам - Π£Π½ΠΈΡ„ΠΈΡ†ΠΈΡ€ΠΎΠ²Π°Π½Π° drag-Π»ΠΎΠ³ΠΈΠΊΠ° для всСх ΠΏΠΈΠΊΠ΅Ρ€ΠΎΠ²: Π½ΠΎΠ²Ρ‹ΠΉ chat attach picker, fallback media picker ΠΈ picker Π°Π²Π°Ρ‚Π°Ρ€ΠΊΠΈ - Π£Π»ΡƒΡ‡ΡˆΠ΅Π½ snap послС отпускания: ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½Ρ‹ΠΉ ΠΈ ΠΏΠ»Π°Π²Π½Ρ‹ΠΉ доскок ΠΊ Π±Π»ΠΈΠΆΠ°ΠΉΡˆΠ΅ΠΌΡƒ ΡΠΎΡΡ‚ΠΎΡΠ½ΠΈΡŽ Π±Π΅Π· Ρ€Π΅Π·ΠΊΠΈΡ… Ρ€Ρ‹Π²ΠΊΠΎΠ² + Π“Ρ€ΡƒΠΏΠΏΡ‹ ΠΈ интСрфСйс + - ΠžΠ±Π½ΠΎΠ²Π»Ρ‘Π½ Π±Π»ΠΎΠΊ Add Members / Encryption Key Π½Π° экранС Π³Ρ€ΡƒΠΏΠΏΡ‹ + - ΠŸΡ€ΠΈΠ²Π΅Π΄Ρ‘Π½ ΡΡ‚ΠΈΠ»ΡŒ мСню Π² Π³Ρ€ΡƒΠΏΠΏΠ°Ρ… ΠΊ Π΅Π΄ΠΈΠ½ΠΎΠΌΡƒ ΡΡ‚ΠΈΠ»ΡŽ прилоТСния + - Π£Π»ΡƒΡ‡ΡˆΠ΅Π½ΠΎ ΠΊΠΎΠΏΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ тСкста сообщСний + Π’ΠΈΠ·ΡƒΠ°Π»ΡŒΠ½Ρ‹Π΅ исправлСния - Π˜ΡΠΏΡ€Π°Π²Π»Π΅Π½ свСтлый «шов» ΠΌΠ΅ΠΆΠ΄Ρƒ статус-Π±Π°Ρ€ΠΎΠΌ ΠΈ ΠΌΠ΅Π΄ΠΈΠ°-ΠΏΠΈΠΊΠ΅Ρ€ΠΎΠΌ Π² Ρ‚Ρ‘ΠΌΠ½ΠΎΠΉ Ρ‚Π΅ΠΌΠ΅ """.trimIndent() diff --git a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt index ae24d72..18bd950 100644 --- a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt +++ b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt @@ -142,7 +142,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { lower.contains("body") } if (!handledMessageData && !isReadEvent && looksLikeMessagePayload) { - showSimpleNotification(senderName, messagePreview) + showSimpleNotification(senderName, messagePreview, senderPublicKey) handledMessageData = true } } @@ -218,13 +218,13 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { } /** ΠŸΠΎΠΊΠ°Π·Π°Ρ‚ΡŒ простоС ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ */ - private fun showSimpleNotification(title: String, body: String) { + private fun showSimpleNotification(title: String, body: String, senderPublicKey: String? = null) { // πŸ”₯ НС ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅ΠΌ ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ Ссли ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΠΎ if (isAppInForeground || !areNotificationsEnabled()) { return } // Dedup: suppress duplicate pushes within DEDUP_WINDOW_MS - val dedupKey = "__simple__" + val dedupKey = senderPublicKey?.trim()?.ifEmpty { null } ?: "__simple__" val now = System.currentTimeMillis() val lastTs = lastNotifTimestamps[dedupKey] if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) { @@ -234,8 +234,12 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { createNotificationChannel() - // Deterministic ID β€” duplicates replace each other instead of stacking - val notifId = (title + body).hashCode() and 0x7FFFFFFF + // Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ sender-based ID Ссли извСстСн ΠΊΠ»ΡŽΡ‡ β€” Ρ‡Ρ‚ΠΎΠ±Ρ‹ cancelNotificationForChat ΠΌΠΎΠ³ ΡƒΠ±Ρ€Π°Ρ‚ΡŒ ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ + val notifId = if (!senderPublicKey.isNullOrBlank()) { + getNotificationIdForChat(senderPublicKey.trim()) + } else { + (title + body).hashCode() and 0x7FFFFFFF + } val intent = Intent(this, MainActivity::class.java).apply { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index adcc67a..1209646 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -17,8 +17,13 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.SizeTransform @@ -53,6 +58,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -119,6 +125,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap() + private data class IncomingRunAvatarAccumulator( val senderPublicKey: String, val senderDisplayName: String, @@ -515,11 +523,15 @@ fun ChatDetailScreen( val chatsListViewModel: ChatsListViewModel = viewModel() val dialogsList by chatsListViewModel.dialogs.collectAsState() val groupRepository = remember { GroupRepository.getInstance(context) } + val groupMembersCacheKey = + remember(user.publicKey, currentUserPublicKey) { + "${currentUserPublicKey.trim()}::${user.publicKey.trim()}" + } var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) { mutableStateOf>(emptySet()) } - var groupMembersCount by remember(user.publicKey, currentUserPublicKey) { - mutableStateOf(null) + var groupMembersCount by remember(groupMembersCacheKey) { + mutableStateOf(groupMembersCountCache[groupMembersCacheKey]) } var mentionCandidates by remember(user.publicKey, currentUserPublicKey) { mutableStateOf>(emptyList()) @@ -540,14 +552,25 @@ fun ChatDetailScreen( return@LaunchedEffect } + val cachedMembersCount = groupMembersCountCache[groupMembersCacheKey] + groupMembersCount = cachedMembersCount + val members = withContext(Dispatchers.IO) { - groupRepository.requestGroupMembers(user.publicKey).orEmpty() + groupRepository.requestGroupMembers(user.publicKey) + } + if (members == null) { + groupAdminKeys = emptySet() + mentionCandidates = emptyList() + return@LaunchedEffect } val normalizedMembers = members.map { it.trim() } .filter { it.isNotBlank() } .distinct() groupMembersCount = normalizedMembers.size + if (normalizedMembers.isNotEmpty()) { + groupMembersCountCache[groupMembersCacheKey] = normalizedMembers.size + } val adminKey = normalizedMembers.firstOrNull().orEmpty() groupAdminKeys = @@ -1006,9 +1029,13 @@ fun ChatDetailScreen( val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) || user.username.equals("rosetta", ignoreCase = true) || isSystemAccount - val groupMembersSubtitleCount = groupMembersCount ?: 0 + val groupMembersSubtitleCount = groupMembersCount val groupMembersSubtitle = - "$groupMembersSubtitleCount member${if (groupMembersSubtitleCount == 1) "" else "s"}" + if (groupMembersSubtitleCount == null) { + "" + } else { + "$groupMembersSubtitleCount member${if (groupMembersSubtitleCount == 1) "" else "s"}" + } val chatSubtitle = when { isSavedMessages -> "Notes" @@ -1249,19 +1276,10 @@ fun ChatDetailScreen( extractCopyableMessageText( msg ) - if (messageText.isBlank()) { - null - } else { - val time = - SimpleDateFormat( - "HH:mm", - Locale.getDefault() - ) - .format( - msg.timestamp - ) - "[${if (msg.isOutgoing) "You" else chatTitle}] $time\n$messageText" - } + messageText + .takeIf { + it.isNotBlank() + } } .joinToString( "\n\n" @@ -1582,6 +1600,10 @@ fun ChatDetailScreen( isDarkTheme = isDarkTheme ) + } else if (isGroupChat && + groupMembersCount == null + ) { + GroupMembersSubtitleSkeleton() } else { Text( text = @@ -3601,6 +3623,53 @@ fun ChatDetailScreen( } // Π—Π°ΠΊΡ€Ρ‹Ρ‚ΠΈΠ΅ outer Box } +@Composable +private fun GroupMembersSubtitleSkeleton() { + val transition = rememberInfiniteTransition(label = "groupMembersSkeleton") + val shimmerProgress by + transition.animateFloat( + initialValue = -1f, + targetValue = 2f, + animationSpec = + infiniteRepeatable( + animation = tween(1200, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "groupMembersSkeletonShimmer" + ) + + BoxWithConstraints( + modifier = + Modifier.padding(top = 2.dp) + .width(76.dp) + .height(12.dp) + ) { + val density = LocalDensity.current + val widthPx = with(density) { maxWidth.toPx() }.coerceAtLeast(1f) + val gradientWidthPx = with(density) { 44.dp.toPx() } + val shimmerX = shimmerProgress * widthPx + + val shimmerBrush = + Brush.linearGradient( + colorStops = + arrayOf( + 0f to Color.White.copy(alpha = 0.22f), + 0.5f to Color.White.copy(alpha = 0.56f), + 1f to Color.White.copy(alpha = 0.22f) + ), + start = Offset(shimmerX - gradientWidthPx, 0f), + end = Offset(shimmerX, 0f) + ) + + Box( + modifier = + Modifier.fillMaxSize() + .clip(RoundedCornerShape(6.dp)) + .background(shimmerBrush) + ) + } +} + @Composable private fun TiledChatWallpaper( wallpaperResId: Int, 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 2f066f6..d6e8ec8 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 @@ -2171,39 +2171,102 @@ fun ChatsListScreen( } } - if (requestsCount > 0) { - item( - key = - "requests_section" + item(key = "requests_section") { + val isRequestsSectionVisible = + requestsCount > 0 && + isRequestsVisible + AnimatedVisibility( + visible = + isRequestsSectionVisible, + enter = + slideInVertically( + initialOffsetY = { + fullHeight -> + -fullHeight / + 3 + }, + animationSpec = + tween( + durationMillis = + 260, + easing = + FastOutSlowInEasing + ) + ) + + expandVertically( + expandFrom = + Alignment + .Top, + animationSpec = + tween( + durationMillis = + 260, + easing = + FastOutSlowInEasing + ) + ) + + fadeIn( + animationSpec = + tween( + durationMillis = + 180 + ), + initialAlpha = + 0.7f + ), + exit = + slideOutVertically( + targetOffsetY = { + fullHeight -> + -fullHeight / + 3 + }, + animationSpec = + tween( + durationMillis = + 220, + easing = + FastOutSlowInEasing + ) + ) + + shrinkVertically( + shrinkTowards = + Alignment + .Top, + animationSpec = + tween( + durationMillis = + 220, + easing = + FastOutSlowInEasing + ) + ) + + fadeOut( + animationSpec = + tween( + durationMillis = + 140 + ) + ) ) { - AnimatedVisibility( - visible = isRequestsVisible, - enter = expandVertically( - animationSpec = tween(250, easing = FastOutSlowInEasing) - ) + fadeIn(animationSpec = tween(200)), - exit = shrinkVertically( - animationSpec = tween(250, easing = FastOutSlowInEasing) - ) + fadeOut(animationSpec = tween(200)) - ) { - Column { - RequestsSection( - count = - requestsCount, - requests = - requests, - isDarkTheme = - isDarkTheme, - onClick = { - openRequestsRouteSafely() - } - ) - Divider( - color = - dividerColor, - thickness = - 0.5.dp - ) - } + Column { + RequestsSection( + count = + requestsCount, + requests = + requests, + isDarkTheme = + isDarkTheme, + onClick = { + openRequestsRouteSafely() + } + ) + Divider( + color = + dividerColor, + thickness = + 0.5.dp + ) } } } @@ -2886,37 +2949,62 @@ private fun DeviceResolveDialog( @Composable private fun ChatsListSkeleton(isDarkTheme: Boolean) { val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) - val shimmerBase = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0) - val shimmerHighlight = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFF0F0F0) + val shimmerBase = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFE5E5EA) + val shimmerHighlight = if (isDarkTheme) Color(0xFF48484A) else Color(0xFFFFFFFF) - val transition = rememberInfiniteTransition(label = "shimmer") - val shimmerProgress by - transition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = - infiniteRepeatable( - animation = - tween(durationMillis = 1200, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "shimmerAlpha" + BoxWithConstraints(modifier = Modifier.fillMaxSize().background(backgroundColor)) { + val density = LocalDensity.current + val maxWidthPx = with(density) { maxWidth.toPx() }.coerceAtLeast(1f) + val gradientWidthPx = with(density) { 280.dp.toPx() } + + val transition = rememberInfiniteTransition(label = "shimmer") + val shimmerX by transition.animateFloat( + initialValue = -gradientWidthPx, + targetValue = maxWidthPx + gradientWidthPx, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1400, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "shimmerX" ) - val shimmerColor = lerp(shimmerBase, shimmerHighlight, shimmerProgress) - LazyColumn( - modifier = Modifier.fillMaxSize().background(backgroundColor), - userScrollEnabled = false - ) { - items(10) { - SkeletonDialogItem(shimmerColor = shimmerColor, isDarkTheme = isDarkTheme) + val shimmerBrush = Brush.linearGradient( + colorStops = arrayOf( + 0f to shimmerBase, + 0.4f to shimmerHighlight, + 0.6f to shimmerHighlight, + 1f to shimmerBase + ), + start = Offset(shimmerX - gradientWidthPx / 2f, 0f), + end = Offset(shimmerX + gradientWidthPx / 2f, 0f) + ) + + val nameWidths = remember { + floatArrayOf(0.38f, 0.52f, 0.44f, 0.61f, 0.35f, 0.48f, 0.56f, 0.41f, 0.63f, 0.39f) + } + val msgWidths = remember { + floatArrayOf(0.72f, 0.55f, 0.82f, 0.64f, 0.78f, 0.51f, 0.69f, 0.87f, 0.60f, 0.74f) + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + userScrollEnabled = false + ) { + items(10) { index -> + SkeletonDialogItem( + shimmerBrush = shimmerBrush, + nameWidth = nameWidths[index], + messageWidth = msgWidths[index], + isDarkTheme = isDarkTheme + ) + } } } } @Composable -private fun SkeletonDialogItem(shimmerColor: Color, isDarkTheme: Boolean) { - val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) +private fun SkeletonDialogItem(shimmerBrush: Brush, nameWidth: Float, messageWidth: Float, isDarkTheme: Boolean) { + val dividerColor = if (isDarkTheme) Color(0xFF38383A) else Color(0xFFE5E5EA) Row( modifier = @@ -2932,41 +3020,42 @@ private fun SkeletonDialogItem(shimmerColor: Color, isDarkTheme: Boolean) { modifier = Modifier.size(TELEGRAM_DIALOG_AVATAR_SIZE) .clip(CircleShape) - .background(shimmerColor) + .background(shimmerBrush) ) Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP)) - Column(modifier = Modifier.weight(1f)) { - // Name placeholder + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(7.dp)) { + // Name + time row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = + Modifier.fillMaxWidth(nameWidth) + .height(15.dp) + .clip(RoundedCornerShape(6.dp)) + .background(shimmerBrush) + ) + Box( + modifier = + Modifier.width(34.dp) + .height(11.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + } + // Message preview placeholder Box( modifier = - Modifier.fillMaxWidth(0.45f) - .height(16.dp) - .clip(RoundedCornerShape(4.dp)) - .background(shimmerColor) - ) - Spacer(modifier = Modifier.height(8.dp)) - // Message placeholder - Box( - modifier = - Modifier.fillMaxWidth(0.7f) - .height(14.dp) - .clip(RoundedCornerShape(4.dp)) - .background(shimmerColor) + Modifier.fillMaxWidth(messageWidth) + .height(13.dp) + .clip(RoundedCornerShape(5.dp)) + .background(shimmerBrush) ) } - - Spacer(modifier = Modifier.width(8.dp)) - - // Time placeholder - Box( - modifier = - Modifier.width(36.dp) - .height(12.dp) - .clip(RoundedCornerShape(4.dp)) - .background(shimmerColor) - ) } Divider( color = dividerColor, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index 6597dd2..ffa455a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack @@ -58,10 +59,10 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Divider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Tab @@ -90,6 +91,7 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager @@ -107,6 +109,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.PopupProperties import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.LottieConstants @@ -1007,52 +1010,71 @@ fun GroupInfoScreen( tint = Color.White ) } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false } + val menuBgColor = if (isDarkTheme) Color(0xFF272829) else Color.White + val menuTextColor = if (isDarkTheme) Color.White else Color(0xFF222222) + val menuIconColor = if (isDarkTheme) Color.White.copy(alpha = 0.47f) else Color(0xFF676B70) + val menuDividerColor = if (isDarkTheme) Color(0xFF1C1D1F) else Color(0xFFF5F5F5) + val menuDangerColor = Color(0xFFFF3B30) + + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = menuBgColor, + onSurface = menuTextColor + ) ) { - DropdownMenuItem( - text = { Text("Search members") }, - leadingIcon = { - Icon(Icons.Default.Search, contentDescription = null) - }, - onClick = { - showMenu = false - selectedTab = GroupInfoTab.MEMBERS - showSearch = true - } - ) - DropdownMenuItem( - text = { Text("Copy invite") }, - leadingIcon = { - Icon(Icons.Default.PersonAdd, contentDescription = null) - }, - onClick = { - showMenu = false - copyInvite() - } - ) - DropdownMenuItem( - text = { Text("Encryption key") }, - leadingIcon = { - Icon(Icons.Default.Lock, contentDescription = null) - }, - onClick = { - showMenu = false - openEncryptionKey() - } - ) - Divider() - DropdownMenuItem( - text = { Text("Leave group", color = Color(0xFFFF3B30)) }, - leadingIcon = { - Icon(Icons.Default.ExitToApp, contentDescription = null, tint = Color(0xFFFF3B30)) - }, - onClick = { - showMenu = false - showLeaveConfirm = true - } - ) + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + modifier = Modifier.widthIn(min = 196.dp).background(menuBgColor), + properties = PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = true + ) + ) { + GroupInfoMenuItem( + icon = Icons.Default.Search, + text = "Search members", + tintColor = menuIconColor, + textColor = menuTextColor, + onClick = { + showMenu = false + selectedTab = GroupInfoTab.MEMBERS + showSearch = true + } + ) + GroupInfoMenuItem( + icon = Icons.Default.PersonAdd, + text = "Copy invite", + tintColor = menuIconColor, + textColor = menuTextColor, + onClick = { + showMenu = false + copyInvite() + } + ) + GroupInfoMenuItem( + icon = Icons.Default.Lock, + text = "Encryption key", + tintColor = menuIconColor, + textColor = menuTextColor, + onClick = { + showMenu = false + openEncryptionKey() + } + ) + Divider(color = menuDividerColor) + GroupInfoMenuItem( + icon = Icons.Default.ExitToApp, + text = "Leave group", + tintColor = menuDangerColor, + textColor = menuDangerColor, + onClick = { + showMenu = false + showLeaveConfirm = true + } + ) + } } } @@ -1158,75 +1180,81 @@ fun GroupInfoScreen( } } + Spacer(modifier = Modifier.height(8.dp)) Surface( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), color = sectionColor, - shape = RoundedCornerShape(0.dp) + shape = RoundedCornerShape(12.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { openInviteSharePicker() } - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.PersonAdd, - contentDescription = null, - tint = accentColor - ) - Spacer(modifier = Modifier.size(12.dp)) - Text( - text = "Add Members", - color = accentColor, - fontSize = 17.sp, - fontWeight = FontWeight.Medium - ) - } - } - - Surface( - modifier = Modifier.fillMaxWidth(), - color = sectionColor, - shape = RoundedCornerShape(0.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .clickable { openEncryptionKey() } - .padding(horizontal = 16.dp, vertical = 11.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Column { + // Add Members + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { openInviteSharePicker() } + .padding(horizontal = 16.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Add Members", + color = primaryText, + fontSize = 16.sp, + modifier = Modifier.weight(1f) + ) Icon( - imageVector = Icons.Default.Lock, + imageVector = Icons.Default.PersonAdd, contentDescription = null, tint = accentColor, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(22.dp) ) - Spacer(modifier = Modifier.width(12.dp)) + } + + Divider( + color = borderColor, + thickness = 0.5.dp, + modifier = Modifier.padding(start = 16.dp) + ) + + // Encryption Key + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { openEncryptionKey() } + .padding(horizontal = 16.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically + ) { Text( text = "Encryption Key", color = primaryText, fontSize = 16.sp, - fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f) ) if (encryptionKeyLoading) { CircularProgressIndicator( - modifier = Modifier.size(16.dp), + modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = accentColor ) + } else { + val identiconKey = encryptionKey.ifBlank { dialogPublicKey } + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(6.dp)) + ) { + TelegramStyleIdenticon( + keyRender = identiconKey, + size = 34.dp, + isDarkTheme = isDarkTheme + ) + } } } - Spacer(modifier = Modifier.height(3.dp)) - Text( - text = "Tap to view key used for secure group communication", - color = secondaryText, - fontSize = 12.sp - ) } } + Spacer(modifier = Modifier.height(8.dp)) TabRow( selectedTabIndex = GroupInfoTab.entries.indexOf(selectedTab), @@ -1812,6 +1840,39 @@ private fun GroupEncryptionKeyPage( } } +@Composable +private fun GroupInfoMenuItem( + icon: ImageVector, + text: String, + tintColor: Color, + textColor: Color, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .clickable(onClick = onClick) + .padding(horizontal = 18.dp), + contentAlignment = Alignment.CenterStart + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tintColor, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(19.dp)) + Text( + text = text, + color = textColor, + fontSize = 16.sp + ) + } + } +} + @Composable private fun GroupActionButton( modifier: Modifier = Modifier, 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 20ed071..3d45db4 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 @@ -2596,19 +2596,40 @@ fun MessageSkeletonList( label = "telegramSkeletonProgress" ) - // 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) } + // Deterministic pseudo-random widths (fraction of bubbleMaxWidth) + val widthRandom = remember { + floatArrayOf(0.55f, 0.72f, 0.38f, 0.65f, 0.48f, 0.80f, 0.42f, 0.68f, + 0.35f, 0.75f, 0.58f, 0.44f, 0.70f, 0.52f, 0.82f, 0.40f, + 0.63f, 0.77f, 0.46f, 0.60f, 0.34f, 0.71f, 0.50f, 0.66f) + } + // Realistic message bubble heights in dp (single-line to ~3-line) + val heightRandom = remember { + intArrayOf(44, 52, 40, 60, 36, 56, 44, 48, 64, 40, 52, 44, + 36, 68, 52, 44, 60, 36, 48, 56, 40, 64, 44, 52) + } + // false = incoming (left), true = outgoing (right) + val sidePattern = remember { + booleanArrayOf(false, false, true, true, true, false, true, + false, false, true, true, false, true, true, false, + false, false, true, true, true, false, false, true, true) + } - val bubbleShape = - remember { - RoundedCornerShape( - topStart = TelegramBubbleSpec.bubbleRadius, - topEnd = TelegramBubbleSpec.bubbleRadius, - bottomStart = TelegramBubbleSpec.nearRadius, - bottomEnd = TelegramBubbleSpec.bubbleRadius - ) - } + val incomingShape = remember { + RoundedCornerShape( + topStart = TelegramBubbleSpec.bubbleRadius, + topEnd = TelegramBubbleSpec.bubbleRadius, + bottomStart = TelegramBubbleSpec.nearRadius, + bottomEnd = TelegramBubbleSpec.bubbleRadius + ) + } + val outgoingShape = remember { + RoundedCornerShape( + topStart = TelegramBubbleSpec.bubbleRadius, + topEnd = TelegramBubbleSpec.bubbleRadius, + bottomStart = TelegramBubbleSpec.bubbleRadius, + bottomEnd = TelegramBubbleSpec.nearRadius + ) + } BoxWithConstraints(modifier = modifier.fillMaxSize()) { val density = LocalDensity.current @@ -2616,37 +2637,33 @@ fun MessageSkeletonList( 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 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 - ), + 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 avatarSize = 36.dp val avatarGap = 6.dp + val containerWidth = this@BoxWithConstraints.maxWidth + val bubbleMaxWidth = containerWidth * 0.78f - if (isGroupChat) (avatarSize + avatarGap) else 0.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) + while (rowCount < heightRandom.size && usedHeight < availableHeight) { + usedHeight += (heightRandom[rowCount].dp + 4.dp) rowCount++ } if (rowCount < 6) rowCount = 6 @@ -2655,49 +2672,37 @@ fun MessageSkeletonList( modifier = Modifier.align(Alignment.BottomStart) .fillMaxWidth() - .padding(start = 3.dp, end = 8.dp, bottom = bottomInset), - verticalArrangement = Arrangement.spacedBy(3.dp) + .padding(start = 8.dp, end = 8.dp, bottom = bottomInset), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { repeat(rowCount) { index -> - val lineWidth = - minOf( - bubbleMaxWidth, - 42.dp + - containerWidth * - (0.4f + - widthRandom[index % widthRandom.size] * - 0.35f) - ) - val lineHeight = - 64.dp + - (heightRandom[index % heightRandom.size] * 64f).dp + val isOutgoing = sidePattern[index % sidePattern.size] + val bubbleWidth = bubbleMaxWidth * widthRandom[index % widthRandom.size] + val bubbleHeight = heightRandom[index % heightRandom.size].dp + val shape = if (isOutgoing) outgoingShape else incomingShape Row( modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start, verticalAlignment = Alignment.Bottom ) { - if (isGroupChat) { + if (!isOutgoing && isGroupChat) { Box( modifier = - Modifier.size(42.dp) + Modifier.size(avatarSize) .clip(CircleShape) .background(shimmerBrush) .border(1.dp, outlineColor, CircleShape) ) Spacer(modifier = Modifier.width(avatarGap)) } - Box( modifier = - Modifier.width(lineWidth) - .height(lineHeight) - .clip(bubbleShape) + Modifier.width(bubbleWidth) + .height(bubbleHeight) + .clip(shape) .background(shimmerBrush) - .border( - width = 1.dp, - color = outlineColor, - shape = bubbleShape - ) + .border(1.dp, outlineColor, shape) ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt index cf7c8f7..3c40cfd 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt @@ -1,6 +1,7 @@ package com.rosetta.messenger.ui.components import android.content.Context +import android.view.animation.DecelerateInterpolator import android.view.inputmethod.InputMethodManager import androidx.compose.animation.core.* import androidx.compose.foundation.background @@ -19,15 +20,21 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.isActive +import kotlin.coroutines.coroutineContext // Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0) private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) +private val TelegramOpenDecelerateEasing = + Easing { input -> DecelerateInterpolator(1.5f).getInterpolation(input) } // Swipe-back thresholds tuned for ultra-light navigation. private const val COMPLETION_THRESHOLD = 0.05f // 5% of screen width private const val FLING_VELOCITY_THRESHOLD = 35f // px/s private const val ANIMATION_DURATION_ENTER = 300 +private const val ANIMATION_DURATION_ENTER_TELEGRAM_CHAT = 150 private const val ANIMATION_DURATION_EXIT = 200 private const val EDGE_ZONE_DP = 320 private const val EDGE_ZONE_WIDTH_FRACTION = 0.85f @@ -36,6 +43,42 @@ private const val HORIZONTAL_DOMINANCE_RATIO = 1.05f private const val BACKGROUND_MIN_SCALE = 0.97f private const val BACKGROUND_PARALLAX_DP = 4 +enum class SwipeBackEnterAnimation { + Fade, + SlideFromRight +} + +private suspend fun runTelegramChatEnterAnimation( + alphaAnimatable: Animatable, + enterOffsetAnimatable: Animatable, + startOffsetPx: Float +) { + var progress = 0f + var lastFrameTimeMs = withFrameNanos { it } / 1_000_000L + + while (coroutineContext.isActive && progress < 1f) { + val nowMs = withFrameNanos { it } / 1_000_000L + var dtMs = nowMs - lastFrameTimeMs + if (dtMs > 40L && progress == 0f) { + dtMs = 0L + } else if (dtMs > 18L) { + dtMs = 18L + } + lastFrameTimeMs = nowMs + + progress = + (progress + (dtMs / ANIMATION_DURATION_ENTER_TELEGRAM_CHAT.toFloat())) + .coerceIn(0f, 1f) + + val interpolated = TelegramOpenDecelerateEasing.transform(progress).coerceIn(0f, 1f) + alphaAnimatable.snapTo(interpolated) + enterOffsetAnimatable.snapTo(startOffsetPx * (1f - interpolated)) + } + + alphaAnimatable.snapTo(1f) + enterOffsetAnimatable.snapTo(0f) +} + /** * Telegram-style swipe back container (optimized) * @@ -89,6 +132,7 @@ fun SwipeBackContainer( swipeEnabled: Boolean = true, propagateBackgroundProgress: Boolean = true, deferToChildren: Boolean = false, + enterAnimation: SwipeBackEnterAnimation = SwipeBackEnterAnimation.Fade, content: @Composable () -> Unit ) { // πŸš€ Lazy composition: skip ALL setup until the screen is opened for the first time. @@ -103,10 +147,12 @@ fun SwipeBackContainer( val edgeZonePx = with(density) { EDGE_ZONE_DP.dp.toPx() } val effectiveEdgeZonePx = maxOf(edgeZonePx, screenWidthPx * EDGE_ZONE_WIDTH_FRACTION) val backgroundParallaxPx = with(density) { BACKGROUND_PARALLAX_DP.dp.toPx() } + val chatEnterOffsetPx = with(density) { 48.dp.toPx() } val containerId = remember { System.identityHashCode(Any()).toLong() } // Animation state for swipe (used only for swipe animations, not during drag) val offsetAnimatable = remember { Animatable(0f) } + val enterOffsetAnimatable = remember { Animatable(0f) } // Alpha animation for fade-in entry val alphaAnimatable = remember { Animatable(0f) } @@ -129,8 +175,15 @@ fun SwipeBackContainer( val view = LocalView.current val focusManager = LocalFocusManager.current - // Current offset: use drag offset during drag, animatable otherwise - val currentOffset = if (isDragging) dragOffset else offsetAnimatable.value + // Current offset: use drag offset during drag, animatable otherwise + optional enter slide + val baseOffset = if (isDragging) dragOffset else offsetAnimatable.value + val enterOffset = + if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) { + enterOffsetAnimatable.value + } else { + 0f + } + val currentOffset = baseOffset + enterOffset // Current alpha: use animatable during fade animations, otherwise 1 val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f @@ -186,17 +239,29 @@ fun SwipeBackContainer( isAnimatingIn = true try { offsetAnimatable.snapTo(0f) // No slide for entry + dragOffset = 0f alphaAnimatable.snapTo(0f) - alphaAnimatable.animateTo( - targetValue = 1f, - animationSpec = - tween( - durationMillis = ANIMATION_DURATION_ENTER, - easing = FastOutSlowInEasing - ) - ) + if (enterAnimation == SwipeBackEnterAnimation.SlideFromRight) { + enterOffsetAnimatable.snapTo(chatEnterOffsetPx) + runTelegramChatEnterAnimation( + alphaAnimatable = alphaAnimatable, + enterOffsetAnimatable = enterOffsetAnimatable, + startOffsetPx = chatEnterOffsetPx + ) + } else { + enterOffsetAnimatable.snapTo(0f) + alphaAnimatable.animateTo( + targetValue = 1f, + animationSpec = + tween( + durationMillis = ANIMATION_DURATION_ENTER, + easing = FastOutSlowInEasing + ) + ) + } } finally { isAnimatingIn = false + enterOffsetAnimatable.snapTo(0f) } } else if (!isVisible && shouldShow) { // Animate out: fade-out (when triggered by button, not swipe) @@ -231,7 +296,7 @@ fun SwipeBackContainer( } ) { // Scrim (dimming layer behind the screen) - only when swiping - if (currentOffset > 0f) { + if (!isAnimatingIn && currentOffset > 0f) { Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = scrimAlpha))) } diff --git a/app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt b/app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt index fd3c5e2..e1eb9f5 100644 --- a/app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt +++ b/app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt @@ -298,6 +298,12 @@ object UpdateManager { } context.startActivity(intent) + // БбрасываСм состояниС β€” установщик Π·Π°ΠΏΡƒΡ‰Π΅Π½, Π±Π°Π½Π½Π΅Ρ€ большС Π½Π΅ Π½ΡƒΠΆΠ΅Π½ + activeApkPath = null + activeApkVersion = null + persistState(context) + _updateState.value = UpdateState.Idle + _downloadProgress.value = 0 } catch (e: Exception) { Log.e(TAG, "Failed to launch installer", e) _updateState.value = UpdateState.Error("Failed to open installer: ${e.message}") @@ -390,8 +396,19 @@ object UpdateManager { if (!restoredPath.isNullOrBlank()) { val apk = File(restoredPath) if (apk.exists()) { - _downloadProgress.value = 100 - _updateState.value = UpdateState.ReadyToInstall(apk.absolutePath) + // ΠŸΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅ΠΌ Π±Π°Π½Π½Π΅Ρ€ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Ссли сохранённая вСрсия ΠΠžΠ’Π•Π• Ρ‚Π΅ΠΊΡƒΡ‰Π΅ΠΉ + val isNewer = !restoredVersion.isNullOrBlank() && + compareVersions(restoredVersion, appVersion) > 0 + if (isNewer) { + _downloadProgress.value = 100 + _updateState.value = UpdateState.ReadyToInstall(apk.absolutePath) + } else { + // APK ΡƒΠΆΠ΅ устарСл (ΡŽΠ·Π΅Ρ€ обновился ΠΈΠ»ΠΈ скачал Ρ‚Ρƒ ΠΆΠ΅ Π²Π΅Ρ€ΡΠΈΡŽ) β€” удаляСм + apk.delete() + activeApkPath = null + activeApkVersion = null + persistState(context) + } } else { activeApkPath = null activeApkVersion = null