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