From 34df198c4df832b1a94c2a296c0462cfd1cfd2b6 Mon Sep 17 00:00:00 2001
From: M57 <41645758+Hy4ri@users.noreply.github.com>
Date: Thu, 18 Jun 2026 21:16:21 +0300
Subject: [PATCH] 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
---
README.md | 8 ---
.../java/com/m57/hermescontrol/Navigation.kt | 44 ++++++++++++--
.../m57/hermescontrol/NavigationController.kt | 20 +++++--
.../hermescontrol/data/local/AuthManager.kt | 18 ++++++
.../ui/settings/SettingsScreen.kt | 59 +++++++++++++++++++
.../ui/settings/SettingsViewModel.kt | 48 +++++++++++++++
6 files changed, 179 insertions(+), 18 deletions(-)
diff --git a/README.md b/README.md
index b597b6a1..4441c902 100644
--- a/README.md
+++ b/README.md
@@ -295,11 +295,3 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
-
----
-
-
- Built with ❤️ by Hy4ri
-
- Hermes Agent by Nous Research
-
diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt
index a8cc4d53..f727abcb 100644
--- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt
+++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt
@@ -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 =
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 = 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 =
+ 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): List =
+ 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 = BOTTOM_NAV_KEYS + DRAWER_ENTRIES.map { it.key }.toSet()
+private val DRAWER_GESTURE_SCREENS: Set = 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) },
diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt
index 6785930f..378417e8 100644
--- a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt
+++ b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt
@@ -17,9 +17,11 @@ import androidx.navigation3.runtime.NavKey
object NavigationController {
var backStack: NavBackStack? = null
- // Bottom-nav primary screens — tapping one clears the stack to root.
- private val primaryScreens: Set =
- 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 =
+ 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) {
+ 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)
diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt
index b2049be1..fc399fd9 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt
@@ -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 {
+ 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) {
+ requirePrefs().edit().putString(KEY_BOTTOM_NAV_ITEMS, items.joinToString(",")).apply()
+ }
}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt
index 04fc9ab4..e5c2ef14 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt
@@ -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,
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt
index 5b73c363..bd3ff509 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt
@@ -22,6 +22,7 @@ data class SettingsUiState(
val isTesting: Boolean = false,
val testResult: String? = null,
val isSaved: Boolean = false,
+ val selectedNavItems: List = 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> =
+ 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) {
+ _uiState.update { it.copy(selectedNavItems = items) }
+ AuthManager.setBottomNavItems(items)
+ }
+
fun save() {
val state = _uiState.value
val port = state.port.toIntOrNull() ?: 9119