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

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,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,

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