feat: adaptive navigation based on window size class (Closes #19) #50

Merged
shahondin1624 merged 1 commits from feature/issue-19-adaptive-navigation into main 2026-03-13 13:16:44 +01:00
3 changed files with 186 additions and 90 deletions
Showing only changes of commit e1320b05fd - Show all commits

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