feat: Implement user search functionality with ViewModel and results display
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,12 +751,29 @@ fun ChatsListScreen(
|
||||
},
|
||||
containerColor = backgroundColor
|
||||
) { paddingValues ->
|
||||
// Dev Console Button in bottom left corner
|
||||
// Main content
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// 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,
|
||||
@@ -778,6 +811,7 @@ fun ChatsListScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // Close Box for circular reveal
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user