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

This commit was merged in pull request #50.
This commit is contained in:
2026-03-13 13:16:44 +01:00
parent f88f71e135
commit 173c4db338
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,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)
}
}
}

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