feat: Optimize search animations and improve content rendering in SearchScreen; update Saved Messages icon handling in DialogItemContent

This commit is contained in:
2026-01-18 13:12:43 +05:00
parent 5833237c3a
commit 89e5f3cfa2
15 changed files with 231 additions and 159 deletions

View File

@@ -171,8 +171,8 @@ class MainActivity : ComponentActivity() {
else -> "main" else -> "main"
}, },
transitionSpec = { transitionSpec = {
fadeIn(animationSpec = tween(600)) togetherWith fadeIn(animationSpec = tween(300)) togetherWith
fadeOut(animationSpec = tween(600)) fadeOut(animationSpec = tween(200))
}, },
label = "screenTransition" label = "screenTransition"
) { screen -> ) { screen ->
@@ -375,20 +375,20 @@ fun MainScreen(
val isExitingSearch = !targetState.second && initialState.second val isExitingSearch = !targetState.second && initialState.second
when { when {
// 🚀 Вход в чат - чистый slide справа (как Telegram/iOS) // 🚀 Вход в чат - быстрый slide справа (как Telegram/iOS)
// Новый экран полностью покрывает старый, никакой прозрачности // Новый экран полностью покрывает старый, никакой прозрачности
isEnteringChat -> { isEnteringChat -> {
slideInHorizontally( slideInHorizontally(
initialOffsetX = { fullWidth -> fullWidth }, // Начинаем за экраном справа initialOffsetX = { fullWidth -> fullWidth },
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy, dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow // Плавно но быстро stiffness = Spring.StiffnessHigh // 🔥 Быстрее для плавности
) )
) togetherWith slideOutHorizontally( ) togetherWith slideOutHorizontally(
targetOffsetX = { fullWidth -> -fullWidth / 4 }, // Старый экран уходит влево на 25% targetOffsetX = { fullWidth -> -fullWidth / 5 }, // Меньше смещение
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy, dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow stiffness = Spring.StiffnessHigh
) )
) )
} }
@@ -396,33 +396,33 @@ fun MainScreen(
// 🔙 Выход из чата - обратный slide // 🔙 Выход из чата - обратный slide
isExitingChat -> { isExitingChat -> {
slideInHorizontally( slideInHorizontally(
initialOffsetX = { fullWidth -> -fullWidth / 4 }, // Список возвращается слева initialOffsetX = { fullWidth -> -fullWidth / 5 },
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy, dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium // Чуть быстрее при выходе stiffness = Spring.StiffnessHigh
) )
) togetherWith slideOutHorizontally( ) togetherWith slideOutHorizontally(
targetOffsetX = { fullWidth -> fullWidth }, // Чат уходит за экран вправо targetOffsetX = { fullWidth -> fullWidth },
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy, dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium stiffness = Spring.StiffnessHigh
) )
) )
} }
// 🔍 Вход в поиск - slide справа (как чаты) // 🔍 Вход в поиск - быстрый slide справа
isEnteringSearch -> { isEnteringSearch -> {
slideInHorizontally( slideInHorizontally(
initialOffsetX = { fullWidth -> fullWidth }, // Начинаем за экраном справа initialOffsetX = { fullWidth -> fullWidth },
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy, dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow stiffness = Spring.StiffnessHigh // 🔥 Быстрее для плавности
) )
) togetherWith slideOutHorizontally( ) togetherWith slideOutHorizontally(
targetOffsetX = { fullWidth -> -fullWidth / 4 }, // Список уходит влево на 25% targetOffsetX = { fullWidth -> -fullWidth / 5 }, // Меньше смещение
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy, dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow stiffness = Spring.StiffnessHigh
) )
) )
} }
@@ -430,16 +430,16 @@ fun MainScreen(
// ❌ Выход из поиска - обратный slide // ❌ Выход из поиска - обратный slide
isExitingSearch -> { isExitingSearch -> {
slideInHorizontally( slideInHorizontally(
initialOffsetX = { fullWidth -> -fullWidth / 4 }, // Список возвращается слева initialOffsetX = { fullWidth -> -fullWidth / 5 },
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy, dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium stiffness = Spring.StiffnessHigh
) )
) togetherWith slideOutHorizontally( ) togetherWith slideOutHorizontally(
targetOffsetX = { fullWidth -> fullWidth }, // Поиск уходит за экран вправо targetOffsetX = { fullWidth -> fullWidth },
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy, dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium stiffness = Spring.StiffnessHigh
) )
) )
} }

View File

@@ -174,9 +174,27 @@ object ProtocolManager {
} }
} }
// 🔥 УБРАН обработчик поиска (0x03) из ProtocolManager // 🔥 Обработчик поиска/user info (0x03)
// Он вызывал бесконечный цикл т.к. updateDialogUserInfo триггерил Flow // Обновляет информацию о пользователе в диалогах когда приходит ответ от сервера
// Обработка 0x03 происходит только в SearchUsersViewModel waitPacket(0x03) { packet ->
val searchPacket = packet as PacketSearch
addLog("🔍 Search/UserInfo response: ${searchPacket.users.size} users")
// Обновляем информацию о пользователях в диалогах
if (searchPacket.users.isNotEmpty()) {
scope.launch {
searchPacket.users.forEach { user ->
addLog(" 📝 Updating user info: ${user.publicKey.take(16)}... title='${user.title}' username='${user.username}'")
messageRepository?.updateDialogUserInfo(
user.publicKey,
user.title,
user.username,
user.verified
)
}
}
}
}
} }
/** /**

View File

@@ -159,9 +159,9 @@ fun ConfirmSeedPhraseScreen(
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(500)) + slideInVertically( enter = fadeIn(tween(250)) + slideInVertically(
initialOffsetY = { -20 }, initialOffsetY = { -40 },
animationSpec = tween(500) animationSpec = tween(250)
) )
) { ) {
Text( Text(

View File

@@ -78,9 +78,9 @@ fun ImportSeedPhraseScreen(
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(500)) + slideInVertically( enter = fadeIn(tween(250)) + slideInVertically(
initialOffsetY = { -20 }, initialOffsetY = { -40 },
animationSpec = tween(500) animationSpec = tween(250)
) )
) { ) {
Text( Text(

View File

@@ -80,9 +80,9 @@ fun SeedPhraseScreen(
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(500)) + slideInVertically( enter = fadeIn(tween(250)) + slideInVertically(
initialOffsetY = { -20 }, initialOffsetY = { -40 },
animationSpec = tween(500) animationSpec = tween(250)
) )
) { ) {
Text( Text(

View File

@@ -103,9 +103,9 @@ fun SelectAccountScreen(
// Header // Header
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(500)) + slideInVertically( enter = fadeIn(tween(250)) + slideInVertically(
initialOffsetY = { -30 }, initialOffsetY = { -40 },
animationSpec = tween(500) animationSpec = tween(250)
) )
) { ) {
Column { Column {
@@ -269,9 +269,9 @@ private fun AccountListItem(
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(400)) + slideInHorizontally( enter = fadeIn(tween(200)) + slideInHorizontally(
initialOffsetX = { -50 }, initialOffsetX = { 50 },
animationSpec = tween(400) animationSpec = tween(200)
) )
) { ) {
Card( Card(
@@ -378,9 +378,9 @@ private fun AddAccountButton(
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(400)) + slideInHorizontally( enter = fadeIn(tween(200)) + slideInHorizontally(
initialOffsetX = { -50 }, initialOffsetX = { -50 },
animationSpec = tween(400) animationSpec = tween(200)
) )
) { ) {
Card( Card(

View File

@@ -147,11 +147,11 @@ fun SetPasswordScreen(
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = enter =
fadeIn(tween(500)) + fadeIn(tween(250)) +
scaleIn( scaleIn(
initialScale = 0.5f, initialScale = 0.5f,
animationSpec = animationSpec =
tween(500, easing = FastOutSlowInEasing) tween(250, easing = FastOutSlowInEasing)
) )
) { ) {
Box( Box(
@@ -460,19 +460,19 @@ fun SetPasswordScreen(
AnimatedVisibility( AnimatedVisibility(
visible = visible && !isKeyboardVisible, visible = visible && !isKeyboardVisible,
enter = enter =
fadeIn(tween(400)) + fadeIn(tween(200)) +
slideInVertically( slideInVertically(
initialOffsetY = { 30 }, initialOffsetY = { 30 },
animationSpec = tween(400) animationSpec = tween(200)
) + ) +
scaleIn(initialScale = 0.9f, animationSpec = tween(400)), scaleIn(initialScale = 0.9f, animationSpec = tween(200)),
exit = exit =
fadeOut(tween(300)) + fadeOut(tween(150)) +
slideOutVertically( slideOutVertically(
targetOffsetY = { 30 }, targetOffsetY = { 30 },
animationSpec = tween(300) animationSpec = tween(150)
) + ) +
scaleOut(targetScale = 0.9f, animationSpec = tween(300)) scaleOut(targetScale = 0.9f, animationSpec = tween(150))
) { ) {
Row( Row(
modifier = modifier =

View File

@@ -181,7 +181,7 @@ fun UnlockScreen(
// Rosetta Logo // Rosetta Logo
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(600)) + scaleIn(tween(600, easing = FastOutSlowInEasing)) enter = fadeIn(tween(300)) + scaleIn(tween(300, easing = FastOutSlowInEasing))
) { ) {
Image( Image(
painter = painterResource(id = R.drawable.rosetta_icon), painter = painterResource(id = R.drawable.rosetta_icon),

View File

@@ -97,7 +97,7 @@ fun WelcomeScreen(
// Animated Lock Icon // Animated Lock Icon
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(600)) + scaleIn(tween(600, easing = FastOutSlowInEasing)) enter = fadeIn(tween(300)) + scaleIn(tween(300, easing = FastOutSlowInEasing))
) { ) {
Box(modifier = Modifier.size(180.dp), contentAlignment = Alignment.Center) { Box(modifier = Modifier.size(180.dp), contentAlignment = Alignment.Center) {
lockComposition?.let { comp -> lockComposition?.let { comp ->

View File

@@ -234,13 +234,22 @@ fun ChatDetailScreen(
.getTotalUnreadCountExcludingFlow(currentUserPublicKey, user.publicKey) .getTotalUnreadCountExcludingFlow(currentUserPublicKey, user.publicKey)
.collectAsState(initial = 0) .collectAsState(initial = 0)
// Цвета как в React Native themes.ts // Цвета как в React Native themes.ts - 🔥 КЭШИРУЕМ для производительности
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) val backgroundColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) }
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black }
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val secondaryTextColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF2F3F5) val inputBackgroundColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF2F3F5) }
// Цвет иконок в хедере - синий как в React Native // Цвет иконок в хедере - синий как в React Native
val headerIconColor = if (isDarkTheme) Color.White else PrimaryBlue val headerIconColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else PrimaryBlue }
// 🔥 ОПТИМИЗАЦИЯ: Отложенный рендеринг тяжелого контента
var isContentReady by remember { mutableStateOf(false) }
// Запускаем отложенный рендеринг после завершения анимации перехода
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(30) // Минимальная задержка для анимации
isContentReady = true
}
// 🚀 Убираем дополнительную анимацию - используем только анимацию навигации из MainActivity // 🚀 Убираем дополнительную анимацию - используем только анимацию навигации из MainActivity
@@ -380,8 +389,8 @@ fun ChatDetailScreen(
// Проверяем, заблокирован ли пользователь (отложенно, не блокирует UI) // Проверяем, заблокирован ли пользователь (отложенно, не блокирует 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) // Даём анимации завершиться kotlinx.coroutines.delay(100) // Даём анимации завершиться
isBlocked = database.blacklistDao().isUserBlocked(user.publicKey, currentUserPublicKey) isBlocked = database.blacklistDao().isUserBlocked(user.publicKey, currentUserPublicKey)
} }
@@ -572,10 +581,10 @@ fun ChatDetailScreen(
} else headerBackground } else headerBackground
) )
) { ) {
// Контент хедера с Crossfade для плавной смены // Контент хедера с Crossfade для плавной смены - ускоренная анимация
Crossfade( Crossfade(
targetState = isSelectionMode, targetState = isSelectionMode,
animationSpec = tween(200), animationSpec = tween(150),
label = "headerContent" label = "headerContent"
) { selectionMode -> ) { selectionMode ->
if (selectionMode) { if (selectionMode) {

View File

@@ -172,11 +172,11 @@ fun ChatsListScreen(
} }
} }
// Colors - instant change, no animation // Colors - instant change, no animation - 🔥 КЭШИРУЕМ для производительности
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) val backgroundColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) }
val drawerBackgroundColor = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) val drawerBackgroundColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) }
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black }
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E8E) else Color(0xFF8E8E93) val secondaryTextColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E8E) else Color(0xFF8E8E93) }
// Protocol connection state // Protocol connection state
val protocolState by ProtocolManager.state.collectAsState() val protocolState by ProtocolManager.state.collectAsState()
@@ -479,12 +479,12 @@ fun ChatsListScreen(
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
var showCopiedToast by remember { mutableStateOf(false) } var showCopiedToast by remember { mutableStateOf(false) }
// Плавная замена текста // Плавная замена текста - ускоренная анимация
AnimatedContent( AnimatedContent(
targetState = showCopiedToast, targetState = showCopiedToast,
transitionSpec = { transitionSpec = {
fadeIn(animationSpec = tween(300)) togetherWith fadeIn(animationSpec = tween(150)) togetherWith
fadeOut(animationSpec = tween(300)) fadeOut(animationSpec = tween(150))
}, },
label = "copiedAnimation" label = "copiedAnimation"
) { isCopied -> ) { isCopied ->
@@ -894,35 +894,35 @@ fun ChatsListScreen(
targetState = showRequestsScreen, targetState = showRequestsScreen,
transitionSpec = { transitionSpec = {
if (targetState) { if (targetState) {
// Переход на Requests: slide in from right + fade // Переход на Requests: быстрый slide + fade
(slideInHorizontally( (slideInHorizontally(
animationSpec = animationSpec =
tween(300, easing = FastOutSlowInEasing), tween(200, easing = FastOutSlowInEasing),
initialOffsetX = { it } initialOffsetX = { it }
) + fadeIn(tween(300))) togetherWith ) + fadeIn(tween(150))) togetherWith
(slideOutHorizontally( (slideOutHorizontally(
animationSpec = animationSpec =
tween( tween(
300, 200,
easing = FastOutSlowInEasing easing = FastOutSlowInEasing
), ),
targetOffsetX = { -it / 3 } targetOffsetX = { -it / 4 }
) + fadeOut(tween(200))) ) + fadeOut(tween(100)))
} else { } else {
// Возврат из Requests: slide out to right // Возврат из Requests: slide out to right
(slideInHorizontally( (slideInHorizontally(
animationSpec = animationSpec =
tween(300, easing = FastOutSlowInEasing), tween(200, easing = FastOutSlowInEasing),
initialOffsetX = { -it / 3 } initialOffsetX = { -it / 4 }
) + fadeIn(tween(300))) togetherWith ) + fadeIn(tween(150))) togetherWith
(slideOutHorizontally( (slideOutHorizontally(
animationSpec = animationSpec =
tween( tween(
300, 200,
easing = FastOutSlowInEasing easing = FastOutSlowInEasing
), ),
targetOffsetX = { it } targetOffsetX = { it }
) + fadeOut(tween(200))) ) + fadeOut(tween(100)))
} }
}, },
label = "RequestsTransition" label = "RequestsTransition"
@@ -1554,25 +1554,35 @@ fun DialogItemContent(
// 📁 Для Saved Messages показываем специальное имя // 📁 Для Saved Messages показываем специальное имя
val displayName = val displayName =
remember(dialog.opponentTitle, dialog.opponentKey, dialog.isSavedMessages) { remember(dialog.opponentTitle, dialog.opponentUsername, dialog.opponentKey, dialog.isSavedMessages) {
if (dialog.isSavedMessages) { if (dialog.isSavedMessages) {
"Saved Messages" "Saved Messages"
} else if (dialog.opponentTitle.isNotEmpty()) {
// 🔥 Показываем title как основное имя (как в десктопной версии)
dialog.opponentTitle
} else if (dialog.opponentUsername.isNotEmpty()) {
// Username только если нет title
"@${dialog.opponentUsername}"
} else { } else {
dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) } dialog.opponentKey.take(8)
} }
} }
// 📁 Для Saved Messages показываем иконку закладки // 📁 Для Saved Messages показываем иконку закладки
val initials = val initials =
remember(dialog.opponentTitle, dialog.opponentKey, dialog.isSavedMessages) { remember(dialog.opponentTitle, dialog.opponentUsername, dialog.opponentKey, dialog.isSavedMessages) {
if (dialog.isSavedMessages) { if (dialog.isSavedMessages) {
"📁" // Иконка для Saved Messages "" // Для Saved Messages - пустая строка, будет использоваться иконка
} else if (dialog.opponentTitle.isNotEmpty()) { } else if (dialog.opponentTitle.isNotEmpty()) {
// Используем title для инициалов
dialog.opponentTitle dialog.opponentTitle
.split(" ") .split(" ")
.take(2) .take(2)
.mapNotNull { it.firstOrNull()?.uppercase() } .mapNotNull { it.firstOrNull()?.uppercase() }
.joinToString("") .joinToString("")
} else if (dialog.opponentUsername.isNotEmpty()) {
// Если только username - берем первые 2 символа
dialog.opponentUsername.take(2).uppercase()
} else { } else {
dialog.opponentKey.take(2).uppercase() dialog.opponentKey.take(2).uppercase()
} }
@@ -1592,9 +1602,20 @@ fun DialogItemContent(
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize()
.clip(CircleShape) .clip(CircleShape)
.background(avatarColors.backgroundColor), .background(
if (dialog.isSavedMessages) PrimaryBlue
else avatarColors.backgroundColor
),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (dialog.isSavedMessages) {
Icon(
Icons.Default.Bookmark,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
} else {
Text( Text(
text = initials, text = initials,
color = avatarColors.textColor, color = avatarColors.textColor,
@@ -1602,6 +1623,7 @@ fun DialogItemContent(
fontSize = 18.sp fontSize = 18.sp
) )
} }
}
// Online indicator - зелёный кружок с белой обводкой // Online indicator - зелёный кружок с белой обводкой
if (dialog.isOnline == 1) { if (dialog.isOnline == 1) {

View File

@@ -107,6 +107,14 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
.map { dialogsList -> .map { dialogsList ->
// 🔓 Расшифровываем lastMessage на IO потоке (PBKDF2 - тяжелая операция!) // 🔓 Расшифровываем lastMessage на IO потоке (PBKDF2 - тяжелая операция!)
dialogsList.map { dialog -> dialogsList.map { dialog ->
// 🔥 Загружаем информацию о пользователе если её нет
// 📁 НЕ загружаем для Saved Messages
val isSavedMessages = (dialog.account == dialog.opponentKey)
if (!isSavedMessages && (dialog.opponentTitle.isEmpty() || dialog.opponentTitle == dialog.opponentKey || dialog.opponentTitle == dialog.opponentKey.take(7))) {
android.util.Log.d("ChatsListVM", "🔍 Dialog needs user info: ${dialog.opponentKey.take(16)}... title='${dialog.opponentTitle}'")
loadUserInfoForDialog(dialog.opponentKey)
}
val decryptedLastMessage = try { val decryptedLastMessage = try {
if (privateKey.isNotEmpty() && dialog.lastMessage.isNotEmpty()) { if (privateKey.isNotEmpty() && dialog.lastMessage.isNotEmpty()) {
CryptoManager.decryptWithPassword(dialog.lastMessage, privateKey) CryptoManager.decryptWithPassword(dialog.lastMessage, privateKey)
@@ -130,7 +138,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
isOnline = dialog.isOnline, isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen, lastSeen = dialog.lastSeen,
verified = dialog.verified, verified = dialog.verified,
isSavedMessages = (dialog.account == dialog.opponentKey) // 📁 Saved Messages isSavedMessages = isSavedMessages // 📁 Saved Messages
) )
} }
} }
@@ -372,20 +380,28 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
* 📁 НЕ загружаем для Saved Messages (свой publicKey) * 📁 НЕ загружаем для Saved Messages (свой publicKey)
*/ */
private fun loadUserInfoForRequest(publicKey: String) { private fun loadUserInfoForRequest(publicKey: String) {
loadUserInfoForDialog(publicKey)
}
/**
* 🔥 Загрузить информацию о пользователе для диалога
* 📁 НЕ загружаем для Saved Messages (свой publicKey)
*/
private fun loadUserInfoForDialog(publicKey: String) {
// 📁 Не запрашиваем информацию о самом себе (Saved Messages) // 📁 Не запрашиваем информацию о самом себе (Saved Messages)
if (publicKey == currentAccount) { if (publicKey == currentAccount) {
android.util.Log.d("ChatsListVM", "📁 Skipping loadUserInfoForRequest for Saved Messages") android.util.Log.d("ChatsListVM", "📁 Skipping loadUserInfoForDialog for Saved Messages")
return return
} }
// 🔥 Не запрашиваем если уже запрашивали // 🔥 Не запрашиваем если уже запрашивали
if (requestedUserInfoKeys.contains(publicKey)) { if (requestedUserInfoKeys.contains(publicKey)) {
android.util.Log.d("ChatsListVM", "⏭️ Skipping loadUserInfoForRequest - already requested for ${publicKey.take(16)}...") android.util.Log.d("ChatsListVM", "⏭️ Skipping loadUserInfoForDialog - already requested for ${publicKey.take(16)}...")
return return
} }
requestedUserInfoKeys.add(publicKey) requestedUserInfoKeys.add(publicKey)
android.util.Log.d("ChatsListVM", "🔍 loadUserInfoForRequest: ${publicKey.take(16)}...") android.util.Log.d("ChatsListVM", "🔍 loadUserInfoForDialog: ${publicKey.take(16)}...")
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {

View File

@@ -208,16 +208,17 @@ private fun ForwardDialogItem(
} }
val displayName = val displayName =
remember(dialog.opponentTitle, dialog.opponentKey, isSavedMessages) { remember(dialog.opponentTitle, dialog.opponentUsername, dialog.opponentKey, isSavedMessages) {
when { when {
isSavedMessages -> "Saved Messages" isSavedMessages -> "Saved Messages"
dialog.opponentTitle.isNotEmpty() -> dialog.opponentTitle dialog.opponentTitle.isNotEmpty() -> dialog.opponentTitle
dialog.opponentUsername.isNotEmpty() -> "@${dialog.opponentUsername}"
else -> dialog.opponentKey.take(8) else -> dialog.opponentKey.take(8)
} }
} }
val initials = val initials =
remember(dialog.opponentTitle, dialog.opponentKey, isSavedMessages) { remember(dialog.opponentTitle, dialog.opponentUsername, dialog.opponentKey, isSavedMessages) {
when { when {
isSavedMessages -> "📁" isSavedMessages -> "📁"
dialog.opponentTitle.isNotEmpty() -> { dialog.opponentTitle.isNotEmpty() -> {
@@ -227,6 +228,7 @@ private fun ForwardDialogItem(
.mapNotNull { it.firstOrNull()?.uppercase() } .mapNotNull { it.firstOrNull()?.uppercase() }
.joinToString("") .joinToString("")
} }
dialog.opponentUsername.isNotEmpty() -> dialog.opponentUsername.take(2).uppercase()
else -> dialog.opponentKey.take(2).uppercase() else -> dialog.opponentKey.take(2).uppercase()
} }
} }

View File

@@ -16,21 +16,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.airbnb.lottie.compose.* import com.airbnb.lottie.compose.*
import com.airbnb.lottie.LottieComposition import com.airbnb.lottie.LottieComposition
import com.rosetta.messenger.R
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
/** /**
* Компонент отображения результатов поиска пользователей * Компонент отображения результатов поиска пользователей
* Минималистичный дизайн с плавными анимациями * Оптимизированный дизайн с минимальными анимациями для плавной работы
*/ */
@Composable @Composable
fun SearchResultsList( fun SearchResultsList(
@@ -46,11 +44,11 @@ fun SearchResultsList(
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
// Loading state // Loading state - упрощенная анимация
AnimatedVisibility( AnimatedVisibility(
visible = isSearching, visible = isSearching,
enter = fadeIn(animationSpec = tween(200)), enter = fadeIn(animationSpec = snap()),
exit = fadeOut(animationSpec = tween(150)) exit = fadeOut(animationSpec = tween(100))
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -79,8 +77,8 @@ fun SearchResultsList(
// Empty state - поднимается при открытии клавиатуры // Empty state - поднимается при открытии клавиатуры
AnimatedVisibility( AnimatedVisibility(
visible = !isSearching && searchResults.isEmpty(), visible = !isSearching && searchResults.isEmpty(),
enter = fadeIn(animationSpec = tween(300)), enter = fadeIn(animationSpec = tween(150)),
exit = fadeOut(animationSpec = tween(200)) exit = fadeOut(animationSpec = tween(100))
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -125,32 +123,20 @@ fun SearchResultsList(
} }
} }
// Results list with staggered animation // Results list - упрощенная анимация без staggered
AnimatedVisibility( AnimatedVisibility(
visible = !isSearching && searchResults.isNotEmpty(), visible = !isSearching && searchResults.isNotEmpty(),
enter = fadeIn(animationSpec = tween(250)), enter = fadeIn(animationSpec = tween(150)),
exit = fadeOut(animationSpec = tween(150)) exit = fadeOut(animationSpec = tween(100))
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 4.dp) contentPadding = PaddingValues(vertical = 4.dp)
) { ) {
itemsIndexed(searchResults) { index, user -> itemsIndexed(
// Staggered animation items = searchResults,
var isVisible by remember { mutableStateOf(false) } key = { _, user -> user.publicKey }
LaunchedEffect(Unit) { ) { index, user ->
kotlinx.coroutines.delay(index * 40L)
isVisible = true
}
AnimatedVisibility(
visible = isVisible,
enter = fadeIn(tween(200)) + slideInHorizontally(
initialOffsetX = { it / 3 },
animationSpec = tween(250, easing = FastOutSlowInEasing)
),
exit = fadeOut(tween(100))
) {
SearchResultItem( SearchResultItem(
user = user, user = user,
isOwnAccount = user.publicKey == currentUserPublicKey, isOwnAccount = user.publicKey == currentUserPublicKey,
@@ -163,7 +149,6 @@ fun SearchResultsList(
} }
} }
} }
}
/** Элемент результата поиска - минималистичный дизайн */ /** Элемент результата поиска - минималистичный дизайн */
@Composable @Composable

View File

@@ -17,6 +17,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -27,6 +28,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.airbnb.lottie.compose.* import com.airbnb.lottie.compose.*
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.RecentSearchesManager
@@ -59,46 +61,54 @@ fun SearchScreen(
focusManager.clearFocus() focusManager.clearFocus()
} }
// Цвета ТОЧНО как в ChatsListScreen // Цвета ТОЧНО как в ChatsListScreen - remember для избежания пересоздания
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) val backgroundColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) }
val textColor = if (isDarkTheme) Color.White else Color(0xFF1a1a1a) val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color(0xFF1a1a1a) }
val secondaryTextColor = if (isDarkTheme) Color(0xFFB0B0B0) else Color(0xFF6c757d) val secondaryTextColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFFB0B0B0) else Color(0xFF6c757d) }
val surfaceColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color.White
// Search ViewModel // 🔥 ОПТИМИЗАЦИЯ: Отложенный рендеринг контента для плавной анимации перехода
val searchViewModel = remember { SearchUsersViewModel() } var isContentReady by remember { mutableStateOf(false) }
// Search ViewModel - правильное создание через viewModel()
val searchViewModel: SearchUsersViewModel = viewModel()
val searchQuery by searchViewModel.searchQuery.collectAsState() val searchQuery by searchViewModel.searchQuery.collectAsState()
val searchResults by searchViewModel.searchResults.collectAsState() val searchResults by searchViewModel.searchResults.collectAsState()
val isSearching by searchViewModel.isSearching.collectAsState() val isSearching by searchViewModel.isSearching.collectAsState()
// Recent users (не текстовые запросы, а пользователи) // Recent users - отложенная подписка
val recentUsers by RecentSearchesManager.recentUsers.collectAsState() val recentUsers by RecentSearchesManager.recentUsers.collectAsState()
// Preload Lottie composition for search animation // 🔥 ОПТИМИЗАЦИЯ: Lottie загружается асинхронно и не блокирует первый кадр
val searchLottieComposition by val searchLottieComposition by rememberLottieComposition(
rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.search)) LottieCompositionSpec.RawRes(R.raw.search)
)
// Устанавливаем аккаунт для RecentSearchesManager
LaunchedEffect(currentUserPublicKey) {
if (currentUserPublicKey.isNotEmpty()) {
RecentSearchesManager.setAccount(currentUserPublicKey)
}
}
// Устанавливаем privateKeyHash
LaunchedEffect(privateKeyHash) {
if (privateKeyHash.isNotEmpty()) {
searchViewModel.setPrivateKeyHash(privateKeyHash)
}
}
// Focus requester для автофокуса // Focus requester для автофокуса
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
// Автофокус при открытии // 🔥 ОПТИМИЗАЦИЯ: Все тяжелые операции выполняем после первого кадра
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Даем анимации перехода завершиться
kotlinx.coroutines.delay(50)
isContentReady = true
// Устанавливаем аккаунт для RecentSearchesManager
if (currentUserPublicKey.isNotEmpty()) {
RecentSearchesManager.setAccount(currentUserPublicKey)
}
// Устанавливаем privateKeyHash
if (privateKeyHash.isNotEmpty()) {
searchViewModel.setPrivateKeyHash(privateKeyHash)
}
// Автофокус с небольшой задержкой
kotlinx.coroutines.delay(100) kotlinx.coroutines.delay(100)
try {
focusRequester.requestFocus() focusRequester.requestFocus()
} catch (e: Exception) {
// Игнорируем ошибки фокуса
}
} }
Scaffold( Scaffold(
@@ -193,7 +203,17 @@ fun SearchScreen(
containerColor = backgroundColor containerColor = backgroundColor
) { paddingValues -> ) { paddingValues ->
// Контент - показываем recent users если поле пустое, иначе результаты // Контент - показываем recent users если поле пустое, иначе результаты
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
// 🔥 ОПТИМИЗАЦИЯ: Скрываем контент до готовности без блокировки рендера
.drawWithContent {
if (isContentReady) {
drawContent()
}
}
) {
if (searchQuery.isEmpty() && recentUsers.isNotEmpty()) { if (searchQuery.isEmpty() && recentUsers.isNotEmpty()) {
// Recent Users с аватарками // Recent Users с аватарками
LazyColumn( LazyColumn(