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

@@ -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