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:
@@ -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(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(" ", "_")}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user