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.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,9 +260,10 @@ 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( ChatDetailScreen(
user = user, user = user,
@@ -265,7 +272,22 @@ fun MainScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onBack = { selectedUser = null } onBack = { selectedUser = null }
) )
} else { }
isSearchOpen -> {
// Экран поиска
SearchScreen(
privateKeyHash = privateKeyHash,
currentUserPublicKey = accountPublicKey,
isDarkTheme = isDarkTheme,
protocolState = protocolState,
onBackClick = { showSearchScreen = false },
onUserSelect = { user ->
showSearchScreen = false
selectedUser = user
}
)
}
else -> {
// Список чатов // Список чатов
ChatsListScreen( ChatsListScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
@@ -296,7 +318,7 @@ fun MainScreen(
// TODO: Share invite link // TODO: Share invite link
}, },
onSearchClick = { onSearchClick = {
// TODO: Show search showSearchScreen = true
}, },
onNewChat = { onNewChat = {
// TODO: Show new chat screen // TODO: Show new chat screen
@@ -309,3 +331,4 @@ fun MainScreen(
} }
} }
} }
}

View File

@@ -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,41 +402,12 @@ fun ChatsListScreen(
) )
) { ) {
key(isDarkTheme) { key(isDarkTheme) {
// Custom header с фиксированной структурой для плавной анимации без скачков TopAppBar(
Surface( navigationIcon = {
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( IconButton(
onClick = { onClick = {
if (!isSearchExpanded) {
scope.launch { drawerState.open() } scope.launch { drawerState.open() }
} }
},
enabled = !isSearchExpanded,
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 4.dp)
.graphicsLayer {
alpha = burgerAlpha
}
) { ) {
Icon( Icon(
Icons.Default.Menu, Icons.Default.Menu,
@@ -458,184 +415,21 @@ fun ChatsListScreen(
tint = textColor tint = textColor
) )
} }
},
// Center content - Title и Search в одном месте title = {
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
Crossfade(
targetState = isSearchExpanded,
animationSpec = tween(
durationMillis = 300,
easing = FastOutSlowInEasing
),
modifier = Modifier.fillMaxWidth(),
label = "searchCrossfade"
) { expanded ->
if (expanded) {
// Search Mode
Row( Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable {
modifier = Modifier.fillMaxWidth() val currentTime = System.currentTimeMillis()
) { if (currentTime - lastClickTime < 500) {
// 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 {
// Title Mode - Triple click to open dev console
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
val currentTime =
System.currentTimeMillis()
if (currentTime -
lastClickTime <
500
) {
titleClickCount++ titleClickCount++
if (titleClickCount >= if (titleClickCount >= 3) {
3 showDevConsole = true
) { titleClickCount = 0
showDevConsole =
true
titleClickCount =
0
} }
} else { } else {
titleClickCount = titleClickCount = 1
1
} }
lastClickTime = lastClickTime = currentTime
currentTime
}, },
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -646,69 +440,46 @@ fun ChatsListScreen(
color = textColor color = textColor
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
// Status indicator dot
Box( Box(
modifier = Modifier modifier = Modifier
.size(10.dp) .size(10.dp)
.clip(CircleShape) .clip(CircleShape)
.background( .background(
when (protocolState) { when (protocolState) {
ProtocolState.AUTHENTICATED -> Color(0xFF4CAF50) // Green ProtocolState.AUTHENTICATED -> Color(0xFF4CAF50)
ProtocolState.CONNECTING, ProtocolState.CONNECTED, ProtocolState.HANDSHAKING -> Color(0xFFFFC107) // Yellow ProtocolState.CONNECTING, ProtocolState.CONNECTED, ProtocolState.HANDSHAKING -> Color(0xFFFFC107)
ProtocolState.DISCONNECTED -> Color(0xFFF44336) // Red ProtocolState.DISCONNECTED -> Color(0xFFF44336)
} }
) )
.clickable { .clickable { showStatusDialog = true }
showStatusDialog = true
}
) )
} }
} },
} actions = {
}
// Search button - справа, всегда на месте
val searchButtonScale by animateFloatAsState(
targetValue = if (isSearchExpanded) 0f else 1f,
animationSpec = tween(
durationMillis = 250,
easing = FastOutSlowInEasing
),
label = "searchButtonScale"
)
val searchButtonAlpha by animateFloatAsState(
targetValue = if (isSearchExpanded) 0f else 1f,
animationSpec = tween(
durationMillis = 250,
easing = FastOutSlowInEasing
),
label = "searchButtonAlpha"
)
IconButton( IconButton(
onClick = { onClick = {
if (protocolState == ProtocolState.AUTHENTICATED) { if (protocolState == ProtocolState.AUTHENTICATED) {
searchViewModel.expandSearch() onSearchClick()
} }
}, },
enabled = protocolState == ProtocolState.AUTHENTICATED && !isSearchExpanded, enabled = protocolState == ProtocolState.AUTHENTICATED
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( Icon(
Icons.Default.Search, Icons.Default.Search,
contentDescription = "Search", contentDescription = "Search",
tint = textColor 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
)
)
} }
} }
}, },
@@ -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,

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