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:
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
|
||||
|
||||
Reference in new issue
Block a user