android: unify main tabs shell and hide bottom bar on scroll
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user