android: unify main tabs shell and hide bottom bar on scroll
Some checks failed
Android CI / android (push) Failing after 4m25s
Android Release / release (push) Failing after 4m49s
CI / test (push) Failing after 2m11s

This commit is contained in:
Codex
2026-03-09 20:06:05 +03:00
parent 448ed3243d
commit d29ad4cfb7
7 changed files with 420 additions and 136 deletions

View File

@@ -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.

View File

@@ -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<Long>) -> 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?,

View File

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

View File

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

View File

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

View File

@@ -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(

View File

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