refactor: remove bottom navigation bar (#665 #666 #667 #668) (#672)

* refactor(nav): remove bottom NavigationBar from Scaffold (#665)

* refactor(theme): remove BottomNavDisplayMode enum (#666)

* refactor(settings): remove nav bar customization page (#667)

* chore: clean up unused imports after bottom nav removal (#668)

* chore: remove dead bottom nav infrastructure after removal

- Remove SettingsNavBar NavKey (NavigationKeys.kt)
- Remove bottomNavItems field from ServerStoreState + migration code
- Remove _bottomNavItemsFlow, getBottomNavItems, setBottomNavItems from AuthManager
- Remove bottomNavItems handling from selfHealed() (ServerCollectionOps)
- Remove redundant bottomBar={} from Scaffold (Navigation.kt)
- Remove unused nav bar strings from values/strings.xml + values-ko/strings.xml
- Remove content_desc_move_up/down/remove_item strings
- Update SettingsViewModelTest.kt (drop stale getBottomNavItems mock)
- Update ServerStoreTest.kt (remove bottomNavItems assertions)

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-22 10:14:50 +03:00
1 parent c43904b2b8
commit 0edb67338d
17 files changed
-580

No files matched your search

@@ -1,60 +0,0 @@
package com.m57.hermescontrol
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertHeightIsEqualTo
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.theme.BottomNavDisplayMode
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented tests for [Modifier.bottomNavigationHeight].
*
* Each compact bottom-nav display mode maps to a specific bar height:
* - [BottomNavDisplayMode.ICON_ONLY] -> 56.dp (fixed)
* - [BottomNavDisplayMode.TEXT_ONLY] -> 44.dp (fixed)
* - [BottomNavDisplayMode.ICON_AND_TEXT] -> min 80.dp (can grow)
*
* We apply the modifier to a tagged [Box] and assert the resulting height on
* a real Compose host (this requires the instrumentation test runner, hence
* androidTest rather than the Robolectric-free JVM test source set).
*/
@RunWith(AndroidJUnit4::class)
@MediumTest
class BottomNavigationLayoutTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun iconOnly_barHeightIs56dp() {
composeTestRule.setContent {
Box(Modifier.testTag("bar").bottomNavigationHeight(BottomNavDisplayMode.ICON_ONLY))
}
composeTestRule.onNodeWithTag("bar").assertHeightIsEqualTo(56.dp)
}
@Test
fun textOnly_barHeightIs44dp() {
composeTestRule.setContent {
Box(Modifier.testTag("bar").bottomNavigationHeight(BottomNavDisplayMode.TEXT_ONLY))
}
composeTestRule.onNodeWithTag("bar").assertHeightIsEqualTo(44.dp)
}
@Test
fun iconAndText_barHeightIsAtLeast80dp() {
composeTestRule.setContent {
Box(Modifier.testTag("bar").bottomNavigationHeight(BottomNavDisplayMode.ICON_AND_TEXT))
}
// heightIn(min = 80.dp) — assert the measured height meets the minimum.
composeTestRule.onNodeWithTag("bar").assertHeightIsAtLeast(80.dp)
}
}
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -22,9 +21,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
@@ -32,15 +28,12 @@ import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -52,7 +45,6 @@ import androidx.navigation3.ui.NavDisplay
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.DisableDrawerGestures
import com.m57.hermescontrol.ui.common.DrawerGestureController
@@ -62,46 +54,11 @@ import com.m57.hermescontrol.ui.settings.SettingsAppearancePage
import com.m57.hermescontrol.ui.settings.SettingsBehaviorPage
import com.m57.hermescontrol.ui.settings.SettingsChatPage
import com.m57.hermescontrol.ui.settings.SettingsConnectionPage
import com.m57.hermescontrol.ui.settings.SettingsNavBarPage
import com.m57.hermescontrol.ui.settings.SettingsViewModel
import kotlinx.coroutines.launch
import com.m57.hermescontrol.ui.authlogin.AuthLoginScreen as AuthLoginScreenContent
import com.m57.hermescontrol.ui.landing.LandingScreen as LandingScreenContent
// NOTE: if a new Settings drill-down screen is added, its NavKey MUST be added
// here too, or the bottom-nav Settings highlight will drop on that sub-page.
// (Keys are flat data objects, not a sealed hierarchy, so no type-based check
// is possible without a larger refactor — see issue #637.)
private val settingsDestinations: Set<NavKey> =
setOf(
SettingsScreen,
SettingsConnection,
SettingsAppearance,
SettingsChat,
SettingsNavBar,
SettingsBehavior,
SettingsAbout,
)
/**
* Keep Settings highlighted in the bottom nav while one of its drill-down
* sub-pages is visible. Navigation is unaffected — this only maps the
* *selection* key back to the Settings parent so the indicator doesn't drop.
*/
internal fun selectedBottomNavDestination(currentScreen: NavKey): NavKey =
if (currentScreen in settingsDestinations) SettingsScreen else currentScreen
/** Maps a compact bottom-nav display mode to its bar height. */
internal fun Modifier.bottomNavigationHeight(displayMode: BottomNavDisplayMode): Modifier =
when (displayMode) {
BottomNavDisplayMode.ICON_ONLY -> height(56.dp)
BottomNavDisplayMode.TEXT_ONLY -> height(44.dp)
BottomNavDisplayMode.ICON_AND_TEXT -> heightIn(min = 80.dp)
}
private fun resolveBottomNavItems(names: List<String>): List<ScreenDefinition> =
names.mapNotNull { name -> ScreenRegistry.ALL_SCREENS.firstOrNull { it.key::class.simpleName == name } }
private fun appEntryProvider(
sessionId: String?,
openDrawer: () -> Unit,
@@ -159,12 +116,6 @@ private fun appEntryProvider(
viewModel = viewModel { SettingsViewModel() },
)
}
entry<SettingsNavBar> {
SettingsNavBarPage(
onBack = { NavigationController.goBack() },
viewModel = viewModel { SettingsViewModel() },
)
}
entry<SettingsBehavior> {
SettingsBehaviorPage(
onBack = { NavigationController.goBack() },
@@ -189,7 +140,6 @@ fun MainNavigation(sessionId: String? = null) {
NavigationController.backStack = backStack
val currentScreen = backStack.lastOrNull() ?: startScreen
val selectedDestination = selectedBottomNavDestination(currentScreen)
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
@@ -204,19 +154,6 @@ fun MainNavigation(sessionId: String? = null) {
DrawerGestureController(drawerState, scope)
}
val bottomNavItemsState by AuthManager.bottomNavItemsFlow.collectAsState()
val bottomNavDisplayMode by AuthManager.bottomNavDisplayModeFlow.collectAsState()
val bottomNavItems = resolveBottomNavItems(bottomNavItemsState)
val bottomNavKeys = remember(bottomNavItems) { bottomNavItems.mapTo(mutableSetOf()) { it.key } }
LaunchedEffect(bottomNavKeys) {
NavigationController.updatePrimaryScreens(bottomNavKeys)
}
val showBottomBar =
currentScreen != LandingScreen &&
currentScreen != AuthLoginScreen
val openDrawer: () -> Unit = { scope.launch { drawerState.open() } }
CompositionLocalProvider(LocalDrawerGestureController provides gestureController) {
@@ -312,60 +249,6 @@ fun MainNavigation(sessionId: String? = null) {
) {
Scaffold(
contentWindowInsets = WindowInsets.navigationBars,
bottomBar = {
if (showBottomBar) {
NavigationBar(
modifier = Modifier.bottomNavigationHeight(bottomNavDisplayMode),
) {
bottomNavItems.forEach { item ->
val showIcon =
bottomNavDisplayMode == BottomNavDisplayMode.ICON_AND_TEXT ||
bottomNavDisplayMode == BottomNavDisplayMode.ICON_ONLY
val showLabel =
bottomNavDisplayMode == BottomNavDisplayMode.ICON_AND_TEXT ||
bottomNavDisplayMode == BottomNavDisplayMode.TEXT_ONLY
val isSelected = selectedDestination == item.key
NavigationBarItem(
selected = isSelected,
onClick = { NavigationController.navigateTo(item.key) },
colors =
if (bottomNavDisplayMode == BottomNavDisplayMode.TEXT_ONLY) {
NavigationBarItemDefaults.colors(
indicatorColor = Color.Transparent,
)
} else {
NavigationBarItemDefaults.colors()
},
icon = {
if (showIcon) {
Icon(item.icon, contentDescription = stringResource(item.labelRes))
} else {
Text(
text = stringResource(item.labelRes),
style = MaterialTheme.typography.labelMedium,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
)
}
},
label =
if (showLabel && showIcon) {
{ Text(stringResource(item.labelRes)) }
} else {
null
},
modifier =
Modifier.testTag(
"nav_${item.key::class.simpleName?.lowercase()?.removeSuffix(
"screen",
) ?: ""}",
),
)
}
}
}
},
) { paddingValues ->
NavDisplay(
backStack = backStack,
@@ -61,8 +61,6 @@ import kotlinx.serialization.Serializable
@Serializable data object SettingsChat : NavKey
@Serializable data object SettingsNavBar : NavKey
@Serializable data object SettingsBehavior : NavKey
@Serializable data object SettingsAbout : NavKey
@@ -20,17 +20,8 @@ fun ServerStoreState.selfHealed(): ServerStoreState {
val hasActive = connectionProfiles.any { it.id == selectedProfileId }
val newSelected = if (selectedProfileId != null && !hasActive) null else selectedProfileId
val validItems = bottomNavItems.filter { it.isNotBlank() }
val finalBottomNavItems =
if (validItems.isEmpty()) {
listOf("ChatScreen", "SkillsScreen", "CronJobsScreen", "SystemScreen", "SettingsScreen")
} else {
validItems
}
return copy(
selectedProfileId = newSelected,
bottomNavItems = finalBottomNavItems,
)
}
@@ -8,7 +8,6 @@ import androidx.security.crypto.MasterKeys
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.serialization.decodeFromString
@@ -27,8 +26,6 @@ class ServerStoreMigration(
"theme_preference",
"use_dynamic_colors",
"theme_preset",
"bottom_nav_display_mode",
"bottom_nav_items",
"connection_profiles",
"selected_profile_id",
"pinned_models",
@@ -65,20 +62,6 @@ class ServerStoreMigration(
runCatching { ThemePreset.valueOf(themePresetString!!) }
.getOrDefault(ThemePreset.DEFAULT)
val bottomNavDisplayModeString =
prefs.getString(
"bottom_nav_display_mode",
BottomNavDisplayMode.ICON_AND_TEXT.name,
)
val bottomNavDisplayMode =
runCatching { BottomNavDisplayMode.valueOf(bottomNavDisplayModeString!!) }
.getOrDefault(BottomNavDisplayMode.ICON_AND_TEXT)
val bottomNavItemsRaw = prefs.getString("bottom_nav_items", null)
val bottomNavItems =
bottomNavItemsRaw?.split(",")?.filter { it.isNotBlank() }
?: listOf("ChatScreen", "SkillsScreen", "CronJobsScreen", "SystemScreen", "SettingsScreen")
val connectionProfilesRaw = prefs.getString("connection_profiles", null)
val connectionProfiles =
if (connectionProfilesRaw != null) {
@@ -117,8 +100,6 @@ class ServerStoreMigration(
themePreference = themePreference,
useDynamicColors = useDynamicColors,
themePreset = themePreset,
bottomNavDisplayMode = bottomNavDisplayMode,
bottomNavItems = bottomNavItems,
connectionProfiles =
connectionProfiles.map { profile ->
profile.copy(
@@ -1,7 +1,6 @@
package com.m57.hermescontrol.data.config
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.serialization.Serializable
@@ -16,9 +15,6 @@ data class ServerStoreState(
val themePreference: ThemePreference = ThemePreference.SYSTEM,
val useDynamicColors: Boolean = true,
val themePreset: ThemePreset = ThemePreset.DEFAULT,
val bottomNavDisplayMode: BottomNavDisplayMode = BottomNavDisplayMode.ICON_AND_TEXT,
val bottomNavItems: List<String> =
listOf("ChatScreen", "SkillsScreen", "CronJobsScreen", "SystemScreen", "SettingsScreen"),
val connectionProfiles: List<ConnectionProfile> = emptyList(),
val selectedProfileId: String? = null,
val pinnedModels: List<PinnedModel> = emptyList(),
@@ -16,7 +16,6 @@ import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.coroutines.CoroutineScope
@@ -61,9 +60,6 @@ object AuthManager {
"AuthManager not initialized. Call init(context) first.",
)
private val _bottomNavItemsFlow = MutableStateFlow<List<String>>(emptyList())
val bottomNavItemsFlow: StateFlow<List<String>> = _bottomNavItemsFlow.asStateFlow()
private val _themePreferenceFlow = MutableStateFlow<ThemePreference>(ThemePreference.SYSTEM)
val themePreferenceFlow: StateFlow<ThemePreference> = _themePreferenceFlow.asStateFlow()
@@ -73,9 +69,6 @@ object AuthManager {
private val _themePresetFlow = MutableStateFlow<ThemePreset>(ThemePreset.DEFAULT)
val themePresetFlow: StateFlow<ThemePreset> = _themePresetFlow.asStateFlow()
private val _bottomNavDisplayModeFlow = MutableStateFlow<BottomNavDisplayMode>(BottomNavDisplayMode.ICON_AND_TEXT)
val bottomNavDisplayModeFlow: StateFlow<BottomNavDisplayMode> = _bottomNavDisplayModeFlow.asStateFlow()
private val _tokenFlow = MutableStateFlow<String?>(null)
val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow()
@@ -133,11 +126,9 @@ object AuthManager {
scope.launch {
store.stateFlow.collect { state ->
_bottomNavItemsFlow.value = state.bottomNavItems
_themePreferenceFlow.value = state.themePreference
_useDynamicColorsFlow.value = state.useDynamicColors
_themePresetFlow.value = state.themePreset
_bottomNavDisplayModeFlow.value = state.bottomNavDisplayMode
// B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile.
syncCookieStoreForProfile(state.selectedProfileId)
}
@@ -512,16 +503,6 @@ object AuthManager {
)
}
// ── Bottom nav bar items ──────────────────────────────────────────────
/** Returns the list of selected bottom-nav item keys (data-object names). */
fun getBottomNavItems(): List<String> = serverStore.getLatestState().bottomNavItems
/** Persist the ordered list of bottom-nav item keys (max 5, data-object names). */
fun setBottomNavItems(items: List<String>) {
serverStore.update { it.copy(bottomNavItems = items) }
}
// ── Typing Effect ───────────────────────────────────────────────────
fun isTypingEffectEnabled(): Boolean = serverStore.getLatestState().typingEffectEnabled
@@ -544,12 +525,4 @@ object AuthManager {
fun setTypingEffectDelayMs(delayMs: Int) {
serverStore.update { it.copy(typingEffectDelayMs = delayMs) }
}
// ── Bottom Nav Display Mode ──────────────────────────────────────────
fun getBottomNavDisplayMode(): BottomNavDisplayMode = serverStore.getLatestState().bottomNavDisplayMode
fun setBottomNavDisplayMode(mode: BottomNavDisplayMode) {
serverStore.update { it.copy(bottomNavDisplayMode = mode) }
}
}
@@ -41,9 +41,6 @@ enum class ThemePreference { SYSTEM, LIGHT, DARK }
@Serializable
enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NEON_NOIR }
@Serializable
enum class BottomNavDisplayMode { ICON_AND_TEXT, ICON_ONLY, TEXT_ONLY }
val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT }
@@ -11,7 +11,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.automirrored.filled.ListAlt
import androidx.compose.material.icons.filled.ChatBubbleOutline
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Tune
@@ -40,7 +39,6 @@ import com.m57.hermescontrol.SettingsAppearance
import com.m57.hermescontrol.SettingsBehavior
import com.m57.hermescontrol.SettingsChat
import com.m57.hermescontrol.SettingsConnection
import com.m57.hermescontrol.SettingsNavBar
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
@@ -100,11 +98,6 @@ fun SettingsScreen(
},
onClick = { NavigationController.navigateTo(SettingsChat) },
),
SettingsRow(
icon = Icons.Filled.Dashboard,
label = stringResource(R.string.settings_sec_nav_bar),
onClick = { NavigationController.navigateTo(SettingsNavBar) },
),
SettingsRow(
icon = Icons.Filled.Tune,
label = stringResource(R.string.settings_sec_behavior),
@@ -31,7 +31,6 @@ import com.m57.hermescontrol.ui.settings.components.AppearanceSection
import com.m57.hermescontrol.ui.settings.components.BehaviorSection
import com.m57.hermescontrol.ui.settings.components.ChatSection
import com.m57.hermescontrol.ui.settings.components.ConnectionSection
import com.m57.hermescontrol.ui.settings.components.NavBarSection
import com.m57.hermescontrol.ui.settings.components.TestConnectionButton
import com.m57.hermescontrol.ui.settings.components.TestResultCard
@@ -165,39 +164,6 @@ internal fun SettingsChatPage(
}
}
@Composable
internal fun SettingsNavBarPage(
onBack: () -> Unit,
viewModel: SettingsViewModel = viewModel { SettingsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
HermesScaffold(
title = { Text(stringResource(R.string.settings_sec_nav_bar)) },
navigationIcon = NavIcon.Back(onBack),
drawerGesturesEnabled = false,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
NavBarSection(
bottomNavDisplayMode = state.bottomNavDisplayMode,
onBottomNavDisplayModeChange = viewModel::onBottomNavDisplayModeChange,
selectedNavItems = state.selectedNavItems,
availableNavItems = viewModel.availableNavItems,
onReorderNavItems = viewModel::reorderNavItems,
onRemoveNavItem = viewModel::removeNavItem,
onAddNavItem = viewModel::addNavItem,
)
}
}
}
@Composable
internal fun SettingsBehaviorPage(
onBack: () -> Unit,
@@ -2,7 +2,6 @@ package com.m57.hermescontrol.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.ScreenRegistry
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.config.resolveBaseUrl
import com.m57.hermescontrol.data.local.AuthManager
@@ -12,7 +11,6 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.coroutines.CoroutineDispatcher
@@ -35,13 +33,11 @@ data class SettingsUiState(
val isTesting: Boolean = false,
val testResult: String? = null,
val isSaved: Boolean = false,
val selectedNavItems: List<String> = emptyList(),
val typingEffectEnabled: Boolean = false,
val typingEffectDelayMs: Int = 30,
val profiles: List<ConnectionProfile> = emptyList(),
val selectedProfileId: String? = null,
val renameProfileName: String = "",
val bottomNavDisplayMode: BottomNavDisplayMode = BottomNavDisplayMode.ICON_AND_TEXT,
val appLanguage: String = "system",
// Profile add/edit dialog
val showProfileDialog: Boolean = false,
@@ -77,11 +73,9 @@ class SettingsViewModel(
val themePreference = AuthManager.getThemePreference()
val useDynamicColors = AuthManager.isUseDynamicColors()
val themePreset = AuthManager.getThemePreset()
val selectedNavItems = AuthManager.getBottomNavItems()
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
val profiles = AuthManager.getConnectionProfiles()
val bottomNavDisplayMode = AuthManager.getBottomNavDisplayMode()
val appLanguage = AuthManager.getAppLanguage()
val renameProfileName =
profiles.firstOrNull { p -> p.id == selectedId }?.name ?: ""
@@ -98,13 +92,11 @@ class SettingsViewModel(
themePreference = themePreference,
useDynamicColors = useDynamicColors,
themePreset = themePreset,
selectedNavItems = selectedNavItems,
typingEffectEnabled = typingEffectEnabled,
typingEffectDelayMs = typingEffectDelayMs,
profiles = profiles,
selectedProfileId = selectedId,
renameProfileName = renameProfileName,
bottomNavDisplayMode = bottomNavDisplayMode,
appLanguage = appLanguage,
)
}
@@ -312,11 +304,6 @@ class SettingsViewModel(
AuthManager.setAppLanguage(code)
}
fun onBottomNavDisplayModeChange(mode: BottomNavDisplayMode) {
_uiState.update { it.copy(bottomNavDisplayMode = mode, isSaved = false) }
AuthManager.setBottomNavDisplayMode(mode)
}
fun onTypingEffectEnabledChange(enabled: Boolean) {
_uiState.update { it.copy(typingEffectEnabled = enabled, isSaved = false) }
AuthManager.setTypingEffectEnabled(enabled)
@@ -336,34 +323,6 @@ class SettingsViewModel(
// Don't rebuild ApiClient here — let the navigation complete first
}
/** All screens available for bottom-nav selection (name → display label). */
val availableNavItems: List<Pair<String, Int>> =
ScreenRegistry.ALL_SCREENS.map { screen ->
screen.key::class.simpleName!! to screen.labelRes
}
/** 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 normalized =
@@ -379,7 +338,6 @@ class SettingsViewModel(
AuthManager.setThemePreference(state.themePreference)
AuthManager.setUseDynamicColors(state.useDynamicColors)
AuthManager.setThemePreset(state.themePreset)
AuthManager.setBottomNavDisplayMode(state.bottomNavDisplayMode)
AuthManager.setTypingEffectEnabled(state.typingEffectEnabled)
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
ApiClient.rebuild()
@@ -11,16 +11,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SegmentedButton
@@ -40,7 +32,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.ui.settings.SectionCard
@@ -315,157 +306,3 @@ internal fun ChatSection(
}
}
}
@Composable
internal fun NavBarSection(
bottomNavDisplayMode: BottomNavDisplayMode,
onBottomNavDisplayModeChange: (BottomNavDisplayMode) -> Unit,
selectedNavItems: List<String>,
availableNavItems: List<Pair<String, Int>>,
onReorderNavItems: (List<String>) -> Unit,
onRemoveNavItem: (String) -> Unit,
onAddNavItem: (String) -> Unit,
) {
SectionCard {
Text(
text = stringResource(R.string.settings_item_bottom_nav_display_mode),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(8.dp))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
BottomNavDisplayMode.entries.forEachIndexed { index, mode ->
SegmentedButton(
selected = bottomNavDisplayMode == mode,
onClick = { onBottomNavDisplayModeChange(mode) },
shape =
SegmentedButtonDefaults.itemShape(
index = index,
count = BottomNavDisplayMode.entries.size,
),
) {
Text(
when (mode) {
BottomNavDisplayMode.ICON_AND_TEXT -> {
stringResource(
R.string.settings_display_mode_icon_and_text,
)
}
BottomNavDisplayMode.ICON_ONLY -> {
stringResource(
R.string.settings_display_mode_icon_only,
)
}
BottomNavDisplayMode.TEXT_ONLY -> {
stringResource(
R.string.settings_display_mode_text_only,
)
}
},
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.settings_item_nav_items, selectedNavItems.size),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(8.dp))
selectedNavItems.forEachIndexed { index, name ->
val labelRes = availableNavItems.firstOrNull { it.first == name }?.second
val label = if (labelRes != null) stringResource(labelRes) else name
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(
onClick = {
val list = selectedNavItems.toMutableList()
val temp = list[index]
list[index] = list[index - 1]
list[index - 1] = temp
onReorderNavItems(list)
},
enabled = index > 0,
) {
Icon(
Icons.Filled.KeyboardArrowUp,
contentDescription = stringResource(R.string.content_desc_move_up),
)
}
IconButton(
onClick = {
val list = selectedNavItems.toMutableList()
val temp = list[index]
list[index] = list[index + 1]
list[index + 1] = temp
onReorderNavItems(list)
},
enabled = index < selectedNavItems.lastIndex,
) {
Icon(
Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.content_desc_move_down),
)
}
IconButton(onClick = { onRemoveNavItem(name) }) {
Icon(
Icons.Filled.Close,
contentDescription =
stringResource(
R.string.content_desc_remove_item,
label,
),
)
}
}
}
}
if (selectedNavItems.size < 5) {
val available =
availableNavItems.filter { it.first !in 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(stringResource(R.string.settings_action_add_item))
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
available.forEach { (name, labelRes) ->
DropdownMenuItem(
text = { Text(stringResource(labelRes)) },
onClick = {
onAddNavItem(name)
expanded = false
},
)
}
}
}
}
} else {
Text(
text = stringResource(R.string.settings_nav_max_reached),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
-11
View File
@@ -161,14 +161,6 @@
<string name="settings_item_typing_delay">지연: %1$d ms</string>
<string name="settings_delay_10ms">10 ms</string>
<string name="settings_delay_100ms">100 ms</string>
<string name="settings_sec_nav_bar">내비게이션 바</string>
<string name="settings_item_nav_items">하단 바 항목 (%1$d/5)</string>
<string name="settings_action_add_item">항목 추가</string>
<string name="settings_nav_max_reached">최대 5개 항목</string>
<string name="settings_item_bottom_nav_display_mode">하단 바 표시 모드</string>
<string name="settings_display_mode_icon_and_text">아이콘 + 텍스트</string>
<string name="settings_display_mode_icon_only">아이콘만</string>
<string name="settings_display_mode_text_only">텍스트만</string>
<string name="settings_action_test_connection">연결 테스트</string>
<string name="settings_sec_about">정보</string>
<string name="settings_about_app_name">⚡ 헤르메스 모바일</string>
@@ -188,9 +180,6 @@
<string name="content_desc_delete_profile">프로필 삭제</string>
<string name="content_desc_hide_token">토큰 숨기기</string>
<string name="content_desc_show_token">토큰 보기</string>
<string name="content_desc_move_up">위로</string>
<string name="content_desc_move_down">아래로</string>
<string name="content_desc_remove_item">%1$s 제거</string>
<string name="theme_system">시스템</string>
<string name="theme_light">라이트</string>
<string name="theme_dark">다크</string>
-11
View File
@@ -175,14 +175,6 @@
<string name="settings_item_typing_delay">Delay: %1$d ms</string>
<string name="settings_delay_10ms">10 ms</string>
<string name="settings_delay_100ms">100 ms</string>
<string name="settings_sec_nav_bar">Navigation Bar</string>
<string name="settings_item_nav_items">Bottom bar items (%1$d/5)</string>
<string name="settings_action_add_item">Add Item</string>
<string name="settings_nav_max_reached">Maximum of 5 items</string>
<string name="settings_item_bottom_nav_display_mode">Bottom Bar Display Mode</string>
<string name="settings_display_mode_icon_and_text">Icon &amp; Text</string>
<string name="settings_display_mode_icon_only">Icons Only</string>
<string name="settings_display_mode_text_only">Text Only</string>
<string name="settings_action_test_connection">Test Connection</string>
<string name="settings_sec_about">About</string>
<string name="settings_about_app_name">⚡ Hermes Mobile</string>
@@ -212,9 +204,6 @@
<string name="content_desc_delete_profile">Delete Profile</string>
<string name="content_desc_hide_token">Hide token</string>
<string name="content_desc_show_token">Show token</string>
<string name="content_desc_move_up">Move up</string>
<string name="content_desc_move_down">Move down</string>
<string name="content_desc_remove_item">Remove %1$s</string>
<string name="theme_system">System</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
@@ -1,58 +0,0 @@
package com.m57.hermescontrol
import androidx.navigation3.runtime.NavKey
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Verifies that the bottom-nav selection key maps Settings drill-down
* destinations back to [SettingsScreen] so the indicator stays highlighted,
* while every other screen is returned unchanged.
*/
class BottomNavigationSelectionTest {
@Test
fun `SettingsScreen selects itself`() {
assertEquals(SettingsScreen, selectedBottomNavDestination(SettingsScreen))
}
@Test
fun `every Settings drill-down maps back to SettingsScreen`() {
val drillDowns: List<NavKey> =
listOf(
SettingsConnection,
SettingsAppearance,
SettingsChat,
SettingsNavBar,
SettingsBehavior,
SettingsAbout,
)
for (destination in drillDowns) {
assertEquals(
"Expected $destination to map to SettingsScreen",
SettingsScreen,
selectedBottomNavDestination(destination),
)
}
}
@Test
fun `non-Settings screens are returned unchanged`() {
val others: List<NavKey> =
listOf(
ChatScreen,
CronJobsScreen,
WebhooksScreen,
GatewayScreen,
SkillsScreen,
LandingScreen,
AuthLoginScreen,
)
for (screen in others) {
assertEquals(
"Expected $screen to be returned unchanged",
screen,
selectedBottomNavDestination(screen),
)
}
}
}
@@ -68,15 +68,6 @@ class ServerStoreTest {
)
val healed = state.selfHealed()
assertNull(healed.selectedProfileId)
// Test empty bottomNavItems
val state2 =
ServerStoreState(
bottomNavItems = emptyList(),
)
val healed2 = state2.selfHealed()
assertEquals(5, healed2.bottomNavItems.size)
assertEquals("ChatScreen", healed2.bottomNavItems[0])
}
@Test
@@ -3,7 +3,6 @@ package com.m57.hermescontrol.ui.settings
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import io.mockk.every
@@ -63,12 +62,10 @@ class SettingsViewModelTest {
every { AuthManager.getThemePreference() } returns ThemePreference.SYSTEM
every { AuthManager.isUseDynamicColors() } returns true
every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT
every { AuthManager.getBottomNavItems() } returns emptyList()
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.getConnectionProfiles() } returns emptyList()
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
every { AuthManager.getBottomNavDisplayMode() } returns BottomNavDisplayMode.ICON_AND_TEXT
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
every { AuthManager.setHost(any()) } returns Unit
every { AuthManager.setPort(any()) } returns Unit
@@ -78,7 +75,6 @@ class SettingsViewModelTest {
every { AuthManager.setThemePreference(any()) } returns Unit
every { AuthManager.setUseDynamicColors(any()) } returns Unit
every { AuthManager.setThemePreset(any()) } returns Unit
every { AuthManager.setBottomNavDisplayMode(any()) } returns Unit
every { AuthManager.setTypingEffectEnabled(any()) } returns Unit
every { AuthManager.setTypingEffectDelayMs(any()) } returns Unit
every { AuthManager.setSelectedProfileId(any()) } answers {