Слит dev в master: изменения после 1.2.1 и обновлены release notes
All checks were successful
Android Kernel Build / build (push) Successful in 1h10m41s
All checks were successful
Android Kernel Build / build (push) Successful in 1h10m41s
This commit is contained in:
@@ -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 ->
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String, Int>()
|
||||
|
||||
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<Set<String>>(emptySet())
|
||||
}
|
||||
var groupMembersCount by remember(user.publicKey, currentUserPublicKey) {
|
||||
mutableStateOf<Int?>(null)
|
||||
var groupMembersCount by remember(groupMembersCacheKey) {
|
||||
mutableStateOf<Int?>(groupMembersCountCache[groupMembersCacheKey])
|
||||
}
|
||||
var mentionCandidates by remember(user.publicKey, currentUserPublicKey) {
|
||||
mutableStateOf<List<MentionCandidate>>(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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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)
|
||||
*
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user