diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index d390b08..e93d112 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -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 ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 2a3753f..9f53c4b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -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() - ) } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt new file mode 100644 index 0000000..41209d2 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt @@ -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, + 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 + ) + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt new file mode 100644 index 0000000..6148910 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt @@ -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 = _searchQuery.asStateFlow() + + private val _searchResults = MutableStateFlow>(emptyList()) + val searchResults: StateFlow> = _searchResults.asStateFlow() + + private val _isSearching = MutableStateFlow(false) + val isSearching: StateFlow = _isSearching.asStateFlow() + + private val _isSearchExpanded = MutableStateFlow(false) + val isSearchExpanded: StateFlow = _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() + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt b/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt new file mode 100644 index 0000000..f68153c --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt @@ -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) + ) +}