From 718c2e705f27ed21d764309344cfdf967b7fc645 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Mon, 12 Jan 2026 16:24:23 +0500 Subject: [PATCH] feat: Replace custom header with TopAppBar for improved layout and smoother transitions in ChatsListScreen --- .../com/rosetta/messenger/MainActivity.kt | 135 +++--- .../messenger/ui/chats/ChatsListScreen.kt | 398 ++++-------------- .../messenger/ui/chats/SearchScreen.kt | 177 ++++++++ 3 files changed, 329 insertions(+), 381 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index ce63f8c..0986280 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -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(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 + ) + } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index d267bcc..c32e768 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -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, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt new file mode 100644 index 0000000..3c949cb --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -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) + } + ) + } + } +}