From d29ad4cfb73ea41a0ef3853c77480930c0cf1be2 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 20:06:05 +0300 Subject: [PATCH] android: unify main tabs shell and hide bottom bar on scroll --- android/CHANGELOG.md | 8 + .../messenger/ui/chats/ChatListScreen.kt | 83 ++--- .../messenger/ui/contacts/ContactsScreen.kt | 122 +++++++ .../messenger/ui/navigation/AppNavGraph.kt | 303 +++++++++++++----- .../messenger/ui/profile/ProfileScreen.kt | 20 +- .../messenger/ui/settings/SettingsScreen.kt | 18 ++ docs/android-ui-batch-1-checklist.md | 2 + 7 files changed, 420 insertions(+), 136 deletions(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/ui/contacts/ContactsScreen.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index e19a812..d7671e2 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -474,3 +474,11 @@ - attachment/media markers (`movie`, `attach file`). - Replaced chat list management FAB glyph toggle (`+`/`×`) with Material `Add`/`Close` icons. - Added `androidx.compose.material:material-icons-extended` dependency for consistent icon usage. + +### Step 76 - Shared main tabs shell with scroll-aware visibility +- Moved `Chats / Contacts / Settings / Profile` bottom panel to a shared navigation shell (`AppNavGraph`) so it behaves as global page navigation. +- Added dedicated `Contacts` page route and wired it into main tabs. +- Removed local duplicated bottom panel from chat list screen. +- Implemented scroll-direction behavior for all 4 main pages: + - hide panel on downward scroll, + - show panel on upward scroll / at top. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt index c81f79b..9568143 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.horizontalScroll import androidx.compose.material3.AssistChip @@ -37,6 +38,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,8 +46,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -53,6 +53,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close +import kotlinx.coroutines.flow.collectLatest import coil.compose.AsyncImage import ru.daemonlord.messenger.domain.chat.model.ChatItem import java.time.Instant @@ -62,10 +63,9 @@ import java.time.format.DateTimeFormatter @Composable fun ChatListRoute( onOpenChat: (Long) -> Unit, - onOpenSettings: () -> Unit, - onOpenProfile: () -> Unit, inviteToken: String?, onInviteTokenConsumed: () -> Unit, + onMainBarVisibilityChanged: (Boolean) -> Unit, viewModel: ChatListViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -87,9 +87,8 @@ fun ChatListRoute( onSearchChanged = viewModel::onSearchChanged, onGlobalSearchChanged = viewModel::onGlobalSearchChanged, onRefresh = viewModel::onPullToRefresh, - onOpenSettings = onOpenSettings, - onOpenProfile = onOpenProfile, onOpenChat = onOpenChat, + onMainBarVisibilityChanged = onMainBarVisibilityChanged, onCreateGroup = viewModel::createGroup, onCreateChannel = viewModel::createChannel, onDiscoverChats = viewModel::discoverChats, @@ -114,9 +113,8 @@ fun ChatListScreen( onSearchChanged: (String) -> Unit, onGlobalSearchChanged: (String) -> Unit, onRefresh: () -> Unit, - onOpenSettings: () -> Unit, - onOpenProfile: () -> Unit, onOpenChat: (Long) -> Unit, + onMainBarVisibilityChanged: (Boolean) -> Unit, onCreateGroup: (String, List) -> Unit, onCreateChannel: (String, String, String?) -> Unit, onDiscoverChats: (String?) -> Unit, @@ -140,6 +138,27 @@ fun ChatListScreen( var manageUserIdText by remember { mutableStateOf("") } var manageRoleText by remember { mutableStateOf("member") } val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 + val listState = rememberLazyListState() + + LaunchedEffect(Unit) { + onMainBarVisibilityChanged(true) + } + LaunchedEffect(listState) { + var prevIndex = 0 + var prevOffset = 0 + snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } + .collectLatest { (index, offset) -> + val movedDown = index > prevIndex || (index == prevIndex && offset > prevOffset) + val movedUp = index < prevIndex || (index == prevIndex && offset < prevOffset) + when { + index == 0 && offset == 0 -> onMainBarVisibilityChanged(true) + movedDown -> onMainBarVisibilityChanged(false) + movedUp -> onMainBarVisibilityChanged(true) + } + prevIndex = index + prevOffset = offset + } + } Box( modifier = Modifier @@ -271,6 +290,7 @@ fun ChatListScreen( else -> { LazyColumn( modifier = Modifier.fillMaxSize(), + state = listState, ) { if (state.selectedTab == ChatTab.ALL && state.archivedChatsCount > 0) { item(key = "archive_row") { @@ -305,23 +325,6 @@ fun ChatListScreen( contentDescription = if (managementExpanded) "Close management" else "Open management", ) } - Surface( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(horizontal = 16.dp, vertical = 14.dp), - shape = CircleShape, - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.92f), - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - BottomNavPill(label = "Chats", selected = true, onClick = {}) - BottomNavPill(label = "Contacts", selected = false, onClick = {}) - BottomNavPill(label = "Settings", selected = false, onClick = onOpenSettings) - BottomNavPill(label = "Profile", selected = false, onClick = onOpenProfile) - } - } } if (managementExpanded) { Surface( @@ -672,36 +675,6 @@ private fun ArchiveRow( } } -@Composable -private fun BottomNavPill( - label: String, - selected: Boolean, - onClick: () -> Unit, -) { - Surface( - shape = CircleShape, - color = if (selected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surface - }, - modifier = Modifier - .clickable(onClick = onClick) - .semantics { contentDescription = "$label tab" }, - ) { - Text( - text = label, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - style = MaterialTheme.typography.labelMedium, - color = if (selected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - } -} - @Composable private fun CenterState( text: String?, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/contacts/ContactsScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/contacts/ContactsScreen.kt new file mode 100644 index 0000000..ee40c8d --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/contacts/ContactsScreen.kt @@ -0,0 +1,122 @@ +package ru.daemonlord.messenger.ui.contacts + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun ContactsRoute( + onMainBarVisibilityChanged: (Boolean) -> Unit, +) { + ContactsScreen(onMainBarVisibilityChanged = onMainBarVisibilityChanged) +} + +@Composable +private fun ContactsScreen( + onMainBarVisibilityChanged: (Boolean) -> Unit, +) { + val listState = rememberLazyListState() + val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 + var query by remember { mutableStateOf("") } + val contacts = remember { + listOf( + "Alex", "Marta", "Danya", "Ilya", "Mila", "Artem", "Nika", "Vlad", + "Sasha", "Roma", "Katya", "Nastya", "Boris", "Pavel", "Alyona", + ) + } + val filtered = remember(query) { + if (query.isBlank()) contacts else contacts.filter { it.contains(query, ignoreCase = true) } + } + + LaunchedEffect(Unit) { + onMainBarVisibilityChanged(true) + } + LaunchedEffect(listState) { + var prevIndex = 0 + var prevOffset = 0 + snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } + .collectLatest { (index, offset) -> + val movedDown = index > prevIndex || (index == prevIndex && offset > prevOffset) + val movedUp = index < prevIndex || (index == prevIndex && offset < prevOffset) + when { + index == 0 && offset == 0 -> onMainBarVisibilityChanged(true) + movedDown -> onMainBarVisibilityChanged(false) + movedUp -> onMainBarVisibilityChanged(true) + } + prevIndex = index + prevOffset = offset + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing), + contentAlignment = Alignment.TopCenter, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .then(if (isTabletLayout) Modifier.widthIn(max = 820.dp) else Modifier) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Contacts", + style = MaterialTheme.typography.headlineSmall, + ) + OutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Search contacts") }, + ) + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(filtered) { name -> + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(text = name, fontWeight = FontWeight.SemiBold) + Text( + text = "last seen recently", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt index 798f5e7..db9fd28 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt @@ -6,23 +6,49 @@ import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.filled.Contacts +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavType import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.navArgument import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -35,6 +61,7 @@ import ru.daemonlord.messenger.ui.auth.reset.ResetPasswordRoute import ru.daemonlord.messenger.ui.auth.verify.VerifyEmailRoute import ru.daemonlord.messenger.ui.chat.ChatRoute import ru.daemonlord.messenger.ui.chats.ChatListRoute +import ru.daemonlord.messenger.ui.contacts.ContactsRoute import ru.daemonlord.messenger.ui.profile.ProfileRoute import ru.daemonlord.messenger.ui.settings.SettingsRoute @@ -44,6 +71,7 @@ private object Routes { const val VerifyEmail = "verify_email" const val ResetPassword = "reset_password" const val Chats = "chats" + const val Contacts = "contacts" const val Settings = "settings" const val Profile = "profile" const val Chat = "chat" @@ -68,6 +96,13 @@ fun MessengerNavHost( val notificationPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), ) {} + var isMainBarVisible by remember { mutableStateOf(true) } + val backStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = backStackEntry?.destination?.route + val mainTabRoutes = remember { + setOf(Routes.Chats, Routes.Contacts, Routes.Settings, Routes.Profile) + } + val showMainBar = currentRoute in mainTabRoutes LaunchedEffect(Unit) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect @@ -127,97 +162,205 @@ fun MessengerNavHost( } } - NavHost( - navController = navController, - startDestination = Routes.AuthGraph, - ) { - navigation( - route = Routes.AuthGraph, - startDestination = Routes.Login, + Box(modifier = Modifier.fillMaxSize()) { + NavHost( + navController = navController, + startDestination = Routes.AuthGraph, ) { - composable(route = Routes.Login) { - if (uiState.isCheckingSession) { - SessionCheckingScreen() - } else { - LoginScreen( - state = uiState, - onEmailChanged = viewModel::onEmailChanged, - onPasswordChanged = viewModel::onPasswordChanged, - onLoginClick = viewModel::login, - onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) }, - onOpenResetPassword = { navController.navigate(Routes.ResetPassword) }, - ) + navigation( + route = Routes.AuthGraph, + startDestination = Routes.Login, + ) { + composable(route = Routes.Login) { + if (uiState.isCheckingSession) { + SessionCheckingScreen() + } else { + LoginScreen( + state = uiState, + onEmailChanged = viewModel::onEmailChanged, + onPasswordChanged = viewModel::onPasswordChanged, + onLoginClick = viewModel::login, + onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) }, + onOpenResetPassword = { navController.navigate(Routes.ResetPassword) }, + ) + } } } + + composable( + route = "${Routes.VerifyEmail}?token={token}", + arguments = listOf( + navArgument("token") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ), + ) { entry -> + VerifyEmailRoute( + token = entry.arguments?.getString("token"), + onBackToLogin = { navController.navigate(Routes.Login) }, + ) + } + + composable( + route = "${Routes.ResetPassword}?token={token}", + arguments = listOf( + navArgument("token") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ), + ) { entry -> + ResetPasswordRoute( + token = entry.arguments?.getString("token"), + onBackToLogin = { navController.navigate(Routes.Login) }, + ) + } + + composable(route = Routes.Chats) { + ChatListRoute( + inviteToken = inviteToken, + onInviteTokenConsumed = onInviteTokenConsumed, + onOpenChat = { chatId -> + navController.navigate("${Routes.Chat}/$chatId") + }, + onMainBarVisibilityChanged = { isMainBarVisible = it }, + ) + } + + composable(route = Routes.Contacts) { + ContactsRoute(onMainBarVisibilityChanged = { isMainBarVisible = it }) + } + + composable(route = Routes.Settings) { + SettingsRoute( + onBackToChats = { navController.navigate(Routes.Chats) }, + onOpenProfile = { navController.navigate(Routes.Profile) }, + onLogout = viewModel::logout, + onMainBarVisibilityChanged = { isMainBarVisible = it }, + ) + } + + composable(route = Routes.Profile) { + ProfileRoute( + onBackToChats = { navController.navigate(Routes.Chats) }, + onOpenSettings = { navController.navigate(Routes.Settings) }, + onMainBarVisibilityChanged = { isMainBarVisible = it }, + ) + } + + composable( + route = "${Routes.Chat}/{chatId}", + arguments = listOf( + navArgument("chatId") { type = NavType.LongType } + ), + ) { backStackEntry -> + ChatRoute( + onBack = { navController.popBackStack() }, + ) + } } - composable( - route = "${Routes.VerifyEmail}?token={token}", - arguments = listOf( - navArgument("token") { - type = NavType.StringType - nullable = true - defaultValue = null - } - ), - ) { entry -> - VerifyEmailRoute( - token = entry.arguments?.getString("token"), - onBackToLogin = { navController.navigate(Routes.Login) }, + AnimatedVisibility( + visible = showMainBar && isMainBarVisible, + enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = 12.dp), + ) { + MainBottomBar( + currentRoute = currentRoute, + onNavigate = { route -> + if (currentRoute == route) return@MainBottomBar + navController.navigate(route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, ) } + } +} - composable( - route = "${Routes.ResetPassword}?token={token}", - arguments = listOf( - navArgument("token") { - type = NavType.StringType - nullable = true - defaultValue = null - } - ), - ) { entry -> - ResetPasswordRoute( - token = entry.arguments?.getString("token"), - onBackToLogin = { navController.navigate(Routes.Login) }, +@Composable +private fun MainBottomBar( + currentRoute: String?, + onNavigate: (String) -> Unit, +) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.92f), + ) { + androidx.compose.foundation.layout.Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + MainTabPill( + label = "Chats", + selected = currentRoute == Routes.Chats, + icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) }, + onClick = { onNavigate(Routes.Chats) }, + ) + MainTabPill( + label = "Contacts", + selected = currentRoute == Routes.Contacts, + icon = { Icon(Icons.Filled.Contacts, contentDescription = null) }, + onClick = { onNavigate(Routes.Contacts) }, + ) + MainTabPill( + label = "Settings", + selected = currentRoute == Routes.Settings, + icon = { Icon(Icons.Filled.Settings, contentDescription = null) }, + onClick = { onNavigate(Routes.Settings) }, + ) + MainTabPill( + label = "Profile", + selected = currentRoute == Routes.Profile, + icon = { Icon(Icons.Filled.Person, contentDescription = null) }, + onClick = { onNavigate(Routes.Profile) }, ) } + } +} - composable(route = Routes.Chats) { - ChatListRoute( - inviteToken = inviteToken, - onInviteTokenConsumed = onInviteTokenConsumed, - onOpenSettings = { navController.navigate(Routes.Settings) }, - onOpenProfile = { navController.navigate(Routes.Profile) }, - onOpenChat = { chatId -> - navController.navigate("${Routes.Chat}/$chatId") - } - ) - } - - composable(route = Routes.Settings) { - SettingsRoute( - onBackToChats = { navController.popBackStack() }, - onOpenProfile = { navController.navigate(Routes.Profile) }, - onLogout = viewModel::logout, - ) - } - - composable(route = Routes.Profile) { - ProfileRoute( - onBackToChats = { navController.popBackStack() }, - onOpenSettings = { navController.navigate(Routes.Settings) }, - ) - } - - composable( - route = "${Routes.Chat}/{chatId}", - arguments = listOf( - navArgument("chatId") { type = NavType.LongType } - ), - ) { backStackEntry -> - ChatRoute( - onBack = { navController.popBackStack() }, +@Composable +private fun MainTabPill( + label: String, + selected: Boolean, + icon: @Composable () -> Unit, + onClick: () -> Unit, +) { + Surface( + shape = CircleShape, + color = if (selected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + modifier = Modifier + .clickable(onClick = onClick) + .semantics { contentDescription = "$label tab" }, + ) { + androidx.compose.foundation.layout.Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + Text( + text = label, + color = if (selected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, ) } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt index c716282..0c9b860 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/profile/ProfileScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.Alignment @@ -45,6 +46,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.collectLatest import coil.compose.AsyncImage import ru.daemonlord.messenger.ui.account.AccountViewModel import java.io.ByteArrayOutputStream @@ -53,11 +55,13 @@ import java.io.ByteArrayOutputStream fun ProfileRoute( onBackToChats: () -> Unit, onOpenSettings: () -> Unit, + onMainBarVisibilityChanged: (Boolean) -> Unit, viewModel: AccountViewModel = hiltViewModel(), ) { ProfileScreen( onBackToChats = onBackToChats, onOpenSettings = onOpenSettings, + onMainBarVisibilityChanged = onMainBarVisibilityChanged, viewModel = viewModel, ) } @@ -66,6 +70,7 @@ fun ProfileRoute( fun ProfileScreen( onBackToChats: () -> Unit, onOpenSettings: () -> Unit, + onMainBarVisibilityChanged: (Boolean) -> Unit, viewModel: AccountViewModel, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -74,14 +79,27 @@ fun ProfileScreen( var username by remember(profile?.username) { mutableStateOf(profile?.username.orEmpty()) } var bio by remember(profile?.bio) { mutableStateOf(profile?.bio.orEmpty()) } var avatarUrl by remember(profile?.avatarUrl) { mutableStateOf(profile?.avatarUrl.orEmpty()) } + val scrollState = rememberScrollState() LaunchedEffect(Unit) { viewModel.refresh() + onMainBarVisibilityChanged(true) + } + LaunchedEffect(scrollState) { + var prevOffset = 0 + snapshotFlow { scrollState.value } + .collectLatest { offset -> + when { + offset == 0 -> onMainBarVisibilityChanged(true) + offset > prevOffset -> onMainBarVisibilityChanged(false) + offset < prevOffset -> onMainBarVisibilityChanged(true) + } + prevOffset = offset + } } val context = androidx.compose.ui.platform.LocalContext.current val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 - val scrollState = rememberScrollState() val pickAvatarLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent(), ) { uri -> diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt index f51386e..660c743 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.appcompat.app.AppCompatDelegate import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -34,6 +35,7 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.collectLatest import ru.daemonlord.messenger.ui.account.AccountViewModel @Composable @@ -41,12 +43,14 @@ fun SettingsRoute( onBackToChats: () -> Unit, onOpenProfile: () -> Unit, onLogout: () -> Unit, + onMainBarVisibilityChanged: (Boolean) -> Unit, viewModel: AccountViewModel = hiltViewModel(), ) { SettingsScreen( onBackToChats = onBackToChats, onOpenProfile = onOpenProfile, onLogout = onLogout, + onMainBarVisibilityChanged = onMainBarVisibilityChanged, viewModel = viewModel, ) } @@ -56,6 +60,7 @@ fun SettingsScreen( onBackToChats: () -> Unit, onOpenProfile: () -> Unit, onLogout: () -> Unit, + onMainBarVisibilityChanged: (Boolean) -> Unit, viewModel: AccountViewModel, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -74,6 +79,19 @@ fun SettingsScreen( LaunchedEffect(Unit) { viewModel.refresh() viewModel.refreshRecoveryStatus() + onMainBarVisibilityChanged(true) + } + LaunchedEffect(scrollState) { + var prevOffset = 0 + snapshotFlow { scrollState.value } + .collectLatest { offset -> + when { + offset == 0 -> onMainBarVisibilityChanged(true) + offset > prevOffset -> onMainBarVisibilityChanged(false) + offset < prevOffset -> onMainBarVisibilityChanged(true) + } + prevOffset = offset + } } Box( diff --git a/docs/android-ui-batch-1-checklist.md b/docs/android-ui-batch-1-checklist.md index 20e7cf7..ad03b50 100644 --- a/docs/android-ui-batch-1-checklist.md +++ b/docs/android-ui-batch-1-checklist.md @@ -6,6 +6,8 @@ ## P0 — Navigation + Layout Shell - [x] Floating bottom navigation в rounded контейнере с полупрозрачным тёмным фоном. - [x] Активный tab в фиолетовом pill-состоянии, неактивные — белые/серые. +- [x] Глобальная панель `Chats / Contacts / Settings / Profile` на уровне app-shell (одна на все 4 страницы). +- [x] Поведение панели: скрывается только при скролле вниз, возвращается при скролле вверх/в начале списка. - [ ] Safe area для status/nav bars на всех экранах списка/настроек/профиля. ## P0 — Settings Visual System