feat: page routing with NavHost and NavController (Closes #20) (#51)

This commit was merged in pull request #51.
This commit is contained in:
2026-03-13 13:19:06 +01:00
parent 173c4db338
commit 5e4a95f54f
3 changed files with 127 additions and 27 deletions

View File

@@ -13,18 +13,23 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.launch
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.shahondin1624.lib.components.TestTags
import org.shahondin1624.lib.components.UiConstants
import org.shahondin1624.lib.components.charactermodel.attributespage.AttributesPage
import org.shahondin1624.lib.components.settings.SettingsPage
import org.shahondin1624.model.EXAMPLE_CHARACTER
import org.shahondin1624.navigation.AppRoutes
import org.shahondin1624.theme.AppTheme
import org.shahondin1624.theme.LocalThemeIsDark
import org.shahondin1624.theme.LocalWindowSizeClass
@@ -40,20 +45,44 @@ fun App(
val windowSizeClass = WindowSizeClass.fromWidth(maxWidth)
CompositionLocalProvider(LocalWindowSizeClass provides windowSizeClass) {
AppContent()
val navController = rememberNavController()
AppContent(navController)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AppContent() {
private fun AppContent(navController: NavHostController) {
val windowSizeClass = LocalWindowSizeClass.current
when (windowSizeClass) {
WindowSizeClass.Compact -> CompactNavigation()
WindowSizeClass.Medium -> MediumNavigation()
WindowSizeClass.Expanded -> ExpandedNavigation()
WindowSizeClass.Compact -> CompactNavigation(navController)
WindowSizeClass.Medium -> MediumNavigation(navController)
WindowSizeClass.Expanded -> ExpandedNavigation(navController)
}
}
/**
* Returns the current route from the NavController's back stack.
*/
@Composable
private fun currentRoute(navController: NavHostController): String? {
val navBackStackEntry by navController.currentBackStackEntryAsState()
return navBackStackEntry?.destination?.route
}
/**
* Navigate to a route, popping back to the start destination to avoid
* building up a deep back stack.
*/
private fun navigateTo(navController: NavHostController, route: String) {
navController.navigate(route) {
popUpTo(AppRoutes.CHARACTER_SHEET) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
@@ -62,19 +91,27 @@ private fun AppContent() {
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CompactNavigation() {
private fun CompactNavigation(navController: NavHostController) {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
val currentRoute = currentRoute(navController)
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
DrawerContent()
DrawerContent(
currentRoute = currentRoute,
onNavigate = { route ->
navigateTo(navController, route)
scope.launch { drawerState.close() }
}
)
}
}
) {
MainScaffold(
navController = navController,
showMenuButton = true,
onMenuClick = {
scope.launch {
@@ -90,7 +127,9 @@ private fun CompactNavigation() {
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MediumNavigation() {
private fun MediumNavigation(navController: NavHostController) {
val currentRoute = currentRoute(navController)
Row(modifier = Modifier.fillMaxSize()) {
NavigationRail(
modifier = Modifier.testTag(TestTags.NAV_RAIL)
@@ -99,19 +138,20 @@ private fun MediumNavigation() {
NavigationRailItem(
icon = { Icon(Icons.Default.Person, contentDescription = null) },
label = { Text("Character") },
selected = true,
onClick = { /* TODO: Navigate */ },
selected = currentRoute == AppRoutes.CHARACTER_SHEET,
onClick = { navigateTo(navController, AppRoutes.CHARACTER_SHEET) },
modifier = Modifier.testTag(TestTags.NAV_CHARACTER_SHEET)
)
NavigationRailItem(
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
label = { Text("Settings") },
selected = false,
onClick = { /* TODO: Navigate to settings */ },
selected = currentRoute == AppRoutes.SETTINGS,
onClick = { navigateTo(navController, AppRoutes.SETTINGS) },
modifier = Modifier.testTag(TestTags.NAV_SETTINGS)
)
}
MainScaffold(
navController = navController,
showMenuButton = false,
onMenuClick = {},
modifier = Modifier.weight(1f)
@@ -124,15 +164,21 @@ private fun MediumNavigation() {
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ExpandedNavigation() {
private fun ExpandedNavigation(navController: NavHostController) {
val currentRoute = currentRoute(navController)
PermanentNavigationDrawer(
drawerContent = {
PermanentDrawerSheet(modifier = Modifier.testTag(TestTags.NAV_PERMANENT_DRAWER)) {
DrawerContent()
DrawerContent(
currentRoute = currentRoute,
onNavigate = { route -> navigateTo(navController, route) }
)
}
}
) {
MainScaffold(
navController = navController,
showMenuButton = false,
onMenuClick = {}
)
@@ -143,7 +189,10 @@ private fun ExpandedNavigation() {
* Shared drawer content used by Compact (ModalDrawerSheet) and Expanded (PermanentDrawerSheet).
*/
@Composable
private fun DrawerContent() {
private fun DrawerContent(
currentRoute: String?,
onNavigate: (String) -> Unit
) {
Spacer(Modifier.height(16.dp))
Text(
"Menu",
@@ -156,8 +205,8 @@ private fun DrawerContent() {
NavigationDrawerItem(
icon = { Icon(Icons.Default.Person, contentDescription = null) },
label = { Text("Character Sheet") },
selected = true,
onClick = { /* TODO: Navigate */ },
selected = currentRoute == AppRoutes.CHARACTER_SHEET,
onClick = { onNavigate(AppRoutes.CHARACTER_SHEET) },
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 4.dp)
.testTag(TestTags.NAV_CHARACTER_SHEET)
@@ -166,8 +215,8 @@ private fun DrawerContent() {
NavigationDrawerItem(
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
label = { Text("Settings") },
selected = false,
onClick = { /* TODO: Navigate to settings */ },
selected = currentRoute == AppRoutes.SETTINGS,
onClick = { onNavigate(AppRoutes.SETTINGS) },
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 4.dp)
.testTag(TestTags.NAV_SETTINGS)
@@ -175,8 +224,9 @@ private fun DrawerContent() {
}
/**
* Main scaffold with top app bar and content area.
* Main scaffold with top app bar and routed content area.
*
* @param navController the NavHostController for page routing
* @param showMenuButton whether to show the hamburger menu icon (hidden when nav is persistent)
* @param onMenuClick callback for hamburger menu button click
* @param modifier optional modifier (e.g., weight in Row for Medium layout)
@@ -184,11 +234,11 @@ private fun DrawerContent() {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MainScaffold(
navController: NavHostController,
showMenuButton: Boolean,
onMenuClick: () -> Unit,
modifier: Modifier = Modifier
) {
val character = EXAMPLE_CHARACTER
var isDark by LocalThemeIsDark.current
Scaffold(
@@ -235,14 +285,25 @@ private fun MainScaffold(
.windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.TopCenter
) {
Column(
NavHost(
navController = navController,
startDestination = AppRoutes.CHARACTER_SHEET,
modifier = Modifier
.widthIn(max = UiConstants.MAX_CONTENT_WIDTH)
.fillMaxWidth()
.padding(contentPadding),
horizontalAlignment = Alignment.CenterHorizontally
.padding(contentPadding)
) {
AttributesPage(character)
composable(AppRoutes.CHARACTER_SHEET) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
AttributesPage(EXAMPLE_CHARACTER)
}
}
composable(AppRoutes.SETTINGS) {
SettingsPage()
}
}
}
}

View File

@@ -1,2 +1,32 @@
package org.shahondin1624.lib.components.settings
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
/**
* Placeholder settings page.
* Will be expanded in story 5.3 (Theme Selection).
*/
@Composable
fun SettingsPage() {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.testTag("settings_page"),
contentAlignment = Alignment.Center
) {
Text(
text = "Settings",
style = MaterialTheme.typography.headlineMedium
)
}
}

View File

@@ -0,0 +1,9 @@
package org.shahondin1624.navigation
/**
* Route definitions for the app's navigation graph.
*/
object AppRoutes {
const val CHARACTER_SHEET = "character_sheet"
const val SETTINGS = "settings"
}