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.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"
|
||||
|
||||
@@ -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 с использованием приватного ключа
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user