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))
}
}
},
@@ -2,42 +2,59 @@ package com.m57.hermescontrol.theme
import androidx.compose.ui.graphics.Color
// Primary - Deep Purple
val HermesPurple = Color(0xFF6B4EFF)
val HermesPurpleLight = Color(0xFF9D8AFF)
val HermesPurpleDark = Color(0xFF4A35B3)
val HermesPurple80 = Color(0xFFCFC3FF)
/**
* Hermes Control brand palette — v2 (modern redesign).
*
* Design intent:
* - Primary (voltage purple) is the single accent; surfaces stay calm.
* - Secondary (plasma amber) is reserved for opt-in highlights only.
* - Surfaces use a 3-step elevation ladder with a slight blue tint so dark
* mode no longer reads as flat black.
* - Semantic colors (success/warning/error/info) share the same saturation
* band so they don't fight the brand.
*/
// Secondary - Amber / Gold
val HermesAmber = Color(0xFFFFB300)
val HermesAmberLight = Color(0xFFFFE54C)
val HermesPurple = Color(0xFF7C5CFF)
val HermesPurpleLight = Color(0xFFAC93FF)
val HermesPurpleDark = Color(0xFF5A3FE0)
val HermesPurpleContainer = Color(0xFF2B2159)
val HermesPurpleOnContainer = Color(0xFFD9CCFF)
val HermesAmber = Color(0xFFFFB627)
val HermesAmberLight = Color(0xFFFFE082)
val HermesAmberDark = Color(0xFFC68400)
val HermesAmber80 = Color(0xFFFFE082)
// Dark theme surfaces
val DarkBackground = Color(0xFF121212)
val DarkSurface = Color(0xFF1E1E2E)
val DarkSurfaceVariant = Color(0xFF2A2A3C)
val DarkOnSurface = Color(0xFFE6E1E5)
val DarkOnSurfaceVariant = Color(0xFFCAC4D0)
val DarkBackground = Color(0xFF0B0B11)
val DarkSurface = Color(0xFF121218)
val DarkSurfaceVariant = Color(0xFF1C1C26)
val DarkSurfaceHigh = Color(0xFF262633)
val DarkOnSurface = Color(0xFFE8E6EE)
val DarkOnSurfaceVariant = Color(0xFFB6B2C4)
val DarkOutline = Color(0xFF4A4A5A)
val DarkOutlineVariant = Color(0xFF2F2F3D)
// Light theme surfaces
val LightBackground = Color(0xFFFFFBFE)
val LightSurface = Color(0xFFFFFBFE)
val LightSurfaceVariant = Color(0xFFE7E0EC)
val LightOnSurface = Color(0xFF1C1B1F)
val LightOnSurfaceVariant = Color(0xFF49454F)
val LightBackground = Color(0xFFF7F6FB)
val LightSurface = Color(0xFFFFFFFF)
val LightSurfaceVariant = Color(0xFFEFEDF4)
val LightSurfaceHigh = Color(0xFFE6E3EE)
val LightOnSurface = Color(0xFF1A1A24)
val LightOnSurfaceVariant = Color(0xFF56536A)
val LightOutline = Color(0xFF79748E)
val LightOutlineVariant = Color(0xFFC9C5D6)
// Status colors
val StatusGreen = Color(0xFF4CAF50)
val StatusRed = Color(0xFFEF5350)
val StatusYellow = Color(0xFFFFC107)
val StatusGreen = Color(0xFF3DDC84)
val StatusGreenContainer = Color(0xFF143A23)
val StatusRed = Color(0xFFFF5C5C)
val StatusRedContainer = Color(0xFF3D1414)
val StatusYellow = Color(0xFFFFB627)
val StatusYellowContainer = Color(0xFF3D2F0F)
val StatusBlue = Color(0xFF4DA8FF)
val StatusBlueContainer = Color(0xFF0F2A3D)
// Chat bubble colors
val UserBubble = Color(0xFF6B4EFF)
val UserBubbleDark = Color(0xFF5A3FE0)
val AssistantBubble = Color(0xFF2A2A3C)
val AssistantBubbleLight = Color(0xFFE7E0EC)
val SystemMessageColor = Color(0xFF9E9E9E)
val ToolChipColor = Color(0xFF37474F)
val ToolChipColorLight = Color(0xFFECEFF1)
val UserBubble = HermesPurple
val UserBubbleDark = HermesPurpleDark
val AssistantBubble = Color(0xFF1C1C26)
val AssistantBubbleLight = Color(0xFFEDEAF4)
val SystemMessageColor = Color(0xFF8B879A)
val ToolChipColor = Color(0xFF262633)
val ToolChipColorLight = Color(0xFFE6E3EE)
@@ -0,0 +1,23 @@
package com.m57.hermescontrol.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
/**
* Unified shape system for Hermes Control.
*
* - xs: chips, small badges
* - sm: text fields, small cards
* - md: standard cards, list rows
* - lg: large cards, bottom sheets
* - xl: feature cards, full-bleed surfaces
*/
val HermesShapes =
Shapes(
extraSmall = RoundedCornerShape(6.dp),
small = RoundedCornerShape(10.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(24.dp),
extraLarge = RoundedCornerShape(32.dp),
)
@@ -0,0 +1,24 @@
package com.m57.hermescontrol.theme
import androidx.compose.ui.unit.dp
/**
* Spacing tokens — use these instead of ad-hoc dp values so every screen
* shares the same rhythm.
*
* Convention (Material 3 spacing scale, condensed):
* xs = 4 hairline / chip gaps
* sm = 8 default inner padding
* md = 12 card content
* lg = 16 screen edge, section gaps
* xl = 24 hero spacing
* xxl = 32 full-feature spacing
*/
object Spacing {
val xs = 4.dp
val sm = 8.dp
val md = 12.dp
val lg = 16.dp
val xl = 24.dp
val xxl = 32.dp
}
@@ -16,16 +16,22 @@ enum class ThemePreference { SYSTEM, LIGHT, DARK }
val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
/**
* Set to true to opt into Material You dynamic color (Android 12+).
* Off by default — brand identity (Voltage Purple) is the priority.
*/
private const val ENABLE_DYNAMIC_COLOR = false
private val HermesDarkColorScheme =
darkColorScheme(
primary = HermesPurple,
onPrimary = Color.White,
primaryContainer = HermesPurpleDark,
onPrimaryContainer = HermesPurple80,
primaryContainer = HermesPurpleContainer,
onPrimaryContainer = HermesPurpleOnContainer,
secondary = HermesAmber,
onSecondary = Color.Black,
secondaryContainer = HermesAmberDark,
onSecondaryContainer = HermesAmber80,
secondaryContainer = Color(0xFF3D2F0F),
onSecondaryContainer = HermesAmberLight,
tertiary = HermesAmberLight,
background = DarkBackground,
onBackground = DarkOnSurface,
@@ -33,21 +39,30 @@ private val HermesDarkColorScheme =
onSurface = DarkOnSurface,
surfaceVariant = DarkSurfaceVariant,
onSurfaceVariant = DarkOnSurfaceVariant,
error = Color(0xFFCF6679),
onError = Color.Black,
outline = Color(0xFF938F99),
surfaceTint = HermesPurple,
surfaceContainer = DarkSurfaceVariant,
surfaceContainerHigh = DarkSurfaceHigh,
surfaceContainerHighest = DarkSurfaceHigh,
inverseSurface = Color(0xFFE8E6EE),
inverseOnSurface = Color(0xFF1A1A24),
error = StatusRed,
onError = Color.White,
errorContainer = StatusRedContainer,
onErrorContainer = Color(0xFFFFB4B4),
outline = DarkOutline,
outlineVariant = DarkOutlineVariant,
)
private val HermesLightColorScheme =
lightColorScheme(
primary = HermesPurple,
primary = HermesPurpleDark,
onPrimary = Color.White,
primaryContainer = HermesPurple80,
onPrimaryContainer = HermesPurpleDark,
secondary = HermesAmber,
onSecondary = Color.Black,
secondaryContainer = HermesAmber80,
onSecondaryContainer = HermesAmberDark,
primaryContainer = HermesPurpleLight,
onPrimaryContainer = Color(0xFF1E0F66),
secondary = HermesAmberDark,
onSecondary = Color.White,
secondaryContainer = HermesAmberLight,
onSecondaryContainer = Color(0xFF3D2F0F),
tertiary = HermesAmberDark,
background = LightBackground,
onBackground = LightOnSurface,
@@ -55,14 +70,24 @@ private val HermesLightColorScheme =
onSurface = LightOnSurface,
surfaceVariant = LightSurfaceVariant,
onSurfaceVariant = LightOnSurfaceVariant,
error = Color(0xFFB00020),
surfaceTint = HermesPurple,
surfaceContainer = LightSurfaceVariant,
surfaceContainerHigh = LightSurfaceHigh,
surfaceContainerHighest = LightSurfaceHigh,
inverseSurface = Color(0xFF1A1A24),
inverseOnSurface = Color(0xFFE8E6EE),
error = Color(0xFFB3261E),
onError = Color.White,
outline = Color(0xFF79747E),
errorContainer = Color(0xFFF9DEDC),
onErrorContainer = Color(0xFF410E0B),
outline = LightOutline,
outlineVariant = LightOutlineVariant,
)
@Composable
fun HermesControlTheme(
themePreference: ThemePreference = LocalThemePreference.current,
enableDynamicColor: Boolean = ENABLE_DYNAMIC_COLOR,
content: @Composable () -> Unit,
) {
val darkTheme =
@@ -73,11 +98,11 @@ fun HermesControlTheme(
}
val context = LocalContext.current
val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val dynamicAvailable = enableDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val colorScheme =
when {
dynamicColor && darkTheme -> dynamicDarkColorScheme(context)
dynamicColor && !darkTheme -> dynamicLightColorScheme(context)
dynamicAvailable && darkTheme -> dynamicDarkColorScheme(context)
dynamicAvailable && !darkTheme -> dynamicLightColorScheme(context)
darkTheme -> HermesDarkColorScheme
else -> HermesLightColorScheme
}
@@ -85,6 +110,7 @@ fun HermesControlTheme(
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
shapes = HermesShapes,
content = content,
)
}
@@ -6,31 +6,138 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
/**
* Full Material 3 type hierarchy — every style explicitly tuned so the
* app stops inheriting Compose defaults.
*
* Letter spacing is tighter than M3 defaults (modern look), line heights are
* comfortable for long-form.
*/
val Typography =
Typography(
// Display — hero numbers, big stats
displayLarge =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 44.sp,
lineHeight = 52.sp,
letterSpacing = (-0.5).sp,
),
displayMedium =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 36.sp,
lineHeight = 44.sp,
letterSpacing = (-0.25).sp,
),
displaySmall =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 30.sp,
lineHeight = 38.sp,
letterSpacing = 0.sp,
),
// Headline — empty-state titles, screen intros
headlineLarge =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
lineHeight = 34.sp,
letterSpacing = 0.sp,
),
headlineMedium =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 30.sp,
letterSpacing = 0.sp,
),
headlineSmall =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 26.sp,
letterSpacing = 0.1.sp,
),
// Title — TopAppBar, card headings
titleLarge =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 26.sp,
letterSpacing = 0.1.sp,
),
titleMedium =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 22.sp,
letterSpacing = 0.15.sp,
),
titleSmall =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
// Body — primary reading
bodyLarge =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.25.sp,
),
bodyMedium =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.2.sp,
),
bodySmall =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp,
),
// Label — buttons, chips, captions
labelLarge =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
labelMedium =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
labelSmall =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -0,0 +1,98 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* Shared Scaffold wrapper that standardizes the drawer-aware TopAppBar.
*
* Eliminates the per-screen duplication of:
* - Menu icon (drawer toggle)
* - Title
* - Optional Refresh / actions slot
*
* Screens that still need a Back arrow instead of a Menu icon can set
* `showBack = true` and pass an `onBack` lambda.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HermesScaffold(
title: String,
onOpenDrawer: (() -> Unit)? = null,
onRefresh: (() -> Unit)? = null,
onBack: (() -> Unit)? = null,
showBack: Boolean = false,
actions: @Composable () -> Unit = {},
content: @Composable (androidx.compose.foundation.layout.PaddingValues) -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
when {
showBack && onBack != null -> {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
)
}
}
onOpenDrawer != null -> {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
}
},
title = { Text(title) },
actions = {
if (onRefresh != null) {
IconButton(onClick = onRefresh) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Refresh",
)
}
}
actions()
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
),
)
},
containerColor = MaterialTheme.colorScheme.background,
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
content(paddingValues)
}
}
}
@@ -0,0 +1,131 @@
package com.m57.hermescontrol.ui.common
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.Inbox
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* Unified loading / error / empty placeholders so every list screen
* shares the same visual language.
*/
@Composable
fun LoadingState(modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
strokeWidth = 3.dp,
modifier = Modifier.size(40.dp),
)
}
}
@Composable
fun ErrorState(
message: String,
onRetry: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
Column(
modifier =
modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "⚠",
fontSize = MaterialTheme.typography.headlineMedium.fontSize,
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = message,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
)
if (onRetry != null) {
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(onClick = onRetry) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.size(8.dp))
Text("Retry")
}
}
}
}
@Composable
fun EmptyState(
title: String,
subtitle: String? = null,
icon: ImageVector = Icons.Filled.Inbox,
modifier: Modifier = Modifier,
) {
Column(
modifier =
modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(48.dp),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (subtitle != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
/**
* Padding convention for List screens — outer 16, gaps of 8 between items.
*/
val listContentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp)
val listItemSpacing = Arrangement.spacedBy(8.dp)
@@ -3,7 +3,6 @@ package com.m57.hermescontrol.ui.connect
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -16,22 +15,22 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -40,14 +39,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -66,22 +64,13 @@ fun ConnectScreen(
}
var passwordVisible by remember { mutableStateOf(false) }
val backgroundGradient =
Brush.verticalGradient(
colors =
listOf(
MaterialTheme.colorScheme.background,
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
),
)
var showManualFields by remember { mutableStateOf(false) }
Box(
modifier =
modifier
.fillMaxSize()
.background(backgroundGradient)
.background(MaterialTheme.colorScheme.background)
.imePadding(),
contentAlignment = Alignment.Center,
) {
@@ -90,204 +79,165 @@ fun ConnectScreen(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(24.dp),
.padding(horizontal = 24.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Branding
Text(
text = "⚡",
fontSize = 64.sp,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
// Hero brand
Box(
modifier =
Modifier
.size(80.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Bolt,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(44.dp),
)
}
Text(
text = "Hermes",
style =
MaterialTheme.typography.headlineLarge.copy(
MaterialTheme.typography.displayMedium.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
color = MaterialTheme.colorScheme.onBackground,
),
)
Text(
text = "AI Agent Control",
style =
MaterialTheme.typography.titleMedium.copy(
MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
Spacer(modifier = Modifier.height(32.dp))
Spacer(modifier = Modifier.height(24.dp))
// Connection Card
Card(
// Pairing (primary path)
Column(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
),
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(
Text(
text = "Pairing string",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
)
var pairingString by remember { mutableStateOf("") }
OutlinedTextField(
value = pairingString,
onValueChange = {
pairingString = it
if (it.isNotBlank()) viewModel.onPairingString(it)
},
placeholder = { Text("Paste hermes://… or Base64 config") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Error message
AnimatedVisibility(
visible = state.errorMessage != null,
enter = fadeIn(),
exit = fadeOut(),
) {
state.errorMessage?.let { error ->
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth(),
)
}
}
Button(
onClick = viewModel::connect,
enabled = !state.isConnecting,
modifier =
Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
.height(52.dp),
) {
if (state.isConnecting) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp,
)
} else {
Text("Connect", style = MaterialTheme.typography.labelLarge)
}
}
TextButton(
onClick = { showManualFields = !showManualFields },
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Connect to Hermes",
style =
MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold,
),
text = if (showManualFields) "Hide manual entry" else "Enter manually instead",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
var pairingString by remember { mutableStateOf("") }
OutlinedTextField(
value = pairingString,
onValueChange = {
pairingString = it
if (it.isNotBlank()) {
viewModel.onPairingString(it)
}
},
label = { Text("Pairing Link / Connection String") },
placeholder = { Text("Paste hermes://... or Base64 config") },
singleLine = true,
AnimatedVisibility(visible = showManualFields) {
Column(
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.secondary,
cursorColor = MaterialTheme.colorScheme.secondary,
),
)
// Token field
OutlinedTextField(
value = state.token,
onValueChange = viewModel::onTokenChange,
label = { Text("Auth Token") },
placeholder = { Text("Enter your API token") },
singleLine = true,
visualTransformation =
if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
HorizontalDivider()
OutlinedTextField(
value = state.token,
onValueChange = viewModel::onTokenChange,
label = { Text("Auth Token") },
placeholder = { Text("API token") },
singleLine = true,
visualTransformation =
if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector =
if (passwordVisible) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
},
contentDescription =
if (passwordVisible) "Hide token" else "Show token",
)
}
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector =
if (passwordVisible) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
},
contentDescription =
if (passwordVisible) {
"Hide token"
} else {
"Show token"
},
)
}
},
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
// Host field
OutlinedTextField(
value = state.host,
onValueChange = viewModel::onHostChange,
label = { Text("Host") },
placeholder = { Text("127.0.0.1") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
// Port field
OutlinedTextField(
value = state.port,
onValueChange = viewModel::onPortChange,
label = { Text("Port") },
placeholder = { Text("9119") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
// Error message
AnimatedVisibility(
visible = state.errorMessage != null,
enter = fadeIn(),
exit = fadeOut(),
) {
state.errorMessage?.let { error ->
Card(
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp),
)
}
}
}
// Connect button
Button(
onClick = viewModel::connect,
enabled = !state.isConnecting,
modifier =
Modifier
.fillMaxWidth()
.height(52.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
if (state.isConnecting) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp,
)
} else {
Text(
text = "Connect",
style = MaterialTheme.typography.labelLarge,
)
}
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.host,
onValueChange = viewModel::onHostChange,
label = { Text("Host") },
placeholder = { Text("127.0.0.1") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.port,
onValueChange = viewModel::onPortChange,
label = { Text("Port") },
placeholder = { Text("9119") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@@ -2,41 +2,40 @@ package com.m57.hermescontrol.ui.skills
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
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
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.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SkillsScreen(
modifier: Modifier = Modifier,
@@ -45,6 +44,7 @@ fun SkillsScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var query by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
viewModel.loadSkills()
@@ -57,57 +57,60 @@ fun SkillsScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = androidx.compose.material.icons.Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Skills Management") },
actions = {
IconButton(onClick = { viewModel.loadSkills() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Refresh",
val filtered =
if (query.isBlank()) {
state.skills
} else {
state.skills.filter {
it.name.contains(query, ignoreCase = true) ||
it.category?.contains(query, ignoreCase = true) == true ||
it.description?.contains(query, ignoreCase = true) == true
}
}
HermesScaffold(
title = "Skills",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadSkills() },
) {
when {
state.isLoading -> LoadingState()
state.errorMessage != null ->
ErrorState(
message = state.errorMessage ?: "Unknown error",
onRetry = { viewModel.loadSkills() },
)
state.skills.isEmpty() ->
EmptyState(
title = "No skills",
subtitle = "Tap refresh to retry loading skills from Hermes.",
icon = Icons.Filled.Extension,
)
else ->
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
// Search field
item {
androidx.compose.material3.OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search skills…") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
)
},
modifier = modifier,
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null) {
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.skills) { skill ->
Card(modifier = Modifier.fillMaxWidth()) {
// Filtered list
items(filtered) { skill ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Row(
modifier =
Modifier
@@ -116,18 +119,40 @@ fun SkillsScreen(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = skill.name, style = MaterialTheme.typography.titleMedium)
Text(
text = skill.category ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = skill.description ?: "",
style = MaterialTheme.typography.bodyMedium,
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.Filled.Extension,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = skill.name,
style =
MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold,
),
)
skill.category?.let { cat ->
Text(
text = cat,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
skill.description?.let { desc ->
Text(
text = desc,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
)
}
}
}
Switch(
checked = skill.enabled,
@@ -137,7 +162,6 @@ fun SkillsScreen(
}
}
}
}
}
}
}