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 <noreply@anthropic.com>
This commit is contained in:
shahondin1624
2026-03-13 13:16:25 +01:00
parent f88f71e135
commit e1320b05fd
3 changed files with 186 additions and 90 deletions

View File

@@ -40,18 +40,29 @@ fun App(
val windowSizeClass = WindowSizeClass.fromWidth(maxWidth) val windowSizeClass = WindowSizeClass.fromWidth(maxWidth)
CompositionLocalProvider(LocalWindowSizeClass provides windowSizeClass) { CompositionLocalProvider(LocalWindowSizeClass provides windowSizeClass) {
AppContent(onThemeChanged = {}) AppContent()
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun AppContent( private fun AppContent() {
onThemeChanged: @Composable (isDark: Boolean) -> Unit = {} val windowSizeClass = LocalWindowSizeClass.current
) {
val character = EXAMPLE_CHARACTER when (windowSizeClass) {
var isDark by LocalThemeIsDark.current 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 drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -59,6 +70,80 @@ private fun AppContent(
drawerState = drawerState, drawerState = drawerState,
drawerContent = { drawerContent = {
ModalDrawerSheet { ModalDrawerSheet {
DrawerContent()
}
}
) {
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)) Spacer(Modifier.height(16.dp))
Text( Text(
"Menu", "Menu",
@@ -72,9 +157,7 @@ private fun AppContent(
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 = true,
onClick = { onClick = { /* TODO: Navigate */ },
scope.launch { drawerState.close() }
},
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)
@@ -84,37 +167,45 @@ private fun AppContent(
icon = { Icon(Icons.Default.Settings, contentDescription = null) }, icon = { Icon(Icons.Default.Settings, contentDescription = null) },
label = { Text("Settings") }, label = { Text("Settings") },
selected = false, selected = false,
onClick = { onClick = { /* TODO: Navigate to settings */ },
scope.launch { drawerState.close() }
// TODO: Navigate to 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)
) )
} }
}
) { /**
* 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( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Shadowrun Character Sheet") }, title = { Text("Shadowrun Character Sheet") },
modifier = Modifier.testTag(TestTags.TOP_APP_BAR),
navigationIcon = { navigationIcon = {
if (showMenuButton) {
IconButton( IconButton(
onClick = { onClick = onMenuClick,
scope.launch {
if (drawerState.isClosed) {
drawerState.open()
} else {
drawerState.close()
}
}
},
modifier = Modifier.testTag(TestTags.MENU_BUTTON) modifier = Modifier.testTag(TestTags.MENU_BUTTON)
) { ) {
Icon(Icons.Default.Menu, contentDescription = "Menu") Icon(Icons.Default.Menu, contentDescription = "Menu")
} }
}
}, },
actions = { actions = {
IconButton( IconButton(
@@ -155,5 +246,4 @@ private fun AppContent(
} }
} }
} }
}
} }

View File

@@ -24,6 +24,8 @@ object TestTags {
// --- Navigation items --- // --- Navigation items ---
const val NAV_CHARACTER_SHEET = "nav_character_sheet" const val NAV_CHARACTER_SHEET = "nav_character_sheet"
const val NAV_SETTINGS = "nav_settings" const val NAV_SETTINGS = "nav_settings"
const val NAV_RAIL = "nav_rail"
const val NAV_PERMANENT_DRAWER = "nav_permanent_drawer"
// --- Dice / roll buttons --- // --- Dice / roll buttons ---
fun rollButton(name: String): String = "roll_button_${name.lowercase().replace(" ", "_")}" fun rollButton(name: String): String = "roll_button_${name.lowercase().replace(" ", "_")}"

View File

@@ -30,6 +30,8 @@ class TestTagsTest {
fun navTagsAreDefined() { fun navTagsAreDefined() {
assertEquals("nav_character_sheet", TestTags.NAV_CHARACTER_SHEET) assertEquals("nav_character_sheet", TestTags.NAV_CHARACTER_SHEET)
assertEquals("nav_settings", TestTags.NAV_SETTINGS) assertEquals("nav_settings", TestTags.NAV_SETTINGS)
assertEquals("nav_rail", TestTags.NAV_RAIL)
assertEquals("nav_permanent_drawer", TestTags.NAV_PERMANENT_DRAWER)
} }
@Test @Test
@@ -49,6 +51,8 @@ class TestTagsTest {
TestTags.PANEL_DAMAGE_MONITOR, TestTags.PANEL_DAMAGE_MONITOR,
TestTags.NAV_CHARACTER_SHEET, TestTags.NAV_CHARACTER_SHEET,
TestTags.NAV_SETTINGS, TestTags.NAV_SETTINGS,
TestTags.NAV_RAIL,
TestTags.NAV_PERMANENT_DRAWER,
TestTags.TOP_APP_BAR, TestTags.TOP_APP_BAR,
TestTags.THEME_TOGGLE, TestTags.THEME_TOGGLE,
TestTags.MENU_BUTTON TestTags.MENU_BUTTON