feat: configurable bottom navigation bar (Issue #23)

## What
Adds a new Navigation Bar section to Settings where users can customize which items appear in the bottom navigation bar.

## Features
- Pick from any of the 19 drawer screens (max 5 items)
- Remove items with a close/delete button
- Items persist via SharedPreferences (CSV format)
- Invalid names are silently dropped
- Existing screens and auth-aware filtering respected

Closes #23
This commit is contained in:
M57 authored and GitHub committed 2026-06-18 21:16:21 +03:00
1 parent 837678c590
commit 34df198c4d
6 files changed
+179 -18

No files matched your search

@@ -40,7 +40,9 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@@ -73,7 +75,7 @@ import com.m57.hermescontrol.ui.system.SystemScreen as SystemScreenContent
import com.m57.hermescontrol.ui.toolsets.ToolsetsScreen as ToolsetsScreenContent
import com.m57.hermescontrol.ui.webhooks.WebhooksScreen as WebhooksScreenContent
// ── Bottom-nav primary screens ─────────────────────────────────────────
// ── Bottom-nav item model ──────────────────────────────────────────────
private data class BottomNavItem(
val key: NavKey,
@@ -81,16 +83,37 @@ private data class BottomNavItem(
val icon: ImageVector,
)
private val BOTTOM_NAV_ITEMS =
/** Master list of ALL screens available for bottom-nav selection. */
private val ALL_NAV_ITEMS: List<BottomNavItem> =
listOf(
BottomNavItem(ChatScreen, "Chat", Icons.AutoMirrored.Filled.Chat),
BottomNavItem(SkillsScreen, "Skills", Icons.Filled.Extension),
BottomNavItem(CronJobsScreen, "Cron", Icons.Filled.Schedule),
BottomNavItem(SystemScreen, "System", Icons.Filled.Info),
BottomNavItem(SettingsScreen, "Settings", Icons.Filled.Settings),
BottomNavItem(ProfilesScreen, "Profiles", Icons.Filled.AccountCircle),
BottomNavItem(WebhooksScreen, "Webhooks", Icons.Filled.Webhook),
BottomNavItem(GatewayScreen, "Gateway", Icons.Filled.Bolt),
BottomNavItem(ToolsetsScreen, "Toolsets", Icons.Filled.Build),
BottomNavItem(PluginsScreen, "Plugins", Icons.Filled.Memory),
BottomNavItem(ConfigScreen, "Config", Icons.Filled.Code),
BottomNavItem(McpServersScreen, "MCP Servers", Icons.Filled.Dashboard),
BottomNavItem(ModelScreen, "Models", Icons.Filled.Settings),
BottomNavItem(PairingScreen, "Pairing", Icons.Filled.Devices),
BottomNavItem(KeysScreen, "Keys", Icons.Filled.Key),
BottomNavItem(ChannelsScreen, "Channels", Icons.Filled.ListAlt),
BottomNavItem(LogsScreen, "Logs", Icons.Filled.HistoryEdu),
BottomNavItem(KanbanScreen, "Kanban", Icons.Filled.Dashboard),
BottomNavItem(AchievementsScreen, "Achievements", Icons.Filled.Info),
)
private val BOTTOM_NAV_KEYS: Set<NavKey> = BOTTOM_NAV_ITEMS.map { it.key }.toSet()
/** Lookup: data-object simple name → NavKey (used by bottom-nav config). */
private val NAV_KEY_BY_NAME: Map<String, NavKey> =
ALL_NAV_ITEMS.associate { it.key::class.simpleName!! to it.key }
/** Resolve a persisted list of nav-item names to BottomNavItem instances. */
private fun resolveBottomNavItems(names: List<String>): List<BottomNavItem> =
names.mapNotNull { name -> NAV_KEY_BY_NAME[name]?.let { key -> ALL_NAV_ITEMS.first { it.key == key } } }
// ── Drawer sections for secondary screens ──────────────────────────────
@@ -130,7 +153,7 @@ private val DRAWER_ENTRIES =
DrawerEntry(AchievementsScreen, "Achievements", Icons.Filled.Info, DrawerSection.INSPECT),
)
private val DRAWER_GESTURE_SCREENS: Set<NavKey> = BOTTOM_NAV_KEYS + DRAWER_ENTRIES.map { it.key }.toSet()
private val DRAWER_GESTURE_SCREENS: Set<NavKey> = ALL_NAV_ITEMS.map { it.key }.toSet()
@Composable
fun MainNavigation() {
@@ -144,7 +167,16 @@ fun MainNavigation() {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
val isPrimaryScreen = currentScreen in BOTTOM_NAV_KEYS
// Read dynamic bottom-nav config
val bottomNavItems = remember { resolveBottomNavItems(AuthManager.getBottomNavItems()) }
val bottomNavKeys = remember(bottomNavItems) { bottomNavItems.map { it.key }.toSet() }
// Sync primary screens to NavigationController
LaunchedEffect(bottomNavKeys) {
NavigationController.updatePrimaryScreens(bottomNavKeys)
}
val isPrimaryScreen = currentScreen in bottomNavKeys
val gesturesEnabled = currentScreen in DRAWER_GESTURE_SCREENS
val openDrawer = { scope.launch { drawerState.open() } }
@@ -222,7 +254,7 @@ fun MainNavigation() {
bottomBar = {
if (isPrimaryScreen) {
NavigationBar {
BOTTOM_NAV_ITEMS.forEach { item ->
bottomNavItems.forEach { item ->
NavigationBarItem(
selected = currentScreen == item.key,
onClick = { NavigationController.navigateTo(item.key) },