From e1320b05fd02082524d43c8e7fc94e63814d8559 Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Fri, 13 Mar 2026 13:16:25 +0100 Subject: [PATCH] feat: adaptive navigation based on window size class (Closes #19) Compact uses ModalNavigationDrawer with hamburger button, Medium uses NavigationRail, Expanded uses PermanentNavigationDrawer. Hamburger icon is hidden when navigation is permanently visible. Extracted shared DrawerContent and MainScaffold composables for reuse across navigation modes. Added NAV_RAIL and NAV_PERMANENT_DRAWER test tags. Co-Authored-By: Claude Opus 4.6 --- .../kotlin/org/shahondin1624/App.kt | 270 ++++++++++++------ .../shahondin1624/lib/components/TestTags.kt | 2 + .../kotlin/org/shahondin1624/TestTagsTest.kt | 4 + 3 files changed, 186 insertions(+), 90 deletions(-) diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt index 29511d7..bd9e085 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt @@ -40,18 +40,29 @@ fun App( val windowSizeClass = WindowSizeClass.fromWidth(maxWidth) CompositionLocalProvider(LocalWindowSizeClass provides windowSizeClass) { - AppContent(onThemeChanged = {}) + AppContent() } } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun AppContent( - onThemeChanged: @Composable (isDark: Boolean) -> Unit = {} -) { - val character = EXAMPLE_CHARACTER - var isDark by LocalThemeIsDark.current +private fun AppContent() { + val windowSizeClass = LocalWindowSizeClass.current + + when (windowSizeClass) { + WindowSizeClass.Compact -> CompactNavigation() + WindowSizeClass.Medium -> MediumNavigation() + WindowSizeClass.Expanded -> ExpandedNavigation() + } +} + +/** + * Compact layout: ModalNavigationDrawer with hamburger menu button. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CompactNavigation() { val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() @@ -59,100 +70,179 @@ private fun AppContent( drawerState = drawerState, drawerContent = { ModalDrawerSheet { - Spacer(Modifier.height(16.dp)) - Text( - "Menu", - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.titleLarge - ) - HorizontalDivider() - Spacer(Modifier.height(8.dp)) - - NavigationDrawerItem( - icon = { Icon(Icons.Default.Person, contentDescription = null) }, - label = { Text("Character Sheet") }, - selected = true, - onClick = { - scope.launch { drawerState.close() } - }, - modifier = Modifier - .padding(horizontal = 12.dp, vertical = 4.dp) - .testTag(TestTags.NAV_CHARACTER_SHEET) - ) - - NavigationDrawerItem( - icon = { Icon(Icons.Default.Settings, contentDescription = null) }, - label = { Text("Settings") }, - selected = false, - onClick = { - scope.launch { drawerState.close() } - // TODO: Navigate to settings - }, - modifier = Modifier - .padding(horizontal = 12.dp, vertical = 4.dp) - .testTag(TestTags.NAV_SETTINGS) - ) + DrawerContent() } } ) { - Scaffold( - modifier = Modifier.fillMaxSize(), - topBar = { - TopAppBar( - title = { Text("Shadowrun Character Sheet") }, - navigationIcon = { + MainScaffold( + showMenuButton = true, + onMenuClick = { + scope.launch { + if (drawerState.isClosed) drawerState.open() else drawerState.close() + } + } + ) + } +} + +/** + * Medium layout: NavigationRail on the left side with main content. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MediumNavigation() { + Row(modifier = Modifier.fillMaxSize()) { + NavigationRail( + modifier = Modifier.testTag(TestTags.NAV_RAIL) + ) { + Spacer(Modifier.height(8.dp)) + NavigationRailItem( + icon = { Icon(Icons.Default.Person, contentDescription = null) }, + label = { Text("Character") }, + selected = true, + onClick = { /* TODO: Navigate */ }, + 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 */ }, + modifier = Modifier.testTag(TestTags.NAV_SETTINGS) + ) + } + MainScaffold( + showMenuButton = false, + onMenuClick = {}, + modifier = Modifier.weight(1f) + ) + } +} + +/** + * Expanded layout: PermanentNavigationDrawer always visible. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ExpandedNavigation() { + PermanentNavigationDrawer( + drawerContent = { + PermanentDrawerSheet(modifier = Modifier.testTag(TestTags.NAV_PERMANENT_DRAWER)) { + DrawerContent() + } + } + ) { + MainScaffold( + showMenuButton = false, + onMenuClick = {} + ) + } +} + +/** + * Shared drawer content used by Compact (ModalDrawerSheet) and Expanded (PermanentDrawerSheet). + */ +@Composable +private fun DrawerContent() { + Spacer(Modifier.height(16.dp)) + Text( + "Menu", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.titleLarge + ) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + NavigationDrawerItem( + icon = { Icon(Icons.Default.Person, contentDescription = null) }, + label = { Text("Character Sheet") }, + selected = true, + onClick = { /* TODO: Navigate */ }, + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 4.dp) + .testTag(TestTags.NAV_CHARACTER_SHEET) + ) + + NavigationDrawerItem( + icon = { Icon(Icons.Default.Settings, contentDescription = null) }, + label = { Text("Settings") }, + selected = false, + onClick = { /* TODO: Navigate to settings */ }, + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 4.dp) + .testTag(TestTags.NAV_SETTINGS) + ) +} + +/** + * Main scaffold with top app bar and content area. + * + * @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) + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MainScaffold( + showMenuButton: Boolean, + onMenuClick: () -> Unit, + modifier: Modifier = Modifier +) { + val character = EXAMPLE_CHARACTER + var isDark by LocalThemeIsDark.current + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Shadowrun Character Sheet") }, + modifier = Modifier.testTag(TestTags.TOP_APP_BAR), + navigationIcon = { + if (showMenuButton) { IconButton( - onClick = { - scope.launch { - if (drawerState.isClosed) { - drawerState.open() - } else { - drawerState.close() - } - } - }, + onClick = onMenuClick, modifier = Modifier.testTag(TestTags.MENU_BUTTON) ) { Icon(Icons.Default.Menu, contentDescription = "Menu") } - }, - actions = { - IconButton( - onClick = { isDark = !isDark }, - modifier = Modifier.testTag(TestTags.THEME_TOGGLE) - ) { - Icon( - imageVector = if (isDark) { - Icons.Default.LightMode - } else { - Icons.Default.DarkMode - }, - contentDescription = "Toggle ${if (isDark) "Light" else "Dark"} mode" - ) - } } - ) - } - ) { paddingValues -> - val windowSizeClass = LocalWindowSizeClass.current - val contentPadding = UiConstants.Spacing.large(windowSizeClass) - - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .windowInsetsPadding(WindowInsets.safeDrawing), - contentAlignment = Alignment.TopCenter - ) { - Column( - modifier = Modifier - .widthIn(max = UiConstants.MAX_CONTENT_WIDTH) - .fillMaxWidth() - .padding(contentPadding), - horizontalAlignment = Alignment.CenterHorizontally - ) { - AttributesPage(character) + }, + actions = { + IconButton( + onClick = { isDark = !isDark }, + modifier = Modifier.testTag(TestTags.THEME_TOGGLE) + ) { + Icon( + imageVector = if (isDark) { + Icons.Default.LightMode + } else { + Icons.Default.DarkMode + }, + contentDescription = "Toggle ${if (isDark) "Light" else "Dark"} mode" + ) + } } + ) + } + ) { paddingValues -> + val windowSizeClass = LocalWindowSizeClass.current + val contentPadding = UiConstants.Spacing.large(windowSizeClass) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .windowInsetsPadding(WindowInsets.safeDrawing), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .widthIn(max = UiConstants.MAX_CONTENT_WIDTH) + .fillMaxWidth() + .padding(contentPadding), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AttributesPage(character) } } } diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt index 3690c75..6f0e6f4 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt @@ -24,6 +24,8 @@ object TestTags { // --- Navigation items --- const val NAV_CHARACTER_SHEET = "nav_character_sheet" const val NAV_SETTINGS = "nav_settings" + const val NAV_RAIL = "nav_rail" + const val NAV_PERMANENT_DRAWER = "nav_permanent_drawer" // --- Dice / roll buttons --- fun rollButton(name: String): String = "roll_button_${name.lowercase().replace(" ", "_")}" diff --git a/sharedUI/src/commonTest/kotlin/org/shahondin1624/TestTagsTest.kt b/sharedUI/src/commonTest/kotlin/org/shahondin1624/TestTagsTest.kt index bd4b539..1996155 100644 --- a/sharedUI/src/commonTest/kotlin/org/shahondin1624/TestTagsTest.kt +++ b/sharedUI/src/commonTest/kotlin/org/shahondin1624/TestTagsTest.kt @@ -30,6 +30,8 @@ class TestTagsTest { fun navTagsAreDefined() { assertEquals("nav_character_sheet", TestTags.NAV_CHARACTER_SHEET) assertEquals("nav_settings", TestTags.NAV_SETTINGS) + assertEquals("nav_rail", TestTags.NAV_RAIL) + assertEquals("nav_permanent_drawer", TestTags.NAV_PERMANENT_DRAWER) } @Test @@ -49,6 +51,8 @@ class TestTagsTest { TestTags.PANEL_DAMAGE_MONITOR, TestTags.NAV_CHARACTER_SHEET, TestTags.NAV_SETTINGS, + TestTags.NAV_RAIL, + TestTags.NAV_PERMANENT_DRAWER, TestTags.TOP_APP_BAR, TestTags.THEME_TOGGLE, TestTags.MENU_BUTTON