Слит dev в master: изменения после 1.2.1 и обновлены release notes
All checks were successful
Android Kernel Build / build (push) Successful in 1h10m41s

This commit is contained in:
2026-03-18 23:55:19 +05:00
9 changed files with 593 additions and 266 deletions

View File

@@ -49,6 +49,7 @@ import com.rosetta.messenger.ui.chats.SearchScreen
import com.rosetta.messenger.ui.components.OptimizedEmojiCache import com.rosetta.messenger.ui.components.OptimizedEmojiCache
import com.rosetta.messenger.ui.components.SwipeBackBackgroundEffect import com.rosetta.messenger.ui.components.SwipeBackBackgroundEffect
import com.rosetta.messenger.ui.components.SwipeBackContainer 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.crashlogs.CrashLogsScreen
import com.rosetta.messenger.ui.onboarding.OnboardingScreen import com.rosetta.messenger.ui.onboarding.OnboardingScreen
import com.rosetta.messenger.ui.settings.BackupScreen import com.rosetta.messenger.ui.settings.BackupScreen
@@ -423,6 +424,8 @@ class MainActivity : FragmentActivity() {
super.onResume() super.onResume()
// 🔥 Приложение стало видимым - отключаем уведомления // 🔥 Приложение стало видимым - отключаем уведомления
com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = true com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = true
// 🔔 Сбрасываем все уведомления из шторки при открытии приложения
(getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager).cancelAll()
// ⚡ На возврате в приложение пробуем мгновенный reconnect без ожидания backoff. // ⚡ На возврате в приложение пробуем мгновенный reconnect без ожидания backoff.
ProtocolManager.reconnectNowIfNeeded("activity_onResume") ProtocolManager.reconnectNowIfNeeded("activity_onResume")
} }
@@ -1027,6 +1030,7 @@ fun MainScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
layer = 1, layer = 1,
swipeEnabled = !isChatSwipeLocked, swipeEnabled = !isChatSwipeLocked,
enterAnimation = SwipeBackEnterAnimation.SlideFromRight,
propagateBackgroundProgress = false propagateBackgroundProgress = false
) { ) {
selectedUser?.let { currentChatUser -> selectedUser?.let { currentChatUser ->

View File

@@ -19,11 +19,24 @@ object ReleaseNotes {
Что обновлено после версии 1.2.1 Что обновлено после версии 1.2.1
Уведомления и обновления
- Исправлена обработка push-уведомлений при открытии приложения
- Исправлено поведение кнопки установки обновления (без растягивания/залипания)
Анимации и скелетоны
- Синхронизированы анимации открытия чата и блока Requests в стиле Telegram
- Добавлен и доработан shimmer skeleton для светлой и тёмной тем
Медиа-пикеры (поведение как в Telegram) Медиа-пикеры (поведение как в Telegram)
- Добавлено интерактивное поведение: панель следует за пальцем во время drag, а не просто раскрывается по шагам - Добавлено интерактивное поведение: панель следует за пальцем во время drag, а не просто раскрывается по шагам
- Унифицирована drag-логика для всех пикеров: новый chat attach picker, fallback media picker и picker аватарки - Унифицирована drag-логика для всех пикеров: новый chat attach picker, fallback media picker и picker аватарки
- Улучшен snap после отпускания: корректный и плавный доскок к ближайшему состоянию без резких рывков - Улучшен snap после отпускания: корректный и плавный доскок к ближайшему состоянию без резких рывков
Группы и интерфейс
- Обновлён блок Add Members / Encryption Key на экране группы
- Приведён стиль меню в группах к единому стилю приложения
- Улучшено копирование текста сообщений
Визуальные исправления Визуальные исправления
- Исправлен светлый «шов» между статус-баром и медиа-пикером в тёмной теме - Исправлен светлый «шов» между статус-баром и медиа-пикером в тёмной теме
""".trimIndent() """.trimIndent()

View File

@@ -142,7 +142,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
lower.contains("body") lower.contains("body")
} }
if (!handledMessageData && !isReadEvent && looksLikeMessagePayload) { if (!handledMessageData && !isReadEvent && looksLikeMessagePayload) {
showSimpleNotification(senderName, messagePreview) showSimpleNotification(senderName, messagePreview, senderPublicKey)
handledMessageData = true 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()) { if (isAppInForeground || !areNotificationsEnabled()) {
return return
} }
// Dedup: suppress duplicate pushes within DEDUP_WINDOW_MS // Dedup: suppress duplicate pushes within DEDUP_WINDOW_MS
val dedupKey = "__simple__" val dedupKey = senderPublicKey?.trim()?.ifEmpty { null } ?: "__simple__"
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val lastTs = lastNotifTimestamps[dedupKey] val lastTs = lastNotifTimestamps[dedupKey]
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) { if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) {
@@ -234,8 +234,12 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
createNotificationChannel() createNotificationChannel()
// Deterministic ID — duplicates replace each other instead of stacking // Используем sender-based ID если известен ключ — чтобы cancelNotificationForChat мог убрать уведомление
val notifId = (title + body).hashCode() and 0x7FFFFFFF val notifId = if (!senderPublicKey.isNullOrBlank()) {
getNotificationIdForChat(senderPublicKey.trim())
} else {
(title + body).hashCode() and 0x7FFFFFFF
}
val intent = val intent =
Intent(this, MainActivity::class.java).apply { Intent(this, MainActivity::class.java).apply {

View File

@@ -17,8 +17,13 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Spring 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.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState 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.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.SizeTransform import androidx.compose.animation.SizeTransform
@@ -53,6 +58,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -119,6 +125,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap<String, Int>()
private data class IncomingRunAvatarAccumulator( private data class IncomingRunAvatarAccumulator(
val senderPublicKey: String, val senderPublicKey: String,
val senderDisplayName: String, val senderDisplayName: String,
@@ -515,11 +523,15 @@ fun ChatDetailScreen(
val chatsListViewModel: ChatsListViewModel = viewModel() val chatsListViewModel: ChatsListViewModel = viewModel()
val dialogsList by chatsListViewModel.dialogs.collectAsState() val dialogsList by chatsListViewModel.dialogs.collectAsState()
val groupRepository = remember { GroupRepository.getInstance(context) } val groupRepository = remember { GroupRepository.getInstance(context) }
val groupMembersCacheKey =
remember(user.publicKey, currentUserPublicKey) {
"${currentUserPublicKey.trim()}::${user.publicKey.trim()}"
}
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) { var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<Set<String>>(emptySet()) mutableStateOf<Set<String>>(emptySet())
} }
var groupMembersCount by remember(user.publicKey, currentUserPublicKey) { var groupMembersCount by remember(groupMembersCacheKey) {
mutableStateOf<Int?>(null) mutableStateOf<Int?>(groupMembersCountCache[groupMembersCacheKey])
} }
var mentionCandidates by remember(user.publicKey, currentUserPublicKey) { var mentionCandidates by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<List<MentionCandidate>>(emptyList()) mutableStateOf<List<MentionCandidate>>(emptyList())
@@ -540,14 +552,25 @@ fun ChatDetailScreen(
return@LaunchedEffect return@LaunchedEffect
} }
val cachedMembersCount = groupMembersCountCache[groupMembersCacheKey]
groupMembersCount = cachedMembersCount
val members = withContext(Dispatchers.IO) { 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 = val normalizedMembers =
members.map { it.trim() } members.map { it.trim() }
.filter { it.isNotBlank() } .filter { it.isNotBlank() }
.distinct() .distinct()
groupMembersCount = normalizedMembers.size groupMembersCount = normalizedMembers.size
if (normalizedMembers.isNotEmpty()) {
groupMembersCountCache[groupMembersCacheKey] = normalizedMembers.size
}
val adminKey = normalizedMembers.firstOrNull().orEmpty() val adminKey = normalizedMembers.firstOrNull().orEmpty()
groupAdminKeys = groupAdminKeys =
@@ -1006,9 +1029,13 @@ fun ChatDetailScreen(
val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) || val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) ||
user.username.equals("rosetta", ignoreCase = true) || user.username.equals("rosetta", ignoreCase = true) ||
isSystemAccount isSystemAccount
val groupMembersSubtitleCount = groupMembersCount ?: 0 val groupMembersSubtitleCount = groupMembersCount
val groupMembersSubtitle = val groupMembersSubtitle =
"$groupMembersSubtitleCount member${if (groupMembersSubtitleCount == 1) "" else "s"}" if (groupMembersSubtitleCount == null) {
""
} else {
"$groupMembersSubtitleCount member${if (groupMembersSubtitleCount == 1) "" else "s"}"
}
val chatSubtitle = val chatSubtitle =
when { when {
isSavedMessages -> "Notes" isSavedMessages -> "Notes"
@@ -1249,19 +1276,10 @@ fun ChatDetailScreen(
extractCopyableMessageText( extractCopyableMessageText(
msg msg
) )
if (messageText.isBlank()) { messageText
null .takeIf {
} else { it.isNotBlank()
val time = }
SimpleDateFormat(
"HH:mm",
Locale.getDefault()
)
.format(
msg.timestamp
)
"[${if (msg.isOutgoing) "You" else chatTitle}] $time\n$messageText"
}
} }
.joinToString( .joinToString(
"\n\n" "\n\n"
@@ -1582,6 +1600,10 @@ fun ChatDetailScreen(
isDarkTheme = isDarkTheme =
isDarkTheme isDarkTheme
) )
} else if (isGroupChat &&
groupMembersCount == null
) {
GroupMembersSubtitleSkeleton()
} else { } else {
Text( Text(
text = text =
@@ -3601,6 +3623,53 @@ fun ChatDetailScreen(
} // Закрытие outer Box } // Закрытие 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 @Composable
private fun TiledChatWallpaper( private fun TiledChatWallpaper(
wallpaperResId: Int, wallpaperResId: Int,

View File

@@ -2171,39 +2171,102 @@ fun ChatsListScreen(
} }
} }
if (requestsCount > 0) { item(key = "requests_section") {
item( val isRequestsSectionVisible =
key = requestsCount > 0 &&
"requests_section" 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( Column {
visible = isRequestsVisible, RequestsSection(
enter = expandVertically( count =
animationSpec = tween(250, easing = FastOutSlowInEasing) requestsCount,
) + fadeIn(animationSpec = tween(200)), requests =
exit = shrinkVertically( requests,
animationSpec = tween(250, easing = FastOutSlowInEasing) isDarkTheme =
) + fadeOut(animationSpec = tween(200)) isDarkTheme,
) { onClick = {
Column { openRequestsRouteSafely()
RequestsSection( }
count = )
requestsCount, Divider(
requests = color =
requests, dividerColor,
isDarkTheme = thickness =
isDarkTheme, 0.5.dp
onClick = { )
openRequestsRouteSafely()
}
)
Divider(
color =
dividerColor,
thickness =
0.5.dp
)
}
} }
} }
} }
@@ -2886,37 +2949,62 @@ private fun DeviceResolveDialog(
@Composable @Composable
private fun ChatsListSkeleton(isDarkTheme: Boolean) { private fun ChatsListSkeleton(isDarkTheme: Boolean) {
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val shimmerBase = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0) val shimmerBase = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFE5E5EA)
val shimmerHighlight = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFF0F0F0) val shimmerHighlight = if (isDarkTheme) Color(0xFF48484A) else Color(0xFFFFFFFF)
val transition = rememberInfiniteTransition(label = "shimmer") BoxWithConstraints(modifier = Modifier.fillMaxSize().background(backgroundColor)) {
val shimmerProgress by val density = LocalDensity.current
transition.animateFloat( val maxWidthPx = with(density) { maxWidth.toPx() }.coerceAtLeast(1f)
initialValue = 0f, val gradientWidthPx = with(density) { 280.dp.toPx() }
targetValue = 1f,
animationSpec = val transition = rememberInfiniteTransition(label = "shimmer")
infiniteRepeatable( val shimmerX by transition.animateFloat(
animation = initialValue = -gradientWidthPx,
tween(durationMillis = 1200, easing = LinearEasing), targetValue = maxWidthPx + gradientWidthPx,
repeatMode = RepeatMode.Restart animationSpec = infiniteRepeatable(
), animation = tween(durationMillis = 1400, easing = LinearEasing),
label = "shimmerAlpha" repeatMode = RepeatMode.Restart
),
label = "shimmerX"
) )
val shimmerColor = lerp(shimmerBase, shimmerHighlight, shimmerProgress)
LazyColumn( val shimmerBrush = Brush.linearGradient(
modifier = Modifier.fillMaxSize().background(backgroundColor), colorStops = arrayOf(
userScrollEnabled = false 0f to shimmerBase,
) { 0.4f to shimmerHighlight,
items(10) { 0.6f to shimmerHighlight,
SkeletonDialogItem(shimmerColor = shimmerColor, isDarkTheme = isDarkTheme) 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 @Composable
private fun SkeletonDialogItem(shimmerColor: Color, isDarkTheme: Boolean) { private fun SkeletonDialogItem(shimmerBrush: Brush, nameWidth: Float, messageWidth: Float, isDarkTheme: Boolean) {
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) val dividerColor = if (isDarkTheme) Color(0xFF38383A) else Color(0xFFE5E5EA)
Row( Row(
modifier = modifier =
@@ -2932,41 +3020,42 @@ private fun SkeletonDialogItem(shimmerColor: Color, isDarkTheme: Boolean) {
modifier = modifier =
Modifier.size(TELEGRAM_DIALOG_AVATAR_SIZE) Modifier.size(TELEGRAM_DIALOG_AVATAR_SIZE)
.clip(CircleShape) .clip(CircleShape)
.background(shimmerColor) .background(shimmerBrush)
) )
Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP)) Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(7.dp)) {
// Name placeholder // 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( Box(
modifier = modifier =
Modifier.fillMaxWidth(0.45f) Modifier.fillMaxWidth(messageWidth)
.height(16.dp) .height(13.dp)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(5.dp))
.background(shimmerColor) .background(shimmerBrush)
)
Spacer(modifier = Modifier.height(8.dp))
// Message placeholder
Box(
modifier =
Modifier.fillMaxWidth(0.7f)
.height(14.dp)
.clip(RoundedCornerShape(4.dp))
.background(shimmerColor)
) )
} }
Spacer(modifier = Modifier.width(8.dp))
// Time placeholder
Box(
modifier =
Modifier.width(36.dp)
.height(12.dp)
.clip(RoundedCornerShape(4.dp))
.background(shimmerColor)
)
} }
Divider( Divider(
color = dividerColor, color = dividerColor,

View File

@@ -44,6 +44,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack 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.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Tab 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.graphics.Color
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager 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.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.PopupProperties
import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.LottieConstants
@@ -1007,52 +1010,71 @@ fun GroupInfoScreen(
tint = Color.White tint = Color.White
) )
} }
DropdownMenu( val menuBgColor = if (isDarkTheme) Color(0xFF272829) else Color.White
expanded = showMenu, val menuTextColor = if (isDarkTheme) Color.White else Color(0xFF222222)
onDismissRequest = { showMenu = false } 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( DropdownMenu(
text = { Text("Search members") }, expanded = showMenu,
leadingIcon = { onDismissRequest = { showMenu = false },
Icon(Icons.Default.Search, contentDescription = null) modifier = Modifier.widthIn(min = 196.dp).background(menuBgColor),
}, properties = PopupProperties(
onClick = { focusable = true,
showMenu = false dismissOnBackPress = true,
selectedTab = GroupInfoTab.MEMBERS dismissOnClickOutside = true
showSearch = true )
} ) {
) GroupInfoMenuItem(
DropdownMenuItem( icon = Icons.Default.Search,
text = { Text("Copy invite") }, text = "Search members",
leadingIcon = { tintColor = menuIconColor,
Icon(Icons.Default.PersonAdd, contentDescription = null) textColor = menuTextColor,
}, onClick = {
onClick = { showMenu = false
showMenu = false selectedTab = GroupInfoTab.MEMBERS
copyInvite() showSearch = true
} }
) )
DropdownMenuItem( GroupInfoMenuItem(
text = { Text("Encryption key") }, icon = Icons.Default.PersonAdd,
leadingIcon = { text = "Copy invite",
Icon(Icons.Default.Lock, contentDescription = null) tintColor = menuIconColor,
}, textColor = menuTextColor,
onClick = { onClick = {
showMenu = false showMenu = false
openEncryptionKey() copyInvite()
} }
) )
Divider() GroupInfoMenuItem(
DropdownMenuItem( icon = Icons.Default.Lock,
text = { Text("Leave group", color = Color(0xFFFF3B30)) }, text = "Encryption key",
leadingIcon = { tintColor = menuIconColor,
Icon(Icons.Default.ExitToApp, contentDescription = null, tint = Color(0xFFFF3B30)) textColor = menuTextColor,
}, onClick = {
onClick = { showMenu = false
showMenu = false openEncryptionKey()
showLeaveConfirm = true }
} )
) 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( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
color = sectionColor, color = sectionColor,
shape = RoundedCornerShape(0.dp) shape = RoundedCornerShape(12.dp)
) { ) {
Row( Column {
modifier = Modifier // Add Members
.fillMaxWidth() Row(
.clickable { openInviteSharePicker() } modifier = Modifier
.padding(horizontal = 16.dp, vertical = 12.dp), .fillMaxWidth()
verticalAlignment = Alignment.CenterVertically .clickable { openInviteSharePicker() }
) { .padding(horizontal = 16.dp, vertical = 13.dp),
Icon( verticalAlignment = Alignment.CenterVertically
imageVector = Icons.Default.PersonAdd, ) {
contentDescription = null, Text(
tint = accentColor text = "Add Members",
) color = primaryText,
Spacer(modifier = Modifier.size(12.dp)) fontSize = 16.sp,
Text( modifier = Modifier.weight(1f)
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) {
Icon( Icon(
imageVector = Icons.Default.Lock, imageVector = Icons.Default.PersonAdd,
contentDescription = null, contentDescription = null,
tint = accentColor, 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(
text = "Encryption Key", text = "Encryption Key",
color = primaryText, color = primaryText,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
if (encryptionKeyLoading) { if (encryptionKeyLoading) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(16.dp), modifier = Modifier.size(20.dp),
strokeWidth = 2.dp, strokeWidth = 2.dp,
color = accentColor 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( TabRow(
selectedTabIndex = GroupInfoTab.entries.indexOf(selectedTab), 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 @Composable
private fun GroupActionButton( private fun GroupActionButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,

View File

@@ -2596,19 +2596,40 @@ fun MessageSkeletonList(
label = "telegramSkeletonProgress" label = "telegramSkeletonProgress"
) )
// Telegram-style deterministic pseudo-randomness. // Deterministic pseudo-random widths (fraction of bubbleMaxWidth)
val widthRandom = remember { floatArrayOf(0.18f, 0.74f, 0.32f, 0.61f, 0.27f, 0.84f, 0.49f, 0.12f) } val widthRandom = remember {
val heightRandom = remember { floatArrayOf(0.25f, 0.68f, 0.14f, 0.52f, 0.37f, 0.79f, 0.29f, 0.57f) } 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 = val incomingShape = remember {
remember { RoundedCornerShape(
RoundedCornerShape( topStart = TelegramBubbleSpec.bubbleRadius,
topStart = TelegramBubbleSpec.bubbleRadius, topEnd = TelegramBubbleSpec.bubbleRadius,
topEnd = TelegramBubbleSpec.bubbleRadius, bottomStart = TelegramBubbleSpec.nearRadius,
bottomStart = TelegramBubbleSpec.nearRadius, bottomEnd = TelegramBubbleSpec.bubbleRadius
bottomEnd = TelegramBubbleSpec.bubbleRadius )
) }
} val outgoingShape = remember {
RoundedCornerShape(
topStart = TelegramBubbleSpec.bubbleRadius,
topEnd = TelegramBubbleSpec.bubbleRadius,
bottomStart = TelegramBubbleSpec.bubbleRadius,
bottomEnd = TelegramBubbleSpec.nearRadius
)
}
BoxWithConstraints(modifier = modifier.fillMaxSize()) { BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val density = LocalDensity.current val density = LocalDensity.current
@@ -2616,37 +2637,33 @@ fun MessageSkeletonList(
val gradientWidthPx = with(density) { 200.dp.toPx() } val gradientWidthPx = with(density) { 200.dp.toPx() }
val shimmerX = shimmerProgress * maxWidthPx val shimmerX = shimmerProgress * maxWidthPx
val color0 = val color0 = if (isDarkTheme) Color(0x36FFFFFF) else Color(0x1F000000)
if (isDarkTheme) Color(0x36FFFFFF) else Color(0x1F000000) val color1 = if (isDarkTheme) Color(0x1CFFFFFF) else Color(0x12000000)
val color1 = val outlineColor = if (isDarkTheme) Color(0x40FFFFFF) else Color(0x24FFFFFF)
if (isDarkTheme) Color(0x1CFFFFFF) else Color(0x12000000)
val outlineColor =
if (isDarkTheme) Color(0x40FFFFFF) else Color(0x24FFFFFF)
val shimmerBrush = val shimmerBrush =
Brush.linearGradient( Brush.linearGradient(
colorStops = colorStops = arrayOf(
arrayOf( 0f to color1,
0f to color1, 0.4f to color0,
0.4f to color0, 0.6f to color0,
0.6f to color0, 1f to color1
1f to color1 ),
),
start = Offset(shimmerX - gradientWidthPx, 0f), start = Offset(shimmerX - gradientWidthPx, 0f),
end = Offset(shimmerX, 0f) end = Offset(shimmerX, 0f)
) )
val bottomInset = 58.dp val bottomInset = 58.dp
val containerWidth = this@BoxWithConstraints.maxWidth val avatarSize = 36.dp
val bubbleMaxWidth = (containerWidth * 0.8f) - if (isGroupChat) 42.dp else 0.dp
val avatarGap = 6.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) val availableHeight = (maxHeight - bottomInset).coerceAtLeast(0.dp)
var usedHeight = 0.dp var usedHeight = 0.dp
var rowCount = 0 var rowCount = 0
while (rowCount < 24 && usedHeight < availableHeight) { while (rowCount < heightRandom.size && usedHeight < availableHeight) {
val randomHeight = heightRandom[rowCount % heightRandom.size] usedHeight += (heightRandom[rowCount].dp + 4.dp)
usedHeight += (64.dp + (randomHeight * 64f).dp + 3.dp)
rowCount++ rowCount++
} }
if (rowCount < 6) rowCount = 6 if (rowCount < 6) rowCount = 6
@@ -2655,49 +2672,37 @@ fun MessageSkeletonList(
modifier = modifier =
Modifier.align(Alignment.BottomStart) Modifier.align(Alignment.BottomStart)
.fillMaxWidth() .fillMaxWidth()
.padding(start = 3.dp, end = 8.dp, bottom = bottomInset), .padding(start = 8.dp, end = 8.dp, bottom = bottomInset),
verticalArrangement = Arrangement.spacedBy(3.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
repeat(rowCount) { index -> repeat(rowCount) { index ->
val lineWidth = val isOutgoing = sidePattern[index % sidePattern.size]
minOf( val bubbleWidth = bubbleMaxWidth * widthRandom[index % widthRandom.size]
bubbleMaxWidth, val bubbleHeight = heightRandom[index % heightRandom.size].dp
42.dp + val shape = if (isOutgoing) outgoingShape else incomingShape
containerWidth *
(0.4f +
widthRandom[index % widthRandom.size] *
0.35f)
)
val lineHeight =
64.dp +
(heightRandom[index % heightRandom.size] * 64f).dp
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom verticalAlignment = Alignment.Bottom
) { ) {
if (isGroupChat) { if (!isOutgoing && isGroupChat) {
Box( Box(
modifier = modifier =
Modifier.size(42.dp) Modifier.size(avatarSize)
.clip(CircleShape) .clip(CircleShape)
.background(shimmerBrush) .background(shimmerBrush)
.border(1.dp, outlineColor, CircleShape) .border(1.dp, outlineColor, CircleShape)
) )
Spacer(modifier = Modifier.width(avatarGap)) Spacer(modifier = Modifier.width(avatarGap))
} }
Box( Box(
modifier = modifier =
Modifier.width(lineWidth) Modifier.width(bubbleWidth)
.height(lineHeight) .height(bubbleHeight)
.clip(bubbleShape) .clip(shape)
.background(shimmerBrush) .background(shimmerBrush)
.border( .border(1.dp, outlineColor, shape)
width = 1.dp,
color = outlineColor,
shape = bubbleShape
)
) )
} }
} }

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.ui.components package com.rosetta.messenger.ui.components
import android.content.Context import android.content.Context
import android.view.animation.DecelerateInterpolator
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.background 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.LocalFocusManager
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.isActive
import kotlin.coroutines.coroutineContext
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0) // 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 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. // Swipe-back thresholds tuned for ultra-light navigation.
private const val COMPLETION_THRESHOLD = 0.05f // 5% of screen width private const val COMPLETION_THRESHOLD = 0.05f // 5% of screen width
private const val FLING_VELOCITY_THRESHOLD = 35f // px/s private const val FLING_VELOCITY_THRESHOLD = 35f // px/s
private const val ANIMATION_DURATION_ENTER = 300 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 ANIMATION_DURATION_EXIT = 200
private const val EDGE_ZONE_DP = 320 private const val EDGE_ZONE_DP = 320
private const val EDGE_ZONE_WIDTH_FRACTION = 0.85f 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_MIN_SCALE = 0.97f
private const val BACKGROUND_PARALLAX_DP = 4 private const val BACKGROUND_PARALLAX_DP = 4
enum class SwipeBackEnterAnimation {
Fade,
SlideFromRight
}
private suspend fun runTelegramChatEnterAnimation(
alphaAnimatable: Animatable<Float, AnimationVector1D>,
enterOffsetAnimatable: Animatable<Float, AnimationVector1D>,
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) * Telegram-style swipe back container (optimized)
* *
@@ -89,6 +132,7 @@ fun SwipeBackContainer(
swipeEnabled: Boolean = true, swipeEnabled: Boolean = true,
propagateBackgroundProgress: Boolean = true, propagateBackgroundProgress: Boolean = true,
deferToChildren: Boolean = false, deferToChildren: Boolean = false,
enterAnimation: SwipeBackEnterAnimation = SwipeBackEnterAnimation.Fade,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time. // 🚀 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 edgeZonePx = with(density) { EDGE_ZONE_DP.dp.toPx() }
val effectiveEdgeZonePx = maxOf(edgeZonePx, screenWidthPx * EDGE_ZONE_WIDTH_FRACTION) val effectiveEdgeZonePx = maxOf(edgeZonePx, screenWidthPx * EDGE_ZONE_WIDTH_FRACTION)
val backgroundParallaxPx = with(density) { BACKGROUND_PARALLAX_DP.dp.toPx() } val backgroundParallaxPx = with(density) { BACKGROUND_PARALLAX_DP.dp.toPx() }
val chatEnterOffsetPx = with(density) { 48.dp.toPx() }
val containerId = remember { System.identityHashCode(Any()).toLong() } val containerId = remember { System.identityHashCode(Any()).toLong() }
// Animation state for swipe (used only for swipe animations, not during drag) // Animation state for swipe (used only for swipe animations, not during drag)
val offsetAnimatable = remember { Animatable(0f) } val offsetAnimatable = remember { Animatable(0f) }
val enterOffsetAnimatable = remember { Animatable(0f) }
// Alpha animation for fade-in entry // Alpha animation for fade-in entry
val alphaAnimatable = remember { Animatable(0f) } val alphaAnimatable = remember { Animatable(0f) }
@@ -129,8 +175,15 @@ fun SwipeBackContainer(
val view = LocalView.current val view = LocalView.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
// Current offset: use drag offset during drag, animatable otherwise // Current offset: use drag offset during drag, animatable otherwise + optional enter slide
val currentOffset = if (isDragging) dragOffset else offsetAnimatable.value 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 // Current alpha: use animatable during fade animations, otherwise 1
val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f
@@ -186,17 +239,29 @@ fun SwipeBackContainer(
isAnimatingIn = true isAnimatingIn = true
try { try {
offsetAnimatable.snapTo(0f) // No slide for entry offsetAnimatable.snapTo(0f) // No slide for entry
dragOffset = 0f
alphaAnimatable.snapTo(0f) alphaAnimatable.snapTo(0f)
alphaAnimatable.animateTo( if (enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
targetValue = 1f, enterOffsetAnimatable.snapTo(chatEnterOffsetPx)
animationSpec = runTelegramChatEnterAnimation(
tween( alphaAnimatable = alphaAnimatable,
durationMillis = ANIMATION_DURATION_ENTER, enterOffsetAnimatable = enterOffsetAnimatable,
easing = FastOutSlowInEasing startOffsetPx = chatEnterOffsetPx
) )
) } else {
enterOffsetAnimatable.snapTo(0f)
alphaAnimatable.animateTo(
targetValue = 1f,
animationSpec =
tween(
durationMillis = ANIMATION_DURATION_ENTER,
easing = FastOutSlowInEasing
)
)
}
} finally { } finally {
isAnimatingIn = false isAnimatingIn = false
enterOffsetAnimatable.snapTo(0f)
} }
} else if (!isVisible && shouldShow) { } else if (!isVisible && shouldShow) {
// Animate out: fade-out (when triggered by button, not swipe) // 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 // 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))) Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = scrimAlpha)))
} }

View File

@@ -298,6 +298,12 @@ object UpdateManager {
} }
context.startActivity(intent) context.startActivity(intent)
// Сбрасываем состояние — установщик запущен, баннер больше не нужен
activeApkPath = null
activeApkVersion = null
persistState(context)
_updateState.value = UpdateState.Idle
_downloadProgress.value = 0
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to launch installer", e) Log.e(TAG, "Failed to launch installer", e)
_updateState.value = UpdateState.Error("Failed to open installer: ${e.message}") _updateState.value = UpdateState.Error("Failed to open installer: ${e.message}")
@@ -390,8 +396,19 @@ object UpdateManager {
if (!restoredPath.isNullOrBlank()) { if (!restoredPath.isNullOrBlank()) {
val apk = File(restoredPath) val apk = File(restoredPath)
if (apk.exists()) { 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 { } else {
activeApkPath = null activeApkPath = null
activeApkVersion = null activeApkVersion = null