feat: Optimize chat screen transitions by removing redundant animations for a smoother user experience
This commit is contained in:
@@ -6,6 +6,9 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
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.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
@@ -234,7 +237,7 @@ fun MainScreen(
|
|||||||
var selectedUser by remember { mutableStateOf<SearchUser?>(null) }
|
var selectedUser by remember { mutableStateOf<SearchUser?>(null) }
|
||||||
var showSearchScreen by remember { mutableStateOf(false) }
|
var showSearchScreen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Анимированный переход между экранами - Telegram-style
|
// 🔥 TELEGRAM-STYLE анимация - чистый slide БЕЗ прозрачности
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = Triple(selectedUser, showSearchScreen, Unit),
|
targetState = Triple(selectedUser, showSearchScreen, Unit),
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
@@ -244,113 +247,79 @@ fun MainScreen(
|
|||||||
val isExitingSearch = !targetState.second && initialState.second
|
val isExitingSearch = !targetState.second && initialState.second
|
||||||
|
|
||||||
when {
|
when {
|
||||||
// 🚀 Вход в чат - slide справа + fade (как Telegram)
|
// 🚀 Вход в чат - чистый slide справа (как Telegram/iOS)
|
||||||
|
// Новый экран полностью покрывает старый, никакой прозрачности
|
||||||
isEnteringChat -> {
|
isEnteringChat -> {
|
||||||
slideInHorizontally(
|
slideInHorizontally(
|
||||||
initialOffsetX = { fullWidth -> fullWidth / 3 }, // Начинаем на 1/3 экрана справа
|
initialOffsetX = { fullWidth -> fullWidth }, // Начинаем за экраном справа
|
||||||
animationSpec = tween(
|
animationSpec = spring(
|
||||||
durationMillis = 250,
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
easing = FastOutSlowInEasing
|
stiffness = Spring.StiffnessMediumLow // Плавно но быстро
|
||||||
)
|
|
||||||
) + fadeIn(
|
|
||||||
initialAlpha = 0.3f,
|
|
||||||
animationSpec = tween(
|
|
||||||
durationMillis = 200,
|
|
||||||
easing = FastOutSlowInEasing
|
|
||||||
)
|
)
|
||||||
) togetherWith slideOutHorizontally(
|
) togetherWith slideOutHorizontally(
|
||||||
targetOffsetX = { fullWidth -> -fullWidth / 6 }, // Список уходит немного влево
|
targetOffsetX = { fullWidth -> -fullWidth / 4 }, // Старый экран уходит влево на 25%
|
||||||
animationSpec = tween(
|
animationSpec = spring(
|
||||||
durationMillis = 250,
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
easing = FastOutSlowInEasing
|
stiffness = Spring.StiffnessMediumLow
|
||||||
)
|
|
||||||
) + fadeOut(
|
|
||||||
targetAlpha = 0.5f,
|
|
||||||
animationSpec = tween(
|
|
||||||
durationMillis = 200,
|
|
||||||
easing = FastOutSlowInEasing
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔙 Выход из чата - slide вправо + fade (быстрее)
|
// 🔙 Выход из чата - обратный slide
|
||||||
isExitingChat -> {
|
isExitingChat -> {
|
||||||
slideInHorizontally(
|
slideInHorizontally(
|
||||||
initialOffsetX = { fullWidth -> -fullWidth / 6 }, // Список возвращается слева
|
initialOffsetX = { fullWidth -> -fullWidth / 4 }, // Список возвращается слева
|
||||||
animationSpec = tween(
|
animationSpec = spring(
|
||||||
durationMillis = 200,
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
easing = FastOutSlowInEasing
|
stiffness = Spring.StiffnessMedium // Чуть быстрее при выходе
|
||||||
)
|
|
||||||
) + fadeIn(
|
|
||||||
initialAlpha = 0.5f,
|
|
||||||
animationSpec = tween(
|
|
||||||
durationMillis = 180,
|
|
||||||
easing = FastOutSlowInEasing
|
|
||||||
)
|
)
|
||||||
) togetherWith slideOutHorizontally(
|
) togetherWith slideOutHorizontally(
|
||||||
targetOffsetX = { fullWidth -> fullWidth / 2 }, // Чат уходит вправо
|
targetOffsetX = { fullWidth -> fullWidth }, // Чат уходит за экран вправо
|
||||||
animationSpec = tween(
|
animationSpec = spring(
|
||||||
durationMillis = 200,
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
easing = FastOutSlowInEasing
|
stiffness = Spring.StiffnessMedium
|
||||||
)
|
|
||||||
) + fadeOut(
|
|
||||||
animationSpec = tween(
|
|
||||||
durationMillis = 180,
|
|
||||||
easing = FastOutSlowInEasing
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔍 Вход в поиск - slide снизу
|
// 🔍 Вход в поиск - slide сверху
|
||||||
isEnteringSearch -> {
|
isEnteringSearch -> {
|
||||||
slideInVertically(
|
slideInVertically(
|
||||||
initialOffsetY = { fullHeight -> fullHeight / 4 },
|
initialOffsetY = { -it },
|
||||||
animationSpec = tween(
|
animationSpec = spring(
|
||||||
durationMillis = 220,
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
easing = FastOutSlowInEasing
|
stiffness = Spring.StiffnessMediumLow
|
||||||
)
|
|
||||||
) + fadeIn(
|
|
||||||
animationSpec = tween(
|
|
||||||
durationMillis = 200,
|
|
||||||
easing = FastOutSlowInEasing
|
|
||||||
)
|
|
||||||
) togetherWith fadeOut(
|
|
||||||
animationSpec = tween(
|
|
||||||
durationMillis = 150,
|
|
||||||
easing = FastOutSlowInEasing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ❌ Выход из поиска - slide вниз
|
|
||||||
isExitingSearch -> {
|
|
||||||
fadeIn(
|
|
||||||
animationSpec = tween(
|
|
||||||
durationMillis = 180,
|
|
||||||
easing = FastOutSlowInEasing
|
|
||||||
)
|
)
|
||||||
) togetherWith slideOutVertically(
|
) togetherWith slideOutVertically(
|
||||||
targetOffsetY = { fullHeight -> fullHeight / 4 },
|
targetOffsetY = { it / 4 },
|
||||||
animationSpec = tween(
|
animationSpec = spring(
|
||||||
durationMillis = 200,
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
easing = FastOutSlowInEasing
|
stiffness = Spring.StiffnessMediumLow
|
||||||
)
|
|
||||||
) + fadeOut(
|
|
||||||
animationSpec = tween(
|
|
||||||
durationMillis = 150,
|
|
||||||
easing = FastOutSlowInEasing
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default fade
|
// ❌ Выход из поиска
|
||||||
else -> {
|
isExitingSearch -> {
|
||||||
fadeIn(
|
slideInVertically(
|
||||||
animationSpec = tween(durationMillis = 200)
|
initialOffsetY = { it / 4 },
|
||||||
) togetherWith fadeOut(
|
animationSpec = spring(
|
||||||
animationSpec = tween(durationMillis = 150)
|
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"
|
label = "screenNavigation"
|
||||||
|
|||||||
@@ -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
|
// Extension functions
|
||||||
private fun MessageEntity.toMessage(): Message {
|
private fun MessageEntity.toMessage(): Message {
|
||||||
// 🔓 Расшифровываем plainMessage с использованием приватного ключа
|
// 🔓 Расшифровываем plainMessage с использованием приватного ключа
|
||||||
|
|||||||
@@ -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)
|
// Обработчик typing (0x0B)
|
||||||
waitPacket(0x0B) { packet ->
|
waitPacket(0x0B) { packet ->
|
||||||
val typingPacket = packet as PacketTyping
|
val typingPacket = packet as PacketTyping
|
||||||
@@ -109,6 +126,20 @@ object ProtocolManager {
|
|||||||
_typingUsers.value = _typingUsers.value - typingPacket.fromPublicKey
|
_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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -229,16 +229,7 @@ fun ChatDetailScreen(
|
|||||||
// Цвет иконок в хедере - синий как в React Native
|
// Цвет иконок в хедере - синий как в React Native
|
||||||
val headerIconColor = if (isDarkTheme) Color.White else PrimaryBlue
|
val headerIconColor = if (isDarkTheme) Color.White else PrimaryBlue
|
||||||
|
|
||||||
// <EFBFBD> Fade-in анимация для всего экрана
|
// 🚀 Убираем дополнительную анимацию - используем только анимацию навигации из MainActivity
|
||||||
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 }
|
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -297,18 +288,13 @@ fun ChatDetailScreen(
|
|||||||
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
|
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
val isSelectionMode = selectedMessages.isNotEmpty()
|
val isSelectionMode = selectedMessages.isNotEmpty()
|
||||||
|
|
||||||
// 🔥 Быстрое закрытие с fade-out анимацией
|
// 🔥 Быстрое закрытие - мгновенный выход без дополнительной анимации
|
||||||
val hideKeyboardAndBack: () -> Unit = {
|
val hideKeyboardAndBack: () -> Unit = {
|
||||||
// Мгновенно убираем фокус и клавиатуру
|
// Мгновенно убираем фокус и клавиатуру
|
||||||
focusManager.clearFocus(force = true)
|
focusManager.clearFocus(force = true)
|
||||||
keyboardController?.hide()
|
keyboardController?.hide()
|
||||||
// Запускаем fade-out
|
// Сразу выходим - анимация в MainActivity
|
||||||
isVisible = false
|
onBack()
|
||||||
// Выходим после короткой анимации
|
|
||||||
scope.launch {
|
|
||||||
delay(150)
|
|
||||||
onBack()
|
|
||||||
}
|
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,9 +314,11 @@ fun ChatDetailScreen(
|
|||||||
var showBlockConfirm by remember { mutableStateOf(false) }
|
var showBlockConfirm by remember { mutableStateOf(false) }
|
||||||
var showUnblockConfirm by remember { mutableStateOf(false) }
|
var showUnblockConfirm by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Проверяем, заблокирован ли пользователь
|
// Проверяем, заблокирован ли пользователь (отложенно, не блокирует UI)
|
||||||
var isBlocked by remember { mutableStateOf(false) }
|
var isBlocked by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(user.publicKey, currentUserPublicKey) {
|
LaunchedEffect(user.publicKey, currentUserPublicKey) {
|
||||||
|
// Отложенная проверка - не блокирует анимацию
|
||||||
|
kotlinx.coroutines.delay(50) // Даём анимации завершиться
|
||||||
isBlocked = database.blacklistDao().isUserBlocked(user.publicKey, currentUserPublicKey)
|
isBlocked = database.blacklistDao().isUserBlocked(user.publicKey, currentUserPublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,11 +420,8 @@ fun ChatDetailScreen(
|
|||||||
isDarkTheme
|
isDarkTheme
|
||||||
)
|
)
|
||||||
|
|
||||||
// 🚀 Весь контент с fade-in анимацией
|
// 🚀 Весь контент без дополнительной анимации (анимация в MainActivity)
|
||||||
Box(modifier = Modifier
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
.fillMaxSize()
|
|
||||||
.graphicsLayer { alpha = screenAlpha }
|
|
||||||
) {
|
|
||||||
// Telegram-style solid header background (без blur)
|
// Telegram-style solid header background (без blur)
|
||||||
val headerBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF)
|
val headerBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
|||||||
@@ -396,8 +396,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// Подписываемся на онлайн статус
|
// Подписываемся на онлайн статус
|
||||||
subscribeToOnlineStatus()
|
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 account = myPublicKey ?: return
|
||||||
val opponent = opponentKey ?: return
|
val opponent = opponentKey ?: return
|
||||||
|
|
||||||
@@ -421,6 +423,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
loadingJob = viewModelScope.launch(Dispatchers.IO) {
|
loadingJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
// 🔥 Задержка перед загрузкой чтобы анимация перехода успела завершиться!
|
||||||
|
// Это критично для плавности - иначе расшифровка блокирует UI thread
|
||||||
|
if (delayMs > 0) {
|
||||||
|
delay(delayMs)
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 Сначала показываем loading НА ГЛАВНОМ потоке - мгновенно
|
// 🔥 Сначала показываем loading НА ГЛАВНОМ потоке - мгновенно
|
||||||
withContext(Dispatchers.Main.immediate) {
|
withContext(Dispatchers.Main.immediate) {
|
||||||
_isLoading.value = true
|
_isLoading.value = true
|
||||||
@@ -441,11 +449,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
hasMoreMessages = entities.size >= PAGE_SIZE
|
hasMoreMessages = entities.size >= PAGE_SIZE
|
||||||
currentOffset = entities.size
|
currentOffset = entities.size
|
||||||
|
|
||||||
// 🔥 Расшифровка сообщений при загрузке (как в архиве)
|
// 🔥 ЧАНКОВАЯ расшифровка - по DECRYPT_CHUNK_SIZE сообщений с yield между ними
|
||||||
|
// Это предотвращает блокировку UI thread
|
||||||
val messages = ArrayList<ChatMessage>(entities.size)
|
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)
|
val chatMsg = entityToChatMessage(entity)
|
||||||
messages.add(chatMsg)
|
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")
|
ProtocolManager.addLog("📋 Decrypted and loaded ${messages.size} messages from DB")
|
||||||
|
|||||||
Reference in New Issue
Block a user