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

View File

@@ -174,9 +174,27 @@ object ProtocolManager {
}
}
// 🔥 УБРАН обработчик поиска (0x03) из ProtocolManager
// Он вызывал бесконечный цикл т.к. updateDialogUserInfo триггерил Flow
// Обработка 0x03 происходит только в SearchUsersViewModel
// 🔥 Обработчик поиска/user info (0x03)
// Обновляет информацию о пользователе в диалогах когда приходит ответ от сервера
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(
visible = visible,
enter = fadeIn(tween(500)) + slideInVertically(
initialOffsetY = { -20 },
animationSpec = tween(500)
enter = fadeIn(tween(250)) + slideInVertically(
initialOffsetY = { -40 },
animationSpec = tween(250)
)
) {
Text(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -97,7 +97,7 @@ fun WelcomeScreen(
// Animated Lock Icon
AnimatedVisibility(
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) {
lockComposition?.let { comp ->

View File

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

View File

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

View File

@@ -107,6 +107,14 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
.map { dialogsList ->
// 🔓 Расшифровываем lastMessage на IO потоке (PBKDF2 - тяжелая операция!)
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 {
if (privateKey.isNotEmpty() && dialog.lastMessage.isNotEmpty()) {
CryptoManager.decryptWithPassword(dialog.lastMessage, privateKey)
@@ -130,7 +138,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen,
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)
*/
private fun loadUserInfoForRequest(publicKey: String) {
loadUserInfoForDialog(publicKey)
}
/**
* 🔥 Загрузить информацию о пользователе для диалога
* 📁 НЕ загружаем для Saved Messages (свой publicKey)
*/
private fun loadUserInfoForDialog(publicKey: String) {
// 📁 Не запрашиваем информацию о самом себе (Saved Messages)
if (publicKey == currentAccount) {
android.util.Log.d("ChatsListVM", "📁 Skipping loadUserInfoForRequest for Saved Messages")
android.util.Log.d("ChatsListVM", "📁 Skipping loadUserInfoForDialog for Saved Messages")
return
}
// 🔥 Не запрашиваем если уже запрашивали
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
}
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) {
try {

View File

@@ -208,16 +208,17 @@ private fun ForwardDialogItem(
}
val displayName =
remember(dialog.opponentTitle, dialog.opponentKey, isSavedMessages) {
remember(dialog.opponentTitle, dialog.opponentUsername, dialog.opponentKey, isSavedMessages) {
when {
isSavedMessages -> "Saved Messages"
dialog.opponentTitle.isNotEmpty() -> dialog.opponentTitle
dialog.opponentUsername.isNotEmpty() -> "@${dialog.opponentUsername}"
else -> dialog.opponentKey.take(8)
}
}
val initials =
remember(dialog.opponentTitle, dialog.opponentKey, isSavedMessages) {
remember(dialog.opponentTitle, dialog.opponentUsername, dialog.opponentKey, isSavedMessages) {
when {
isSavedMessages -> "📁"
dialog.opponentTitle.isNotEmpty() -> {
@@ -227,6 +228,7 @@ private fun ForwardDialogItem(
.mapNotNull { it.firstOrNull()?.uppercase() }
.joinToString("")
}
dialog.opponentUsername.isNotEmpty() -> dialog.opponentUsername.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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.airbnb.lottie.compose.*
import com.airbnb.lottie.LottieComposition
import com.rosetta.messenger.R
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
/**
* Компонент отображения результатов поиска пользователей
* Минималистичный дизайн с плавными анимациями
* Оптимизированный дизайн с минимальными анимациями для плавной работы
*/
@Composable
fun SearchResultsList(
@@ -46,11 +44,11 @@ fun SearchResultsList(
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
Box(modifier = modifier.fillMaxSize()) {
// Loading state
// Loading state - упрощенная анимация
AnimatedVisibility(
visible = isSearching,
enter = fadeIn(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(150))
enter = fadeIn(animationSpec = snap()),
exit = fadeOut(animationSpec = tween(100))
) {
Box(
modifier = Modifier
@@ -79,8 +77,8 @@ fun SearchResultsList(
// Empty state - поднимается при открытии клавиатуры
AnimatedVisibility(
visible = !isSearching && searchResults.isEmpty(),
enter = fadeIn(animationSpec = tween(300)),
exit = fadeOut(animationSpec = tween(200))
enter = fadeIn(animationSpec = tween(150)),
exit = fadeOut(animationSpec = tween(100))
) {
Box(
modifier = Modifier
@@ -125,40 +123,27 @@ fun SearchResultsList(
}
}
// Results list with staggered animation
// Results list - упрощенная анимация без staggered
AnimatedVisibility(
visible = !isSearching && searchResults.isNotEmpty(),
enter = fadeIn(animationSpec = tween(250)),
exit = fadeOut(animationSpec = tween(150))
enter = fadeIn(animationSpec = tween(150)),
exit = fadeOut(animationSpec = tween(100))
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 4.dp)
) {
itemsIndexed(searchResults) { index, user ->
// Staggered animation
var isVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
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(
user = user,
isOwnAccount = user.publicKey == currentUserPublicKey,
isDarkTheme = isDarkTheme,
isLastItem = index == searchResults.size - 1,
onClick = { onUserClick(user) }
)
}
itemsIndexed(
items = searchResults,
key = { _, user -> user.publicKey }
) { index, user ->
SearchResultItem(
user = user,
isOwnAccount = user.publicKey == currentUserPublicKey,
isDarkTheme = isDarkTheme,
isLastItem = index == searchResults.size - 1,
onClick = { onUserClick(user) }
)
}
}
}

View File

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