From 173c4db33817c52150cc552ef184f859d8ef70fe Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Fri, 13 Mar 2026 13:16:44 +0100 Subject: [PATCH] feat: adaptive navigation based on window size class (Closes #19) (#50) --- .../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