From 39105ebfbfa2887f9abb4774f4f83a8eaadc008f Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Fri, 13 Mar 2026 13:18:48 +0100 Subject: [PATCH] feat: page routing with NavHost and NavController (Closes #20) Introduce NavHost with routes "character_sheet" and "settings". Navigation items in drawer, rail, and permanent drawer now call navController.navigate() with popUpTo/launchSingleTop for proper back navigation. Current page is highlighted in all navigation modes. Added AppRoutes constants and SettingsPage placeholder composable. Co-Authored-By: Claude Opus 4.6 --- .../kotlin/org/shahondin1624/App.kt | 115 ++++++++++++++---- .../lib/components/settings/Settings.kt | 30 +++++ .../org/shahondin1624/navigation/AppRoutes.kt | 9 ++ 3 files changed, 127 insertions(+), 27 deletions(-) create mode 100644 sharedUI/src/commonMain/kotlin/org/shahondin1624/navigation/AppRoutes.kt diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt index bd9e085..68e98c1 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt @@ -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() + } } } } diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/Settings.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/Settings.kt index 7c747f5..9f18e25 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/Settings.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/Settings.kt @@ -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 + ) + } +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/navigation/AppRoutes.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/navigation/AppRoutes.kt new file mode 100644 index 0000000..2a2ccff --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/navigation/AppRoutes.kt @@ -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" +} -- 2.49.1