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`). - attachment/media markers (`movie`, `attach file`).
- Replaced chat list management FAB glyph toggle (`+`/`×`) with Material `Add`/`Close` icons. - Replaced chat list management FAB glyph toggle (`+`/`×`) with Material `Add`/`Close` icons.
- Added `androidx.compose.material:material-icons-extended` dependency for consistent icon usage. - 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.layout.safeDrawing
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
@@ -37,6 +38,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.platform.LocalConfiguration 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel 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.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import kotlinx.coroutines.flow.collectLatest
import coil.compose.AsyncImage import coil.compose.AsyncImage
import ru.daemonlord.messenger.domain.chat.model.ChatItem import ru.daemonlord.messenger.domain.chat.model.ChatItem
import java.time.Instant import java.time.Instant
@@ -62,10 +63,9 @@ import java.time.format.DateTimeFormatter
@Composable @Composable
fun ChatListRoute( fun ChatListRoute(
onOpenChat: (Long) -> Unit, onOpenChat: (Long) -> Unit,
onOpenSettings: () -> Unit,
onOpenProfile: () -> Unit,
inviteToken: String?, inviteToken: String?,
onInviteTokenConsumed: () -> Unit, onInviteTokenConsumed: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit,
viewModel: ChatListViewModel = hiltViewModel(), viewModel: ChatListViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
@@ -87,9 +87,8 @@ fun ChatListRoute(
onSearchChanged = viewModel::onSearchChanged, onSearchChanged = viewModel::onSearchChanged,
onGlobalSearchChanged = viewModel::onGlobalSearchChanged, onGlobalSearchChanged = viewModel::onGlobalSearchChanged,
onRefresh = viewModel::onPullToRefresh, onRefresh = viewModel::onPullToRefresh,
onOpenSettings = onOpenSettings,
onOpenProfile = onOpenProfile,
onOpenChat = onOpenChat, onOpenChat = onOpenChat,
onMainBarVisibilityChanged = onMainBarVisibilityChanged,
onCreateGroup = viewModel::createGroup, onCreateGroup = viewModel::createGroup,
onCreateChannel = viewModel::createChannel, onCreateChannel = viewModel::createChannel,
onDiscoverChats = viewModel::discoverChats, onDiscoverChats = viewModel::discoverChats,
@@ -114,9 +113,8 @@ fun ChatListScreen(
onSearchChanged: (String) -> Unit, onSearchChanged: (String) -> Unit,
onGlobalSearchChanged: (String) -> Unit, onGlobalSearchChanged: (String) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onOpenSettings: () -> Unit,
onOpenProfile: () -> Unit,
onOpenChat: (Long) -> Unit, onOpenChat: (Long) -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit,
onCreateGroup: (String, List<Long>) -> Unit, onCreateGroup: (String, List<Long>) -> Unit,
onCreateChannel: (String, String, String?) -> Unit, onCreateChannel: (String, String, String?) -> Unit,
onDiscoverChats: (String?) -> Unit, onDiscoverChats: (String?) -> Unit,
@@ -140,6 +138,27 @@ fun ChatListScreen(
var manageUserIdText by remember { mutableStateOf("") } var manageUserIdText by remember { mutableStateOf("") }
var manageRoleText by remember { mutableStateOf("member") } var manageRoleText by remember { mutableStateOf("member") }
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 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( Box(
modifier = Modifier modifier = Modifier
@@ -271,6 +290,7 @@ fun ChatListScreen(
else -> { else -> {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = listState,
) { ) {
if (state.selectedTab == ChatTab.ALL && state.archivedChatsCount > 0) { if (state.selectedTab == ChatTab.ALL && state.archivedChatsCount > 0) {
item(key = "archive_row") { item(key = "archive_row") {
@@ -305,23 +325,6 @@ fun ChatListScreen(
contentDescription = if (managementExpanded) "Close management" else "Open management", 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) { if (managementExpanded) {
Surface( 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 @Composable
private fun CenterState( private fun CenterState(
text: String?, 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.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding 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.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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navArgument import androidx.navigation.navArgument
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost 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.auth.verify.VerifyEmailRoute
import ru.daemonlord.messenger.ui.chat.ChatRoute import ru.daemonlord.messenger.ui.chat.ChatRoute
import ru.daemonlord.messenger.ui.chats.ChatListRoute 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.profile.ProfileRoute
import ru.daemonlord.messenger.ui.settings.SettingsRoute import ru.daemonlord.messenger.ui.settings.SettingsRoute
@@ -44,6 +71,7 @@ private object Routes {
const val VerifyEmail = "verify_email" const val VerifyEmail = "verify_email"
const val ResetPassword = "reset_password" const val ResetPassword = "reset_password"
const val Chats = "chats" const val Chats = "chats"
const val Contacts = "contacts"
const val Settings = "settings" const val Settings = "settings"
const val Profile = "profile" const val Profile = "profile"
const val Chat = "chat" const val Chat = "chat"
@@ -68,6 +96,13 @@ fun MessengerNavHost(
val notificationPermissionLauncher = rememberLauncherForActivityResult( val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(), 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) { LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect
@@ -127,97 +162,205 @@ fun MessengerNavHost(
} }
} }
NavHost( Box(modifier = Modifier.fillMaxSize()) {
navController = navController, NavHost(
startDestination = Routes.AuthGraph, navController = navController,
) { startDestination = Routes.AuthGraph,
navigation(
route = Routes.AuthGraph,
startDestination = Routes.Login,
) { ) {
composable(route = Routes.Login) { navigation(
if (uiState.isCheckingSession) { route = Routes.AuthGraph,
SessionCheckingScreen() startDestination = Routes.Login,
} else { ) {
LoginScreen( composable(route = Routes.Login) {
state = uiState, if (uiState.isCheckingSession) {
onEmailChanged = viewModel::onEmailChanged, SessionCheckingScreen()
onPasswordChanged = viewModel::onPasswordChanged, } else {
onLoginClick = viewModel::login, LoginScreen(
onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) }, state = uiState,
onOpenResetPassword = { navController.navigate(Routes.ResetPassword) }, 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( AnimatedVisibility(
route = "${Routes.VerifyEmail}?token={token}", visible = showMainBar && isMainBarVisible,
arguments = listOf( enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
navArgument("token") { exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
type = NavType.StringType modifier = Modifier
nullable = true .align(Alignment.BottomCenter)
defaultValue = null .navigationBarsPadding()
} .padding(bottom = 12.dp),
), ) {
) { entry -> MainBottomBar(
VerifyEmailRoute( currentRoute = currentRoute,
token = entry.arguments?.getString("token"), onNavigate = { route ->
onBackToLogin = { navController.navigate(Routes.Login) }, if (currentRoute == route) return@MainBottomBar
navController.navigate(route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
) )
} }
}
}
composable( @Composable
route = "${Routes.ResetPassword}?token={token}", private fun MainBottomBar(
arguments = listOf( currentRoute: String?,
navArgument("token") { onNavigate: (String) -> Unit,
type = NavType.StringType ) {
nullable = true Surface(
defaultValue = null shape = CircleShape,
} color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.92f),
), ) {
) { entry -> androidx.compose.foundation.layout.Row(
ResetPasswordRoute( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
token = entry.arguments?.getString("token"), horizontalArrangement = Arrangement.spacedBy(8.dp),
onBackToLogin = { navController.navigate(Routes.Login) }, ) {
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) { @Composable
ChatListRoute( private fun MainTabPill(
inviteToken = inviteToken, label: String,
onInviteTokenConsumed = onInviteTokenConsumed, selected: Boolean,
onOpenSettings = { navController.navigate(Routes.Settings) }, icon: @Composable () -> Unit,
onOpenProfile = { navController.navigate(Routes.Profile) }, onClick: () -> Unit,
onOpenChat = { chatId -> ) {
navController.navigate("${Routes.Chat}/$chatId") Surface(
} shape = CircleShape,
) color = if (selected) {
} MaterialTheme.colorScheme.primaryContainer
} else {
composable(route = Routes.Settings) { MaterialTheme.colorScheme.surface
SettingsRoute( },
onBackToChats = { navController.popBackStack() }, modifier = Modifier
onOpenProfile = { navController.navigate(Routes.Profile) }, .clickable(onClick = onClick)
onLogout = viewModel::logout, .semantics { contentDescription = "$label tab" },
) ) {
} androidx.compose.foundation.layout.Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
composable(route = Routes.Profile) { horizontalArrangement = Arrangement.spacedBy(6.dp),
ProfileRoute( verticalAlignment = Alignment.CenterVertically,
onBackToChats = { navController.popBackStack() }, ) {
onOpenSettings = { navController.navigate(Routes.Settings) }, icon()
) Text(
} text = label,
color = if (selected) {
composable( MaterialTheme.colorScheme.onPrimaryContainer
route = "${Routes.Chat}/{chatId}", } else {
arguments = listOf( MaterialTheme.colorScheme.onSurface
navArgument("chatId") { type = NavType.LongType } },
),
) { backStackEntry ->
ChatRoute(
onBack = { navController.popBackStack() },
) )
} }
} }

View File

@@ -38,6 +38,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -45,6 +46,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import coil.compose.AsyncImage import coil.compose.AsyncImage
import ru.daemonlord.messenger.ui.account.AccountViewModel import ru.daemonlord.messenger.ui.account.AccountViewModel
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
@@ -53,11 +55,13 @@ import java.io.ByteArrayOutputStream
fun ProfileRoute( fun ProfileRoute(
onBackToChats: () -> Unit, onBackToChats: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit,
viewModel: AccountViewModel = hiltViewModel(), viewModel: AccountViewModel = hiltViewModel(),
) { ) {
ProfileScreen( ProfileScreen(
onBackToChats = onBackToChats, onBackToChats = onBackToChats,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onMainBarVisibilityChanged = onMainBarVisibilityChanged,
viewModel = viewModel, viewModel = viewModel,
) )
} }
@@ -66,6 +70,7 @@ fun ProfileRoute(
fun ProfileScreen( fun ProfileScreen(
onBackToChats: () -> Unit, onBackToChats: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit,
viewModel: AccountViewModel, viewModel: AccountViewModel,
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
@@ -74,14 +79,27 @@ fun ProfileScreen(
var username by remember(profile?.username) { mutableStateOf(profile?.username.orEmpty()) } var username by remember(profile?.username) { mutableStateOf(profile?.username.orEmpty()) }
var bio by remember(profile?.bio) { mutableStateOf(profile?.bio.orEmpty()) } var bio by remember(profile?.bio) { mutableStateOf(profile?.bio.orEmpty()) }
var avatarUrl by remember(profile?.avatarUrl) { mutableStateOf(profile?.avatarUrl.orEmpty()) } var avatarUrl by remember(profile?.avatarUrl) { mutableStateOf(profile?.avatarUrl.orEmpty()) }
val scrollState = rememberScrollState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.refresh() 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 context = androidx.compose.ui.platform.LocalContext.current
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
val scrollState = rememberScrollState()
val pickAvatarLauncher = rememberLauncherForActivityResult( val pickAvatarLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(), contract = ActivityResultContracts.GetContent(),
) { uri -> ) { uri ->

View File

@@ -27,6 +27,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -34,6 +35,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import ru.daemonlord.messenger.ui.account.AccountViewModel import ru.daemonlord.messenger.ui.account.AccountViewModel
@Composable @Composable
@@ -41,12 +43,14 @@ fun SettingsRoute(
onBackToChats: () -> Unit, onBackToChats: () -> Unit,
onOpenProfile: () -> Unit, onOpenProfile: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit,
viewModel: AccountViewModel = hiltViewModel(), viewModel: AccountViewModel = hiltViewModel(),
) { ) {
SettingsScreen( SettingsScreen(
onBackToChats = onBackToChats, onBackToChats = onBackToChats,
onOpenProfile = onOpenProfile, onOpenProfile = onOpenProfile,
onLogout = onLogout, onLogout = onLogout,
onMainBarVisibilityChanged = onMainBarVisibilityChanged,
viewModel = viewModel, viewModel = viewModel,
) )
} }
@@ -56,6 +60,7 @@ fun SettingsScreen(
onBackToChats: () -> Unit, onBackToChats: () -> Unit,
onOpenProfile: () -> Unit, onOpenProfile: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit,
viewModel: AccountViewModel, viewModel: AccountViewModel,
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
@@ -74,6 +79,19 @@ fun SettingsScreen(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.refresh() viewModel.refresh()
viewModel.refreshRecoveryStatus() 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( Box(

View File

@@ -6,6 +6,8 @@
## P0 — Navigation + Layout Shell ## P0 — Navigation + Layout Shell
- [x] Floating bottom navigation в rounded контейнере с полупрозрачным тёмным фоном. - [x] Floating bottom navigation в rounded контейнере с полупрозрачным тёмным фоном.
- [x] Активный tab в фиолетовом pill-состоянии, неактивные — белые/серые. - [x] Активный tab в фиолетовом pill-состоянии, неактивные — белые/серые.
- [x] Глобальная панель `Chats / Contacts / Settings / Profile` на уровне app-shell (одна на все 4 страницы).
- [x] Поведение панели: скрывается только при скролле вниз, возвращается при скролле вверх/в начале списка.
- [ ] Safe area для status/nav bars на всех экранах списка/настроек/профиля. - [ ] Safe area для status/nav bars на всех экранах списка/настроек/профиля.
## P0 — Settings Visual System ## P0 — Settings Visual System