feat: UI Changes

* fix(#220): implement edit soul and set model dialogs

* fix(#211): make UI more compact and information dense

* fix(#211): make navigation drawer sidebar more compact

* feat(#252): move settings item to end of navigation drawer

* feat(#250): add bottom navigation bar display mode settings

* feat(#225): add search bar filtering to remaining screens

* fix: make bottom navigation bar display modes more compact

* fix: resolve compilation error in KanbanScreen by referencing assignedTo

* feat: add search to achievements screen and top padding for icon-only navigation bar

* fix: adjust icon-only bottom bar height and remove padding

* fix(#249): remove unused PaddingValues imports in 6 screens

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-21 20:42:23 +03:00
1 parent 32def80694
commit 60bf6e8a33
20 files changed
+424 -61

No files matched your search

@@ -44,6 +44,7 @@ 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
@@ -70,6 +71,7 @@ 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 kotlinx.coroutines.launch
import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent
import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent
@@ -164,7 +166,6 @@ private val DRAWER_ENTRIES =
DrawerEntry(GatewayScreen, R.string.screen_gateway, Icons.Filled.Bolt, DrawerSection.AUTOMATE),
// Configure
DrawerEntry(SkillsScreen, R.string.screen_skills, Icons.Filled.Extension, DrawerSection.CONFIGURE),
DrawerEntry(SettingsScreen, R.string.screen_settings, Icons.Filled.Settings, DrawerSection.CONFIGURE),
DrawerEntry(ToolsetsScreen, R.string.screen_toolsets, Icons.Filled.Build, DrawerSection.CONFIGURE),
DrawerEntry(PluginsScreen, R.string.screen_plugins, Icons.Filled.Memory, DrawerSection.CONFIGURE),
DrawerEntry(ConfigScreen, R.string.screen_config, Icons.Filled.Code, DrawerSection.CONFIGURE),
@@ -183,6 +184,7 @@ private val DRAWER_ENTRIES =
DrawerEntry(LogsScreen, R.string.screen_logs, Icons.Filled.HistoryEdu, DrawerSection.INSPECT),
DrawerEntry(KanbanScreen, R.string.screen_kanban, Icons.Filled.Dashboard, DrawerSection.INSPECT),
DrawerEntry(AchievementsScreen, R.string.screen_achievements, Icons.Filled.Info, DrawerSection.INSPECT),
DrawerEntry(SettingsScreen, R.string.screen_settings, Icons.Filled.Settings, DrawerSection.INSPECT),
)
private val DRAWER_GESTURE_SCREENS: Set<NavKey> = ALL_NAV_ITEMS.mapTo(mutableSetOf()) { it.key }
@@ -339,6 +341,7 @@ fun MainNavigation(sessionId: String? = null) {
// customises items in Settings. Using collectAsState() ensures the NavigationBar
// recomposes and reflects choices instantly.
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 } }
@@ -375,7 +378,7 @@ fun MainNavigation(sessionId: String? = null) {
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 20.dp, top = 20.dp, end = 16.dp, bottom = 4.dp),
modifier = Modifier.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 2.dp),
) {
Text(
text = stringResource(R.string.nav_drawer_title),
@@ -395,7 +398,7 @@ fun MainNavigation(sessionId: String? = null) {
}
Text(
text = stringResource(R.string.nav_drawer_subtitle),
modifier = Modifier.padding(start = 20.dp, bottom = 12.dp, end = 16.dp),
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, end = 16.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -406,9 +409,9 @@ fun MainNavigation(sessionId: String? = null) {
text = stringResource(section.titleRes).uppercase(),
modifier =
Modifier.padding(
start = 20.dp,
top = 16.dp,
bottom = 6.dp,
start = 16.dp,
top = 8.dp,
bottom = 4.dp,
end = 16.dp,
),
style = MaterialTheme.typography.labelSmall,
@@ -434,7 +437,7 @@ fun MainNavigation(sessionId: String? = null) {
),
modifier =
Modifier
.padding(horizontal = 12.dp, vertical = 2.dp)
.padding(horizontal = 8.dp, vertical = 1.dp)
.testTag(
"drawer_${entry.key::class.simpleName?.lowercase()?.removeSuffix(
"screen",
@@ -455,13 +458,53 @@ fun MainNavigation(sessionId: String? = null) {
contentWindowInsets = WindowInsets.navigationBars,
bottomBar = {
if (showBottomBar) {
NavigationBar {
val barHeight =
when (bottomNavDisplayMode) {
BottomNavDisplayMode.ICON_ONLY -> 56.dp
BottomNavDisplayMode.TEXT_ONLY -> 44.dp
BottomNavDisplayMode.ICON_AND_TEXT -> 80.dp
}
NavigationBar(
modifier = Modifier.height(barHeight),
) {
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 = currentScreen == item.key
NavigationBarItem(
selected = currentScreen == item.key,
selected = isSelected,
onClick = { NavigationController.navigateTo(item.key) },
icon = { Icon(item.icon, contentDescription = stringResource(item.labelRes)) },
label = { Text(stringResource(item.labelRes)) },
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") ?: ""}",
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.coroutines.flow.MutableStateFlow
@@ -31,6 +32,7 @@ object AuthManager {
private const val KEY_SELECTED_PROFILE_ID = "selected_profile_id"
private const val KEY_USE_DYNAMIC_COLORS = "use_dynamic_colors"
private const val KEY_THEME_PRESET = "theme_preset"
private const val KEY_BOTTOM_NAV_DISPLAY_MODE = "bottom_nav_display_mode"
private const val DEFAULT_HOST = "127.0.0.1"
private const val DEFAULT_PORT = 9119
@@ -52,6 +54,9 @@ 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 gson = com.google.gson.Gson()
/**
@@ -81,6 +86,7 @@ object AuthManager {
_themePreferenceFlow.value = getThemePreference()
_useDynamicColorsFlow.value = isUseDynamicColors()
_themePresetFlow.value = getThemePreset()
_bottomNavDisplayModeFlow.value = getBottomNavDisplayMode()
}
}
@@ -282,4 +288,16 @@ object AuthManager {
fun setTypingEffectDelayMs(delayMs: Int) {
requirePrefs().edit().putInt(KEY_TYPING_EFFECT_DELAY_MS, delayMs).apply()
}
// ── Bottom Nav Display Mode ──────────────────────────────────────────
fun getBottomNavDisplayMode(): BottomNavDisplayMode =
requirePrefs().getString(KEY_BOTTOM_NAV_DISPLAY_MODE, BottomNavDisplayMode.ICON_AND_TEXT.name)?.let { name ->
runCatching { BottomNavDisplayMode.valueOf(name) }.getOrNull()
} ?: BottomNavDisplayMode.ICON_AND_TEXT
fun setBottomNavDisplayMode(mode: BottomNavDisplayMode) {
requirePrefs().edit().putString(KEY_BOTTOM_NAV_DISPLAY_MODE, mode.name).apply()
_bottomNavDisplayModeFlow.value = mode
}
}
@@ -18,12 +18,12 @@ import androidx.compose.ui.unit.dp
* Access in composables via `LocalSpacing.current` or the [Spacing] object.
*/
object Spacing {
val xs = 4.dp
val sm = 8.dp
val md = 16.dp
val lg = 24.dp
val xl = 32.dp
val xxl = 48.dp
val xs = 2.dp
val sm = 6.dp
val md = 12.dp
val lg = 18.dp
val xl = 24.dp
val xxl = 36.dp
}
/** Alias for backwards-compatibility — existing `Spacing.xs` calls still work. */
@@ -17,6 +17,8 @@ enum class ThemePreference { SYSTEM, LIGHT, DARK }
enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED }
enum class BottomNavDisplayMode { ICON_AND_TEXT, ICON_ONLY, TEXT_ONLY }
val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT }
@@ -242,8 +244,14 @@ fun HermesControlTheme(
val dynamicAvailable = useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val colorScheme =
when {
dynamicAvailable && darkTheme -> dynamicDarkColorScheme(context)
dynamicAvailable && !darkTheme -> dynamicLightColorScheme(context)
dynamicAvailable && darkTheme -> {
dynamicDarkColorScheme(context)
}
dynamicAvailable && !darkTheme -> {
dynamicLightColorScheme(context)
}
else -> {
when (themePreset) {
ThemePreset.DEFAULT -> if (darkTheme) HermesDarkColorScheme else HermesLightColorScheme
@@ -269,6 +277,7 @@ fun HermesControlTheme(
infoContainer = Color(0xFF3C3836),
)
}
themePreset == ThemePreset.GRUVBOX && !darkTheme -> {
HermesStatusColors(
success = Color(0xFF98971A),
@@ -281,6 +290,7 @@ fun HermesControlTheme(
infoContainer = Color(0xFFEBDBB2),
)
}
themePreset == ThemePreset.CATPPUCCIN && darkTheme -> {
HermesStatusColors(
success = Color(0xFFA6E3A1),
@@ -293,6 +303,7 @@ fun HermesControlTheme(
infoContainer = Color(0xFF313244),
)
}
themePreset == ThemePreset.CATPPUCCIN && !darkTheme -> {
HermesStatusColors(
success = Color(0xFF40A02B),
@@ -305,6 +316,7 @@ fun HermesControlTheme(
infoContainer = Color(0xFFE6E9EF),
)
}
darkTheme -> {
HermesStatusColors(
success = StatusGreen,
@@ -317,6 +329,7 @@ fun HermesControlTheme(
infoContainer = StatusBlueContainer,
)
}
else -> {
HermesStatusColors(
success = Color(0xFF1B873A),
@@ -21,6 +21,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -33,6 +36,7 @@ import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar
@Composable
fun AchievementsScreen(
@@ -41,6 +45,16 @@ fun AchievementsScreen(
viewModel: AchievementsViewModel = viewModel { AchievementsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
val filteredAchievements =
remember(query, state.achievements) {
state.achievements.filter { achievement ->
achievement.name.contains(query, ignoreCase = true) ||
achievement.description?.contains(query, ignoreCase = true) == true ||
achievement.category?.contains(query, ignoreCase = true) == true
}
}
LaunchedEffect(Unit) {
viewModel.loadAchievements()
@@ -80,7 +94,14 @@ fun AchievementsScreen(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.achievements) { achievement ->
item {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = "Search achievements...",
)
}
items(filteredAchievements) { achievement ->
val pct = achievement.progress_pct?.toFloat()?.div(100f) ?: 0f
Card(
@@ -3,7 +3,6 @@ package com.m57.hermescontrol.ui.channels
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -39,6 +38,9 @@ import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
@Composable
fun ChannelsScreen(
@@ -49,6 +51,15 @@ fun ChannelsScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var query by remember { mutableStateOf("") }
val filteredPlatforms =
remember(query, state.platforms) {
state.platforms.filter { platform ->
platform.name.contains(query, ignoreCase = true)
}
}
LaunchedEffect(Unit) {
viewModel.loadPlatforms()
}
@@ -90,10 +101,17 @@ fun ChannelsScreen(
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
items(state.platforms, key = { it.id }) { platform ->
item {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = "Search channels...",
)
}
items(filteredPlatforms, key = { it.id }) { platform ->
var showConfigureForm by remember { mutableStateOf(false) }
Card(modifier = Modifier.fillMaxWidth()) {
@@ -157,7 +157,7 @@ private fun UserBubble(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp),
.padding(horizontal = 8.dp, vertical = 2.dp),
contentAlignment = Alignment.CenterEnd,
) {
val gradientBrush =
@@ -208,7 +208,7 @@ private fun UserBubble(
color = Color.Transparent,
tonalElevation = 0.dp,
) {
Column(modifier = Modifier.padding(12.dp)) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
SelectionContainer {
Text(
text = highlightedText,
@@ -304,7 +304,7 @@ private fun AssistantBubble(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp),
.padding(horizontal = 8.dp, vertical = 2.dp),
contentAlignment = Alignment.CenterStart,
) {
Box {
@@ -335,7 +335,7 @@ private fun AssistantBubble(
),
tonalElevation = 1.dp,
) {
Column(modifier = Modifier.padding(12.dp)) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
SelectionContainer {
RichText(
text = message.content,
@@ -402,7 +402,7 @@ private fun SystemBubble(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 6.dp),
.padding(horizontal = 16.dp, vertical = 4.dp),
contentAlignment = Alignment.Center,
) {
Text(
@@ -612,7 +612,7 @@ private fun ToolBubble(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
.padding(horizontal = 8.dp, vertical = 1.dp),
contentAlignment = Alignment.CenterStart,
) {
Card(
@@ -624,7 +624,7 @@ private fun ToolBubble(
modifier =
Modifier
.animateContentSize()
.padding(horizontal = 12.dp, vertical = 8.dp),
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -157,6 +157,6 @@ fun EmptyState(
// ── Padding conventions ──────────────────────────────────────────────
val listContentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp)
val listContentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
val listItemSpacing = Arrangement.spacedBy(8.dp)
val listItemSpacing = Arrangement.spacedBy(6.dp)
@@ -49,6 +49,7 @@ import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar
private const val DEFAULT_COLUMN = "todo"
@@ -61,6 +62,18 @@ fun KanbanScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var query by remember { mutableStateOf("") }
val filteredTasks =
remember(query, state.tasks) {
state.tasks.filter { task ->
task.title.contains(query, ignoreCase = true) ||
task.description?.contains(query, ignoreCase = true) == true ||
task.status.contains(query, ignoreCase = true) ||
task.assignedTo?.contains(query, ignoreCase = true) == true
}
}
var showAddTaskDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
@@ -107,6 +120,12 @@ fun KanbanScreen(
Column(
modifier = Modifier.fillMaxSize().padding(paddingValues),
) {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = "Filter tasks by title, status, or assignee...",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
// Board selector tab row
if (state.boards.isNotEmpty()) {
PrimaryScrollableTabRow(
@@ -140,7 +159,7 @@ fun KanbanScreen(
items(state.columns) { column ->
val colName = column.name
val colTasks =
state.tasks.filter {
filteredTasks.filter {
it.status.equals(colName, ignoreCase = true)
}
val columnIndex = state.columns.indexOf(column)
@@ -4,7 +4,6 @@ import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -46,6 +45,9 @@ import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -57,6 +59,16 @@ fun KeysScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var query by remember { mutableStateOf("") }
val filteredEnvVars =
remember(query, state.envVars) {
state.envVars.filter { (key, config) ->
key.contains(query, ignoreCase = true) ||
config.description?.contains(query, ignoreCase = true) == true
}
}
LaunchedEffect(Unit) {
viewModel.loadKeys()
}
@@ -110,10 +122,17 @@ fun KeysScreen(
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
items(state.envVars.toList()) { (key, config) ->
item {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = "Search keys...",
)
}
items(filteredEnvVars.toList()) { (key, config) ->
var isEditing by remember { mutableStateOf(false) }
val revealedVal = state.revealedValues[key]
val isRevealed = revealedVal != null
@@ -16,6 +16,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -30,6 +33,7 @@ import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar
@Composable
fun LogsScreen(
@@ -42,6 +46,15 @@ fun LogsScreen(
val spacing = LocalSpacing.current
val listState = rememberLazyListState()
var query by remember { mutableStateOf("") }
val filteredLogs =
remember(query, state.logs) {
state.logs.filter { logLine ->
logLine.contains(query, ignoreCase = true)
}
}
LaunchedEffect(Unit) {
viewModel.loadLogs()
}
@@ -54,9 +67,9 @@ fun LogsScreen(
}
// Auto scroll to bottom when new logs arrive
LaunchedEffect(state.logs.size) {
if (state.logs.isNotEmpty()) {
listState.animateScrollToItem(state.logs.size - 1)
LaunchedEffect(filteredLogs.size) {
if (filteredLogs.isNotEmpty()) {
listState.animateScrollToItem(filteredLogs.size)
}
}
@@ -101,7 +114,15 @@ fun LogsScreen(
vertical = spacing.sm,
),
) {
items(state.logs) { logLine ->
item {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = "Search logs...",
modifier = Modifier.padding(bottom = 8.dp),
)
}
items(filteredLogs) { logLine ->
Text(
text = logLine,
color = MaterialTheme.colorScheme.onSurface,
@@ -4,7 +4,6 @@ import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -25,6 +24,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -38,6 +40,9 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -49,6 +54,17 @@ fun McpServersScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var query by remember { mutableStateOf("") }
val filteredServers =
remember(query, state.servers) {
state.servers.filter { server ->
server.name.contains(query, ignoreCase = true) ||
server.command?.contains(query, ignoreCase = true) == true ||
server.transport?.contains(query, ignoreCase = true) == true
}
}
LaunchedEffect(Unit) {
viewModel.loadServers()
}
@@ -97,10 +113,17 @@ fun McpServersScreen(
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
items(state.servers) { server ->
item {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = "Search MCP servers...",
)
}
items(filteredServers) { server ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
@@ -45,6 +45,7 @@ import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -56,6 +57,18 @@ fun ModelScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var expandedProviderSlug by remember { mutableStateOf<String?>(null) }
var query by remember { mutableStateOf("") }
val filteredProviders =
remember(query, state.providers) {
state.providers.filter { provider ->
provider.name.contains(query, ignoreCase = true) ||
provider.slug.contains(query, ignoreCase = true) ||
provider.models.orEmpty().any { model ->
model.contains(query, ignoreCase = true)
}
}
}
LaunchedEffect(Unit) {
viewModel.loadModelOptions()
@@ -121,7 +134,14 @@ fun ModelScreen(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.providers) { provider ->
item {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = "Search models...",
)
}
items(filteredProviders) { provider ->
val isExpanded = expandedProviderSlug == provider.slug
val isCurrent = provider.is_current == true
@@ -4,7 +4,6 @@ import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -25,6 +24,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -37,6 +39,9 @@ import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -48,6 +53,16 @@ fun PluginsScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var query by remember { mutableStateOf("") }
val filteredPlugins =
remember(query, state.plugins) {
state.plugins.filter { plugin ->
plugin.name.contains(query, ignoreCase = true) ||
plugin.description?.contains(query, ignoreCase = true) == true
}
}
LaunchedEffect(Unit) {
viewModel.loadPlugins()
}
@@ -101,10 +116,17 @@ fun PluginsScreen(
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
items(state.plugins) { plugin ->
item {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = "Search plugins...",
)
}
items(filteredPlugins) { plugin ->
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
@@ -19,6 +19,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -33,6 +36,7 @@ import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
@@ -45,6 +49,16 @@ fun SessionsScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current
var query by remember { mutableStateOf("") }
val filteredSessions =
remember(query, state.sessions) {
state.sessions.filter { session ->
session.title?.contains(query, ignoreCase = true) == true ||
session.status?.contains(query, ignoreCase = true) == true
}
}
LaunchedEffect(Unit) {
viewModel.loadSessions()
}
@@ -82,7 +96,14 @@ fun SessionsScreen(
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
items(state.sessions) { session ->
item {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = "Search sessions...",
)
}
items(filteredSessions) { session ->
Card(
modifier =
Modifier
@@ -63,6 +63,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.BuildConfig
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.common.HermesScaffold
@@ -478,6 +479,51 @@ fun SettingsScreen(
// Navigation Bar section
SectionCard(title = stringResource(R.string.settings_sec_nav_bar)) {
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 = state.bottomNavDisplayMode == mode,
onClick = { viewModel.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, state.selectedNavItems.size),
style = MaterialTheme.typography.bodyMedium,
@@ -7,6 +7,7 @@ import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.coroutines.Dispatchers
@@ -34,6 +35,7 @@ data class SettingsUiState(
val profiles: List<com.m57.hermescontrol.data.model.ConnectionProfile> = emptyList(),
val selectedProfileId: String? = null,
val renameProfileName: String = "",
val bottomNavDisplayMode: BottomNavDisplayMode = BottomNavDisplayMode.ICON_AND_TEXT,
)
class SettingsViewModel : ViewModel() {
@@ -69,6 +71,7 @@ class SettingsViewModel : ViewModel() {
.firstOrNull { p ->
p.id == selectedId
}?.name ?: "",
bottomNavDisplayMode = AuthManager.getBottomNavDisplayMode(),
)
}
}
@@ -137,6 +140,11 @@ class SettingsViewModel : ViewModel() {
AuthManager.setThemePreset(preset)
}
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) }
}
@@ -205,6 +213,7 @@ class SettingsViewModel : ViewModel() {
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()
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -29,6 +28,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -42,6 +44,9 @@ import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -53,6 +58,17 @@ fun ToolsetsScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var query by remember { mutableStateOf("") }
val filteredToolsets =
remember(query, state.toolsets) {
state.toolsets.filter { toolset ->
toolset.name.contains(query, ignoreCase = true) ||
toolset.label?.contains(query, ignoreCase = true) == true ||
toolset.description?.contains(query, ignoreCase = true) == true
}
}
LaunchedEffect(Unit) {
viewModel.loadToolsets()
}
@@ -114,10 +130,17 @@ fun ToolsetsScreen(
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
items(state.toolsets) { toolset ->
item {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = "Search toolsets...",
)
}
items(filteredToolsets) { toolset ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -26,6 +25,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -39,6 +41,9 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -50,6 +55,16 @@ fun WebhooksScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var query by remember { mutableStateOf("") }
val filteredSubscriptions =
remember(query, state.subscriptions) {
state.subscriptions.filter { sub ->
sub.name.contains(query, ignoreCase = true) ||
sub.url.contains(query, ignoreCase = true)
}
}
LaunchedEffect(Unit) {
viewModel.loadWebhooks()
}
@@ -98,8 +113,8 @@ fun WebhooksScreen(
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
// Global Status Switch
item {
@@ -155,7 +170,15 @@ fun WebhooksScreen(
)
}
if (state.subscriptions.isEmpty()) {
item {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = "Search subscriptions...",
)
}
if (filteredSubscriptions.isEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
@@ -176,7 +199,7 @@ fun WebhooksScreen(
}
}
} else {
items(state.subscriptions) { sub ->
items(filteredSubscriptions) { sub ->
Card(
modifier = Modifier.fillMaxWidth(),
) {