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)
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,6 +70,80 @@ private fun AppContent(
drawerState = drawerState,
drawerContent = {
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))
Text(
"Menu",
@@ -72,9 +157,7 @@ private fun AppContent(
icon = { Icon(Icons.Default.Person, contentDescription = null) },
label = { Text("Character Sheet") },
selected = true,
onClick = {
scope.launch { drawerState.close() }
},
onClick = { /* TODO: Navigate */ },
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 4.dp)
.testTag(TestTags.NAV_CHARACTER_SHEET)
@@ -84,37 +167,45 @@ private fun AppContent(
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
label = { Text("Settings") },
selected = false,
onClick = {
scope.launch { drawerState.close() }
// TODO: Navigate to settings
},
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(),
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(
@@ -155,5 +246,4 @@ private fun AppContent(
}
}
}
}
}

View File

@@ -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(" ", "_")}"

View File

@@ -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