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 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user