Change bottom bar navigation and make space for new order

This commit is contained in:
jonasgaudian
2026-02-16 13:12:15 +01:00
parent 7fccda7f77
commit d2d2f53b59
3 changed files with 169 additions and 30 deletions

View File

@@ -60,9 +60,9 @@ fun AppNavHost(
val mainTabRoutes = setOf( val mainTabRoutes = setOf(
Screen.Home.route, Screen.Home.route,
Screen.Translation.route, Screen.Translation.route,
"main_dictionary", Screen.Vocabulary.route,
"main_vocabulary", Screen.Dictionary.route,
"main_exercise", Screen.Exercises.route,
SettingsRoutes.LIST SettingsRoutes.LIST
) )

View File

@@ -11,20 +11,33 @@ import androidx.compose.animation.core.spring
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
@@ -41,6 +54,7 @@ import androidx.navigation.NavDestination.Companion.hierarchy
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.ThemePreviews import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.view.LocalShowExperimentalFeatures import eu.gaudian.translator.view.LocalShowExperimentalFeatures
import kotlinx.coroutines.launch
sealed class Screen( sealed class Screen(
val route: String, val route: String,
@@ -50,33 +64,39 @@ sealed class Screen(
) { ) {
object Home : Screen("home", R.string.label_home, AppIcons.Home, AppIcons.Home) object Home : Screen("home", R.string.label_home, AppIcons.Home, AppIcons.Home)
object Translation : Screen("translation", R.string.label_translation, AppIcons.TranslateFilled, AppIcons.TranslateOutlined) object Translation : Screen("translation", R.string.label_translation, AppIcons.TranslateFilled, AppIcons.TranslateOutlined)
object Dictionary : Screen("dictionary", R.string.label_dictionary, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
object Exercises : Screen("exercises", R.string.label_exercises, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
object Vocabulary : Screen("vocabulary", R.string.label_vocabulary, AppIcons.VocabularyFilled, AppIcons.VocabularyOutlined) object Vocabulary : Screen("vocabulary", R.string.label_vocabulary, AppIcons.VocabularyFilled, AppIcons.VocabularyOutlined)
object Settings : Screen("settings", R.string.title_settings, AppIcons.SettingsFilled, AppIcons.SettingsOutlined) object Settings : Screen("settings", R.string.title_settings, AppIcons.SettingsFilled, AppIcons.SettingsOutlined)
object More : Screen("more", R.string.label_more, AppIcons.MoreVert, AppIcons.MoreVert)
object Dictionary : Screen("dictionary", R.string.label_dictionary, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
object Exercises : Screen("exercises", R.string.label_exercises, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
companion object { companion object {
fun getAllScreens(showExperimental: Boolean = false): List<Screen> { fun getAllScreens(showExperimental: Boolean = false): List<Screen> {
val screens = mutableListOf(Home, Translation, Dictionary, Vocabulary, Settings) return listOf(Home, Vocabulary)
if (showExperimental) {
screens.add(3, Exercises)
} }
return screens
fun getMoreMenuItems(showExperimental: Boolean = false): List<Screen> {
val items = mutableListOf<Screen>()
items.add(Translation)
items.add(Dictionary)
items.add(Settings)
if (showExperimental) {
items.add(Exercises)
}
return items
} }
@Composable @Composable
fun fromDestination(destination: NavDestination?): Screen { fun fromDestination(destination: NavDestination?): Screen {
val showExperimental = LocalShowExperimentalFeatures.current val showExperimental = LocalShowExperimentalFeatures.current
return getAllScreens(showExperimental).find { screen -> val allScreens = getAllScreens(showExperimental) + getMoreMenuItems(showExperimental) + More
return allScreens.find { screen ->
destination?.hierarchy?.any { it.route == screen.route } == true destination?.hierarchy?.any { it.route == screen.route } == true
} ?: Home } ?: Home
} }
} }
} }
/**
* A modernized Material 3 bottom navigation bar with spring animations and haptic feedback.
*/
@SuppressLint("UnusedBoxWithConstraintsScope") @SuppressLint("UnusedBoxWithConstraintsScope")
@Composable @Composable
fun BottomNavigationBar( fun BottomNavigationBar(
@@ -88,7 +108,11 @@ fun BottomNavigationBar(
) { ) {
val showExperimental = LocalShowExperimentalFeatures.current val showExperimental = LocalShowExperimentalFeatures.current
val screens = remember(showExperimental) { Screen.getAllScreens(showExperimental) } val screens = remember(showExperimental) { Screen.getAllScreens(showExperimental) }
val moreScreen = remember { Screen.More }
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
var showMoreMenu by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
AnimatedVisibility( AnimatedVisibility(
visible = isVisible, visible = isVisible,
@@ -101,7 +125,6 @@ fun BottomNavigationBar(
targetOffsetY = { it } targetOffsetY = { it }
) )
) { ) {
val baseHeight = if (showLabels) 80.dp else 56.dp val baseHeight = if (showLabels) 80.dp else 56.dp
val density = LocalDensity.current val density = LocalDensity.current
val navBarDp = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } val navBarDp = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
@@ -109,16 +132,15 @@ fun BottomNavigationBar(
NavigationBar( NavigationBar(
modifier = modifier.height(height), modifier = modifier.height(height),
containerColor = MaterialTheme.colorScheme.surface, // Cleaner look than surfaceVariant containerColor = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp, // Slight elevation for depth tonalElevation = 8.dp,
) { ) {
screens.forEach { screen -> screens.forEach { screen ->
val isSelected = screen == selectedItem val isSelected = screen == selectedItem
val title = stringResource(id = screen.title) val title = stringResource(id = screen.title)
// 1. Spring Animation for the Icon Scale
val scale by animateFloatAsState( val scale by animateFloatAsState(
targetValue = if (isSelected) 1.2f else 1.0f, // Subtle pop effect targetValue = if (isSelected) 1.2f else 1.0f,
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy, dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow stiffness = Spring.StiffnessLow
@@ -129,10 +151,8 @@ fun BottomNavigationBar(
NavigationBarItem( NavigationBarItem(
selected = isSelected, selected = isSelected,
onClick = { onClick = {
if (!isSelected) { haptic.performHapticFeedback(HapticFeedbackType.LongPress)
haptic.performHapticFeedback(HapticFeedbackType.LongPress) // 2. Tactile feedback
onItemSelected(screen) onItemSelected(screen)
}
}, },
label = if (showLabels) { label = if (showLabels) {
{ {
@@ -146,12 +166,11 @@ fun BottomNavigationBar(
} }
} else null, } else null,
icon = { icon = {
// 3. Crossfade between Outlined and Filled icons
Crossfade(targetState = isSelected, label = "iconFade") { selected -> Crossfade(targetState = isSelected, label = "iconFade") { selected ->
Icon( Icon(
imageVector = if (selected) screen.selectedIcon else screen.unselectedIcon, imageVector = if (selected) screen.selectedIcon else screen.unselectedIcon,
contentDescription = title, contentDescription = title,
modifier = Modifier.scale(scale) // Apply the spring scale modifier = Modifier.scale(scale)
) )
} }
}, },
@@ -164,7 +183,126 @@ fun BottomNavigationBar(
) )
) )
} }
// More menu item
val moreSelected = selectedItem is Screen.More ||
Screen.getMoreMenuItems(showExperimental).contains(selectedItem)
val moreTitle = stringResource(R.string.label_more)
val moreScale by animateFloatAsState(
targetValue = if (moreSelected) 1.2f else 1.0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "moreIconScale"
)
NavigationBarItem(
selected = moreSelected,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showMoreMenu = true
},
label = if (showLabels) {
{
Text(
text = moreTitle,
maxLines = 1,
fontSize = 10.sp,
fontWeight = if (moreSelected) FontWeight.Bold else FontWeight.Normal,
color = if(moreSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
} }
} else null,
icon = {
Icon(
imageVector = moreScreen.selectedIcon,
contentDescription = moreTitle,
modifier = Modifier.scale(moreScale)
)
},
colors = NavigationBarItemDefaults.colors(
indicatorColor = MaterialTheme.colorScheme.primaryContainer,
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
}
}
// Modal Bottom Sheet for More menu
if (showMoreMenu) {
ModalBottomSheet(
onDismissRequest = { showMoreMenu = false },
sheetState = sheetState
) {
MoreBottomSheetContent(
showExperimental = showExperimental,
onItemSelected = { screen ->
scope.launch {
sheetState.hide()
showMoreMenu = false
onItemSelected(screen)
}
}
)
}
}
}
@Composable
private fun MoreBottomSheetContent(
showExperimental: Boolean,
onItemSelected: (Screen) -> Unit
) {
val moreItems = remember(showExperimental) { Screen.getMoreMenuItems(showExperimental) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 32.dp)
) {
Text(
text = stringResource(R.string.label_more),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
)
HorizontalDivider()
moreItems.forEach { screen ->
MoreMenuItem(
screen = screen,
onClick = { onItemSelected(screen) }
)
}
}
}
@Composable
private fun MoreMenuItem(
screen: Screen,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = screen.selectedIcon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = stringResource(screen.title),
style = MaterialTheme.typography.bodyLarge
)
} }
} }

View File

@@ -21,9 +21,9 @@ import androidx.compose.material.icons.filled.AddCircle
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.LocalFireDepartment import androidx.compose.material.icons.filled.LocalFireDepartment
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Psychology import androidx.compose.material.icons.filled.Psychology
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.TrendingUp import androidx.compose.material.icons.filled.TrendingUp
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -42,6 +42,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
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.navigation.NavHostController import androidx.navigation.NavHostController
import eu.gaudian.translator.view.composable.Screen
@Composable @Composable
fun HomeScreen( fun HomeScreen(
@@ -60,7 +61,7 @@ fun HomeScreen(
.padding(horizontal = 20.dp, vertical = 24.dp), .padding(horizontal = 20.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp) verticalArrangement = Arrangement.spacedBy(20.dp)
) { ) {
item { TopProfileSection() } item { TopProfileSection(navController = navController) }
item { StreakAndGoalSection() } item { StreakAndGoalSection() }
item { item {
ActionCard( ActionCard(
@@ -90,7 +91,7 @@ fun HomeScreen(
} }
@Composable @Composable
fun TopProfileSection() { fun TopProfileSection(navController: NavHostController) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@@ -123,14 +124,14 @@ fun TopProfileSection() {
} }
IconButton( IconButton(
onClick = { /* TODO: Open notifications */ }, onClick = { navController.navigate(Screen.Settings.route) },
modifier = Modifier modifier = Modifier
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
) { ) {
Icon( Icon(
imageVector = Icons.Default.Notifications, imageVector = Icons.Default.Settings,
contentDescription = "Notifications", contentDescription = "Settings",
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }