From 4881024a9cead600f07ce3c27caa8ebd3b9694c8 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Tue, 13 Jan 2026 18:36:17 +0500 Subject: [PATCH] feat: Optimize chat screen transitions by removing redundant animations for a smoother user experience --- .../com/rosetta/messenger/MainActivity.kt | 133 +++++++----------- .../messenger/data/MessageRepository.kt | 17 +++ .../messenger/network/ProtocolManager.kt | 31 ++++ .../messenger/ui/chats/ChatDetailScreen.kt | 33 ++--- .../messenger/ui/chats/ChatViewModel.kt | 25 +++- 5 files changed, 128 insertions(+), 111 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 8958b90..cc5291f 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -6,6 +6,9 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.animation.* import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween @@ -234,7 +237,7 @@ fun MainScreen( var selectedUser by remember { mutableStateOf(null) } var showSearchScreen by remember { mutableStateOf(false) } - // Анимированный переход между экранами - Telegram-style + // 🔥 TELEGRAM-STYLE анимация - чистый slide БЕЗ прозрачности AnimatedContent( targetState = Triple(selectedUser, showSearchScreen, Unit), transitionSpec = { @@ -244,113 +247,79 @@ fun MainScreen( val isExitingSearch = !targetState.second && initialState.second when { - // 🚀 Вход в чат - slide справа + fade (как Telegram) + // 🚀 Вход в чат - чистый slide справа (как Telegram/iOS) + // Новый экран полностью покрывает старый, никакой прозрачности isEnteringChat -> { slideInHorizontally( - initialOffsetX = { fullWidth -> fullWidth / 3 }, // Начинаем на 1/3 экрана справа - animationSpec = tween( - durationMillis = 250, - easing = FastOutSlowInEasing - ) - ) + fadeIn( - initialAlpha = 0.3f, - animationSpec = tween( - durationMillis = 200, - easing = FastOutSlowInEasing + initialOffsetX = { fullWidth -> fullWidth }, // Начинаем за экраном справа + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow // Плавно но быстро ) ) togetherWith slideOutHorizontally( - targetOffsetX = { fullWidth -> -fullWidth / 6 }, // Список уходит немного влево - animationSpec = tween( - durationMillis = 250, - easing = FastOutSlowInEasing - ) - ) + fadeOut( - targetAlpha = 0.5f, - animationSpec = tween( - durationMillis = 200, - easing = FastOutSlowInEasing + targetOffsetX = { fullWidth -> -fullWidth / 4 }, // Старый экран уходит влево на 25% + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow ) ) } - // 🔙 Выход из чата - slide вправо + fade (быстрее) + // 🔙 Выход из чата - обратный slide isExitingChat -> { slideInHorizontally( - initialOffsetX = { fullWidth -> -fullWidth / 6 }, // Список возвращается слева - animationSpec = tween( - durationMillis = 200, - easing = FastOutSlowInEasing - ) - ) + fadeIn( - initialAlpha = 0.5f, - animationSpec = tween( - durationMillis = 180, - easing = FastOutSlowInEasing + initialOffsetX = { fullWidth -> -fullWidth / 4 }, // Список возвращается слева + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium // Чуть быстрее при выходе ) ) togetherWith slideOutHorizontally( - targetOffsetX = { fullWidth -> fullWidth / 2 }, // Чат уходит вправо - animationSpec = tween( - durationMillis = 200, - easing = FastOutSlowInEasing - ) - ) + fadeOut( - animationSpec = tween( - durationMillis = 180, - easing = FastOutSlowInEasing + targetOffsetX = { fullWidth -> fullWidth }, // Чат уходит за экран вправо + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium ) ) } - // 🔍 Вход в поиск - slide снизу + // 🔍 Вход в поиск - slide сверху isEnteringSearch -> { slideInVertically( - initialOffsetY = { fullHeight -> fullHeight / 4 }, - animationSpec = tween( - durationMillis = 220, - easing = FastOutSlowInEasing - ) - ) + fadeIn( - animationSpec = tween( - durationMillis = 200, - easing = FastOutSlowInEasing - ) - ) togetherWith fadeOut( - animationSpec = tween( - durationMillis = 150, - easing = FastOutSlowInEasing - ) - ) - } - - // ❌ Выход из поиска - slide вниз - isExitingSearch -> { - fadeIn( - animationSpec = tween( - durationMillis = 180, - easing = FastOutSlowInEasing + initialOffsetY = { -it }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow ) ) togetherWith slideOutVertically( - targetOffsetY = { fullHeight -> fullHeight / 4 }, - animationSpec = tween( - durationMillis = 200, - easing = FastOutSlowInEasing - ) - ) + fadeOut( - animationSpec = tween( - durationMillis = 150, - easing = FastOutSlowInEasing + targetOffsetY = { it / 4 }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow ) ) } - // Default fade - else -> { - fadeIn( - animationSpec = tween(durationMillis = 200) - ) togetherWith fadeOut( - animationSpec = tween(durationMillis = 150) + // ❌ Выход из поиска + isExitingSearch -> { + slideInVertically( + initialOffsetY = { it / 4 }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium + ) + ) togetherWith slideOutVertically( + targetOffsetY = { -it }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium + ) ) } + + // Default - мгновенный переход + else -> { + EnterTransition.None togetherWith ExitTransition.None + } } }, label = "screenNavigation" diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index b864bfd..dc5b8f4 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -410,6 +410,23 @@ class MessageRepository private constructor(private val context: Context) { } } + /** + * Обновить онлайн-статус пользователя в диалоге + */ + suspend fun updateOnlineStatus(publicKey: String, isOnline: Boolean) { + val account = currentAccount ?: return + + // Обновляем статус в базе + dialogDao.updateOnlineStatus( + account = account, + opponentKey = publicKey, + isOnline = if (isOnline) 1 else 0, + lastSeen = if (!isOnline) System.currentTimeMillis() else 0 + ) + + android.util.Log.d("MessageRepository", "🟢 Updated online status for ${publicKey.take(16)}... isOnline=$isOnline") + } + // Extension functions private fun MessageEntity.toMessage(): Message { // 🔓 Расшифровываем plainMessage с использованием приватного ключа diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 9be5bb3..ca435a8 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -97,6 +97,23 @@ object ProtocolManager { } } + // 🟢 Обработчик онлайн-статуса (0x05) + waitPacket(0x05) { packet -> + val onlinePacket = packet as PacketOnlineState + addLog("🟢 Online status received: ${onlinePacket.publicKeysState.size} entries") + + onlinePacket.publicKeysState.forEach { item -> + addLog(" User ${item.publicKey.take(16)}... is ${item.state}") + + scope.launch { + messageRepository?.updateOnlineStatus( + publicKey = item.publicKey, + isOnline = item.state == OnlineState.ONLINE + ) + } + } + } + // Обработчик typing (0x0B) waitPacket(0x0B) { packet -> val typingPacket = packet as PacketTyping @@ -109,6 +126,20 @@ object ProtocolManager { _typingUsers.value = _typingUsers.value - typingPacket.fromPublicKey } } + + // 🟢 Обработчик онлайн статуса (0x05) + waitPacket(0x05) { packet -> + val onlinePacket = packet as PacketOnlineState + addLog("🟢 Online status received: ${onlinePacket.publicKeysState.size} entries") + + scope.launch { + onlinePacket.publicKeysState.forEach { item -> + val isOnline = item.state == OnlineState.ONLINE + addLog(" ${item.publicKey.take(16)}... -> ${if (isOnline) "ONLINE" else "OFFLINE"}") + messageRepository?.updateOnlineStatus(item.publicKey, isOnline) + } + } + } } /** diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index df0bde3..421b516 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -229,16 +229,7 @@ fun ChatDetailScreen( // Цвет иконок в хедере - синий как в React Native val headerIconColor = if (isDarkTheme) Color.White else PrimaryBlue - // � Fade-in анимация для всего экрана - var isVisible by remember { mutableStateOf(false) } - val screenAlpha by - animateFloatAsState( - targetValue = if (isVisible) 1f else 0f, - animationSpec = tween(durationMillis = 250, easing = TelegramEasing), - label = "screenFade" - ) - - LaunchedEffect(Unit) { isVisible = true } + // 🚀 Убираем дополнительную анимацию - используем только анимацию навигации из MainActivity val listState = rememberLazyListState() val scope = rememberCoroutineScope() @@ -297,18 +288,13 @@ fun ChatDetailScreen( var selectedMessages by remember { mutableStateOf>(emptySet()) } val isSelectionMode = selectedMessages.isNotEmpty() - // 🔥 Быстрое закрытие с fade-out анимацией + // 🔥 Быстрое закрытие - мгновенный выход без дополнительной анимации val hideKeyboardAndBack: () -> Unit = { // Мгновенно убираем фокус и клавиатуру focusManager.clearFocus(force = true) keyboardController?.hide() - // Запускаем fade-out - isVisible = false - // Выходим после короткой анимации - scope.launch { - delay(150) - onBack() - } + // Сразу выходим - анимация в MainActivity + onBack() Unit } @@ -328,9 +314,11 @@ fun ChatDetailScreen( var showBlockConfirm by remember { mutableStateOf(false) } var showUnblockConfirm by remember { mutableStateOf(false) } - // Проверяем, заблокирован ли пользователь + // Проверяем, заблокирован ли пользователь (отложенно, не блокирует UI) var isBlocked by remember { mutableStateOf(false) } LaunchedEffect(user.publicKey, currentUserPublicKey) { + // Отложенная проверка - не блокирует анимацию + kotlinx.coroutines.delay(50) // Даём анимации завершиться isBlocked = database.blacklistDao().isUserBlocked(user.publicKey, currentUserPublicKey) } @@ -432,11 +420,8 @@ fun ChatDetailScreen( isDarkTheme ) - // 🚀 Весь контент с fade-in анимацией - Box(modifier = Modifier - .fillMaxSize() - .graphicsLayer { alpha = screenAlpha } - ) { + // 🚀 Весь контент без дополнительной анимации (анимация в MainActivity) + Box(modifier = Modifier.fillMaxSize()) { // Telegram-style solid header background (без blur) val headerBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 6499560..5af7c15 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -396,8 +396,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Подписываемся на онлайн статус subscribeToOnlineStatus() - // Загружаем сообщения из БД - loadMessagesFromDatabase() + // 🔥 ОПТИМИЗАЦИЯ: Загружаем сообщения ПОСЛЕ задержки для плавной анимации + // 250ms - это время анимации перехода в чат + loadMessagesFromDatabase(delayMs = 250L) } /** @@ -411,8 +412,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { /** * 🚀 СУПЕР-оптимизированная загрузка сообщений + * 🔥 ОПТИМИЗАЦИЯ: Задержка для завершения анимации + чанковая расшифровка */ - private fun loadMessagesFromDatabase() { + private fun loadMessagesFromDatabase(delayMs: Long = 0L) { val account = myPublicKey ?: return val opponent = opponentKey ?: return @@ -421,6 +423,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { loadingJob = viewModelScope.launch(Dispatchers.IO) { try { + // 🔥 Задержка перед загрузкой чтобы анимация перехода успела завершиться! + // Это критично для плавности - иначе расшифровка блокирует UI thread + if (delayMs > 0) { + delay(delayMs) + } + // 🔥 Сначала показываем loading НА ГЛАВНОМ потоке - мгновенно withContext(Dispatchers.Main.immediate) { _isLoading.value = true @@ -441,11 +449,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { hasMoreMessages = entities.size >= PAGE_SIZE currentOffset = entities.size - // 🔥 Расшифровка сообщений при загрузке (как в архиве) + // 🔥 ЧАНКОВАЯ расшифровка - по DECRYPT_CHUNK_SIZE сообщений с yield между ними + // Это предотвращает блокировку UI thread val messages = ArrayList(entities.size) - for (entity in entities.asReversed()) { + val reversedEntities = entities.asReversed() + for ((index, entity) in reversedEntities.withIndex()) { val chatMsg = entityToChatMessage(entity) messages.add(chatMsg) + + // Каждые DECRYPT_CHUNK_SIZE сообщений даём UI thread "подышать" + if ((index + 1) % DECRYPT_CHUNK_SIZE == 0) { + yield() // Позволяем другим корутинам выполниться + } } ProtocolManager.addLog("📋 Decrypted and loaded ${messages.size} messages from DB")