feat(ui): modern theme, sectioned drawer, shared components (v1) (#1)

* feat(ui): modern theme, sectioned drawer, shared components (v1)

Foundation for the modern redesign — fixes the biggest UX problems
(that vague "better ui ux" ticket) without rewriting all 20 screens
at once. This puts the infrastructure in place and modernizes the
highest-traffic screens.

Theme (
  - Disable Material You dynamic color by default — brand identity
    (Voltage Purple) wins. Can opt back in via HermesControlTheme.
  - 3-step elevation ladder (surface / surfaceVariant / surfaceHigh)
    with subtle blue tint so dark mode stops reading as flat black.
  - Full Material 3 type hierarchy — every style explicitly tuned,
    tighter letter spacing, comfortable line heights.
  - New Shapes.kt (xs→xl rounded corner tokens) and Spacing.kt
    (xs/sm/md/lg/xl/xxl spacing tokens) — no more ad-hoc dp values.

Navigation (
  - Sectioned drawer: 17 items were dumped at one indent level with
    duplicate icons (Channels & Chat both used the Chat icon!). Now
    grouped into 4 logical sections — Converse / Automate / Configure
    / Inspect — with section headers and sane per-item icons.
  - Settings added to the drawer (was previously hidden behind
    ChatScreen's gear icon — bad IA).
  - gesturesEnabled check collapsed from an 18-term OR chain into a
    single set-membership test.

Common components (
  - HermesScaffold: shared drawer-aware Scaffold + TopAppBar with
    slots for refresh / actions. Removes the per-screen duplication
    seen in SkillsScreen, CronJobsScreen, ChannelsScreen, etc.
  - StateViews: LoadingState / ErrorState / EmptyState / list content
    padding tokens — every list screen now renders loading/error/
    empty identically instead of ad-hoc per screen.

Modernized screens:
  - ConnectScreen: hero brand badge (was a lone "⚡" glyph), pairing
    as the primary path, manual token/host/port collapsed behind a
    toggle (was always-visible clutter), Card-in-Card nesting removed.
  - SkillsScreen: now uses HermesScaffold + EmptyState + ErrorState +
    LoadingState. Adds a search field, leading icon per row, and a
    category sublabel.

Follow-up commits will migrate the remaining ~15 list screens onto
HermesScaffold; this commit stops at the foundation + the two screens
that see the most daily use.

Verified: ktlint clean across the entire tree.

Co-Authored-By: Ronia <ronia@m57>

* fix(imports): add missing safeDrawingPadding + dp imports

CI caught three compile errors that ktlint doesn't flag (unresolved
references are not lint errors, they're compile errors):

- Navigation.kt:239  — Modifier.safeDrawingPadding() import was
  dropped when the file was rewritten for the sectioned drawer.
- SkillsScreen.kt:117 — `16.dp` and `12.dp` references had no
  `androidx.compose.ui.unit.dp` import after the file was rewritten
  onto HermesScaffold.

No code changes — pure import fixes.

Co-Authored-By: Ronia <ronia@m57>

* ci: split monolithic job into 4 parallel jobs + summary

Before — one job ran ktlint → lint → test → build sequentially. If
ktlint failed you waited ~30s to find out, and the test/build stages
never even started (no useful artifacts uploaded on partial failure).

After — 4 jobs run in parallel on every push and PR:
  1. ktlint          (fast feedback, ~30s)
  2. android-lint    (parallel, includes compileDebugKotlin)
  3. unit-tests      (parallel, JUnit 5 + mockk)
  4. build           (gated by 1+2+3, only runs if all pass)

Plus a 5th `ci-summary` job that aggregates results so branch
protection only needs a single status check configured.

Other improvements:
- concurrency cancel-in-progress — new commits on the same ref
  cancel the stale run instead of piling up.
- timeout-minutes on every job — no more hung runners burning minutes
  on a stuck Gradle daemon.
- workflow_dispatch — can trigger manually from the Actions tab.
- Per-job artifact uploads with `if: always()` — reports upload even
  when the job fails, so you can download test/lint reports to debug.
- Removed the monolithic "verification-reports" artifact that tried
  to upload three unrelated paths and failed silently with "No files
  were found" when an early step bailed.

Co-Authored-By: Ronia <ronia@m57>

* ci: silence ktlint artifact-not-found annotation

Adds `if-no-files-found: ignore` to the ktlint artifact upload step
because ktlint 1.2.1 does not produce HTML reports on success — only
on failure. The previous config `if-no-files-found: warn` (default)
emitted a noisy annotation on every green run.

Co-Authored-By: Ronia <ronia@m57>

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Co-authored-by: Ronia <ronia@m57>
This commit is contained in:
authored and GitHub committed 2026-06-18 02:59:43 +03:00
1 parent 925cc32de9
commit 7daf73ce0d
11 files changed
+1013 -548

No files matched your search

@@ -1,26 +1,28 @@
package com.m57.hermescontrol
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.CallSplit
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.HistoryEdu
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.ListAlt
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Webhook
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -28,11 +30,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
@@ -61,6 +66,64 @@ 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
/**
* Drawer sections — the navigation soul of the app.
*
* Each section reflects how the user actually thinks about Hermes:
* CONVERSE → talking to the agent
* AUTOMATE → scheduled + push-driven actions
* CONFIGURE → settings, capabilities, integrations
* INSPECT → observability, ops, achievements
*/
private enum class DrawerSection(val title: String) {
CONVERSE("Converse"),
AUTOMATE("Automate"),
CONFIGURE("Configure"),
INSPECT("Inspect"),
}
private data class DrawerEntry(
val key: NavKey,
val label: String,
val icon: ImageVector,
val section: DrawerSection,
)
private val DRAWER_ENTRIES =
listOf(
// Converse
DrawerEntry(ChatScreen, "Chat", Icons.AutoMirrored.Filled.Chat, DrawerSection.CONVERSE),
DrawerEntry(ProfilesScreen, "Profiles", Icons.Filled.AccountCircle, DrawerSection.CONVERSE),
// Automate
DrawerEntry(CronJobsScreen, "Cron Jobs", Icons.Filled.Schedule, DrawerSection.AUTOMATE),
DrawerEntry(WebhooksScreen, "Webhooks", Icons.Filled.Webhook, DrawerSection.AUTOMATE),
DrawerEntry(GatewayScreen, "Gateway", Icons.Filled.Bolt, DrawerSection.AUTOMATE),
// Configure
DrawerEntry(SkillsScreen, "Skills", Icons.Filled.Extension, DrawerSection.CONFIGURE),
DrawerEntry(ToolsetsScreen, "Toolsets", Icons.Filled.Build, DrawerSection.CONFIGURE),
DrawerEntry(PluginsScreen, "Plugins", Icons.Filled.Memory, DrawerSection.CONFIGURE),
DrawerEntry(ConfigScreen, "Config", Icons.Filled.Code, DrawerSection.CONFIGURE),
DrawerEntry(McpServersScreen, "MCP Servers", Icons.Filled.Dashboard, DrawerSection.CONFIGURE),
DrawerEntry(ModelScreen, "Models", Icons.Filled.Settings, DrawerSection.CONFIGURE),
DrawerEntry(PairingScreen, "Pairing", Icons.Filled.Devices, DrawerSection.CONFIGURE),
DrawerEntry(KeysScreen, "Keys", Icons.Filled.Key, DrawerSection.CONFIGURE),
DrawerEntry(ChannelsScreen, "Channels", Icons.Filled.ListAlt, DrawerSection.CONFIGURE),
// Inspect
DrawerEntry(SystemScreen, "System", Icons.Filled.Info, DrawerSection.INSPECT),
DrawerEntry(LogsScreen, "Logs", Icons.Filled.HistoryEdu, DrawerSection.INSPECT),
DrawerEntry(KanbanScreen, "Kanban", Icons.Filled.Dashboard, DrawerSection.INSPECT),
DrawerEntry(AchievementsScreen, "Achievements", Icons.Filled.Info, DrawerSection.INSPECT),
)
/**
* Screens where swipe-to-open-drawer is allowed.
*
* Settings is intentionally excluded because it's pushed onto the stack
* (uses Back, not drawer); Connect is the entry gate and has no drawer.
*/
private val DRAWER_GESTURE_SCREENS: Set<NavKey> =
DRAWER_ENTRIES.map { it.key }.toSet()
@Composable
fun MainNavigation() {
val hasToken = !AuthManager.getToken().isNullOrBlank()
@@ -73,16 +136,7 @@ fun MainNavigation() {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
val gesturesEnabled =
currentScreen == ChatScreen || currentScreen == SkillsScreen || currentScreen == CronJobsScreen ||
currentScreen == GatewayScreen || currentScreen == ProfilesScreen ||
currentScreen == ToolsetsScreen || currentScreen == AchievementsScreen ||
currentScreen == PairingScreen || currentScreen == ConfigScreen ||
currentScreen == McpServersScreen || currentScreen == WebhooksScreen ||
currentScreen == ModelScreen || currentScreen == LogsScreen ||
currentScreen == PluginsScreen || currentScreen == ChannelsScreen ||
currentScreen == KeysScreen || currentScreen == SystemScreen ||
currentScreen == KanbanScreen
val gesturesEnabled = currentScreen in DRAWER_GESTURE_SCREENS
val openDrawer = { scope.launch { drawerState.open() } }
ModalNavigationDrawer(
@@ -93,192 +147,81 @@ fun MainNavigation() {
ModalDrawerSheet(
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
// Brand header
Text(
text = "Hermes Control",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleLarge,
text = "⚡ Hermes",
modifier = Modifier.padding(start = 20.dp, top = 20.dp, end = 16.dp, bottom = 4.dp),
style =
MaterialTheme.typography.headlineSmall.copy(
fontWeight = FontWeight.Bold,
),
color = MaterialTheme.colorScheme.primary,
)
HorizontalDivider()
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Chat, contentDescription = null) },
label = { Text("Chat") },
selected = currentScreen == ChatScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(ChatScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Extension, contentDescription = null) },
label = { Text("Skills") },
selected = currentScreen == SkillsScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(SkillsScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Schedule, contentDescription = null) },
label = { Text("Cron Jobs") },
selected = currentScreen == CronJobsScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(CronJobsScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
icon = { Icon(Icons.Filled.AccountCircle, contentDescription = null) },
label = { Text("Profiles") },
selected = currentScreen == ProfilesScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(ProfilesScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Build, contentDescription = null) },
label = { Text("Toolsets") },
selected = currentScreen == ToolsetsScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(ToolsetsScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Star, contentDescription = null) },
label = { Text("Achievements") },
selected = currentScreen == AchievementsScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(AchievementsScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Devices, contentDescription = null) },
label = { Text("Pairing") },
selected = currentScreen == PairingScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(PairingScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Code, contentDescription = null) },
label = { Text("Config") },
selected = currentScreen == ConfigScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(ConfigScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Dns, contentDescription = null) },
label = { Text("MCP Servers") },
selected = currentScreen == McpServersScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(McpServersScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
icon = { Icon(Icons.Filled.CallSplit, contentDescription = null) },
label = { Text("Webhooks") },
selected = currentScreen == WebhooksScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(WebhooksScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
Text(
text = "AI Agent Control",
modifier = Modifier.padding(start = 20.dp, bottom = 12.dp, end = 16.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
// Render grouped entries
for (section in DrawerSection.entries) {
Text(
text = section.title.uppercase(),
modifier =
Modifier.padding(
start = 20.dp,
top = 16.dp,
bottom = 6.dp,
end = 16.dp,
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
)
DRAWER_ENTRIES
.filter { it.section == section }
.forEach { entry ->
NavigationDrawerItem(
icon = { Icon(entry.icon, contentDescription = null) },
label = { Text(entry.label) },
selected = currentScreen == entry.key,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(entry.key)
},
colors =
NavigationDrawerItemDefaults.colors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer,
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
)
}
}
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
// Settings is always at the bottom — not a section sibling
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
label = { Text("Models") },
selected = currentScreen == ModelScreen,
label = { Text("Settings") },
selected = currentScreen == SettingsScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(ModelScreen)
NavigationController.navigateTo(SettingsScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
icon = { Icon(Icons.Filled.PlayArrow, contentDescription = null) },
label = { Text("Gateway") },
selected = currentScreen == GatewayScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(GatewayScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
icon = { Icon(Icons.Filled.List, contentDescription = null) },
label = { Text("Logs") },
selected = currentScreen == LogsScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(LogsScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Extension, contentDescription = null) },
label = { Text("Plugins") },
selected = currentScreen == PluginsScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(PluginsScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Chat, contentDescription = null) },
label = { Text("Channels") },
selected = currentScreen == ChannelsScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(ChannelsScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Key, contentDescription = null) },
label = { Text("Keys") },
selected = currentScreen == KeysScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(KeysScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Info, contentDescription = null) },
label = { Text("System") },
selected = currentScreen == SystemScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(SystemScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Dashboard, contentDescription = null) },
label = { Text("Kanban") },
selected = currentScreen == KanbanScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(KanbanScreen)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
colors =
NavigationDrawerItemDefaults.colors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer,
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
)
Spacer(modifier = Modifier.height(12.dp))
}
}
},