feat: Replace custom header with TopAppBar for improved layout and smoother transitions in ChatsListScreen
This commit is contained in:
@@ -24,10 +24,12 @@ 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.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
|
||||||
import com.rosetta.messenger.ui.chats.ChatsListScreen
|
import com.rosetta.messenger.ui.chats.ChatsListScreen
|
||||||
import com.rosetta.messenger.ui.chats.ChatDetailScreen
|
import com.rosetta.messenger.ui.chats.ChatDetailScreen
|
||||||
|
import com.rosetta.messenger.ui.chats.SearchScreen
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
import com.rosetta.messenger.ui.onboarding.OnboardingScreen
|
import com.rosetta.messenger.ui.onboarding.OnboardingScreen
|
||||||
import com.rosetta.messenger.ui.splash.SplashScreen
|
import com.rosetta.messenger.ui.splash.SplashScreen
|
||||||
@@ -218,12 +220,16 @@ fun MainScreen(
|
|||||||
val accountPrivateKey = account?.privateKey ?: ""
|
val accountPrivateKey = account?.privateKey ?: ""
|
||||||
val privateKeyHash = account?.privateKeyHash ?: ""
|
val privateKeyHash = account?.privateKeyHash ?: ""
|
||||||
|
|
||||||
|
// Состояние протокола для передачи в SearchScreen
|
||||||
|
val protocolState by ProtocolManager.state.collectAsState()
|
||||||
|
|
||||||
// Навигация между экранами
|
// Навигация между экранами
|
||||||
var selectedUser by remember { mutableStateOf<SearchUser?>(null) }
|
var selectedUser by remember { mutableStateOf<SearchUser?>(null) }
|
||||||
|
var showSearchScreen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Анимированный переход между чатами - плавный crossfade без прыжков
|
// Анимированный переход между экранами
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = selectedUser,
|
targetState = Triple(selectedUser, showSearchScreen, Unit),
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
// Плавный crossfade для избежания "прыжков" header'а
|
// Плавный crossfade для избежания "прыжков" header'а
|
||||||
val enterAnim = fadeIn(
|
val enterAnim = fadeIn(
|
||||||
@@ -232,7 +238,7 @@ fun MainScreen(
|
|||||||
easing = FastOutSlowInEasing
|
easing = FastOutSlowInEasing
|
||||||
)
|
)
|
||||||
) + slideInHorizontally(
|
) + slideInHorizontally(
|
||||||
initialOffsetX = { if (targetState != null) it / 4 else -it / 4 },
|
initialOffsetX = { it / 4 },
|
||||||
animationSpec = tween(
|
animationSpec = tween(
|
||||||
durationMillis = 300,
|
durationMillis = 300,
|
||||||
easing = FastOutSlowInEasing
|
easing = FastOutSlowInEasing
|
||||||
@@ -245,7 +251,7 @@ fun MainScreen(
|
|||||||
easing = FastOutSlowInEasing
|
easing = FastOutSlowInEasing
|
||||||
)
|
)
|
||||||
) + slideOutHorizontally(
|
) + slideOutHorizontally(
|
||||||
targetOffsetX = { if (targetState != null) -it / 6 else it / 6 },
|
targetOffsetX = { -it / 6 },
|
||||||
animationSpec = tween(
|
animationSpec = tween(
|
||||||
durationMillis = 300,
|
durationMillis = 300,
|
||||||
easing = FastOutSlowInEasing
|
easing = FastOutSlowInEasing
|
||||||
@@ -254,58 +260,75 @@ fun MainScreen(
|
|||||||
|
|
||||||
enterAnim togetherWith exitAnim using SizeTransform(clip = false)
|
enterAnim togetherWith exitAnim using SizeTransform(clip = false)
|
||||||
},
|
},
|
||||||
label = "chatNavigation"
|
label = "screenNavigation"
|
||||||
) { user ->
|
) { (user, isSearchOpen, _) ->
|
||||||
if (user != null) {
|
when {
|
||||||
// Экран чата
|
user != null -> {
|
||||||
ChatDetailScreen(
|
// Экран чата
|
||||||
user = user,
|
ChatDetailScreen(
|
||||||
currentUserPublicKey = accountPublicKey,
|
user = user,
|
||||||
currentUserPrivateKey = accountPrivateKey,
|
currentUserPublicKey = accountPublicKey,
|
||||||
isDarkTheme = isDarkTheme,
|
currentUserPrivateKey = accountPrivateKey,
|
||||||
onBack = { selectedUser = null }
|
isDarkTheme = isDarkTheme,
|
||||||
)
|
onBack = { selectedUser = null }
|
||||||
} else {
|
)
|
||||||
// Список чатов
|
}
|
||||||
ChatsListScreen(
|
isSearchOpen -> {
|
||||||
isDarkTheme = isDarkTheme,
|
// Экран поиска
|
||||||
accountName = accountName,
|
SearchScreen(
|
||||||
accountPhone = accountPhone,
|
privateKeyHash = privateKeyHash,
|
||||||
accountPublicKey = accountPublicKey,
|
currentUserPublicKey = accountPublicKey,
|
||||||
privateKeyHash = privateKeyHash,
|
isDarkTheme = isDarkTheme,
|
||||||
onToggleTheme = onToggleTheme,
|
protocolState = protocolState,
|
||||||
onProfileClick = {
|
onBackClick = { showSearchScreen = false },
|
||||||
// TODO: Navigate to profile
|
onUserSelect = { user ->
|
||||||
},
|
showSearchScreen = false
|
||||||
onNewGroupClick = {
|
selectedUser = user
|
||||||
// TODO: Navigate to new group
|
}
|
||||||
},
|
)
|
||||||
onContactsClick = {
|
}
|
||||||
// TODO: Navigate to contacts
|
else -> {
|
||||||
},
|
// Список чатов
|
||||||
onCallsClick = {
|
ChatsListScreen(
|
||||||
// TODO: Navigate to calls
|
isDarkTheme = isDarkTheme,
|
||||||
},
|
accountName = accountName,
|
||||||
onSavedMessagesClick = {
|
accountPhone = accountPhone,
|
||||||
// TODO: Navigate to saved messages
|
accountPublicKey = accountPublicKey,
|
||||||
},
|
privateKeyHash = privateKeyHash,
|
||||||
onSettingsClick = {
|
onToggleTheme = onToggleTheme,
|
||||||
// TODO: Navigate to settings
|
onProfileClick = {
|
||||||
},
|
// TODO: Navigate to profile
|
||||||
onInviteFriendsClick = {
|
},
|
||||||
// TODO: Share invite link
|
onNewGroupClick = {
|
||||||
},
|
// TODO: Navigate to new group
|
||||||
onSearchClick = {
|
},
|
||||||
// TODO: Show search
|
onContactsClick = {
|
||||||
},
|
// TODO: Navigate to contacts
|
||||||
onNewChat = {
|
},
|
||||||
// TODO: Show new chat screen
|
onCallsClick = {
|
||||||
},
|
// TODO: Navigate to calls
|
||||||
onUserSelect = { user ->
|
},
|
||||||
selectedUser = user
|
onSavedMessagesClick = {
|
||||||
},
|
// TODO: Navigate to saved messages
|
||||||
onLogout = onLogout
|
},
|
||||||
)
|
onSettingsClick = {
|
||||||
|
// TODO: Navigate to settings
|
||||||
|
},
|
||||||
|
onInviteFriendsClick = {
|
||||||
|
// TODO: Share invite link
|
||||||
|
},
|
||||||
|
onSearchClick = {
|
||||||
|
showSearchScreen = true
|
||||||
|
},
|
||||||
|
onNewChat = {
|
||||||
|
// TODO: Show new chat screen
|
||||||
|
},
|
||||||
|
onUserSelect = { user ->
|
||||||
|
selectedUser = user
|
||||||
|
},
|
||||||
|
onLogout = onLogout
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,20 +194,6 @@ fun ChatsListScreen(
|
|||||||
// Status dialog state
|
// Status dialog state
|
||||||
var showStatusDialog by remember { mutableStateOf(false) }
|
var showStatusDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// 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) }
|
var visible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) { visible = true }
|
LaunchedEffect(Unit) { visible = true }
|
||||||
@@ -416,299 +402,84 @@ fun ChatsListScreen(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
key(isDarkTheme) {
|
key(isDarkTheme) {
|
||||||
// Custom header с фиксированной структурой для плавной анимации без скачков
|
TopAppBar(
|
||||||
Surface(
|
navigationIcon = {
|
||||||
modifier = Modifier
|
IconButton(
|
||||||
.fillMaxWidth()
|
onClick = {
|
||||||
.statusBarsPadding(),
|
scope.launch { drawerState.open() }
|
||||||
color = backgroundColor
|
}
|
||||||
) {
|
) {
|
||||||
Box(
|
Icon(
|
||||||
modifier = Modifier
|
Icons.Default.Menu,
|
||||||
.fillMaxWidth()
|
contentDescription = "Menu",
|
||||||
.height(64.dp)
|
tint = textColor
|
||||||
) {
|
)
|
||||||
// Burger menu - всегда на месте, только меняет alpha
|
|
||||||
val burgerAlpha by animateFloatAsState(
|
|
||||||
targetValue = if (isSearchExpanded) 0f else 1f,
|
|
||||||
animationSpec = tween(
|
|
||||||
durationMillis = 250,
|
|
||||||
easing = FastOutSlowInEasing
|
|
||||||
),
|
|
||||||
label = "burgerAlpha"
|
|
||||||
)
|
|
||||||
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
if (!isSearchExpanded) {
|
|
||||||
scope.launch { drawerState.open() }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !isSearchExpanded,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.CenterStart)
|
|
||||||
.padding(start = 4.dp)
|
|
||||||
.graphicsLayer {
|
|
||||||
alpha = burgerAlpha
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Menu,
|
|
||||||
contentDescription = "Menu",
|
|
||||||
tint = textColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Center content - Title и Search в одном месте
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.Center)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 60.dp)
|
|
||||||
) {
|
|
||||||
val focusRequester = remember {
|
|
||||||
FocusRequester()
|
|
||||||
} // Auto-focus when search opens
|
|
||||||
LaunchedEffect(isSearchExpanded) {
|
|
||||||
if (isSearchExpanded) {
|
|
||||||
kotlinx.coroutines.delay(100)
|
|
||||||
focusRequester.requestFocus()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
// Crossfade for smooth transition without position jumps
|
title = {
|
||||||
Crossfade(
|
Row(
|
||||||
targetState = isSearchExpanded,
|
modifier = Modifier.clickable {
|
||||||
animationSpec = tween(
|
val currentTime = System.currentTimeMillis()
|
||||||
durationMillis = 300,
|
if (currentTime - lastClickTime < 500) {
|
||||||
easing = FastOutSlowInEasing
|
titleClickCount++
|
||||||
),
|
if (titleClickCount >= 3) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
showDevConsole = true
|
||||||
label = "searchCrossfade"
|
titleClickCount = 0
|
||||||
) { expanded ->
|
|
||||||
if (expanded) {
|
|
||||||
// Search Mode
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
// Back arrow
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
searchViewModel.collapseSearch()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.ArrowBack,
|
|
||||||
contentDescription =
|
|
||||||
"Close search",
|
|
||||||
tint = textColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search input with underline animation
|
|
||||||
Box(modifier = Modifier.weight(1f)) {
|
|
||||||
TextField(
|
|
||||||
value = searchQuery,
|
|
||||||
onValueChange = {
|
|
||||||
searchViewModel
|
|
||||||
.onSearchQueryChange(
|
|
||||||
it
|
|
||||||
)
|
|
||||||
},
|
|
||||||
placeholder = {
|
|
||||||
Text(
|
|
||||||
"Search",
|
|
||||||
color =
|
|
||||||
secondaryTextColor
|
|
||||||
.copy(
|
|
||||||
alpha =
|
|
||||||
0.7f
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors =
|
|
||||||
TextFieldDefaults
|
|
||||||
.colors(
|
|
||||||
focusedContainerColor =
|
|
||||||
Color.Transparent,
|
|
||||||
unfocusedContainerColor =
|
|
||||||
Color.Transparent,
|
|
||||||
focusedTextColor =
|
|
||||||
textColor,
|
|
||||||
unfocusedTextColor =
|
|
||||||
textColor,
|
|
||||||
cursorColor =
|
|
||||||
PrimaryBlue,
|
|
||||||
focusedIndicatorColor =
|
|
||||||
Color.Transparent,
|
|
||||||
unfocusedIndicatorColor =
|
|
||||||
Color.Transparent
|
|
||||||
),
|
|
||||||
singleLine = true,
|
|
||||||
enabled =
|
|
||||||
protocolState ==
|
|
||||||
ProtocolState
|
|
||||||
.AUTHENTICATED,
|
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.focusRequester(
|
|
||||||
focusRequester
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Underline
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier.align(
|
|
||||||
Alignment
|
|
||||||
.BottomCenter
|
|
||||||
)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(2.dp)
|
|
||||||
.background(
|
|
||||||
PrimaryBlue
|
|
||||||
.copy(
|
|
||||||
alpha = 0.8f
|
|
||||||
),
|
|
||||||
RoundedCornerShape(
|
|
||||||
1.dp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear button with fade-in animation
|
|
||||||
if (searchQuery.isNotEmpty()) {
|
|
||||||
val clearAlpha by
|
|
||||||
animateFloatAsState(
|
|
||||||
targetValue = 1f,
|
|
||||||
animationSpec =
|
|
||||||
tween(
|
|
||||||
durationMillis = 200,
|
|
||||||
easing = FastOutSlowInEasing
|
|
||||||
),
|
|
||||||
label = "clearAlpha"
|
|
||||||
)
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
searchViewModel
|
|
||||||
.clearSearchQuery()
|
|
||||||
},
|
|
||||||
modifier =
|
|
||||||
Modifier.graphicsLayer {
|
|
||||||
alpha = clearAlpha
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Clear,
|
|
||||||
contentDescription =
|
|
||||||
"Clear",
|
|
||||||
tint = secondaryTextColor
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
titleClickCount = 1
|
||||||
}
|
}
|
||||||
}
|
lastClickTime = currentTime
|
||||||
} else {
|
},
|
||||||
// Title Mode - Triple click to open dev console
|
verticalAlignment = Alignment.CenterVertically
|
||||||
Row(
|
) {
|
||||||
modifier = Modifier
|
Text(
|
||||||
.fillMaxWidth()
|
"Rosetta",
|
||||||
.clickable {
|
fontWeight = FontWeight.Bold,
|
||||||
val currentTime =
|
fontSize = 20.sp,
|
||||||
System.currentTimeMillis()
|
color = textColor
|
||||||
if (currentTime -
|
)
|
||||||
lastClickTime <
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
500
|
Box(
|
||||||
) {
|
modifier = Modifier
|
||||||
titleClickCount++
|
.size(10.dp)
|
||||||
if (titleClickCount >=
|
.clip(CircleShape)
|
||||||
3
|
.background(
|
||||||
) {
|
when (protocolState) {
|
||||||
showDevConsole =
|
ProtocolState.AUTHENTICATED -> Color(0xFF4CAF50)
|
||||||
true
|
ProtocolState.CONNECTING, ProtocolState.CONNECTED, ProtocolState.HANDSHAKING -> Color(0xFFFFC107)
|
||||||
titleClickCount =
|
ProtocolState.DISCONNECTED -> Color(0xFFF44336)
|
||||||
0
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
titleClickCount =
|
|
||||||
1
|
|
||||||
}
|
|
||||||
lastClickTime =
|
|
||||||
currentTime
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Rosetta",
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 20.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
// Status indicator dot
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(10.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(
|
|
||||||
when (protocolState) {
|
|
||||||
ProtocolState.AUTHENTICATED -> Color(0xFF4CAF50) // Green
|
|
||||||
ProtocolState.CONNECTING, ProtocolState.CONNECTED, ProtocolState.HANDSHAKING -> Color(0xFFFFC107) // Yellow
|
|
||||||
ProtocolState.DISCONNECTED -> Color(0xFFF44336) // Red
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.clickable {
|
|
||||||
showStatusDialog = true
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
.clickable { showStatusDialog = true }
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
actions = {
|
||||||
// Search button - справа, всегда на месте
|
IconButton(
|
||||||
val searchButtonScale by animateFloatAsState(
|
onClick = {
|
||||||
targetValue = if (isSearchExpanded) 0f else 1f,
|
if (protocolState == ProtocolState.AUTHENTICATED) {
|
||||||
animationSpec = tween(
|
onSearchClick()
|
||||||
durationMillis = 250,
|
}
|
||||||
easing = FastOutSlowInEasing
|
},
|
||||||
),
|
enabled = protocolState == ProtocolState.AUTHENTICATED
|
||||||
label = "searchButtonScale"
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Search,
|
||||||
|
contentDescription = "Search",
|
||||||
|
tint = if (protocolState == ProtocolState.AUTHENTICATED)
|
||||||
|
textColor else textColor.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = backgroundColor,
|
||||||
|
scrolledContainerColor = backgroundColor,
|
||||||
|
navigationIconContentColor = textColor,
|
||||||
|
titleContentColor = textColor,
|
||||||
|
actionIconContentColor = textColor
|
||||||
)
|
)
|
||||||
val searchButtonAlpha by animateFloatAsState(
|
)
|
||||||
targetValue = if (isSearchExpanded) 0f else 1f,
|
|
||||||
animationSpec = tween(
|
|
||||||
durationMillis = 250,
|
|
||||||
easing = FastOutSlowInEasing
|
|
||||||
),
|
|
||||||
label = "searchButtonAlpha"
|
|
||||||
)
|
|
||||||
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
if (protocolState == ProtocolState.AUTHENTICATED) {
|
|
||||||
searchViewModel.expandSearch()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = protocolState == ProtocolState.AUTHENTICATED && !isSearchExpanded,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.CenterEnd)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
.graphicsLayer {
|
|
||||||
scaleX = searchButtonScale
|
|
||||||
scaleY = searchButtonScale
|
|
||||||
alpha = searchButtonAlpha * if (protocolState == ProtocolState.AUTHENTICATED) 1f else 0.5f
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Search,
|
|
||||||
contentDescription = "Search",
|
|
||||||
tint = textColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -735,30 +506,7 @@ fun ChatsListScreen(
|
|||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
// Main content
|
// Main content
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
|
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
|
||||||
// Show search results when search is expanded
|
if (dialogsList.isEmpty()) {
|
||||||
if (isSearchExpanded) {
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
// Search Results List
|
|
||||||
SearchResultsList(
|
|
||||||
searchResults = searchResults,
|
|
||||||
isSearching = isSearching,
|
|
||||||
currentUserPublicKey = accountPublicKey,
|
|
||||||
isDarkTheme = isDarkTheme,
|
|
||||||
onUserClick = { user ->
|
|
||||||
// Логируем выбор пользователя
|
|
||||||
ProtocolManager.addLog(
|
|
||||||
"🎯 User selected: ${user.title.ifEmpty { user.publicKey.take(10) }}"
|
|
||||||
)
|
|
||||||
ProtocolManager.addLog(
|
|
||||||
" PublicKey: ${user.publicKey.take(20)}..."
|
|
||||||
)
|
|
||||||
// Закрываем поиск и вызываем callback
|
|
||||||
searchViewModel.collapseSearch()
|
|
||||||
onUserSelect(user)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (dialogsList.isEmpty()) {
|
|
||||||
// Empty state with Lottie animation
|
// Empty state with Lottie animation
|
||||||
EmptyChatsState(
|
EmptyChatsState(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
|||||||
177
app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt
Normal file
177
app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package com.rosetta.messenger.ui.chats
|
||||||
|
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rosetta.messenger.network.ProtocolState
|
||||||
|
import com.rosetta.messenger.network.SearchUser
|
||||||
|
|
||||||
|
// Primary Blue color
|
||||||
|
private val PrimaryBlue = Color(0xFF54A9EB)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отдельная страница поиска пользователей
|
||||||
|
* Хедер на всю ширину с полем ввода
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SearchScreen(
|
||||||
|
privateKeyHash: String,
|
||||||
|
currentUserPublicKey: String,
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
protocolState: ProtocolState,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
onUserSelect: (SearchUser) -> Unit
|
||||||
|
) {
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF121212) else Color(0xFFF8F9FA)
|
||||||
|
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
|
||||||
|
|
||||||
|
// Search ViewModel
|
||||||
|
val searchViewModel = remember { SearchUsersViewModel() }
|
||||||
|
val searchQuery by searchViewModel.searchQuery.collectAsState()
|
||||||
|
val searchResults by searchViewModel.searchResults.collectAsState()
|
||||||
|
val isSearching by searchViewModel.isSearching.collectAsState()
|
||||||
|
|
||||||
|
// Устанавливаем privateKeyHash
|
||||||
|
LaunchedEffect(privateKeyHash) {
|
||||||
|
if (privateKeyHash.isNotEmpty()) {
|
||||||
|
searchViewModel.setPrivateKeyHash(privateKeyHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus requester для автофокуса
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
// Автофокус при открытии
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
kotlinx.coroutines.delay(100)
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
// Кастомный header с полем ввода на всю ширину
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = backgroundColor,
|
||||||
|
shadowElevation = 4.dp
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.height(64.dp)
|
||||||
|
.padding(horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Кнопка назад
|
||||||
|
IconButton(onClick = onBackClick) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
tint = textColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поле ввода на всю оставшуюся ширину
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 8.dp)
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = { searchViewModel.onSearchQueryChange(it) },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
"Search users...",
|
||||||
|
color = secondaryTextColor.copy(alpha = 0.7f),
|
||||||
|
fontSize = 16.sp
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedTextColor = textColor,
|
||||||
|
unfocusedTextColor = textColor,
|
||||||
|
cursorColor = PrimaryBlue,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent
|
||||||
|
),
|
||||||
|
textStyle = androidx.compose.ui.text.TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = protocolState == ProtocolState.AUTHENTICATED,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Подчеркивание
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(2.dp)
|
||||||
|
.background(
|
||||||
|
PrimaryBlue.copy(alpha = 0.8f),
|
||||||
|
RoundedCornerShape(1.dp)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка очистки
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = searchQuery.isNotEmpty(),
|
||||||
|
enter = fadeIn(tween(150)) + scaleIn(tween(150)),
|
||||||
|
exit = fadeOut(tween(150)) + scaleOut(tween(150))
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { searchViewModel.clearSearchQuery() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Clear,
|
||||||
|
contentDescription = "Clear",
|
||||||
|
tint = secondaryTextColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
containerColor = backgroundColor
|
||||||
|
) { paddingValues ->
|
||||||
|
// Контент - результаты поиска
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
SearchResultsList(
|
||||||
|
searchResults = searchResults,
|
||||||
|
isSearching = isSearching,
|
||||||
|
currentUserPublicKey = currentUserPublicKey,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
onUserClick = { user ->
|
||||||
|
onUserSelect(user)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user