feat: Optimize animations and UI components for smoother transitions and improved performance across chat screens
This commit is contained in:
@@ -78,10 +78,16 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
|
||||||
// Telegram's CubicBezier interpolator (0.199, 0.010, 0.279, 0.910)
|
// Telegram's CubicBezier interpolator (0.199, 0.010, 0.279, 0.910)
|
||||||
val TelegramEasing = CubicBezierEasing(0.199f, 0.010f, 0.279f, 0.910f)
|
val TelegramEasing = CubicBezierEasing(0.199f, 0.010f, 0.279f, 0.910f)
|
||||||
|
|
||||||
|
// 🚀 Помечаем классы как Stable для оптимизации рекомпозиций
|
||||||
|
@Stable
|
||||||
|
class StableSearchUser(val user: SearchUser)
|
||||||
|
|
||||||
/** Telegram Send Icon (горизонтальный самолетик) - кастомная SVG иконка */
|
/** Telegram Send Icon (горизонтальный самолетик) - кастомная SVG иконка */
|
||||||
private val TelegramSendIcon: ImageVector
|
private val TelegramSendIcon: ImageVector
|
||||||
get() =
|
get() =
|
||||||
@@ -1381,7 +1387,7 @@ fun rememberMessageEnterAnimation(messageId: String): Pair<Float, Float> {
|
|||||||
return Pair(alpha, translationY)
|
return Pair(alpha, translationY)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 🚀 Пузырек сообщения Telegram-style */
|
/** 🚀 Пузырек сообщения Telegram-style - ОПТИМИЗИРОВАННЫЙ */
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageBubble(
|
private fun MessageBubble(
|
||||||
@@ -1408,10 +1414,7 @@ private fun MessageBubble(
|
|||||||
// Прогресс свайпа для иконки reply (0..1) - используем абсолютное значение т.к. свайп влево
|
// Прогресс свайпа для иконки reply (0..1) - используем абсолютное значение т.к. свайп влево
|
||||||
val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f)
|
val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f)
|
||||||
|
|
||||||
// ❌ УБРАЛИ: Telegram-style enter animation - она мешает при скролле
|
// Selection animation - только если нужно
|
||||||
// val (alpha, translationY) = rememberMessageEnterAnimation(message.id)
|
|
||||||
|
|
||||||
// Selection animation
|
|
||||||
val selectionScale by animateFloatAsState(
|
val selectionScale by animateFloatAsState(
|
||||||
targetValue = if (isSelected) 0.95f else 1f,
|
targetValue = if (isSelected) 0.95f else 1f,
|
||||||
animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f),
|
animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f),
|
||||||
@@ -1423,34 +1426,34 @@ private fun MessageBubble(
|
|||||||
label = "selectionAlpha"
|
label = "selectionAlpha"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Telegram colors (как в React Native themes.ts и Архив)
|
// 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета - они не меняются для одного сообщения
|
||||||
val bubbleColor =
|
val bubbleColor = remember(message.isOutgoing, isDarkTheme) {
|
||||||
if (message.isOutgoing) {
|
if (message.isOutgoing) {
|
||||||
PrimaryBlue
|
PrimaryBlue
|
||||||
} else {
|
} else {
|
||||||
// Входящие: серый фон чтобы выделялись на белом фоне экрана (как в Архиве)
|
|
||||||
if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
|
if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
|
||||||
}
|
}
|
||||||
val textColor =
|
|
||||||
if (message.isOutgoing) Color.White
|
|
||||||
else {
|
|
||||||
if (isDarkTheme) Color.White else Color(0xFF000000)
|
|
||||||
}
|
}
|
||||||
val timeColor =
|
val textColor = remember(message.isOutgoing, isDarkTheme) {
|
||||||
|
if (message.isOutgoing) Color.White
|
||||||
|
else if (isDarkTheme) Color.White else Color(0xFF000000)
|
||||||
|
}
|
||||||
|
val timeColor = remember(message.isOutgoing, isDarkTheme) {
|
||||||
if (message.isOutgoing) Color.White.copy(alpha = 0.7f)
|
if (message.isOutgoing) Color.White.copy(alpha = 0.7f)
|
||||||
else {
|
else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Telegram bubble shape - хвостик только у последнего сообщения в группе
|
// 🔥 ОПТИМИЗАЦИЯ: Кешируем форму bubble
|
||||||
val bubbleShape =
|
val bubbleShape = remember(message.isOutgoing, showTail) {
|
||||||
RoundedCornerShape(
|
RoundedCornerShape(
|
||||||
topStart = 18.dp,
|
topStart = 18.dp,
|
||||||
topEnd = 18.dp,
|
topEnd = 18.dp,
|
||||||
bottomStart = if (message.isOutgoing) 18.dp else (if (showTail) 4.dp else 18.dp),
|
bottomStart = if (message.isOutgoing) 18.dp else (if (showTail) 4.dp else 18.dp),
|
||||||
bottomEnd = if (message.isOutgoing) (if (showTail) 4.dp else 18.dp) else 18.dp
|
bottomEnd = if (message.isOutgoing) (if (showTail) 4.dp else 18.dp) else 18.dp
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 ОПТИМИЗАЦИЯ: SimpleDateFormat создаём один раз
|
||||||
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||||
|
|
||||||
// 🔥 Swipe-to-reply wrapper
|
// 🔥 Swipe-to-reply wrapper
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🚀 Оптимизированная загрузка сообщений с пагинацией
|
* 🚀 СУПЕР-оптимизированная загрузка сообщений
|
||||||
*/
|
*/
|
||||||
private fun loadMessagesFromDatabase() {
|
private fun loadMessagesFromDatabase() {
|
||||||
val account = myPublicKey ?: return
|
val account = myPublicKey ?: return
|
||||||
@@ -408,7 +408,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
loadingJob = viewModelScope.launch(Dispatchers.IO) {
|
loadingJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.Main) {
|
// 🔥 Сначала показываем loading НА ГЛАВНОМ потоке - мгновенно
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
_isLoading.value = true
|
_isLoading.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +420,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val totalCount = messageDao.getMessageCount(account, dialogKey)
|
val totalCount = messageDao.getMessageCount(account, dialogKey)
|
||||||
ProtocolManager.addLog("📂 Total messages in DB: $totalCount")
|
ProtocolManager.addLog("📂 Total messages in DB: $totalCount")
|
||||||
|
|
||||||
// Получаем первую страницу сообщений
|
// 🔥 Получаем первую страницу - БЕЗ suspend задержки
|
||||||
val entities = messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = 0)
|
val entities = messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = 0)
|
||||||
|
|
||||||
ProtocolManager.addLog("📂 Loaded ${entities.size} messages from DB (offset: 0, limit: $PAGE_SIZE)")
|
ProtocolManager.addLog("📂 Loaded ${entities.size} messages from DB (offset: 0, limit: $PAGE_SIZE)")
|
||||||
@@ -427,23 +428,26 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
hasMoreMessages = entities.size >= PAGE_SIZE
|
hasMoreMessages = entities.size >= PAGE_SIZE
|
||||||
currentOffset = entities.size
|
currentOffset = entities.size
|
||||||
|
|
||||||
// 🔥 Быстрая конвертация без расшифровки (plainMessage уже есть в БД)
|
// 🔥 ОПТИМИЗАЦИЯ: Быстрая конвертация в одном проходе
|
||||||
val messages = entities.map { entity ->
|
val messages = ArrayList<ChatMessage>(entities.size)
|
||||||
entityToChatMessage(entity)
|
for (entity in entities.asReversed()) {
|
||||||
}.reversed()
|
messages.add(entityToChatMessage(entity))
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 Отмечаем все входящие сообщения как прочитанные в БД (как в архиве)
|
// 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
_messages.value = messages
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 Фоновые операции БЕЗ блокировки UI
|
||||||
|
launch(Dispatchers.IO) {
|
||||||
|
// Отмечаем как прочитанные в БД
|
||||||
messageDao.markDialogAsRead(account, dialogKey)
|
messageDao.markDialogAsRead(account, dialogKey)
|
||||||
// 🔥 Очищаем счетчик непрочитанных в диалоге
|
|
||||||
dialogDao.clearUnreadCount(account, opponent)
|
dialogDao.clearUnreadCount(account, opponent)
|
||||||
ProtocolManager.addLog("👁️ Marked all incoming messages as read in DB, cleared unread count")
|
ProtocolManager.addLog("👁️ Marked all incoming messages as read in DB, cleared unread count")
|
||||||
|
|
||||||
// Обновляем UI в Main потоке
|
// Отправляем read receipt собеседнику
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
_messages.value = messages
|
|
||||||
_isLoading.value = false
|
|
||||||
|
|
||||||
// 🔥 Отправляем read receipt собеседнику (как в архиве)
|
|
||||||
if (messages.isNotEmpty()) {
|
if (messages.isNotEmpty()) {
|
||||||
val lastIncoming = messages.lastOrNull { !it.isOutgoing }
|
val lastIncoming = messages.lastOrNull { !it.isOutgoing }
|
||||||
if (lastIncoming != null && lastIncoming.timestamp.time > lastReadMessageTimestamp) {
|
if (lastIncoming != null && lastIncoming.timestamp.time > lastReadMessageTimestamp) {
|
||||||
@@ -457,7 +461,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ProtocolManager.addLog("❌ Error loading messages: ${e.message}")
|
ProtocolManager.addLog("❌ Error loading messages: ${e.message}")
|
||||||
Log.e(TAG, "Error loading messages", e)
|
Log.e(TAG, "Error loading messages", e)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main.immediate) {
|
||||||
_isLoading.value = false
|
_isLoading.value = false
|
||||||
}
|
}
|
||||||
isLoadingMessages = false
|
isLoadingMessages = false
|
||||||
|
|||||||
@@ -781,16 +781,19 @@ fun DrawerMenuItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Элемент диалога из базы данных */
|
/** Элемент диалога из базы данных - ОПТИМИЗИРОВАННЫЙ */
|
||||||
@Composable
|
@Composable
|
||||||
fun DialogItem(dialog: DialogEntity, isDarkTheme: Boolean, onClick: () -> Unit) {
|
fun DialogItem(dialog: DialogEntity, isDarkTheme: Boolean, onClick: () -> Unit) {
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
// 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета и строки
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black }
|
||||||
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
|
val secondaryTextColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
|
||||||
|
val dividerColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) }
|
||||||
|
|
||||||
val avatarColors = getAvatarColor(dialog.opponentKey, isDarkTheme)
|
val avatarColors = remember(dialog.opponentKey, isDarkTheme) { getAvatarColor(dialog.opponentKey, isDarkTheme) }
|
||||||
val displayName = dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) }
|
val displayName = remember(dialog.opponentTitle, dialog.opponentKey) {
|
||||||
val initials =
|
dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) }
|
||||||
|
}
|
||||||
|
val initials = remember(dialog.opponentTitle, dialog.opponentKey) {
|
||||||
if (dialog.opponentTitle.isNotEmpty()) {
|
if (dialog.opponentTitle.isNotEmpty()) {
|
||||||
dialog.opponentTitle
|
dialog.opponentTitle
|
||||||
.split(" ")
|
.split(" ")
|
||||||
@@ -800,6 +803,7 @@ fun DialogItem(dialog: DialogEntity, isDarkTheme: Boolean, onClick: () -> Unit)
|
|||||||
} else {
|
} else {
|
||||||
dialog.opponentKey.take(2).uppercase()
|
dialog.opponentKey.take(2).uppercase()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
Reference in New Issue
Block a user