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:
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(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user