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) },
@@ -17,9 +17,11 @@ import androidx.navigation3.runtime.NavKey
object NavigationController {
var backStack: NavBackStack<NavKey>? = null
// Bottom-nav primary screens — tapping one clears the stack to root.
private val primaryScreens: Set<NavKey> =
setOf(
// Bottom-nav primary screens — dynamic, updated by Navigation.kt via
// updatePrimaryScreens() when the user customises the bottom nav bar.
// Default matches the default 5 bottom-nav items.
private val primaryScreens: MutableSet<NavKey> =
mutableSetOf(
ChatScreen,
SkillsScreen,
CronJobsScreen,
@@ -27,11 +29,21 @@ object NavigationController {
SettingsScreen,
)
/** Returns whether the given key is a primary (bottom-nav) screen. */
fun isPrimaryScreen(key: NavKey): Boolean = key in primaryScreens
/** Replace the primary screen set. Called by Navigation.kt when the
* bottom-nav item config changes. */
fun updatePrimaryScreens(keys: Set<NavKey>) {
primaryScreens.clear()
primaryScreens.addAll(keys)
}
fun navigateTo(key: NavKey) {
val stack = backStack ?: return
if (stack.lastOrNull() == key) return
if (key in primaryScreens) {
if (isPrimaryScreen(key)) {
stack.clear()
}
stack.add(key)
@@ -20,6 +20,7 @@ object AuthManager {
private const val KEY_PORT = "port"
private const val KEY_AUTO_RECONNECT = "auto_reconnect"
private const val KEY_THEME_PREFERENCE = "theme_preference"
private const val KEY_BOTTOM_NAV_ITEMS = "bottom_nav_items"
private const val DEFAULT_HOST = "127.0.0.1"
private const val DEFAULT_PORT = 9119
@@ -117,4 +118,21 @@ object AuthManager {
/** Convenience: build the WebSocket URL with token query param.
* NOTE: Token in query string — trusted local network only. */
fun wsUrl(): String = "ws://${getHost()}:${getPort()}/api/ws?token=${getToken().orEmpty()}"
// ── Bottom nav bar items ──────────────────────────────────────────────
/** Default bottom-nav items (NavKey data-object names). */
private val DEFAULT_BOTTOM_NAV_ITEMS =
listOf("ChatScreen", "SkillsScreen", "CronJobsScreen", "SystemScreen", "SettingsScreen")
/** Returns the list of selected bottom-nav item keys (data-object names). */
fun getBottomNavItems(): List<String> {
val raw = requirePrefs().getString(KEY_BOTTOM_NAV_ITEMS, null) ?: return DEFAULT_BOTTOM_NAV_ITEMS
return raw.split(",").filter { it.isNotBlank() }
}
/** Persist the ordered list of bottom-nav item keys (max 5, data-object names). */
fun setBottomNavItems(items: List<String>) {
requirePrefs().edit().putString(KEY_BOTTOM_NAV_ITEMS, items.joinToString(",")).apply()
}
}
@@ -5,6 +5,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -17,6 +18,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
@@ -24,6 +27,8 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -227,6 +232,60 @@ fun SettingsScreen(
}
}
// Navigation Bar section
SectionCard(title = "Navigation Bar") {
Text(
text = "Bottom bar items (${state.selectedNavItems.size}/5)",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(8.dp))
state.selectedNavItems.forEach { name ->
val label = viewModel.availableNavItems.firstOrNull { it.first == name }?.second ?: name
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = label, style = MaterialTheme.typography.bodyMedium)
IconButton(onClick = { viewModel.removeNavItem(name) }) {
Icon(Icons.Filled.Close, contentDescription = "Remove $label")
}
}
}
if (state.selectedNavItems.size < 5) {
val available = viewModel.availableNavItems.filter { it.first !in state.selectedNavItems }
if (available.isNotEmpty()) {
var expanded by remember { mutableStateOf(false) }
Box {
OutlinedButton(onClick = { expanded = true }) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.size(4.dp))
Text("Add Item")
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
available.forEach { (name, label) ->
DropdownMenuItem(
text = { Text(label) },
onClick = {
viewModel.addNavItem(name)
expanded = false
},
)
}
}
}
}
} else {
Text(
text = "Maximum of 5 items",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Test result
AnimatedVisibility(
visible = state.testResult != null,
@@ -22,6 +22,7 @@ data class SettingsUiState(
val isTesting: Boolean = false,
val testResult: String? = null,
val isSaved: Boolean = false,
val selectedNavItems: List<String> = emptyList(),
)
class SettingsViewModel : ViewModel() {
@@ -43,6 +44,7 @@ class SettingsViewModel : ViewModel() {
// choice from persistent storage on init. Previously this slot
// was missing — always defaulted to ThemePreference.SYSTEM.
themePreference = AuthManager.getThemePreference(),
selectedNavItems = AuthManager.getBottomNavItems(),
)
}
}
@@ -67,6 +69,52 @@ class SettingsViewModel : ViewModel() {
_uiState.update { it.copy(themePreference = theme, isSaved = false) }
}
/** All screens available for bottom-nav selection (name → display label). */
val availableNavItems: List<Pair<String, String>> =
listOf(
"ChatScreen" to "Chat",
"SkillsScreen" to "Skills",
"CronJobsScreen" to "Cron Jobs",
"SystemScreen" to "System",
"SettingsScreen" to "Settings",
"ProfilesScreen" to "Profiles",
"WebhooksScreen" to "Webhooks",
"GatewayScreen" to "Gateway",
"ToolsetsScreen" to "Toolsets",
"PluginsScreen" to "Plugins",
"ConfigScreen" to "Config",
"McpServersScreen" to "MCP Servers",
"ModelScreen" to "Models",
"PairingScreen" to "Pairing",
"KeysScreen" to "Keys",
"ChannelsScreen" to "Channels",
"LogsScreen" to "Logs",
"KanbanScreen" to "Kanban",
"AchievementsScreen" to "Achievements",
)
/** Add a nav item to the bottom bar (max 5). Auto-saves. */
fun addNavItem(name: String) {
val current = _uiState.value.selectedNavItems
if (name in current || current.size >= 5) return
val updated = current + name
_uiState.update { it.copy(selectedNavItems = updated) }
AuthManager.setBottomNavItems(updated)
}
/** Remove a nav item from the bottom bar. Auto-saves. */
fun removeNavItem(name: String) {
val updated = _uiState.value.selectedNavItems - name
_uiState.update { it.copy(selectedNavItems = updated) }
AuthManager.setBottomNavItems(updated)
}
/** Reorder the selected nav items. Auto-saves. */
fun reorderNavItems(items: List<String>) {
_uiState.update { it.copy(selectedNavItems = items) }
AuthManager.setBottomNavItems(items)
}
fun save() {
val state = _uiState.value
val port = state.port.toIntOrNull() ?: 9119