feat: Optimize chat screen transitions by removing redundant animations for a smoother user experience

This commit is contained in:
k1ngsterr1
2026-01-13 18:36:17 +05:00
parent 435c07bf01
commit 4881024a9c
5 changed files with 128 additions and 111 deletions

View File

@@ -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<SearchUser?>(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"

View File

@@ -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 с использованием приватного ключа

View File

@@ -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)
}
}
}
}
/**

View File

@@ -229,16 +229,7 @@ fun ChatDetailScreen(
// Цвет иконок в хедере - синий как в React Native
val headerIconColor = if (isDarkTheme) Color.White else PrimaryBlue
// <EFBFBD> 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<Set<String>>(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)

View File

@@ -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<ChatMessage>(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")