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