feat: Implement user search functionality with ViewModel and results display

This commit is contained in:
k1ngsterr1
2026-01-10 19:10:43 +05:00
parent b4c740b1ba
commit fa634936f5
5 changed files with 508 additions and 43 deletions

View File

@@ -212,12 +212,14 @@ fun MainScreen(
"+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}"
} ?: "+7 775 9932587"
val accountPublicKey = account?.publicKey ?: "04c266b98ae5"
val privateKeyHash = account?.privateKeyHash ?: ""
ChatsListScreen(
isDarkTheme = isDarkTheme,
accountName = accountName,
accountPhone = accountPhone,
accountPublicKey = accountPublicKey,
privateKeyHash = privateKeyHash,
onToggleTheme = onToggleTheme,
onProfileClick = {
// TODO: Navigate to profile
@@ -246,6 +248,10 @@ fun MainScreen(
onNewChat = {
// TODO: Show new chat screen
},
onUserSelect = { user ->
// TODO: Navigate to chat with selected user
android.util.Log.d("MainScreen", "User selected: ${user.publicKey}")
},
onLogout = onLogout
)
}

View File

@@ -136,6 +136,7 @@ fun ChatsListScreen(
accountName: String,
accountPhone: String,
accountPublicKey: String,
privateKeyHash: String = "",
onToggleTheme: () -> Unit,
onProfileClick: () -> Unit,
onNewGroupClick: () -> Unit,
@@ -146,6 +147,7 @@ fun ChatsListScreen(
onInviteFriendsClick: () -> Unit,
onSearchClick: () -> Unit,
onNewChat: () -> Unit,
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
onLogout: () -> Unit
) {
// Theme transition state
@@ -190,9 +192,19 @@ fun ChatsListScreen(
var titleClickCount by remember { mutableStateOf(0) }
var lastClickTime by remember { mutableStateOf(0L) }
// Search state
var isSearchExpanded by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
// Search state - используем ViewModel для поиска пользователей
val searchViewModel = remember { SearchUsersViewModel() }
val searchQuery by searchViewModel.searchQuery.collectAsState()
val searchResults by searchViewModel.searchResults.collectAsState()
val isSearching by searchViewModel.isSearching.collectAsState()
val isSearchExpanded by searchViewModel.isSearchExpanded.collectAsState()
// Устанавливаем privateKeyHash для поиска
LaunchedEffect(privateKeyHash) {
if (privateKeyHash.isNotEmpty()) {
searchViewModel.setPrivateKeyHash(privateKeyHash)
}
}
var visible by remember { mutableStateOf(false) }
@@ -585,8 +597,7 @@ fun ChatsListScreen(
// Animated back arrow
IconButton(
onClick = {
isSearchExpanded = false
searchQuery = ""
searchViewModel.collapseSearch()
},
modifier = Modifier.graphicsLayer {
rotationZ = -90f * (1f - searchProgress)
@@ -603,10 +614,10 @@ fun ChatsListScreen(
Box(modifier = Modifier.weight(1f)) {
TextField(
value = searchQuery,
onValueChange = { searchQuery = it },
onValueChange = { searchViewModel.onSearchQueryChange(it) },
placeholder = {
Text(
"Search chats...",
"Search users...",
color = secondaryTextColor.copy(alpha = 0.7f)
)
},
@@ -620,6 +631,7 @@ fun ChatsListScreen(
unfocusedIndicatorColor = Color.Transparent
),
singleLine = true,
enabled = protocolState == ProtocolState.AUTHENTICATED,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
@@ -649,7 +661,7 @@ fun ChatsListScreen(
label = "clearScale"
)
IconButton(
onClick = { searchQuery = "" },
onClick = { searchViewModel.clearSearchQuery() },
modifier = Modifier.scale(clearScale)
) {
Icon(
@@ -684,13 +696,17 @@ fun ChatsListScreen(
if (searchButtonScale > 0.01f) {
IconButton(
onClick = { /* TODO: Search not implemented yet */ },
enabled = false,
onClick = {
if (protocolState == ProtocolState.AUTHENTICATED) {
searchViewModel.expandSearch()
}
},
enabled = protocolState == ProtocolState.AUTHENTICATED,
modifier = Modifier.graphicsLayer {
scaleX = searchButtonScale
scaleY = searchButtonScale
rotationZ = searchButtonRotation
alpha = 0.5f
alpha = if (protocolState == ProtocolState.AUTHENTICATED) 1f else 0.5f
}
) {
Icon(
@@ -735,46 +751,64 @@ fun ChatsListScreen(
},
containerColor = backgroundColor
) { paddingValues ->
// Dev Console Button in bottom left corner
// Main content
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Console button - hidden for now
AnimatedVisibility(
visible = false,
enter = fadeIn(tween(500, delayMillis = 400)) + slideInHorizontally(
initialOffsetX = { -it },
animationSpec = tween(500, delayMillis = 400)
),
modifier = Modifier
.align(Alignment.BottomStart)
.padding(16.dp)
) {
FloatingActionButton(
onClick = { showDevConsole = true },
containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5),
contentColor = if (protocolState == ProtocolState.AUTHENTICATED)
Color(0xFF4CAF50)
else
Color(0xFFFF9800),
shape = CircleShape,
modifier = Modifier.size(48.dp)
) {
Icon(
Icons.Default.Terminal,
contentDescription = "Dev Console",
modifier = Modifier.size(24.dp)
// Show search results when search is expanded and has query
if (isSearchExpanded && searchQuery.isNotEmpty()) {
Column(modifier = Modifier.fillMaxSize()) {
// Search Results List
SearchResultsList(
searchResults = searchResults,
isSearching = isSearching,
currentUserPublicKey = accountPublicKey,
isDarkTheme = isDarkTheme,
onUserClick = { user ->
// Закрываем поиск и вызываем callback
searchViewModel.collapseSearch()
onUserSelect(user)
}
)
}
} else {
// Console button - hidden for now
AnimatedVisibility(
visible = false,
enter = fadeIn(tween(500, delayMillis = 400)) + slideInHorizontally(
initialOffsetX = { -it },
animationSpec = tween(500, delayMillis = 400)
),
modifier = Modifier
.align(Alignment.BottomStart)
.padding(16.dp)
) {
FloatingActionButton(
onClick = { showDevConsole = true },
containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5),
contentColor = if (protocolState == ProtocolState.AUTHENTICATED)
Color(0xFF4CAF50)
else
Color(0xFFFF9800),
shape = CircleShape,
modifier = Modifier.size(48.dp)
) {
Icon(
Icons.Default.Terminal,
contentDescription = "Dev Console",
modifier = Modifier.size(24.dp)
)
}
}
// Empty state with Lottie animation
EmptyChatsState(
isDarkTheme = isDarkTheme,
modifier = Modifier.fillMaxSize()
)
}
// Empty state with Lottie animation
EmptyChatsState(
isDarkTheme = isDarkTheme,
modifier = Modifier.fillMaxSize()
)
}
}
}

View File

@@ -0,0 +1,227 @@
package com.rosetta.messenger.ui.chats
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.components.VerifiedBadge
/**
* Компонент отображения результатов поиска пользователей
* Аналогичен результатам поиска в React Native приложении
*/
@Composable
fun SearchResultsList(
searchResults: List<SearchUser>,
isSearching: Boolean,
currentUserPublicKey: String,
isDarkTheme: Boolean,
onUserClick: (SearchUser) -> Unit,
modifier: Modifier = Modifier
) {
val backgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color.White
val borderColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.background(backgroundColor)
) {
// Разделительная линия сверху
Divider(
color = borderColor,
thickness = 1.dp
)
when {
isSearching -> {
// Индикатор загрузки
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 20.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = if (isDarkTheme) Color(0xFF9E9E9E) else PrimaryBlue,
strokeWidth = 2.dp
)
}
}
searchResults.isEmpty() -> {
// Пустые результаты - подсказка
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 20.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "You can search by username or public key.",
fontSize = 12.sp,
color = secondaryTextColor
)
}
}
else -> {
// Список результатов
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
) {
itemsIndexed(searchResults) { index, user ->
SearchResultItem(
user = user,
isOwnAccount = user.publicKey == currentUserPublicKey,
isDarkTheme = isDarkTheme,
isLastItem = index == searchResults.size - 1,
onClick = { onUserClick(user) }
)
}
}
}
}
}
}
/**
* Элемент результата поиска - пользователь
*/
@Composable
private fun SearchResultItem(
user: SearchUser,
isOwnAccount: Boolean,
isDarkTheme: Boolean,
isLastItem: 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 avatarColors = getAvatarColor(
if (isOwnAccount) "SavedMessages" else (user.title.ifEmpty { user.publicKey }),
isDarkTheme
)
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Аватар
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(if (isOwnAccount) PrimaryBlue else avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
if (isOwnAccount) {
Icon(
Icons.Default.Bookmark,
contentDescription = "Saved Messages",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
} else {
Text(
text = if (user.title.isNotEmpty()) {
getInitials(user.title)
} else {
user.publicKey.take(2).uppercase()
},
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = avatarColors.textColor
)
}
}
Spacer(modifier = Modifier.width(12.dp))
// Информация о пользователе
Column(modifier = Modifier.weight(1f)) {
// Имя и значок верификации
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = if (isOwnAccount) {
"Saved Messages"
} else {
user.title.ifEmpty { user.publicKey.take(10) }
},
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Значок верификации
if (!isOwnAccount && user.verified > 0) {
VerifiedBadge(
verified = user.verified,
size = 16
)
}
}
Spacer(modifier = Modifier.height(2.dp))
// Юзернейм или публичный ключ
Text(
text = if (isOwnAccount) {
"Notes"
} else {
"@${user.username.ifEmpty { user.publicKey.take(10) + "..." }}"
},
fontSize = 12.sp,
color = secondaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
// Разделитель между элементами
if (!isLastItem) {
Divider(
modifier = Modifier.padding(start = 64.dp),
color = dividerColor,
thickness = 0.5.dp
)
}
}
}

View File

@@ -0,0 +1,153 @@
package com.rosetta.messenger.ui.chats
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.network.SearchUser
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* ViewModel для поиска пользователей через протокол
* Работает аналогично SearchBar в React Native приложении
*/
class SearchUsersViewModel : ViewModel() {
// Состояние поиска
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
private val _searchResults = MutableStateFlow<List<SearchUser>>(emptyList())
val searchResults: StateFlow<List<SearchUser>> = _searchResults.asStateFlow()
private val _isSearching = MutableStateFlow(false)
val isSearching: StateFlow<Boolean> = _isSearching.asStateFlow()
private val _isSearchExpanded = MutableStateFlow(false)
val isSearchExpanded: StateFlow<Boolean> = _isSearchExpanded.asStateFlow()
// Приватные переменные
private var searchJob: Job? = null
private var lastSearchedText: String = ""
private var privateKeyHash: String = ""
// Callback для обработки ответа поиска
private val searchPacketHandler: (com.rosetta.messenger.network.Packet) -> Unit = { packet ->
if (packet is PacketSearch) {
android.util.Log.d("SearchUsersVM", "📥 Search results received: ${packet.users.size} users")
_searchResults.value = packet.users
_isSearching.value = false
}
}
init {
// Регистрируем обработчик пакетов поиска
ProtocolManager.waitPacket(0x03, searchPacketHandler)
}
override fun onCleared() {
super.onCleared()
// Отписываемся от пакетов при уничтожении ViewModel
ProtocolManager.unwaitPacket(0x03, searchPacketHandler)
searchJob?.cancel()
}
/**
* Установить приватный ключ для поиска
*/
fun setPrivateKeyHash(hash: String) {
privateKeyHash = hash
}
/**
* Обработка изменения текста поиска
* Аналогично handleSearch в React Native
*/
fun onSearchQueryChange(query: String) {
_searchQuery.value = query
// Отменяем предыдущий поиск
searchJob?.cancel()
// Если пустой запрос - очищаем результаты
if (query.trim().isEmpty()) {
_searchResults.value = emptyList()
_isSearching.value = false
lastSearchedText = ""
return
}
// Если текст уже был найден - не повторяем поиск
if (query == lastSearchedText) {
return
}
// Показываем индикатор загрузки
_isSearching.value = true
// Запускаем поиск с задержкой 1 секунда (как в React Native)
searchJob = viewModelScope.launch {
delay(1000) // debounce
// Проверяем состояние протокола
if (ProtocolManager.state.value != ProtocolState.AUTHENTICATED) {
android.util.Log.w("SearchUsersVM", "Not authenticated, cannot search")
_isSearching.value = false
return@launch
}
// Проверяем, не изменился ли запрос
if (query != _searchQuery.value) {
return@launch
}
lastSearchedText = query
android.util.Log.d("SearchUsersVM", "🔍 Sending search request: $query")
// Создаем и отправляем пакет поиска
val packetSearch = PacketSearch().apply {
this.privateKey = privateKeyHash
this.search = query
}
ProtocolManager.sendPacket(packetSearch)
}
}
/**
* Открыть панель поиска
*/
fun expandSearch() {
_isSearchExpanded.value = true
}
/**
* Закрыть панель поиска и очистить результаты
*/
fun collapseSearch() {
_isSearchExpanded.value = false
_searchQuery.value = ""
_searchResults.value = emptyList()
_isSearching.value = false
lastSearchedText = ""
searchJob?.cancel()
}
/**
* Очистить только поисковый запрос
*/
fun clearSearchQuery() {
_searchQuery.value = ""
_searchResults.value = emptyList()
_isSearching.value = false
lastSearchedText = ""
searchJob?.cancel()
}
}

View File

@@ -0,0 +1,45 @@
package com.rosetta.messenger.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
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.unit.dp
/**
* Значок верификации пользователя
* Аналогичен VerifiedBadge в React Native приложении
*
* @param verified Уровень верификации (0 = нет, 1 = стандартная, 2+ = особая)
* @param size Размер значка в dp
*/
@Composable
fun VerifiedBadge(
verified: Int,
size: Int = 16,
modifier: Modifier = Modifier
) {
if (verified <= 0) return
// Цвет в зависимости от уровня верификации
val badgeColor = when (verified) {
1 -> Color(0xFF1DA1F2) // Стандартная верификация (синий как в Twitter/Telegram)
2 -> Color(0xFFFFD700) // Золотая верификация
else -> Color(0xFF4CAF50) // Зеленая для других уровней
}
Icon(
Icons.Default.Verified,
contentDescription = "Verified",
tint = badgeColor,
modifier = modifier.size(size.dp)
)
}