feat: Optimize animations and UI components for smoother transitions and improved performance across chat screens

This commit is contained in:
k1ngsterr1
2026-01-13 15:31:43 +05:00
parent 6f577798d4
commit 764127c093
3 changed files with 75 additions and 64 deletions

View File

@@ -78,10 +78,16 @@ import java.text.SimpleDateFormat
import java.util.*
import kotlinx.coroutines.delay
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)
val TelegramEasing = CubicBezierEasing(0.199f, 0.010f, 0.279f, 0.910f)
// 🚀 Помечаем классы как Stable для оптимизации рекомпозиций
@Stable
class StableSearchUser(val user: SearchUser)
/** Telegram Send Icon (горизонтальный самолетик) - кастомная SVG иконка */
private val TelegramSendIcon: ImageVector
get() =
@@ -1381,7 +1387,7 @@ fun rememberMessageEnterAnimation(messageId: String): Pair<Float, Float> {
return Pair(alpha, translationY)
}
/** 🚀 Пузырек сообщения Telegram-style */
/** 🚀 Пузырек сообщения Telegram-style - ОПТИМИЗИРОВАННЫЙ */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun MessageBubble(
@@ -1408,10 +1414,7 @@ private fun MessageBubble(
// Прогресс свайпа для иконки reply (0..1) - используем абсолютное значение т.к. свайп влево
val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f)
// ❌ УБРАЛИ: Telegram-style enter animation - она мешает при скролле
// val (alpha, translationY) = rememberMessageEnterAnimation(message.id)
// Selection animation
// Selection animation - только если нужно
val selectionScale by animateFloatAsState(
targetValue = if (isSelected) 0.95f else 1f,
animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f),
@@ -1423,34 +1426,34 @@ private fun MessageBubble(
label = "selectionAlpha"
)
// Telegram colors (как в React Native themes.ts и Архив)
val bubbleColor =
if (message.isOutgoing) {
PrimaryBlue
} else {
// Входящие: серый фон чтобы выделялись на белом фоне экрана (как в Архиве)
if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
}
val textColor =
if (message.isOutgoing) Color.White
else {
if (isDarkTheme) Color.White else Color(0xFF000000)
}
val timeColor =
if (message.isOutgoing) Color.White.copy(alpha = 0.7f)
else {
if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
}
// 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета - они не меняются для одного сообщения
val bubbleColor = remember(message.isOutgoing, isDarkTheme) {
if (message.isOutgoing) {
PrimaryBlue
} else {
if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
}
}
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)
else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
}
// Telegram bubble shape - хвостик только у последнего сообщения в группе
val bubbleShape =
RoundedCornerShape(
topStart = 18.dp,
topEnd = 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
)
// 🔥 ОПТИМИЗАЦИЯ: Кешируем форму bubble
val bubbleShape = remember(message.isOutgoing, showTail) {
RoundedCornerShape(
topStart = 18.dp,
topEnd = 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
)
}
// 🔥 ОПТИМИЗАЦИЯ: SimpleDateFormat создаём один раз
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
// 🔥 Swipe-to-reply wrapper

View File

@@ -397,7 +397,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
/**
* 🚀 Оптимизированная загрузка сообщений с пагинацией
* 🚀 СУПЕР-оптимизированная загрузка сообщений
*/
private fun loadMessagesFromDatabase() {
val account = myPublicKey ?: return
@@ -408,7 +408,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
loadingJob = viewModelScope.launch(Dispatchers.IO) {
try {
withContext(Dispatchers.Main) {
// 🔥 Сначала показываем loading НА ГЛАВНОМ потоке - мгновенно
withContext(Dispatchers.Main.immediate) {
_isLoading.value = true
}
@@ -419,7 +420,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val totalCount = messageDao.getMessageCount(account, dialogKey)
ProtocolManager.addLog("📂 Total messages in DB: $totalCount")
// Получаем первую страницу сообщений
// 🔥 Получаем первую страницу - БЕЗ suspend задержки
val entities = messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = 0)
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
currentOffset = entities.size
// 🔥 Быстрая конвертация без расшифровки (plainMessage уже есть в БД)
val messages = entities.map { entity ->
entityToChatMessage(entity)
}.reversed()
// 🔥 ОПТИМИЗАЦИЯ: Быстрая конвертация в одном проходе
val messages = ArrayList<ChatMessage>(entities.size)
for (entity in entities.asReversed()) {
messages.add(entityToChatMessage(entity))
}
// 🔥 Отмечаем все входящие сообщения как прочитанные в БД (как в архиве)
messageDao.markDialogAsRead(account, dialogKey)
// 🔥 Очищаем счетчик непрочитанных в диалоге
dialogDao.clearUnreadCount(account, opponent)
ProtocolManager.addLog("👁️ Marked all incoming messages as read in DB, cleared unread count")
// Обновляем UI в Main потоке
withContext(Dispatchers.Main) {
// 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно
withContext(Dispatchers.Main.immediate) {
_messages.value = messages
_isLoading.value = false
}
// 🔥 Фоновые операции БЕЗ блокировки UI
launch(Dispatchers.IO) {
// Отмечаем как прочитанные в БД
messageDao.markDialogAsRead(account, dialogKey)
dialogDao.clearUnreadCount(account, opponent)
ProtocolManager.addLog("👁️ Marked all incoming messages as read in DB, cleared unread count")
// 🔥 Отправляем read receipt собеседнику (как в архиве)
// Отправляем read receipt собеседнику
if (messages.isNotEmpty()) {
val lastIncoming = messages.lastOrNull { !it.isOutgoing }
if (lastIncoming != null && lastIncoming.timestamp.time > lastReadMessageTimestamp) {
@@ -457,7 +461,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} catch (e: Exception) {
ProtocolManager.addLog("❌ Error loading messages: ${e.message}")
Log.e(TAG, "Error loading messages", e)
withContext(Dispatchers.Main) {
withContext(Dispatchers.Main.immediate) {
_isLoading.value = false
}
isLoadingMessages = false

View File

@@ -781,25 +781,29 @@ fun DrawerMenuItem(
}
}
/** Элемент диалога из базы данных */
/** Элемент диалога из базы данных - ОПТИМИЗИРОВАННЫЙ */
@Composable
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 dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
// 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета и строки
val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black }
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 displayName = dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) }
val initials =
if (dialog.opponentTitle.isNotEmpty()) {
dialog.opponentTitle
.split(" ")
.take(2)
.mapNotNull { it.firstOrNull()?.uppercase() }
.joinToString("")
} else {
dialog.opponentKey.take(2).uppercase()
}
val avatarColors = remember(dialog.opponentKey, isDarkTheme) { getAvatarColor(dialog.opponentKey, isDarkTheme) }
val displayName = remember(dialog.opponentTitle, dialog.opponentKey) {
dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) }
}
val initials = remember(dialog.opponentTitle, dialog.opponentKey) {
if (dialog.opponentTitle.isNotEmpty()) {
dialog.opponentTitle
.split(" ")
.take(2)
.mapNotNull { it.firstOrNull()?.uppercase() }
.joinToString("")
} else {
dialog.opponentKey.take(2).uppercase()
}
}
Column {
Row(