feat: Simplify animations across multiple screens by replacing slide transitions with fade animations for improved user experience

This commit is contained in:
2026-01-18 17:26:04 +05:00
parent 89e5f3cfa2
commit 61995e9286
16 changed files with 506 additions and 399 deletions

View File

@@ -59,6 +59,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
@@ -474,8 +477,30 @@ fun ChatDetailScreen(
// 🔥 Обработка системной кнопки назад
BackHandler { hideKeyboardAndBack() }
// 🔥 Cleanup при выходе из экрана - только закрываем диалог
DisposableEffect(Unit) { onDispose { viewModel.closeDialog() } }
// 🔥 Lifecycle-aware отслеживание активности экрана
val lifecycleOwner = LocalLifecycleOwner.current
var isScreenActive by remember { mutableStateOf(true) }
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
isScreenActive = true
viewModel.setDialogActive(true)
}
Lifecycle.Event.ON_PAUSE -> {
isScreenActive = false
viewModel.setDialogActive(false)
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
viewModel.closeDialog()
}
}
// Инициализируем ViewModel с ключами и открываем диалог
LaunchedEffect(user.publicKey) {
@@ -489,9 +514,9 @@ fun ChatDetailScreen(
com.rosetta.messenger.ui.components.EmojiCache.preload(context)
}
// Отмечаем сообщения как прочитанные когда они видны
LaunchedEffect(messages) {
if (messages.isNotEmpty()) {
// Отмечаем сообщения как прочитанные только когда экран активен (RESUMED)
LaunchedEffect(messages, isScreenActive) {
if (messages.isNotEmpty() && isScreenActive) {
viewModel.markVisibleMessagesAsRead()
}
}
@@ -855,28 +880,44 @@ fun ChatDetailScreen(
}
}
// Кнопка меню - открывает bottom sheet
IconButton(
onClick = {
// Закрываем клавиатуру перед открытием меню
keyboardController?.hide()
focusManager.clearFocus()
// Даём клавиатуре время закрыться перед показом
// bottom sheet
scope.launch {
delay(
150
) // Задержка для плавного закрытия клавиатуры
// Кнопка меню - открывает kebab menu
Box {
IconButton(
onClick = {
// Закрываем клавиатуру перед открытием меню
keyboardController?.hide()
focusManager.clearFocus()
showMenu = true
}
},
modifier = Modifier.size(48.dp).clip(CircleShape)
) {
Icon(
Icons.Default.MoreVert,
contentDescription = "More",
tint = headerIconColor.copy(alpha = 0.6f),
modifier = Modifier.size(26.dp)
)
}
// 🔥 TELEGRAM-STYLE KEBAB MENU
KebabMenu(
expanded = showMenu,
onDismiss = { showMenu = false },
isDarkTheme = isDarkTheme,
isSavedMessages = isSavedMessages,
isBlocked = isBlocked,
onBlockClick = {
showMenu = false
showBlockConfirm = true
},
modifier = Modifier.size(48.dp).clip(CircleShape)
) {
Icon(
Icons.Default.MoreVert,
contentDescription = "More",
tint = headerIconColor.copy(alpha = 0.6f),
modifier = Modifier.size(26.dp)
onUnblockClick = {
showMenu = false
showUnblockConfirm = true
},
onDeleteClick = {
showMenu = false
showDeleteConfirm = true
}
)
}
}
@@ -927,29 +968,8 @@ fun ChatDetailScreen(
AnimatedContent(
targetState = isSelectionMode,
transitionSpec = {
if (targetState) {
// Selection mode появляется: снизу вверх
slideInVertically(
animationSpec = tween(300, easing = TelegramEasing),
initialOffsetY = { it }
) + fadeIn(animationSpec = tween(200)) togetherWith
slideOutVertically(
animationSpec =
tween(300, easing = TelegramEasing),
targetOffsetY = { -it }
) + fadeOut(animationSpec = tween(150))
} else {
// Input bar возвращается: снизу вверх
slideInVertically(
animationSpec = tween(300, easing = TelegramEasing),
initialOffsetY = { it }
) + fadeIn(animationSpec = tween(200)) togetherWith
slideOutVertically(
animationSpec =
tween(300, easing = TelegramEasing),
targetOffsetY = { it }
) + fadeOut(animationSpec = tween(150))
}
fadeIn(animationSpec = tween(200)) togetherWith
fadeOut(animationSpec = tween(150))
},
label = "bottomBarContent"
) { selectionMode ->
@@ -1680,74 +1700,7 @@ fun ChatDetailScreen(
)
}
// <EFBFBD> Bottom Sheet меню (вместо popup menu)
if (showMenu) {
ModalBottomSheet(
onDismissRequest = { showMenu = false },
containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White,
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
scrimColor = Color.Black.copy(alpha = 0.6f),
windowInsets =
WindowInsets(0, 0, 0, 0), // Перекрываем весь экран включая status bar
dragHandle = {
Box(
modifier =
Modifier.padding(vertical = 12.dp)
.width(36.dp)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(
if (isDarkTheme) Color.White.copy(alpha = 0.3f)
else Color.Black.copy(alpha = 0.2f)
)
)
}
) {
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp)) {
// Block/Unblock User
if (!isSavedMessages) {
BottomSheetMenuItem(
icon =
if (isBlocked) Icons.Default.CheckCircle
else Icons.Default.Block,
text = if (isBlocked) "Unblock User" else "Block User",
onClick = {
showMenu = false
if (isBlocked) {
showUnblockConfirm = true
} else {
showBlockConfirm = true
}
},
isDarkTheme = isDarkTheme,
tintColor = PrimaryBlue
)
Divider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp,
color =
if (isDarkTheme) Color.White.copy(alpha = 0.1f)
else Color.Black.copy(alpha = 0.08f)
)
}
// Delete Chat
BottomSheetMenuItem(
icon = Icons.Default.Delete,
text = "Delete Chat",
onClick = {
showMenu = false
showDeleteConfirm = true
},
isDarkTheme = isDarkTheme,
isDestructive = true
)
}
}
}
// <20>📨 Forward Chat Picker BottomSheet
// 📨 Forward Chat Picker BottomSheet
if (showForwardPicker) {
ForwardChatPickerBottomSheet(
dialogs = dialogsList,
@@ -3180,3 +3133,108 @@ private fun BottomSheetMenuItem(
Text(text = text, color = textColor, fontSize = 18.sp, fontWeight = FontWeight.Medium)
}
}
/**
* 🔥 TELEGRAM-STYLE KEBAB MENU
* Красивое выпадающее меню с анимациями и современным дизайном
*/
@Composable
private fun KebabMenu(
expanded: Boolean,
onDismiss: () -> Unit,
isDarkTheme: Boolean,
isSavedMessages: Boolean,
isBlocked: Boolean,
onBlockClick: () -> Unit,
onUnblockClick: () -> Unit,
onDeleteClick: () -> Unit
) {
val menuBackgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
val textColor = if (isDarkTheme) Color.White else Color.Black
val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismiss,
modifier = Modifier.width(220.dp),
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = true
)
) {
// Block/Unblock User (не для Saved Messages)
if (!isSavedMessages) {
KebabMenuItem(
icon = if (isBlocked) Icons.Default.CheckCircle else Icons.Default.Block,
text = if (isBlocked) "Unblock User" else "Block User",
onClick = {
if (isBlocked) onUnblockClick() else onBlockClick()
},
tintColor = PrimaryBlue,
textColor = textColor
)
// Divider
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.height(0.5.dp)
.background(dividerColor)
)
}
// Delete Chat
KebabMenuItem(
icon = Icons.Default.Delete,
text = "Delete Chat",
onClick = onDeleteClick,
tintColor = Color(0xFFFF3B30),
textColor = Color(0xFFFF3B30)
)
}
}
/**
* 🔥 Элемент Kebab меню
*/
@Composable
private fun KebabMenuItem(
icon: ImageVector,
text: String,
onClick: () -> Unit,
tintColor: Color,
textColor: Color
) {
val interactionSource = remember { MutableInteractionSource() }
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = tintColor,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(14.dp))
Text(
text = text,
color = textColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
},
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
interactionSource = interactionSource,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)
)
}

View File

@@ -140,6 +140,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Как currentDialogPublicKeyView в архиве
private var isDialogActive = false
// 🟢 Флаг что уже подписаны на онлайн статус собеседника
private var subscribedToOnlineStatus = false
init {
setupPacketListeners()
setupNewMessageListener()
@@ -351,6 +354,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isLoadingMessages = false
lastReadMessageTimestamp = 0L
readReceiptSentForCurrentDialog = false
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога
isDialogActive = true // 🔥 Диалог активен!
// 📨 Применяем Forward сообщения СРАЗУ после сброса
@@ -390,6 +394,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isDialogActive = false
}
/**
* 🔥 Установить состояние активности диалога (вызывается при ON_RESUME/ON_PAUSE)
* Предотвращает отметку сообщений как прочитанных когда приложение в фоне
*/
fun setDialogActive(active: Boolean) {
isDialogActive = active
}
/**
* 🚀 СУПЕР-оптимизированная загрузка сообщений
* 🔥 ОПТИМИЗАЦИЯ: Кэш на уровне диалога + задержка для анимации + чанковая расшифровка
@@ -1428,8 +1440,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/**
* 🟢 Подписаться на онлайн статус собеседника
* 📁 Для Saved Messages - не подписываемся
* 🔥 Проверяем флаг чтобы не подписываться повторно
*/
fun subscribeToOnlineStatus() {
// 🔥 Если уже подписаны - не подписываемся повторно!
if (subscribedToOnlineStatus) return
val opponent = opponentKey ?: return
val privateKey = myPrivateKey ?: return
val account = myPublicKey ?: return
@@ -1439,6 +1455,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
return
}
// 🔥 Устанавливаем флаг ДО отправки пакета
subscribedToOnlineStatus = true
viewModelScope.launch(Dispatchers.IO) {
try {
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -1460,6 +1479,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
super.onCleared()
lastReadMessageTimestamp = 0L
readReceiptSentForCurrentDialog = false
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг при очистке
opponentKey = null
}
}

View File

@@ -235,6 +235,11 @@ fun ChatsListScreen(
}
*/
// Enable UI logs when status dialog is shown
LaunchedEffect(showStatusDialog) {
ProtocolManager.enableUILogs(showStatusDialog)
}
// Status dialog with logs
if (showStatusDialog) {
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
@@ -576,8 +581,12 @@ fun ChatsListScreen(
iconColor = menuIconColor,
textColor = textColor,
onClick = {
scope.launch { drawerState.close() }
onSavedMessagesClick()
scope.launch {
drawerState.close()
// Ждём завершения анимации закрытия drawer
kotlinx.coroutines.delay(250)
onSavedMessagesClick()
}
}
)
@@ -893,37 +902,8 @@ fun ChatsListScreen(
AnimatedContent(
targetState = showRequestsScreen,
transitionSpec = {
if (targetState) {
// Переход на Requests: быстрый slide + fade
(slideInHorizontally(
animationSpec =
tween(200, easing = FastOutSlowInEasing),
initialOffsetX = { it }
) + fadeIn(tween(150))) togetherWith
(slideOutHorizontally(
animationSpec =
tween(
200,
easing = FastOutSlowInEasing
),
targetOffsetX = { -it / 4 }
) + fadeOut(tween(100)))
} else {
// Возврат из Requests: slide out to right
(slideInHorizontally(
animationSpec =
tween(200, easing = FastOutSlowInEasing),
initialOffsetX = { -it / 4 }
) + fadeIn(tween(150))) togetherWith
(slideOutHorizontally(
animationSpec =
tween(
200,
easing = FastOutSlowInEasing
),
targetOffsetX = { it }
) + fadeOut(tween(100)))
}
fadeIn(animationSpec = tween(200)) togetherWith
fadeOut(animationSpec = tween(150))
},
label = "RequestsTransition"
) { isRequestsScreen ->
@@ -1662,11 +1642,63 @@ fun DialogItemContent(
modifier = Modifier.weight(1f)
)
Text(
text = formatTime(Date(dialog.lastMessageTimestamp)),
fontSize = 13.sp,
color = if (dialog.unreadCount > 0) PrimaryBlue else secondaryTextColor
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
// Показываем статус только для исходящих сообщений
if (dialog.lastMessageFromMe == 1) {
when (dialog.lastMessageDelivered) {
2 -> {
// ERROR - показываем иконку ошибки
Icon(
imageVector = Icons.Outlined.ErrorOutline,
contentDescription = "Sending failed",
tint = Color(0xFFFF3B30), // iOS красный
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
}
1 -> {
// DELIVERED - две галочки
val checkmarkColor = if (dialog.lastMessageRead == 1) {
PrimaryBlue // прочитано - синие
} else {
secondaryTextColor.copy(alpha = 0.6f) // доставлено - серые
}
Icon(
imageVector = Icons.Default.Done,
contentDescription = null,
tint = checkmarkColor,
modifier = Modifier.size(14.dp)
)
Icon(
imageVector = Icons.Default.Done,
contentDescription = null,
tint = checkmarkColor,
modifier = Modifier.size(14.dp).offset(x = (-6).dp)
)
Spacer(modifier = Modifier.width(2.dp))
}
0 -> {
// WAITING - одна серая галочка (отправлено, ждём доставку)
Icon(
imageVector = Icons.Default.Done,
contentDescription = null,
tint = secondaryTextColor.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
Text(
text = formatTime(Date(dialog.lastMessageTimestamp)),
fontSize = 13.sp,
color = if (dialog.unreadCount > 0) PrimaryBlue else secondaryTextColor
)
}
}
Spacer(modifier = Modifier.height(4.dp))

View File

@@ -30,7 +30,10 @@ data class DialogUiModel(
val isOnline: Int,
val lastSeen: Long,
val verified: Int,
val isSavedMessages: Boolean = false // 📁 Флаг для Saved Messages (account == opponentKey)
val isSavedMessages: Boolean = false, // 📁 Флаг для Saved Messages (account == opponentKey)
val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1)
val lastMessageDelivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR)
val lastMessageRead: Int = 0 // Прочитано (0/1)
)
/**
@@ -61,6 +64,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы
private val requestedUserInfoKeys = mutableSetOf<String>()
// 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл
private val subscribedOnlineKeys = mutableSetOf<String>()
// Список диалогов с расшифрованными сообщениями
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
val dialogs: StateFlow<List<DialogUiModel>> = _dialogs.asStateFlow()
@@ -97,6 +103,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
if (currentAccount == publicKey) {
return
}
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
requestedUserInfoKeys.clear()
currentAccount = publicKey
currentPrivateKey = privateKey
@@ -138,7 +148,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen,
verified = dialog.verified,
isSavedMessages = isSavedMessages // 📁 Saved Messages
isSavedMessages = isSavedMessages, // 📁 Saved Messages
lastMessageFromMe = dialog.lastMessageFromMe,
lastMessageDelivered = dialog.lastMessageDelivered,
lastMessageRead = dialog.lastMessageRead
)
}
}
@@ -194,7 +207,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen,
verified = dialog.verified,
isSavedMessages = (dialog.account == dialog.opponentKey) // 📁 Saved Messages
isSavedMessages = (dialog.account == dialog.opponentKey), // 📁 Saved Messages
lastMessageFromMe = dialog.lastMessageFromMe,
lastMessageDelivered = dialog.lastMessageDelivered,
lastMessageRead = dialog.lastMessageRead
)
}
}
@@ -216,17 +232,25 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
/**
* 🟢 Подписаться на онлайн-статусы всех собеседников
* 🔥 Фильтруем уже подписанные ключи чтобы избежать бесконечного цикла
*/
private fun subscribeToOnlineStatuses(opponentKeys: List<String>, privateKey: String) {
if (opponentKeys.isEmpty()) return
// 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
val newKeys = opponentKeys.filter { !subscribedOnlineKeys.contains(it) }
if (newKeys.isEmpty()) return // Все уже подписаны
// Добавляем в Set ДО отправки пакета чтобы избежать race condition
subscribedOnlineKeys.addAll(newKeys)
viewModelScope.launch(Dispatchers.IO) {
try {
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val packet = PacketOnlineSubscribe().apply {
this.privateKey = privateKeyHash
opponentKeys.forEach { key ->
newKeys.forEach { key ->
addPublicKey(key)
}
}