feat: Replace custom header with TopAppBar for improved layout and smoother transitions in ChatsListScreen

This commit is contained in:
k1ngsterr1
2026-01-12 16:24:23 +05:00
parent f6c2fd5e1e
commit 718c2e705f
3 changed files with 329 additions and 381 deletions

View File

@@ -24,10 +24,12 @@ import androidx.compose.ui.unit.sp
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
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.AuthFlow
import com.rosetta.messenger.ui.chats.ChatsListScreen
import com.rosetta.messenger.ui.chats.ChatDetailScreen
import com.rosetta.messenger.ui.chats.SearchScreen
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.onboarding.OnboardingScreen
import com.rosetta.messenger.ui.splash.SplashScreen
@@ -218,12 +220,16 @@ fun MainScreen(
val accountPrivateKey = account?.privateKey ?: ""
val privateKeyHash = account?.privateKeyHash ?: ""
// Состояние протокола для передачи в SearchScreen
val protocolState by ProtocolManager.state.collectAsState()
// Навигация между экранами
var selectedUser by remember { mutableStateOf<SearchUser?>(null) }
var showSearchScreen by remember { mutableStateOf(false) }
// Анимированный переход между чатами - плавный crossfade без прыжков
// Анимированный переход между экранами
AnimatedContent(
targetState = selectedUser,
targetState = Triple(selectedUser, showSearchScreen, Unit),
transitionSpec = {
// Плавный crossfade для избежания "прыжков" header'а
val enterAnim = fadeIn(
@@ -232,7 +238,7 @@ fun MainScreen(
easing = FastOutSlowInEasing
)
) + slideInHorizontally(
initialOffsetX = { if (targetState != null) it / 4 else -it / 4 },
initialOffsetX = { it / 4 },
animationSpec = tween(
durationMillis = 300,
easing = FastOutSlowInEasing
@@ -245,7 +251,7 @@ fun MainScreen(
easing = FastOutSlowInEasing
)
) + slideOutHorizontally(
targetOffsetX = { if (targetState != null) -it / 6 else it / 6 },
targetOffsetX = { -it / 6 },
animationSpec = tween(
durationMillis = 300,
easing = FastOutSlowInEasing
@@ -254,58 +260,75 @@ fun MainScreen(
enterAnim togetherWith exitAnim using SizeTransform(clip = false)
},
label = "chatNavigation"
) { user ->
if (user != null) {
// Экран чата
ChatDetailScreen(
user = user,
currentUserPublicKey = accountPublicKey,
currentUserPrivateKey = accountPrivateKey,
isDarkTheme = isDarkTheme,
onBack = { selectedUser = null }
)
} else {
// Список чатов
ChatsListScreen(
isDarkTheme = isDarkTheme,
accountName = accountName,
accountPhone = accountPhone,
accountPublicKey = accountPublicKey,
privateKeyHash = privateKeyHash,
onToggleTheme = onToggleTheme,
onProfileClick = {
// TODO: Navigate to profile
},
onNewGroupClick = {
// TODO: Navigate to new group
},
onContactsClick = {
// TODO: Navigate to contacts
},
onCallsClick = {
// TODO: Navigate to calls
},
onSavedMessagesClick = {
// TODO: Navigate to saved messages
},
onSettingsClick = {
// TODO: Navigate to settings
},
onInviteFriendsClick = {
// TODO: Share invite link
},
onSearchClick = {
// TODO: Show search
},
onNewChat = {
// TODO: Show new chat screen
},
onUserSelect = { user ->
selectedUser = user
},
onLogout = onLogout
)
label = "screenNavigation"
) { (user, isSearchOpen, _) ->
when {
user != null -> {
// Экран чата
ChatDetailScreen(
user = user,
currentUserPublicKey = accountPublicKey,
currentUserPrivateKey = accountPrivateKey,
isDarkTheme = isDarkTheme,
onBack = { selectedUser = null }
)
}
isSearchOpen -> {
// Экран поиска
SearchScreen(
privateKeyHash = privateKeyHash,
currentUserPublicKey = accountPublicKey,
isDarkTheme = isDarkTheme,
protocolState = protocolState,
onBackClick = { showSearchScreen = false },
onUserSelect = { user ->
showSearchScreen = false
selectedUser = user
}
)
}
else -> {
// Список чатов
ChatsListScreen(
isDarkTheme = isDarkTheme,
accountName = accountName,
accountPhone = accountPhone,
accountPublicKey = accountPublicKey,
privateKeyHash = privateKeyHash,
onToggleTheme = onToggleTheme,
onProfileClick = {
// TODO: Navigate to profile
},
onNewGroupClick = {
// TODO: Navigate to new group
},
onContactsClick = {
// TODO: Navigate to contacts
},
onCallsClick = {
// TODO: Navigate to calls
},
onSavedMessagesClick = {
// TODO: Navigate to saved messages
},
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
)
}
}
}
}

View File

@@ -194,20 +194,6 @@ fun ChatsListScreen(
// Status dialog state
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) }
LaunchedEffect(Unit) { visible = true }
@@ -416,299 +402,84 @@ fun ChatsListScreen(
)
) {
key(isDarkTheme) {
// Custom header с фиксированной структурой для плавной анимации без скачков
Surface(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding(),
color = backgroundColor
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
) {
// 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()
}
TopAppBar(
navigationIcon = {
IconButton(
onClick = {
scope.launch { drawerState.open() }
}
) {
Icon(
Icons.Default.Menu,
contentDescription = "Menu",
tint = textColor
)
}
// Crossfade for smooth transition without position jumps
Crossfade(
targetState = isSearchExpanded,
animationSpec = tween(
durationMillis = 300,
easing = FastOutSlowInEasing
),
modifier = Modifier.fillMaxWidth(),
label = "searchCrossfade"
) { 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
)
},
title = {
Row(
modifier = Modifier.clickable {
val currentTime = System.currentTimeMillis()
if (currentTime - lastClickTime < 500) {
titleClickCount++
if (titleClickCount >= 3) {
showDevConsole = true
titleClickCount = 0
}
} else {
titleClickCount = 1
}
}
} else {
// Title Mode - Triple click to open dev console
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
val currentTime =
System.currentTimeMillis()
if (currentTime -
lastClickTime <
500
) {
titleClickCount++
if (titleClickCount >=
3
) {
showDevConsole =
true
titleClickCount =
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
lastClickTime = currentTime
},
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Rosetta",
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = textColor
)
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(
when (protocolState) {
ProtocolState.AUTHENTICATED -> Color(0xFF4CAF50)
ProtocolState.CONNECTING, ProtocolState.CONNECTED, ProtocolState.HANDSHAKING -> Color(0xFFFFC107)
ProtocolState.DISCONNECTED -> Color(0xFFF44336)
}
)
}
}
)
.clickable { showStatusDialog = true }
)
}
}
// Search button - справа, всегда на месте
val searchButtonScale by animateFloatAsState(
targetValue = if (isSearchExpanded) 0f else 1f,
animationSpec = tween(
durationMillis = 250,
easing = FastOutSlowInEasing
),
label = "searchButtonScale"
},
actions = {
IconButton(
onClick = {
if (protocolState == ProtocolState.AUTHENTICATED) {
onSearchClick()
}
},
enabled = protocolState == ProtocolState.AUTHENTICATED
) {
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 ->
// Main content
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
// Show search results when search is expanded
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()) {
if (dialogsList.isEmpty()) {
// Empty state with Lottie animation
EmptyChatsState(
isDarkTheme = isDarkTheme,

View 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)
}
)
}
}
}