feat: Implement Recent Searches functionality in SearchScreen for improved user experience
This commit is contained in:
@@ -24,6 +24,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import com.rosetta.messenger.data.AccountManager
|
import com.rosetta.messenger.data.AccountManager
|
||||||
import com.rosetta.messenger.data.DecryptedAccount
|
import com.rosetta.messenger.data.DecryptedAccount
|
||||||
import com.rosetta.messenger.data.PreferencesManager
|
import com.rosetta.messenger.data.PreferencesManager
|
||||||
|
import com.rosetta.messenger.data.RecentSearchesManager
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import com.rosetta.messenger.ui.auth.AccountInfo
|
import com.rosetta.messenger.ui.auth.AccountInfo
|
||||||
import com.rosetta.messenger.ui.auth.AuthFlow
|
import com.rosetta.messenger.ui.auth.AuthFlow
|
||||||
@@ -46,6 +47,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
preferencesManager = PreferencesManager(this)
|
preferencesManager = PreferencesManager(this)
|
||||||
accountManager = AccountManager(this)
|
accountManager = AccountManager(this)
|
||||||
|
RecentSearchesManager.init(this)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.rosetta.messenger.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import com.rosetta.messenger.network.SearchUser
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Менеджер для хранения недавно открытых пользователей (до 15 записей)
|
||||||
|
*/
|
||||||
|
object RecentSearchesManager {
|
||||||
|
private const val PREFS_NAME = "recent_searches"
|
||||||
|
private const val KEY_USERS = "recent_users"
|
||||||
|
private const val MAX_USERS = 15
|
||||||
|
|
||||||
|
private lateinit var prefs: SharedPreferences
|
||||||
|
private val _recentUsers = MutableStateFlow<List<SearchUser>>(emptyList())
|
||||||
|
val recentUsers: StateFlow<List<SearchUser>> = _recentUsers.asStateFlow()
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadUsers() {
|
||||||
|
val usersJson = prefs.getString(KEY_USERS, "[]") ?: "[]"
|
||||||
|
try {
|
||||||
|
val jsonArray = JSONArray(usersJson)
|
||||||
|
val users = mutableListOf<SearchUser>()
|
||||||
|
for (i in 0 until jsonArray.length()) {
|
||||||
|
val obj = jsonArray.getJSONObject(i)
|
||||||
|
users.add(
|
||||||
|
SearchUser(
|
||||||
|
publicKey = obj.getString("publicKey"),
|
||||||
|
username = obj.optString("username", ""),
|
||||||
|
title = obj.optString("title", ""),
|
||||||
|
verified = obj.optInt("verified", 0),
|
||||||
|
online = obj.optInt("online", 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_recentUsers.value = users
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_recentUsers.value = emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавить пользователя в историю
|
||||||
|
*/
|
||||||
|
fun addUser(user: SearchUser) {
|
||||||
|
val currentUsers = _recentUsers.value.toMutableList()
|
||||||
|
|
||||||
|
// Удаляем если уже есть (чтобы переместить наверх)
|
||||||
|
currentUsers.removeAll { it.publicKey == user.publicKey }
|
||||||
|
|
||||||
|
// Добавляем в начало
|
||||||
|
currentUsers.add(0, user)
|
||||||
|
|
||||||
|
// Ограничиваем до MAX_USERS
|
||||||
|
val limitedUsers = currentUsers.take(MAX_USERS)
|
||||||
|
|
||||||
|
// Сохраняем
|
||||||
|
_recentUsers.value = limitedUsers
|
||||||
|
saveUsers(limitedUsers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить пользователя из истории
|
||||||
|
*/
|
||||||
|
fun removeUser(publicKey: String) {
|
||||||
|
val currentUsers = _recentUsers.value.toMutableList()
|
||||||
|
currentUsers.removeAll { it.publicKey == publicKey }
|
||||||
|
_recentUsers.value = currentUsers
|
||||||
|
saveUsers(currentUsers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очистить всю историю
|
||||||
|
*/
|
||||||
|
fun clearAll() {
|
||||||
|
_recentUsers.value = emptyList()
|
||||||
|
prefs.edit().remove(KEY_USERS).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveUsers(users: List<SearchUser>) {
|
||||||
|
val jsonArray = JSONArray()
|
||||||
|
users.forEach { user ->
|
||||||
|
val obj = JSONObject().apply {
|
||||||
|
put("publicKey", user.publicKey)
|
||||||
|
put("username", user.username)
|
||||||
|
put("title", user.title)
|
||||||
|
put("verified", user.verified)
|
||||||
|
put("online", user.online)
|
||||||
|
}
|
||||||
|
jsonArray.put(obj)
|
||||||
|
}
|
||||||
|
prefs.edit().putString(KEY_USERS, jsonArray.toString()).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -494,6 +494,8 @@ fun SetPasswordScreen(
|
|||||||
|
|
||||||
accountManager.saveAccount(account)
|
accountManager.saveAccount(account)
|
||||||
accountManager.setCurrentAccount(keyPair.publicKey)
|
accountManager.setCurrentAccount(keyPair.publicKey)
|
||||||
|
// Save as last logged account for next time
|
||||||
|
accountManager.setLastLoggedPublicKey(keyPair.publicKey)
|
||||||
|
|
||||||
// 🔌 Connect to server and authenticate
|
// 🔌 Connect to server and authenticate
|
||||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(keyPair.privateKey)
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(keyPair.privateKey)
|
||||||
|
|||||||
@@ -587,6 +587,8 @@ fun UnlockScreen(
|
|||||||
ProtocolManager.authenticate(account.publicKey, privateKeyHash)
|
ProtocolManager.authenticate(account.publicKey, privateKeyHash)
|
||||||
|
|
||||||
accountManager.setCurrentAccount(account.publicKey)
|
accountManager.setCurrentAccount(account.publicKey)
|
||||||
|
// Save as last logged account for next time
|
||||||
|
accountManager.setLastLoggedPublicKey(account.publicKey)
|
||||||
onUnlocked(decryptedAccount)
|
onUnlocked(decryptedAccount)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import androidx.compose.animation.*
|
|||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
@@ -11,12 +14,15 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.*
|
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.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
|
||||||
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.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rosetta.messenger.data.RecentSearchesManager
|
||||||
import com.rosetta.messenger.network.ProtocolState
|
import com.rosetta.messenger.network.ProtocolState
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
|
|
||||||
@@ -37,7 +43,8 @@ fun SearchScreen(
|
|||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onUserSelect: (SearchUser) -> Unit
|
onUserSelect: (SearchUser) -> Unit
|
||||||
) {
|
) {
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF121212) else Color(0xFFF8F9FA)
|
// Цвета ТОЧНО как в ChatsListScreen
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color(0xFF1a1a1a)
|
val textColor = if (isDarkTheme) Color.White else Color(0xFF1a1a1a)
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFFB0B0B0) else Color(0xFF6c757d)
|
val secondaryTextColor = if (isDarkTheme) Color(0xFFB0B0B0) else Color(0xFF6c757d)
|
||||||
val surfaceColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color.White
|
val surfaceColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color.White
|
||||||
@@ -48,6 +55,9 @@ fun SearchScreen(
|
|||||||
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 (не текстовые запросы, а пользователи)
|
||||||
|
val recentUsers by RecentSearchesManager.recentUsers.collectAsState()
|
||||||
|
|
||||||
// Устанавливаем privateKeyHash
|
// Устанавливаем privateKeyHash
|
||||||
LaunchedEffect(privateKeyHash) {
|
LaunchedEffect(privateKeyHash) {
|
||||||
if (privateKeyHash.isNotEmpty()) {
|
if (privateKeyHash.isNotEmpty()) {
|
||||||
@@ -69,8 +79,7 @@ fun SearchScreen(
|
|||||||
// Кастомный header с полем ввода на всю ширину
|
// Кастомный header с полем ввода на всю ширину
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
color = backgroundColor,
|
color = backgroundColor
|
||||||
shadowElevation = 4.dp
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -157,21 +166,164 @@ fun SearchScreen(
|
|||||||
},
|
},
|
||||||
containerColor = backgroundColor
|
containerColor = backgroundColor
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
// Контент - результаты поиска
|
// Контент - показываем recent users если поле пустое, иначе результаты
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
|
if (searchQuery.isEmpty() && recentUsers.isNotEmpty()) {
|
||||||
|
// Recent Users с аватарками
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Recent",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = secondaryTextColor
|
||||||
|
)
|
||||||
|
TextButton(onClick = { RecentSearchesManager.clearAll() }) {
|
||||||
|
Text(
|
||||||
|
"Clear All",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = PrimaryBlue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(recentUsers, key = { it.publicKey }) { user ->
|
||||||
|
RecentUserItem(
|
||||||
|
user = user,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
textColor = textColor,
|
||||||
|
secondaryTextColor = secondaryTextColor,
|
||||||
|
onClick = {
|
||||||
|
RecentSearchesManager.addUser(user)
|
||||||
|
onUserSelect(user)
|
||||||
|
},
|
||||||
|
onRemove = {
|
||||||
|
RecentSearchesManager.removeUser(user.publicKey)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Search Results
|
||||||
SearchResultsList(
|
SearchResultsList(
|
||||||
searchResults = searchResults,
|
searchResults = searchResults,
|
||||||
isSearching = isSearching,
|
isSearching = isSearching,
|
||||||
currentUserPublicKey = currentUserPublicKey,
|
currentUserPublicKey = currentUserPublicKey,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
onUserClick = { user ->
|
onUserClick = { user ->
|
||||||
|
// Сохраняем пользователя в историю
|
||||||
|
RecentSearchesManager.addUser(user)
|
||||||
onUserSelect(user)
|
onUserSelect(user)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecentUserItem(
|
||||||
|
user: SearchUser,
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
textColor: Color,
|
||||||
|
secondaryTextColor: Color,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onRemove: () -> Unit
|
||||||
|
) {
|
||||||
|
val displayName = user.title.ifEmpty {
|
||||||
|
user.username.ifEmpty {
|
||||||
|
user.publicKey.take(8) + "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Используем getInitials из ChatsListScreen
|
||||||
|
val initials = getInitials(displayName)
|
||||||
|
|
||||||
|
// Используем getAvatarColor из ChatsListScreen для правильных цветов
|
||||||
|
val avatarColors = getAvatarColor(user.publicKey, isDarkTheme)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Avatar
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(avatarColors.backgroundColor),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = initials,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = avatarColors.textColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
// Name and username
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = textColor,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
if (user.verified != 0) {
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Verified,
|
||||||
|
contentDescription = "Verified",
|
||||||
|
tint = PrimaryBlue,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (user.username.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "@${user.username}",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = secondaryTextColor,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove button
|
||||||
|
IconButton(
|
||||||
|
onClick = onRemove,
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Remove",
|
||||||
|
tint = secondaryTextColor,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user