feat: Material You design system rewrite — Phases 1–4

Complete UI/UX rewrite of hermes-mobile with Material You design system.

Phase 1: Color/Theme/Type/Shapes/Spacing/Motion tokens
Phase 2: HermesScaffold, StateViews (skeleton/empty/error), SearchBar, StatusBadge, ToggleRow
Phase 3: Bottom nav + drawer navigation with NavigationController
Phase 4: All 17 screens rewritten with HermesScaffold and Material You patterns

Closes PR #3.
This commit is contained in:
M57 authored and GitHub committed 2026-06-18 16:34:44 +03:00
1 parent f833005bec
commit d90e36974c
29 files changed
+2669 -2377

No files matched your search

@@ -1,6 +1,7 @@
package com.m57.hermescontrol
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.safeDrawingPadding
@@ -29,11 +30,15 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@@ -66,15 +71,27 @@ 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
*/
// ── Bottom-nav primary screens ─────────────────────────────────────────
private data class BottomNavItem(
val key: NavKey,
val label: String,
val icon: ImageVector,
)
private val BOTTOM_NAV_ITEMS =
listOf(
BottomNavItem(ChatScreen, "Chat", Icons.AutoMirrored.Filled.Chat),
BottomNavItem(SkillsScreen, "Skills", Icons.Filled.Extension),
BottomNavItem(CronJobsScreen, "Cron", Icons.Filled.Schedule),
BottomNavItem(SystemScreen, "System", Icons.Filled.Info),
BottomNavItem(SettingsScreen, "Settings", Icons.Filled.Settings),
)
private val BOTTOM_NAV_KEYS: Set<NavKey> = BOTTOM_NAV_ITEMS.map { it.key }.toSet()
// ── Drawer sections for secondary screens ──────────────────────────────
private enum class DrawerSection(val title: String) {
CONVERSE("Converse"),
AUTOMATE("Automate"),
@@ -92,14 +109,11 @@ private data class DrawerEntry(
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),
@@ -109,20 +123,12 @@ private val DRAWER_ENTRIES =
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()
private val DRAWER_GESTURE_SCREENS: Set<NavKey> = BOTTOM_NAV_KEYS + DRAWER_ENTRIES.map { it.key }.toSet()
@Composable
fun MainNavigation() {
@@ -136,6 +142,7 @@ fun MainNavigation() {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
val isPrimaryScreen = currentScreen in BOTTOM_NAV_KEYS
val gesturesEnabled = currentScreen in DRAWER_GESTURE_SCREENS
val openDrawer = { scope.launch { drawerState.open() } }
@@ -165,7 +172,6 @@ fun MainNavigation() {
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
// Render grouped entries
for (section in DrawerSection.entries) {
Text(
text = section.title.uppercase(),
@@ -204,174 +210,167 @@ fun MainNavigation() {
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("Settings") },
selected = currentScreen == SettingsScreen,
onClick = {
scope.launch { drawerState.close() }
NavigationController.navigateTo(SettingsScreen)
},
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))
}
}
},
) {
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider =
entryProvider {
entry<ConnectScreen> {
ConnectScreenContent(
onConnected = {
backStack.clear()
backStack.add(ChatScreen)
},
modifier = Modifier.safeDrawingPadding(),
)
Scaffold(
bottomBar = {
if (isPrimaryScreen) {
NavigationBar {
BOTTOM_NAV_ITEMS.forEach { item ->
NavigationBarItem(
selected = currentScreen == item.key,
onClick = { NavigationController.navigateTo(item.key) },
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
)
}
}
}
},
) { paddingValues ->
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider =
entryProvider {
entry<ConnectScreen> {
ConnectScreenContent(
onConnected = {
backStack.clear()
backStack.add(ChatScreen)
},
modifier = Modifier.safeDrawingPadding(),
)
}
entry<ChatScreen> {
ChatScreenContent(
onNavigateToSettings = {
// B7 (Jun 18 2026): previously used
// `backStack.add(SettingsScreen)` directly,
// which bypasses NavigationController's
// navigation guard. The drawer Settings path
// goes through NavigationController which
// deduplicates and prevents stacking duplicate
// SettingsScreen entries when user rapidly
// taps the gear icon. Use the same path for
// consistency and to recover if a duplicate
// SettingsScreen entry was the source of the
// "page unresponsive" symptom reported when
// reaching Settings from the topbar.
NavigationController.navigateTo(SettingsScreen)
},
onOpenDrawer = { openDrawer() },
)
}
entry<ChatScreen> {
ChatScreenContent(
onNavigateToSettings = {
// B7 (Jun 18 2026): route through
// NavigationController to prevent
// stacking duplicate entries.
NavigationController.navigateTo(SettingsScreen)
},
onOpenDrawer = { openDrawer() },
)
}
entry<SettingsScreen> {
SettingsScreenContent(
onBack = {
backStack.removeLastOrNull()
},
)
}
entry<SettingsScreen> {
SettingsScreenContent(
onBack = { backStack.removeLastOrNull() },
)
}
entry<SkillsScreen> {
SkillsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<SkillsScreen> {
SkillsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<CronJobsScreen> {
CronJobsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<CronJobsScreen> {
CronJobsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<ProfilesScreen> {
ProfilesScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<ProfilesScreen> {
ProfilesScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<ToolsetsScreen> {
ToolsetsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<ToolsetsScreen> {
ToolsetsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<AchievementsScreen> {
AchievementsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<AchievementsScreen> {
AchievementsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<PairingScreen> {
PairingScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<PairingScreen> {
PairingScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<ConfigScreen> {
ConfigScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<ConfigScreen> {
ConfigScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<McpServersScreen> {
McpServersScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<McpServersScreen> {
McpServersScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<WebhooksScreen> {
WebhooksScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<WebhooksScreen> {
WebhooksScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<ModelScreen> {
ModelScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<ModelScreen> {
ModelScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<GatewayScreen> {
GatewayScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<GatewayScreen> {
GatewayScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<LogsScreen> {
LogsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<LogsScreen> {
LogsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<PluginsScreen> {
PluginsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<PluginsScreen> {
PluginsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<ChannelsScreen> {
ChannelsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<ChannelsScreen> {
ChannelsScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<KeysScreen> {
KeysScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<KeysScreen> {
KeysScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<SystemScreen> {
SystemScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<SystemScreen> {
SystemScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<KanbanScreen> {
KanbanScreenContent(
onOpenDrawer = { openDrawer() },
)
}
},
)
entry<KanbanScreen> {
KanbanScreenContent(
onOpenDrawer = { openDrawer() },
)
}
},
modifier =
Modifier
.padding(paddingValues)
.fillMaxSize(),
)
}
}
}
@@ -3,14 +3,35 @@ package com.m57.hermescontrol
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
/**
* Central navigation controller with deduplication guard.
*
* Primary screens (bottom nav tabs) clear the back stack and become the new
* root — this matches Material's bottom-nav pattern where each tab has its own
* back stack history (simplified: we just clear).
*
* B7 (Jun 18 2026): Never call `backStack.add()` directly from UI callbacks.
* Always route through [navigateTo] to prevent stacking duplicate screen
* entries that compete for touch events.
*/
object NavigationController {
var backStack: NavBackStack<NavKey>? = null
// Bottom-nav primary screens — tapping one clears the stack to root.
private val primaryScreens: Set<NavKey> =
setOf(
ChatScreen,
SkillsScreen,
CronJobsScreen,
SystemScreen,
SettingsScreen,
)
fun navigateTo(key: NavKey) {
val stack = backStack ?: return
if (stack.lastOrNull() == key) return
if (key == ChatScreen || key == SkillsScreen || key == CronJobsScreen) {
if (key in primaryScreens) {
stack.clear()
}
stack.add(key)
@@ -2,17 +2,16 @@ package com.m57.hermescontrol.theme
import androidx.compose.ui.graphics.Color
/**
* 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.
*/
// Hermes Control brand palette — v3 (Material You rewrite).
//
// On Android 12+ (API 31+) the app uses dynamic colour derived from the user's
// wallpaper — this palette is NOT used for primary/surface roles on those
// devices (see HermesControlTheme). On API 26–30 it serves as the full colour
// scheme. Semantic status colours and chat-bubble colours are ALWAYS
// brand-defined. All pairs verified for WCAG AA contrast (4.5:1 text, 3:1
// large text / icons).
// ── Brand: Voltage Purple (primary) ──────────────────────────────────────
val HermesPurple = Color(0xFF7C5CFF)
val HermesPurpleLight = Color(0xFFAC93FF)
@@ -20,27 +19,45 @@ val HermesPurpleDark = Color(0xFF5A3FE0)
val HermesPurpleContainer = Color(0xFF2B2159)
val HermesPurpleOnContainer = Color(0xFFD9CCFF)
// ── Brand: Plasma Amber (secondary) ─────────────────────────────────────
val HermesAmber = Color(0xFFFFB627)
val HermesAmberLight = Color(0xFFFFE082)
val HermesAmberDark = Color(0xFFC68400)
// ── Dark surfaces (5-step elevation ladder) ─────────────────────────────
val DarkBackground = Color(0xFF0B0B11)
val DarkSurface = Color(0xFF121218)
val DarkSurfaceVariant = Color(0xFF1C1C26)
val DarkSurfaceHigh = Color(0xFF262633)
val DarkSurfaceContainer = Color(0xFF1C1C26)
val DarkSurfaceContainerHigh = Color(0xFF262633)
val DarkSurfaceContainerHighest = Color(0xFF30303D)
val DarkOnSurface = Color(0xFFE8E6EE)
val DarkOnSurfaceVariant = Color(0xFFB6B2C4)
val DarkOutline = Color(0xFF4A4A5A)
val DarkOutlineVariant = Color(0xFF2F2F3D)
val DarkInverseSurface = Color(0xFFE8E6EE)
val DarkInverseOnSurface = Color(0xFF1A1A24)
val DarkScrim = Color(0xFF000000)
// ── Light surfaces (5-step elevation ladder) ────────────────────────────
val LightBackground = Color(0xFFF7F6FB)
val LightSurface = Color(0xFFFFFFFF)
val LightSurfaceVariant = Color(0xFFEFEDF4)
val LightSurfaceHigh = Color(0xFFE6E3EE)
val LightSurfaceContainer = Color(0xFFEFEDF4)
val LightSurfaceContainerHigh = Color(0xFFE6E3EE)
val LightSurfaceContainerHighest = Color(0xFFDDD9EA)
val LightOnSurface = Color(0xFF1A1A24)
val LightOnSurfaceVariant = Color(0xFF56536A)
val LightOutline = Color(0xFF79748E)
val LightOutlineVariant = Color(0xFFC9C5D6)
val LightInverseSurface = Color(0xFF1A1A24)
val LightInverseOnSurface = Color(0xFFE8E6EE)
val LightScrim = Color(0xFF000000)
// ── Semantic status colours (always brand-defined) ──────────────────────
val StatusGreen = Color(0xFF3DDC84)
val StatusGreenContainer = Color(0xFF143A23)
@@ -51,6 +68,8 @@ val StatusYellowContainer = Color(0xFF3D2F0F)
val StatusBlue = Color(0xFF4DA8FF)
val StatusBlueContainer = Color(0xFF0F2A3D)
// ── Chat-specific colours (always brand-defined for consistency) ───────
val UserBubble = HermesPurple
val UserBubbleDark = HermesPurpleDark
val AssistantBubble = Color(0xFF1C1C26)
@@ -58,3 +77,10 @@ val AssistantBubbleLight = Color(0xFFEDEAF4)
val SystemMessageColor = Color(0xFF8B879A)
val ToolChipColor = Color(0xFF262633)
val ToolChipColorLight = Color(0xFFE6E3EE)
// ── Skeleton shimmer colours ───────────────────────────────────────────
val SkeletonBase = Color(0xFF1C1C26)
val SkeletonHighlight = Color(0xFF2F2F3D)
val SkeletonBaseLight = Color(0xFFE6E3EE)
val SkeletonHighlightLight = Color(0xFFEFEDF4)
@@ -0,0 +1,39 @@
package com.m57.hermescontrol.theme
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
/**
* Semantic status colour set — always brand-defined (never dynamic).
*
* Material You changes the *primary* / *surface* palette based on the user's
* wallpaper, but success / warning / error / info colours must stay
* semantically correct (green = good, red = bad) regardless of what the
* wallpaper-derived palette would choose. This struct is provided via
* [LocalHermesStatusColors] and should be used for all status indicators,
* badges, and semantic feedback colours.
*/
data class HermesStatusColors(
val success: Color,
val successContainer: Color,
val warning: Color,
val warningContainer: Color,
val error: Color,
val errorContainer: Color,
val info: Color,
val infoContainer: Color,
)
val LocalHermesStatusColors =
staticCompositionLocalOf {
HermesStatusColors(
success = StatusGreen,
successContainer = StatusGreenContainer,
warning = StatusYellow,
warningContainer = StatusYellowContainer,
error = StatusRed,
errorContainer = StatusRedContainer,
info = StatusBlue,
infoContainer = StatusBlueContainer,
)
}
@@ -0,0 +1,79 @@
package com.m57.hermescontrol.theme
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.spring
import androidx.compose.runtime.staticCompositionLocalOf
/**
* Motion tokens — duration, easing, and spring specs.
*
* Design intent (ui-ux-pro-max §7):
* - Micro-interactions: 150–300 ms
* - Complex transitions: ≤ 400 ms
* - Never exceed 500 ms
* - Enter: ease-out (decelerated), Exit: ease-in (accelerated)
* - Exit shorter than enter (~60–70 %)
* - Spring physics for interactive elements
* - All animations must be interruptible
*
* Access via `LocalMotion.current` in composables.
*/
object Motion {
// ── Duration tokens (ms) ─────────────────────────────────────────
/** Instant — state changes with no perceptible delay. */
const val INSTANT = 0
/** Fast — micro-interactions, ripples, opacity toggles. */
const val FAST = 150
/** Normal — standard UI transitions, sheet slides, expansion. */
const val NORMAL = 250
/** Slow — complex multi-element transitions, screen navigation. */
const val SLOW = 400
// ── Easing curves ────────────────────────────────────────────────
/** Emphasized decelerate — for ENTERING elements (ease-out). */
val emphasizedDecelerate: Easing =
CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f)
/** Emphasized accelerate — for EXITING elements (ease-in). */
val emphasizedAccelerate: Easing =
CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f)
/** Standard — default M3 easing for neutral transitions. */
val standard: Easing = FastOutSlowInEasing
// ── Spring specs ─────────────────────────────────────────────────
/** Bouncy spring for interactive elements (press, release, drag). */
fun <T> bouncySpring(): SpringSpec<T> =
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium,
)
/** Snappy spring for state toggles (switch, selection). */
fun <T> snappySpring(): SpringSpec<T> =
spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessHigh,
)
/** Gentle spring for layout animations (expand, collapse). */
fun <T> gentleSpring(): SpringSpec<T> =
spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessLow,
)
}
val MotionDefaults = Motion
val LocalMotion = staticCompositionLocalOf { Motion }
@@ -5,19 +5,21 @@ import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
/**
* Unified shape system for Hermes Control.
* Unified shape system — aligned to 8 dp rhythm (4/8/12/16/28).
*
* - 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
* | Token | Radius | Use case |
* |--------------|--------|---------------------------------------|
* | extraSmall | 4 dp | Chips, badges, inline toggles |
* | small | 8 dp | Text fields, small cards, small FAB |
* | medium | 12 dp | Standard cards, list rows, dialogs |
* | large | 16 dp | Large cards, bottom sheets, FAB |
* | extraLarge | 28 dp | 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),
extraSmall = RoundedCornerShape(4.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(28.dp),
)
@@ -1,24 +1,32 @@
package com.m57.hermescontrol.theme
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.dp
/**
* Spacing tokens — use these instead of ad-hoc dp values so every screen
* shares the same rhythm.
* Spacing tokens — the 8 dp rhythm that every screen shares.
*
* 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
* | Token | Value | Use case |
* |-------|--------|-------------------------------------------|
* | xs | 4 dp | Hairline gaps, chip inner padding |
* | sm | 8 dp | Default inner padding, icon-to-text gap |
* | md | 16 dp | Card content, screen edge, section gaps |
* | lg | 24 dp | Hero spacing, between major sections |
* | xl | 32 dp | Full-feature spacing, large section break |
* | xxl | 48 dp | Maximum spacing, rare |
*
* Access in composables via `LocalSpacing.current` or the [Spacing] object.
*/
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
val md = 16.dp
val lg = 24.dp
val xl = 32.dp
val xxl = 48.dp
}
/** Alias for backwards-compatibility — existing `Spacing.xs` calls still work. */
val SpacingDefaults = Spacing
val LocalSpacing = staticCompositionLocalOf { Spacing }
@@ -8,6 +8,7 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
@@ -18,9 +19,17 @@ 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.
*
* On API 31+ the app derives its entire colour scheme from the user's
* wallpaper — primary, secondary, tertiary, surfaces, the lot. On older
* devices the [HermesDarkColorScheme] / [HermesLightColorScheme] brand
* palette is used instead.
*
* Semantic status colours (success / warning / error / info) and chat-bubble
* colours are ALWAYS brand-defined regardless of dynamic colour — see
* [Color.kt] and the `LocalHermesStatusColors` provider below.
*/
private const val ENABLE_DYNAMIC_COLOR = false
private const val ENABLE_DYNAMIC_COLOR = true
private val HermesDarkColorScheme =
darkColorScheme(
@@ -33,6 +42,7 @@ private val HermesDarkColorScheme =
secondaryContainer = Color(0xFF3D2F0F),
onSecondaryContainer = HermesAmberLight,
tertiary = HermesAmberLight,
onTertiary = Color.Black,
background = DarkBackground,
onBackground = DarkOnSurface,
surface = DarkSurface,
@@ -40,17 +50,18 @@ private val HermesDarkColorScheme =
surfaceVariant = DarkSurfaceVariant,
onSurfaceVariant = DarkOnSurfaceVariant,
surfaceTint = HermesPurple,
surfaceContainer = DarkSurfaceVariant,
surfaceContainerHigh = DarkSurfaceHigh,
surfaceContainerHighest = DarkSurfaceHigh,
inverseSurface = Color(0xFFE8E6EE),
inverseOnSurface = Color(0xFF1A1A24),
surfaceContainer = DarkSurfaceContainer,
surfaceContainerHigh = DarkSurfaceContainerHigh,
surfaceContainerHighest = DarkSurfaceContainerHighest,
inverseSurface = DarkInverseSurface,
inverseOnSurface = DarkInverseOnSurface,
error = StatusRed,
onError = Color.White,
errorContainer = StatusRedContainer,
onErrorContainer = Color(0xFFFFB4B4),
outline = DarkOutline,
outlineVariant = DarkOutlineVariant,
scrim = DarkScrim,
)
private val HermesLightColorScheme =
@@ -64,6 +75,7 @@ private val HermesLightColorScheme =
secondaryContainer = HermesAmberLight,
onSecondaryContainer = Color(0xFF3D2F0F),
tertiary = HermesAmberDark,
onTertiary = Color.White,
background = LightBackground,
onBackground = LightOnSurface,
surface = LightSurface,
@@ -71,17 +83,18 @@ private val HermesLightColorScheme =
surfaceVariant = LightSurfaceVariant,
onSurfaceVariant = LightOnSurfaceVariant,
surfaceTint = HermesPurple,
surfaceContainer = LightSurfaceVariant,
surfaceContainerHigh = LightSurfaceHigh,
surfaceContainerHighest = LightSurfaceHigh,
inverseSurface = Color(0xFF1A1A24),
inverseOnSurface = Color(0xFFE8E6EE),
surfaceContainer = LightSurfaceContainer,
surfaceContainerHigh = LightSurfaceContainerHigh,
surfaceContainerHighest = LightSurfaceContainerHighest,
inverseSurface = LightInverseSurface,
inverseOnSurface = LightInverseOnSurface,
error = Color(0xFFB3261E),
onError = Color.White,
errorContainer = Color(0xFFF9DEDC),
onErrorContainer = Color(0xFF410E0B),
outline = LightOutline,
outlineVariant = LightOutlineVariant,
scrim = LightScrim,
)
@Composable
@@ -107,10 +120,41 @@ fun HermesControlTheme(
else -> HermesLightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
shapes = HermesShapes,
content = content,
)
val statusColors =
if (darkTheme) {
HermesStatusColors(
success = StatusGreen,
successContainer = StatusGreenContainer,
warning = StatusYellow,
warningContainer = StatusYellowContainer,
error = StatusRed,
errorContainer = StatusRedContainer,
info = StatusBlue,
infoContainer = StatusBlueContainer,
)
} else {
HermesStatusColors(
success = Color(0xFF1B873A),
successContainer = Color(0xFFD7F5E0),
warning = HermesAmberDark,
warningContainer = Color(0xFFFFEAB3),
error = Color(0xFFB3261E),
errorContainer = Color(0xFFF9DEDC),
info = Color(0xFF2E6FBD),
infoContainer = Color(0xFFD4E7FF),
)
}
CompositionLocalProvider(
LocalHermesStatusColors provides statusColors,
LocalSpacing provides SpacingDefaults,
LocalMotion provides MotionDefaults,
) {
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
shapes = HermesShapes,
content = content,
)
}
}
@@ -1,7 +1,6 @@
package com.m57.hermescontrol.ui.achievements
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
@@ -13,19 +12,12 @@ 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.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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
@@ -35,8 +27,11 @@ 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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AchievementsScreen(
modifier: Modifier = Modifier,
@@ -49,54 +44,33 @@ fun AchievementsScreen(
viewModel.loadAchievements()
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Agent Achievements") },
actions = {
IconButton(onClick = { viewModel.loadAchievements() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}
},
)
},
modifier = modifier,
HermesScaffold(
title = "Agent Achievements",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadAchievements() },
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading && state.achievements.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null && state.achievements.isEmpty()) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
IconButton(onClick = { viewModel.loadAchievements() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry")
}
}
} else {
when {
state.isLoading && state.achievements.isEmpty() -> {
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null && state.achievements.isEmpty() -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadAchievements() },
modifier = Modifier.padding(paddingValues),
)
}
state.achievements.isEmpty() -> {
EmptyState(
title = "No achievements yet",
subtitle = "Complete tasks to unlock achievements.",
icon = Icons.Filled.Refresh,
modifier = Modifier.padding(paddingValues),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
@@ -2,7 +2,6 @@ package com.m57.hermescontrol.ui.channels
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
@@ -14,21 +13,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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
@@ -43,8 +33,11 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChannelsScreen(
modifier: Modifier = Modifier,
@@ -65,52 +58,32 @@ fun ChannelsScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Messaging Channels") },
actions = {
IconButton(onClick = { viewModel.loadPlatforms() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Refresh",
)
}
},
)
},
modifier = modifier,
HermesScaffold(
title = "Messaging Channels",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadPlatforms() },
) { 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),
when {
state.isLoading -> {
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadPlatforms() },
modifier = Modifier.padding(paddingValues),
)
} else {
}
state.platforms.isEmpty() -> {
EmptyState(
title = "No channels configured",
subtitle = "Add messaging platforms in Hermes to see them here.",
modifier = Modifier.padding(paddingValues),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
@@ -17,14 +18,16 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
/**
* Shared Scaffold wrapper that standardizes the drawer-aware TopAppBar.
* Shared Scaffold wrapper that standardizes the TopAppBar.
*
* Eliminates the per-screen duplication of:
* - Menu icon (drawer toggle)
* - Title
* - Optional Refresh / actions slot
* Improvements (v2):
* - Scroll-aware top bar (pinned collapse on scroll)
* - Uses Spacing tokens internally
* - Simplified API: onOpenDrawer OR showBack (mutually exclusive)
* - Safe-area aware via Scaffold insets
*
* Screens that still need a Back arrow instead of a Menu icon can set
* `showBack = true` and pass an `onBack` lambda.
@@ -38,11 +41,15 @@ fun HermesScaffold(
onBack: (() -> Unit)? = null,
showBack: Boolean = false,
actions: @Composable () -> Unit = {},
content: @Composable (androidx.compose.foundation.layout.PaddingValues) -> Unit,
content: @Composable (PaddingValues) -> Unit,
) {
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
when {
showBack && onBack != null -> {
@@ -57,13 +64,12 @@ fun HermesScaffold(
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
contentDescription = "Open navigation drawer",
)
}
}
}
},
title = { Text(title) },
actions = {
if (onRefresh != null) {
IconButton(onClick = onRefresh) {
@@ -82,6 +88,7 @@ fun HermesScaffold(
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
),
scrollBehavior = scrollBehavior,
)
},
containerColor = MaterialTheme.colorScheme.background,
@@ -0,0 +1,322 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing
// ── SectionHeader — sticky-ish section label for grouped content ────────
@Composable
fun SectionHeader(
title: String,
modifier: Modifier = Modifier,
trailing: (@Composable () -> Unit)? = null,
) {
val spacing = LocalSpacing.current
Row(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = spacing.md, vertical = spacing.sm),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
trailing?.invoke()
}
}
// ── StatusBadge — coloured pill with optional leading dot ──────────────
@Composable
fun StatusBadge(
text: String,
status: StatusBadgeType = StatusBadgeType.NEUTRAL,
modifier: Modifier = Modifier,
) {
val spacing = LocalSpacing.current
val statusColors = LocalHermesStatusColors.current
val (bgColor, fgColor) =
when (status) {
StatusBadgeType.SUCCESS -> statusColors.successContainer to statusColors.success
StatusBadgeType.WARNING -> statusColors.warningContainer to statusColors.warning
StatusBadgeType.ERROR -> statusColors.errorContainer to statusColors.error
StatusBadgeType.INFO -> statusColors.infoContainer to statusColors.info
StatusBadgeType.NEUTRAL ->
MaterialTheme.colorScheme.surfaceContainerHigh to
MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
color = bgColor,
) {
Row(
modifier =
Modifier.padding(
horizontal = spacing.sm + 2.dp,
vertical = spacing.xs,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
) {
Box(
modifier =
Modifier
.size(6.dp)
.background(fgColor, RoundedCornerShape(50)),
)
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = fgColor,
fontWeight = FontWeight.Medium,
)
}
}
}
enum class StatusBadgeType { SUCCESS, WARNING, ERROR, INFO, NEUTRAL }
// ── ToggleRow — switch row with label + description ────────────────────
@Composable
fun ToggleRow(
title: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
description: String? = null,
icon: ImageVector? = null,
) {
val spacing = LocalSpacing.current
Card(
modifier = modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(spacing.md),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.sm + 4.dp),
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
)
description?.let { desc ->
Text(
text = desc,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
)
}
}
}
// ── SearchBar — M3 search field with clear button ──────────────────────
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String = "Search…",
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier.fillMaxWidth(),
placeholder = { Text(placeholder) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Clear search",
)
}
}
},
singleLine = true,
shape = RoundedCornerShape(12.dp),
)
}
// ── FilterChipRow — horizontally scrollable filter chips ───────────────
@Composable
fun FilterChipRow(
chips: List<String>,
selectedChip: String?,
onChipSelected: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val spacing = LocalSpacing.current
LazyRow(
modifier = modifier.fillMaxWidth(),
contentPadding =
androidx.compose.foundation.layout.PaddingValues(
horizontal = spacing.md,
vertical = spacing.xs,
),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
items(chips) { chip ->
FilterChip(
selected = chip == selectedChip,
onClick = { onChipSelected(chip) },
label = { Text(chip) },
)
}
}
}
// ── StatCard — metric display card for dashboard screens ───────────────
@Composable
fun StatCard(
label: String,
value: String,
modifier: Modifier = Modifier,
icon: ImageVector? = null,
accentColor: Color = MaterialTheme.colorScheme.primary,
) {
val spacing = LocalSpacing.current
Card(
modifier = modifier,
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(
modifier = Modifier.padding(spacing.md),
verticalArrangement = Arrangement.spacedBy(spacing.xs),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(20.dp),
)
}
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = value,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
)
}
}
}
// ── InfoRow — label/value row for detail screens ──────────────────────
@Composable
fun InfoRow(
label: String,
value: String,
modifier: Modifier = Modifier,
) {
val spacing = LocalSpacing.current
Row(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = spacing.md, vertical = spacing.sm),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@@ -1,33 +1,49 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Inbox
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
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.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.theme.Motion
/**
* Unified loading / error / empty placeholders so every list screen
* shares the same visual language.
*/
// Unified loading / error / empty / skeleton placeholders so every screen
// shares the same visual language.
// ── Loading ───────────────────────────────────────────────────────────
@Composable
fun LoadingState(modifier: Modifier = Modifier) {
@@ -43,59 +59,136 @@ fun LoadingState(modifier: Modifier = Modifier) {
}
}
// ── Skeleton Shimmer ──────────────────────────────────────────────────
/**
* Shimmer placeholder for list-based loading states.
* Shows animated placeholder cards while real data loads.
*
* @param itemCount number of skeleton rows to show
*/
@Composable
fun SkeletonList(
itemCount: Int = 6,
modifier: Modifier = Modifier,
) {
val spacing = LocalSpacing.current
val infiniteTransition = rememberInfiniteTransition(label = "shimmer")
val shimmerAlpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 0.7f,
animationSpec =
infiniteRepeatable(
animation = tween(Motion.NORMAL * 2),
repeatMode = RepeatMode.Reverse,
),
label = "shimmerAlpha",
)
val skeletonColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = shimmerAlpha)
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = spacing.md, vertical = spacing.sm),
verticalArrangement = Arrangement.spacedBy(spacing.sm),
) {
items(itemCount) {
SkeletonCard(skeletonColor)
}
}
}
@Composable
private fun SkeletonCard(baseColor: Color) {
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(baseColor)
.padding(16.dp),
) {
Box(
modifier =
Modifier
.width(180.dp)
.height(16.dp)
.clip(RoundedCornerShape(4.dp))
.background(baseColor.copy(alpha = baseColor.alpha + 0.1f)),
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier =
Modifier
.fillMaxWidth(0.7f)
.height(12.dp)
.clip(RoundedCornerShape(4.dp))
.background(baseColor.copy(alpha = baseColor.alpha + 0.05f)),
)
}
}
// ── Error ──────────────────────────────────────────────────────────────
@Composable
fun ErrorState(
message: String,
onRetry: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val spacing = LocalSpacing.current
Column(
modifier =
modifier
.fillMaxSize()
.padding(24.dp),
.padding(spacing.lg),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "⚠",
fontSize = MaterialTheme.typography.headlineMedium.fontSize,
color = MaterialTheme.colorScheme.error,
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(48.dp),
)
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(spacing.sm))
Text(
text = message,
color = MaterialTheme.colorScheme.error,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
)
if (onRetry != null) {
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(onClick = onRetry) {
Spacer(modifier = Modifier.height(spacing.md))
Button(onClick = onRetry) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.size(8.dp))
Spacer(modifier = Modifier.size(spacing.sm))
Text("Retry")
}
}
}
}
// ── Empty ────────────────────────────────────────────────────────────
@Composable
fun EmptyState(
title: String,
subtitle: String? = null,
icon: ImageVector = Icons.Filled.Inbox,
actionLabel: String? = null,
onAction: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val spacing = LocalSpacing.current
Column(
modifier =
modifier
.fillMaxSize()
.padding(24.dp),
.padding(spacing.lg),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
@@ -103,16 +196,16 @@ fun EmptyState(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(48.dp),
modifier = Modifier.size(56.dp),
)
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(spacing.sm))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (subtitle != null) {
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(spacing.xs))
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
@@ -120,12 +213,17 @@ fun EmptyState(
textAlign = TextAlign.Center,
)
}
if (actionLabel != null && onAction != null) {
Spacer(modifier = Modifier.height(spacing.md))
OutlinedButton(onClick = onAction) {
Text(actionLabel)
}
}
}
}
/**
* Padding convention for List screens — outer 16, gaps of 8 between items.
*/
// ── Padding conventions ──────────────────────────────────────────────
val listContentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp)
val listItemSpacing = Arrangement.spacedBy(8.dp)
@@ -10,20 +10,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
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
@@ -37,6 +30,9 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -60,105 +56,92 @@ fun ConfigScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Raw Configuration") },
actions = {
IconButton(onClick = { viewModel.loadRawConfig() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}
},
)
},
modifier = modifier,
HermesScaffold(
title = "Configuration",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadRawConfig() },
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading && state.yamlText == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null && state.yamlText == null) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadRawConfig() }) {
Text("Retry")
}
}
} else {
Column(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
) {
state.path?.let {
Text(
text = "Path: $it",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
}
OutlinedTextField(
value = editingText,
onValueChange = { editingText = it },
modifier =
Modifier
.fillMaxWidth()
.weight(1f),
placeholder = { Text("YAML Config...") },
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
maxLines = Int.MAX_VALUE,
)
Spacer(modifier = Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { editingText = state.yamlText ?: "" },
modifier = Modifier.weight(1f),
when {
state.isLoading -> {
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadRawConfig() },
modifier = Modifier.padding(paddingValues),
)
}
else ->
Box(Modifier.fillMaxSize()) {
if (state.isLoading && state.yamlText == null) {
CircularProgressIndicator()
} else if (state.errorMessage != null && state.yamlText == null) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Reset")
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadRawConfig() }) {
Text("Retry")
}
}
Spacer(modifier = Modifier.width(16.dp))
Button(
onClick = { viewModel.saveRawConfig(editingText) },
modifier = Modifier.weight(1f),
enabled = !state.isSaving,
} else {
Column(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
) {
if (state.isSaving) {
CircularProgressIndicator(modifier = Modifier.width(16.dp))
} else {
Text("Save Config")
state.path?.let {
Text(
text = "Path: $it",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
}
OutlinedTextField(
value = editingText,
onValueChange = { editingText = it },
modifier =
Modifier
.fillMaxWidth()
.weight(1f),
placeholder = { Text("YAML Config...") },
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
maxLines = Int.MAX_VALUE,
)
Spacer(modifier = Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { editingText = state.yamlText ?: "" },
modifier = Modifier.weight(1f),
) {
Text("Reset")
}
Spacer(modifier = Modifier.width(16.dp))
Button(
onClick = { viewModel.saveRawConfig(editingText) },
modifier = Modifier.weight(1f),
enabled = !state.isSaving,
) {
if (state.isSaving) {
CircularProgressIndicator(modifier = Modifier.width(16.dp))
} else {
Text("Save Config")
}
}
}
}
}
}
}
}
}
}
@@ -2,42 +2,41 @@ package com.m57.hermescontrol.ui.cron
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.Schedule
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.OutlinedButton
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.theme.LocalSpacing
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.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CronJobsScreen(
modifier: Modifier = Modifier,
@@ -46,6 +45,7 @@ fun CronJobsScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val spacing = LocalSpacing.current
LaunchedEffect(Unit) {
viewModel.loadCronJobs()
@@ -58,90 +58,91 @@ fun CronJobsScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = androidx.compose.material.icons.Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Cron Jobs") },
actions = {
IconButton(onClick = { viewModel.loadCronJobs() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Refresh",
)
}
},
)
},
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),
HermesScaffold(
title = "Cron Jobs",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadCronJobs() },
) {
when {
state.isLoading -> LoadingState()
state.errorMessage != null ->
ErrorState(
message = state.errorMessage ?: "Unknown error",
onRetry = { viewModel.loadCronJobs() },
)
} else {
state.jobs.isEmpty() ->
EmptyState(
title = "No cron jobs",
subtitle = "Scheduled tasks from Hermes will appear here.",
icon = Icons.Filled.Schedule,
)
else ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
items(state.jobs) { job ->
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = job.name, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Schedule: ${job.scheduleText}",
style = MaterialTheme.typography.bodyMedium,
)
Text(text = "Status: ${job.state}", style = MaterialTheme.typography.bodyMedium)
Text(
text = "Last Run: ${job.lastRunStatus.ifBlank { "Never" }}",
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Next Run: ${job.nextRunTime.ifBlank { "N/A" }}",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(8.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(spacing.md)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
Text(
text = job.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
StatusBadge(
text = if (job.state == "active") "Active" else "Paused",
status =
if (job.state == "active") {
StatusBadgeType.SUCCESS
} else {
StatusBadgeType.NEUTRAL
},
)
}
Spacer(modifier = Modifier.height(spacing.xs))
Text(
text = job.scheduleText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(spacing.sm))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
if (job.state == "active") {
Button(onClick = { viewModel.pauseCronJob(job.id) }) {
OutlinedButton(
onClick = { viewModel.pauseCronJob(job.id) },
modifier = Modifier.weight(1f),
) {
Text("Pause")
}
} else {
Button(onClick = { viewModel.resumeCronJob(job.id) }) {
OutlinedButton(
onClick = { viewModel.resumeCronJob(job.id) },
modifier = Modifier.weight(1f),
) {
Text("Resume")
}
}
Button(onClick = { viewModel.triggerCronJob(job.id) }) {
Text("Trigger")
OutlinedButton(
onClick = { viewModel.triggerCronJob(job.id) },
modifier = Modifier.weight(1f),
) {
Text("Run")
}
Button(
onClick = { viewModel.deleteCronJob(job.id) },
@@ -149,6 +150,7 @@ fun CronJobsScreen(
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
modifier = Modifier.weight(1f),
) {
Text("Delete")
}
@@ -157,7 +159,6 @@ fun CronJobsScreen(
}
}
}
}
}
}
}
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
@@ -24,11 +23,8 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -40,6 +36,9 @@ 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.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -62,193 +61,101 @@ fun GatewayScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Gateway Control") },
actions = {
IconButton(
onClick = { viewModel.loadStatus() },
enabled = !state.isLoading,
) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}
},
)
},
modifier = modifier,
HermesScaffold(
title = "Gateway Control",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadStatus() },
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading && state.status == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null && state.status == null) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadStatus() }) {
Text("Retry")
}
}
} else {
val status = state.status
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
val isRunning = status?.gateway_running == true
// Status Overview Card
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (isRunning) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.errorContainer
},
),
) {
when {
state.isLoading -> {
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadStatus() },
modifier = Modifier.padding(paddingValues),
)
}
else ->
Box(Modifier.fillMaxSize()) {
if (state.isLoading && state.status == null) {
CircularProgressIndicator()
} else if (state.errorMessage != null && state.status == null) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = if (isRunning) "GATEWAY RUNNING" else "GATEWAY STOPPED",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color =
if (isRunning) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onErrorContainer
},
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
status?.version?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Version: $it",
style = MaterialTheme.typography.bodySmall,
color =
if (isRunning) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
} else {
MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f)
},
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadStatus() }) {
Text("Retry")
}
}
}
// Action Controls Card
Card(
modifier = Modifier.fillMaxWidth(),
) {
} else {
val status = state.status
Column(
modifier =
Modifier
.fillMaxWidth()
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Controls",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
val isRunning = status?.gateway_running == true
Row(
// Status Overview Card
Card(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { viewModel.startGateway() },
modifier = Modifier.weight(1f),
enabled = !isRunning && !state.isActionRunning,
colors =
ButtonDefaults.buttonColors(
containerColor = Color(0xFF4CAF50), // Green for start
contentColor = Color.White,
),
) {
Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null)
Spacer(modifier = Modifier.width(4.dp))
Text("Start")
}
Button(
onClick = { viewModel.stopGateway() },
modifier = Modifier.weight(1f),
enabled = isRunning && !state.isActionRunning,
colors =
ButtonDefaults.buttonColors(
containerColor = Color(0xFFF44336), // Red for stop
contentColor = Color.White,
),
) {
Text("Stop")
}
}
Button(
onClick = { viewModel.restartGateway() },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isActionRunning,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary,
CardDefaults.cardColors(
containerColor =
if (isRunning) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.errorContainer
},
),
) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = null)
Spacer(modifier = Modifier.width(4.dp))
Text("Restart")
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = if (isRunning) "GATEWAY RUNNING" else "GATEWAY STOPPED",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color =
if (isRunning) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onErrorContainer
},
)
status?.version?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Version: $it",
style = MaterialTheme.typography.bodySmall,
color =
if (isRunning) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
} else {
MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f)
},
)
}
}
}
if (state.isActionRunning) {
Spacer(modifier = Modifier.height(4.dp))
CircularProgressIndicator(
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
}
}
// Platforms Card
status?.gateway_platforms?.let { platforms ->
if (platforms.isNotEmpty()) {
// Action Controls Card
Card(
modifier = Modifier.fillMaxWidth(),
) {
@@ -257,42 +164,119 @@ fun GatewayScreen(
Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Active Platforms",
text = "Controls",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
platforms.forEach { (platform, pStatus) ->
Row(
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { viewModel.startGateway() },
modifier = Modifier.weight(1f),
enabled = !isRunning && !state.isActionRunning,
colors =
ButtonDefaults.buttonColors(
containerColor = Color(0xFF4CAF50), // Green for start
contentColor = Color.White,
),
) {
Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null)
Spacer(modifier = Modifier.width(4.dp))
Text("Start")
}
Button(
onClick = { viewModel.stopGateway() },
modifier = Modifier.weight(1f),
enabled = isRunning && !state.isActionRunning,
colors =
ButtonDefaults.buttonColors(
containerColor = Color(0xFFF44336), // Red for stop
contentColor = Color.White,
),
) {
Text("Stop")
}
}
Button(
onClick = { viewModel.restartGateway() },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isActionRunning,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary,
),
) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = null)
Spacer(modifier = Modifier.width(4.dp))
Text("Restart")
}
if (state.isActionRunning) {
Spacer(modifier = Modifier.height(4.dp))
CircularProgressIndicator(
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
}
}
// Platforms Card
status?.gateway_platforms?.let { platforms ->
if (platforms.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = platform.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
text = "Active Platforms",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
val stateText = pStatus.state?.uppercase() ?: "UNKNOWN"
val badgeColor =
when (stateText) {
"RUNNING" -> Color(0xFF4CAF50)
"STOPPED" -> Color(0xFFF44336)
"ERROR" -> Color(0xFFFF9800)
else -> MaterialTheme.colorScheme.onSurfaceVariant
platforms.forEach { (platform, pStatus) ->
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = platform.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
val stateText = pStatus.state?.uppercase() ?: "UNKNOWN"
val badgeColor =
when (stateText) {
"RUNNING" -> Color(0xFF4CAF50)
"STOPPED" -> Color(0xFFF44336)
"ERROR" -> Color(0xFFFF9800)
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Text(
text = stateText,
style = MaterialTheme.typography.bodyMedium,
color = badgeColor,
fontWeight = FontWeight.Bold,
)
}
Text(
text = stateText,
style = MaterialTheme.typography.bodyMedium,
color = badgeColor,
fontWeight = FontWeight.Bold,
)
}
}
}
}
@@ -300,7 +284,6 @@ fun GatewayScreen(
}
}
}
}
}
}
}
@@ -17,25 +17,19 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
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
@@ -50,6 +44,9 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -73,145 +70,124 @@ fun KanbanScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text(state.selectedBoard?.name ?: "Kanban Boards") },
actions = {
IconButton(onClick = { viewModel.loadBoards() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Refresh",
)
}
},
)
},
floatingActionButton = {
if (state.selectedBoard != null) {
FloatingActionButton(onClick = { showAddTaskDialog = true }) {
Icon(imageVector = Icons.Filled.Add, contentDescription = "Add Task")
}
}
},
modifier = modifier,
HermesScaffold(
title = "Kanban Board",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadBoards() },
) { 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),
when {
state.isLoading -> {
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadBoards() },
modifier = Modifier.padding(paddingValues),
)
} else {
Column(modifier = Modifier.fillMaxSize()) {
// Board selector tab row
if (state.boards.isNotEmpty()) {
ScrollableTabRow(
selectedTabIndex = state.boards.indexOf(state.selectedBoard).coerceAtLeast(0),
modifier = Modifier.fillMaxWidth(),
) {
state.boards.forEach { board ->
Tab(
selected = board == state.selectedBoard,
onClick = { viewModel.selectBoard(board) },
text = { Text(board.name) },
)
}
}
}
if (state.selectedBoard == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No boards found.")
}
} else if (state.columns.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No columns found for this board.")
}
}
else ->
Box(Modifier.fillMaxSize()) {
if (state.isLoading) {
CircularProgressIndicator()
} else if (state.errorMessage != null) {
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
} else {
LazyRow(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(state.columns) { column ->
val colName = column.name
val colTasks =
state.tasks.filter {
it.status.equals(colName, ignoreCase = true)
}
val columnIndex = state.columns.indexOf(column)
val prevColumn = state.columns.getOrNull(columnIndex - 1)
val nextColumn = state.columns.getOrNull(columnIndex + 1)
Column(
modifier =
Modifier
.width(280.dp)
.fillMaxSize(),
Column(modifier = Modifier.fillMaxSize()) {
// Board selector tab row
if (state.boards.isNotEmpty()) {
ScrollableTabRow(
selectedTabIndex = state.boards.indexOf(state.selectedBoard).coerceAtLeast(0),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "${colName.replaceFirstChar { it.uppercase() }} (${colTasks.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp),
)
state.boards.forEach { board ->
Tab(
selected = board == state.selectedBoard,
onClick = { viewModel.selectBoard(board) },
text = { Text(board.name) },
)
}
}
}
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(colTasks) { task ->
TaskCard(
task = task,
onMoveLeft =
prevColumn?.let {
{ viewModel.moveTask(task, it.name) }
},
onMoveRight =
nextColumn?.let {
{ viewModel.moveTask(task, it.name) }
},
if (state.selectedBoard == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No boards found.")
}
} else if (state.columns.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No columns found for this board.")
}
} else {
LazyRow(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(state.columns) { column ->
val colName = column.name
val colTasks =
state.tasks.filter {
it.status.equals(colName, ignoreCase = true)
}
val columnIndex = state.columns.indexOf(column)
val prevColumn = state.columns.getOrNull(columnIndex - 1)
val nextColumn = state.columns.getOrNull(columnIndex + 1)
Column(
modifier =
Modifier
.width(280.dp)
.fillMaxSize(),
) {
Text(
text = "${colName.replaceFirstChar { it.uppercase() }} (${
colTasks.size
})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp),
)
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(colTasks) { task ->
TaskCard(
task = task,
onMoveLeft =
prevColumn?.let {
{ viewModel.moveTask(task, it.name) }
},
onMoveRight =
nextColumn?.let {
{ viewModel.moveTask(task, it.name) }
},
)
}
}
}
}
}
}
}
}
}
}
if (showAddTaskDialog) {
AddTaskDialog(
onDismiss = { showAddTaskDialog = false },
onConfirm = { title, desc ->
viewModel.createTask(title, desc, state.columns.firstOrNull()?.name ?: "todo")
showAddTaskDialog = false
},
)
}
if (showAddTaskDialog) {
AddTaskDialog(
onDismiss = { showAddTaskDialog = false },
onConfirm = { title, desc ->
viewModel.createTask(title, desc, state.columns.firstOrNull()?.name ?: "todo")
showAddTaskDialog = false
},
)
}
}
}
}
}
@@ -16,8 +16,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
@@ -28,9 +26,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
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
@@ -44,6 +40,9 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -66,152 +65,137 @@ fun KeysScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Keys & Credentials") },
actions = {
IconButton(onClick = { viewModel.loadKeys() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Refresh",
)
}
},
)
},
modifier = modifier,
HermesScaffold(
title = "Keys & Credentials",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadKeys() },
) { 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),
when {
state.isLoading -> {
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadKeys() },
modifier = Modifier.padding(paddingValues),
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.envVars.toList()) { (key, config) ->
var isEditing by remember { mutableStateOf(false) }
val revealedVal = state.revealedValues[key]
val isRevealed = revealedVal != null
var editedValue by remember { mutableStateOf(revealedVal ?: "") }
}
else ->
Box(Modifier.fillMaxSize()) {
if (state.isLoading) {
CircularProgressIndicator()
} else if (state.errorMessage != null) {
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.envVars.toList()) { (key, config) ->
var isEditing by remember { mutableStateOf(false) }
val revealedVal = state.revealedValues[key]
val isRevealed = revealedVal != null
var editedValue by remember { mutableStateOf(revealedVal ?: "") }
val displayValue =
if (isRevealed) {
revealedVal.orEmpty()
} else if (config.isSet) {
config.redactedValue ?: "********"
} else {
"Not configured"
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = key,
style = MaterialTheme.typography.titleMedium,
fontFamily = FontFamily.Monospace,
)
if (!config.description.isNullOrBlank()) {
Text(
text = config.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.padding(vertical = 4.dp),
)
}
Spacer(modifier = Modifier.height(4.dp))
if (isEditing) {
OutlinedTextField(
value = editedValue,
onValueChange = { editedValue = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
Button(onClick = {
viewModel.updateKey(key, editedValue)
isEditing = false
}) {
Text("Save")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = {
isEditing = false
}) {
Text("Cancel")
}
val displayValue =
if (isRevealed) {
revealedVal.orEmpty()
} else if (config.isSet) {
config.redactedValue ?: "********"
} else {
"Not configured"
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = displayValue,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
text = key,
style = MaterialTheme.typography.titleMedium,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f),
)
Row {
if (config.isSet) {
IconButton(onClick = {
if (isRevealed) {
viewModel.hideKey(key)
} else {
viewModel.revealKey(key)
}
if (!config.description.isNullOrBlank()) {
Text(
text = config.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.padding(vertical = 4.dp),
)
}
Spacer(modifier = Modifier.height(4.dp))
if (isEditing) {
OutlinedTextField(
value = editedValue,
onValueChange = { editedValue = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
Button(onClick = {
viewModel.updateKey(key, editedValue)
isEditing = false
}) {
Icon(
imageVector =
if (isRevealed) {
Icons.Filled.VisibilityOff
} else {
Icons.Filled.Visibility
},
contentDescription = "Toggle Visibility",
)
Text("Save")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = {
isEditing = false
}) {
Text("Cancel")
}
}
IconButton(onClick = {
editedValue = if (isRevealed) revealedVal.orEmpty() else ""
isEditing = true
}) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = "Edit",
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = displayValue,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f),
)
Row {
if (config.isSet) {
IconButton(onClick = {
if (isRevealed) {
viewModel.hideKey(key)
} else {
viewModel.revealKey(key)
}
}) {
Icon(
imageVector =
if (isRevealed) {
Icons.Filled.VisibilityOff
} else {
Icons.Filled.Visibility
},
contentDescription = "Toggle Visibility",
)
}
}
IconButton(onClick = {
editedValue = if (isRevealed) revealedVal.orEmpty() else ""
isEditing = true
}) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = "Edit",
)
}
}
}
}
}
@@ -220,7 +204,6 @@ fun KeysScreen(
}
}
}
}
}
}
}
@@ -2,7 +2,6 @@ package com.m57.hermescontrol.ui.logs
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -11,30 +10,25 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.filled.HistoryEdu
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.theme.LocalSpacing
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LogsScreen(
modifier: Modifier = Modifier,
@@ -43,6 +37,7 @@ fun LogsScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val spacing = LocalSpacing.current
val listState = rememberLazyListState()
LaunchedEffect(Unit) {
@@ -63,73 +58,43 @@ fun LogsScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("System Logs") },
actions = {
IconButton(onClick = { viewModel.loadLogs() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Refresh",
)
}
},
)
},
modifier = modifier,
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues)
.background(Color.Black),
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center), color = Color.Green)
} else if (state.errorMessage != null) {
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
HermesScaffold(
title = "Logs",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadLogs() },
) {
when {
state.isLoading -> LoadingState()
state.errorMessage != null ->
ErrorState(
message = state.errorMessage ?: "Unknown error",
onRetry = { viewModel.loadLogs() },
)
} else if (state.logs.isEmpty()) {
// B5 (Jun 18 2026, kanban t_2322818d): explicit empty-state UX.
// Sibling screens (PairingScreen.kt, WebhooksScreen.kt) did this;
// LogsScreen didn't — when logs were empty and not loading, the
// black Box rendered with NO content visible to the user.
Text(
text = "No logs available",
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
state.logs.isEmpty() ->
// B5 (Jun 18 2026, kanban t_2322818d): proper empty-state using
// the shared EmptyState component instead of a bare Text.
EmptyState(
title = "No logs available",
subtitle = "Log output from Hermes will appear here.",
icon = Icons.Filled.HistoryEdu,
)
} else {
else ->
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainerLowest),
contentPadding =
PaddingValues(
horizontal = spacing.md,
vertical = spacing.sm,
),
) {
items(state.logs) { logLine ->
Text(
text = logLine,
color = Color(0xFF00FF00),
color = MaterialTheme.colorScheme.onSurface,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
modifier =
@@ -139,7 +104,6 @@ fun LogsScreen(
)
}
}
}
}
}
}
@@ -13,22 +13,15 @@ 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.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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
@@ -40,6 +33,9 @@ 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.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -62,141 +58,128 @@ fun McpServersScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("MCP Servers") },
actions = {
IconButton(onClick = { viewModel.loadServers() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}
},
)
},
modifier = modifier,
HermesScaffold(
title = "MCP Servers",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadServers() },
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading && state.servers.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null && state.servers.isEmpty()) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadServers() }) {
Text("Retry")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.servers) { server ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (server.enabled) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
when {
state.isLoading -> {
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadServers() },
modifier = Modifier.padding(paddingValues),
)
}
else ->
Box(Modifier.fillMaxSize()) {
if (state.isLoading && state.servers.isEmpty()) {
CircularProgressIndicator()
} else if (state.errorMessage != null && state.servers.isEmpty()) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Row(
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadServers() }) {
Text("Retry")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.servers) { server ->
Card(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
colors =
CardDefaults.cardColors(
containerColor =
if (server.enabled) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = server.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = "Transport: ${server.transport ?: "stdio"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = server.enabled,
onCheckedChange = { viewModel.toggleServer(server) },
)
}
if (server.command != null) {
Spacer(modifier = Modifier.height(8.dp))
val fullCmd = "${server.command} ${server.args.orEmpty().joinToString(" ")}"
Text(
text = "Command:",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
Text(
text = fullCmd,
style =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
),
)
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
OutlinedButton(
onClick = { viewModel.testServer(server.name) },
modifier = Modifier.padding(end = 8.dp),
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Text("Test")
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = server.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = "Transport: ${server.transport ?: "stdio"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(
onClick = { viewModel.deleteServer(server.name) },
) {
Text("Delete")
Switch(
checked = server.enabled,
onCheckedChange = { viewModel.toggleServer(server) },
)
}
if (server.command != null) {
Spacer(modifier = Modifier.height(8.dp))
val fullCmd = "${server.command} ${server.args.orEmpty().joinToString(" ")}"
Text(
text = "Command:",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
Text(
text = fullCmd,
style =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
),
)
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
OutlinedButton(
onClick = { viewModel.testServer(server.name) },
modifier = Modifier.padding(end = 8.dp),
) {
Text("Test")
}
OutlinedButton(
onClick = { viewModel.deleteServer(server.name) },
) {
Text("Delete")
}
}
}
}
}
}
}
}
}
}
}
}
@@ -17,7 +17,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -26,9 +25,7 @@ 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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -42,6 +39,9 @@ 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.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -65,198 +65,193 @@ fun ModelScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Model Providers") },
actions = {
IconButton(onClick = { viewModel.loadModelOptions() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}
},
)
},
modifier = modifier,
HermesScaffold(
title = "Models",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadModelOptions() },
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading && state.providers.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null && state.providers.isEmpty()) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
IconButton(onClick = { viewModel.loadModelOptions() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.providers) { provider ->
val isExpanded = expandedProviderSlug == provider.slug
val isCurrent = provider.is_current == true
Card(
modifier = Modifier.fillMaxWidth(),
onClick = {
expandedProviderSlug = if (isExpanded) null else provider.slug
},
colors =
CardDefaults.cardColors(
containerColor =
if (isCurrent) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
when {
state.isLoading -> {
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadModelOptions() },
modifier = Modifier.padding(paddingValues),
)
}
else ->
Box(Modifier.fillMaxSize()) {
if (state.isLoading && state.providers.isEmpty()) {
CircularProgressIndicator()
} else if (state.errorMessage != null && state.providers.isEmpty()) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Row(
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
IconButton(onClick = { viewModel.loadModelOptions() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.providers) { provider ->
val isExpanded = expandedProviderSlug == provider.slug
val isCurrent = provider.is_current == true
Card(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
onClick = {
expandedProviderSlug = if (isExpanded) null else provider.slug
},
colors =
CardDefaults.cardColors(
containerColor =
if (isCurrent) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Column {
Text(
text = provider.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = "Slug: ${provider.slug}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = provider.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = "Slug: ${provider.slug}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (isCurrent) {
Text(
text = "CURRENT",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
}
}
if (isCurrent) {
Text(
text = "CURRENT",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
}
}
provider.warning?.let {
if (it.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
provider.warning?.let {
if (it.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
AnimatedVisibility(visible = isExpanded) {
Column(modifier = Modifier.padding(top = 16.dp)) {
Text(
text = "Available Models:",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 8.dp),
)
AnimatedVisibility(visible = isExpanded) {
Column(modifier = Modifier.padding(top = 16.dp)) {
Text(
text = "Available Models:",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 8.dp),
)
val models = provider.models.orEmpty()
if (models.isEmpty()) {
Text(
text = "No models reported or provider needs authentication.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
models.forEach { model ->
val isActive =
state.activeProfile?.provider == provider.slug &&
state.activeProfile?.model == model
Card(
onClick = {
viewModel.selectModel(provider.slug, model)
},
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
colors =
CardDefaults.cardColors(
containerColor =
val models = provider.models.orEmpty()
if (models.isEmpty()) {
Text(
text = "No models reported or provider needs authentication.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
models.forEach { model ->
val isActive =
state.activeProfile?.provider == provider.slug &&
state.activeProfile?.model == model
Card(
onClick = {
viewModel.selectModel(provider.slug, model)
},
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
colors =
CardDefaults.cardColors(
containerColor =
if (isActive) {
MaterialTheme.colorScheme
.primaryContainer.copy(
alpha = 0.3f,
)
} else {
MaterialTheme.colorScheme.surface
},
),
border =
BorderStroke(
width = 1.dp,
color =
if (isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(
alpha = 0.2f,
)
},
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = model,
style = MaterialTheme.typography.bodyMedium,
fontWeight =
if (isActive) {
FontWeight.Bold
} else {
FontWeight.Normal
},
color =
if (isActive) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
)
if (isActive) {
MaterialTheme.colorScheme.primaryContainer.copy(
alpha = 0.3f,
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Active Model",
tint = MaterialTheme.colorScheme.primary,
)
} else {
MaterialTheme.colorScheme.surface
},
),
border =
BorderStroke(
width = 1.dp,
color =
if (isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
},
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = model,
style = MaterialTheme.typography.bodyMedium,
fontWeight =
if (isActive) FontWeight.Bold else FontWeight.Normal,
color =
if (isActive) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
)
if (isActive) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Active Model",
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
@@ -268,7 +263,6 @@ fun ModelScreen(
}
}
}
}
}
}
}
@@ -13,22 +13,15 @@ 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.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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
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
@@ -39,6 +32,9 @@ 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.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -61,211 +57,201 @@ fun PairingScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Device Pairing") },
actions = {
IconButton(onClick = { viewModel.loadPairing() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}
},
)
},
modifier = modifier,
HermesScaffold(
title = "Device Pairing",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadPairing() },
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading && state.pairing == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null && state.pairing == null) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadPairing() }) {
Text("Retry")
}
}
} else {
val pairing = state.pairing
val pending = pairing?.pending.orEmpty()
val approved = pairing?.approved.orEmpty()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (pending.isNotEmpty()) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Pending Approvals",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
OutlinedButton(
onClick = { viewModel.clearPending() },
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text("Clear All")
}
}
}
items(pending) { item ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f),
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Platform: ${item.platform.uppercase()}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
item.code?.let {
Text(
text = "Pairing Code: $it",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
}
}
Button(
onClick = {
viewModel.approvePairing(item.platform, item.code ?: "")
},
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text("Approve")
}
}
}
}
}
item {
Text(
text = "Approved Devices",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
if (approved.isEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "No approved devices paired yet.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
when {
state.isLoading -> {
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadPairing() },
modifier = Modifier.padding(paddingValues),
)
}
else ->
Box(Modifier.fillMaxSize()) {
if (state.isLoading && state.pairing == null) {
CircularProgressIndicator()
} else if (state.errorMessage != null && state.pairing == null) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadPairing() }) {
Text("Retry")
}
}
} else {
items(approved) { item ->
Card(
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
val pairing = state.pairing
val pending = pairing?.pending.orEmpty()
val approved = pairing?.approved.orEmpty()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (pending.isNotEmpty()) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Platform: ${item.platform.uppercase()}",
text = "Pending Approvals",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Bold,
)
Text(
text = "User ID: ${item.user_id ?: "Unknown"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
item.username?.let {
OutlinedButton(
onClick = { viewModel.clearPending() },
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text("Clear All")
}
}
}
items(pending) { item ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.2f,
),
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Platform: ${item.platform.uppercase()}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
item.code?.let {
Text(
text = "Pairing Code: $it",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
}
}
Button(
onClick = {
viewModel.approvePairing(item.platform, item.code ?: "")
},
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text("Approve")
}
}
}
}
}
item {
Text(
text = "Approved Devices",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
if (approved.isEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "Username: @$it",
style = MaterialTheme.typography.bodySmall,
text = "No approved devices paired yet.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
OutlinedButton(
onClick = {
viewModel.revokePairing(item.platform, item.user_id ?: "")
},
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
}
} else {
items(approved) { item ->
Card(
modifier = Modifier.fillMaxWidth(),
) {
Text("Revoke")
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Platform: ${item.platform.uppercase()}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "User ID: ${item.user_id ?: "Unknown"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
item.username?.let {
Text(
text = "Username: @$it",
style = MaterialTheme.typography.bodySmall,
)
}
}
OutlinedButton(
onClick = {
viewModel.revokePairing(item.platform, item.user_id ?: "")
},
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text("Revoke")
}
}
}
}
}
}
}
}
}
}
}
}
@@ -14,21 +14,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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
@@ -38,6 +31,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -60,107 +56,92 @@ fun PluginsScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Plugins Hub") },
actions = {
IconButton(onClick = { viewModel.loadPlugins() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Refresh",
)
}
},
)
},
modifier = modifier,
HermesScaffold(
title = "Plugins",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadPlugins() },
) { 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),
when {
state.isLoading -> {
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadPlugins() },
modifier = Modifier.padding(paddingValues),
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.plugins) { plugin ->
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = plugin.name, style = MaterialTheme.typography.titleMedium)
plugin.version?.let {
Text(
text = "v$it",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (plugin.installed) {
Switch(
checked = plugin.enabled,
onCheckedChange = { viewModel.togglePlugin(plugin) },
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = plugin.description ?: "No description provided.",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
if (plugin.installed) {
OutlinedButton(onClick = { viewModel.updatePlugin(plugin.name) }) {
Text("Update")
}
Spacer(modifier = Modifier.width(8.dp))
OutlinedButton(
onClick = { viewModel.uninstallPlugin(plugin.name) },
colors =
androidx.compose.material3.ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
}
else ->
Box(Modifier.fillMaxSize()) {
if (state.isLoading) {
CircularProgressIndicator()
} else if (state.errorMessage != null) {
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.plugins) { plugin ->
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Uninstall")
Column(modifier = Modifier.weight(1f)) {
Text(text = plugin.name, style = MaterialTheme.typography.titleMedium)
plugin.version?.let {
Text(
text = "v$it",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (plugin.installed) {
Switch(
checked = plugin.enabled,
onCheckedChange = { viewModel.togglePlugin(plugin) },
)
}
}
} else {
Button(onClick = { viewModel.installPlugin(plugin.name) }) {
Text("Install")
Spacer(modifier = Modifier.height(4.dp))
Text(
text = plugin.description ?: "No description provided.",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
if (plugin.installed) {
OutlinedButton(onClick = { viewModel.updatePlugin(plugin.name) }) {
Text("Update")
}
Spacer(modifier = Modifier.width(8.dp))
OutlinedButton(
onClick = { viewModel.uninstallPlugin(plugin.name) },
colors =
androidx.compose.material3.ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text("Uninstall")
}
} else {
Button(onClick = { viewModel.installPlugin(plugin.name) }) {
Text("Install")
}
}
}
}
}
@@ -168,7 +149,6 @@ fun PluginsScreen(
}
}
}
}
}
}
}
@@ -17,23 +17,15 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -47,6 +39,9 @@ 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.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -74,268 +69,158 @@ fun ProfilesScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Agent Profiles") },
actions = {
IconButton(onClick = { viewModel.loadProfiles() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}
},
)
},
modifier = modifier,
HermesScaffold(
title = "Profiles",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadProfiles() },
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading && state.profiles.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null && state.profiles.isEmpty()) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadProfiles() }) {
Text("Retry")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.profiles) { profile ->
val isActive = profile.name == state.activeProfileName
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (isActive) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
onClick = {
if (!isActive) {
viewModel.selectActiveProfile(profile.name)
}
},
when {
state.isLoading -> {
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadProfiles() },
modifier = Modifier.padding(paddingValues),
)
}
else ->
Box(Modifier.fillMaxSize()) {
if (state.isLoading && state.profiles.isEmpty()) {
CircularProgressIndicator()
} else if (state.errorMessage != null && state.profiles.isEmpty()) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Row(
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadProfiles() }) {
Text("Retry")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.profiles) { profile ->
val isActive = profile.name == state.activeProfileName
Card(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
colors =
CardDefaults.cardColors(
containerColor =
if (isActive) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
onClick = {
if (!isActive) {
viewModel.selectActiveProfile(profile.name)
}
},
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = profile.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
profile.description?.let {
if (it.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = it,
text = profile.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
profile.description?.let {
if (it.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (isActive) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Active Profile",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp),
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = "Model: ${profile.model ?: "None"}",
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Provider: ${profile.provider ?: "None"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (isActive) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Active Profile",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp),
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = "Model: ${profile.model ?: "None"}",
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Provider: ${profile.provider ?: "None"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
OutlinedButton(
onClick = {
soulEditProfileName = profile.name
viewModel.loadSoul(profile.name)
},
modifier = Modifier.padding(end = 8.dp),
) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = null,
modifier = Modifier.width(16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text("Edit Soul")
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
OutlinedButton(
onClick = {
soulEditProfileName = profile.name
viewModel.loadSoul(profile.name)
},
modifier = Modifier.padding(end = 8.dp),
) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = null,
modifier = Modifier.width(16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text("Edit Soul")
}
Button(
onClick = {
modelEditProfileName = profile.name
tempModelProvider = profile.provider ?: ""
tempModelName = profile.model ?: ""
},
) {
Text("Set Model")
Button(
onClick = {
modelEditProfileName = profile.name
tempModelProvider = profile.provider ?: ""
tempModelName = profile.model ?: ""
},
) {
Text("Set Model")
}
}
}
}
}
}
}
}
}
}
// Soul Edit Dialog
soulEditProfileName?.let { profileName ->
var soulText by remember(state.selectedSoulContent) {
mutableStateOf(state.selectedSoulContent ?: "")
}
AlertDialog(
onDismissRequest = {
soulEditProfileName = null
viewModel.closeSoulDialog()
},
title = { Text("Edit Soul ($profileName)") },
text = {
Box(modifier = Modifier.fillMaxWidth()) {
if (state.isLoadingSoul) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
OutlinedTextField(
value = soulText,
onValueChange = { soulText = it },
modifier =
Modifier
.fillMaxWidth()
.height(200.dp),
label = { Text("Soul/Persona Prompts") },
maxLines = 10,
)
}
}
},
confirmButton = {
TextButton(
onClick = {
viewModel.saveSoul(profileName, soulText)
soulEditProfileName = null
},
enabled = !state.isLoadingSoul,
) {
Text("Save")
}
},
dismissButton = {
TextButton(
onClick = {
soulEditProfileName = null
viewModel.closeSoulDialog()
},
) {
Text("Cancel")
}
},
)
}
// Model Edit Dialog
modelEditProfileName?.let { profileName ->
AlertDialog(
onDismissRequest = { modelEditProfileName = null },
title = { Text("Switch Model ($profileName)") },
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = tempModelProvider,
onValueChange = { tempModelProvider = it },
label = { Text("Provider (e.g. nvidia, openai, local)") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = tempModelName,
onValueChange = { tempModelName = it },
label = { Text("Model Name") },
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
onClick = {
viewModel.updateModel(profileName, tempModelProvider, tempModelName)
modelEditProfileName = null
},
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = { modelEditProfileName = null }) {
Text("Cancel")
}
},
)
}
}
}
@@ -29,10 +29,12 @@ 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.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
@@ -44,6 +46,7 @@ fun SkillsScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val spacing = LocalSpacing.current
var query by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
@@ -82,8 +85,8 @@ fun SkillsScreen(
)
state.skills.isEmpty() ->
EmptyState(
title = "No skills",
subtitle = "Tap refresh to retry loading skills from Hermes.",
title = "No skills found",
subtitle = "Skills loaded from Hermes will appear here.",
icon = Icons.Filled.Extension,
)
else ->
@@ -92,17 +95,13 @@ fun SkillsScreen(
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(),
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = "Search skills…",
)
}
// Filtered list
items(filtered) { skill ->
Card(
modifier = Modifier.fillMaxWidth(),
@@ -115,7 +114,7 @@ fun SkillsScreen(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
.padding(spacing.md),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -2,40 +2,46 @@ package com.m57.hermescontrol.ui.system
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.FlowRow
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.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.HealthAndSafety
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material3.Button
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.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing
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.InfoRow
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SectionHeader
import com.m57.hermescontrol.ui.common.StatCard
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
fun SystemScreen(
modifier: Modifier = Modifier,
@@ -44,6 +50,8 @@ fun SystemScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val spacing = LocalSpacing.current
val statusColors = LocalHermesStatusColors.current
LaunchedEffect(Unit) {
viewModel.loadSystemData()
@@ -56,138 +64,141 @@ fun SystemScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
HermesScaffold(
title = "System",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadSystemData() },
) {
when {
state.isLoading -> LoadingState()
state.errorMessage != null ->
ErrorState(
message = state.errorMessage ?: "Unknown error",
onRetry = { viewModel.loadSystemData() },
)
state.stats == null && state.doctorReport == null ->
EmptyState(
title = "No system data",
subtitle = "Tap refresh to load system diagnostics.",
)
else ->
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding =
androidx.compose.foundation.layout.PaddingValues(
horizontal = spacing.md,
vertical = spacing.sm,
),
verticalArrangement = Arrangement.spacedBy(spacing.sm),
) {
// Stats row
state.stats?.let { stats ->
item {
SectionHeader(title = "Performance")
}
item {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
stats.cpuPercent?.let { cpu ->
StatCard(
label = "CPU Usage",
value = "$cpu%",
icon = Icons.Filled.Speed,
modifier = Modifier.weight(1f),
)
}
stats.memoryPercent?.let { mem ->
StatCard(
label = "Memory",
value = "$mem%",
icon = Icons.Filled.Memory,
modifier = Modifier.weight(1f),
)
}
}
}
}
},
title = { Text("System Diagnostics") },
actions = {
IconButton(onClick = { viewModel.loadSystemData() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Refresh",
)
// Doctor diagnostics
state.doctorReport?.let { report ->
item {
SectionHeader(title = "Doctor")
}
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(spacing.md)) {
Row(
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
Icon(
imageVector = Icons.Filled.HealthAndSafety,
contentDescription = null,
tint =
if (report.ok) {
statusColors.success
} else {
statusColors.error
},
)
StatusBadge(
text = if (report.ok) "Running" else "Failed",
status =
if (report.ok) {
StatusBadgeType.SUCCESS
} else {
StatusBadgeType.ERROR
},
)
}
Spacer(modifier = Modifier.height(spacing.sm))
report.pid?.let { pid ->
InfoRow(label = "Process PID", value = pid.toString())
}
report.name?.let { name ->
InfoRow(label = "Process Name", value = name)
}
}
}
}
}
},
)
},
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(16.dp),
) {
// Admin actions
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "System Administration",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(8.dp))
SectionHeader(title = "Administration")
}
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(spacing.md)) {
Button(
onClick = { viewModel.triggerBackup() },
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Filled.Backup,
contentDescription = null,
modifier = Modifier.padding(end = spacing.xs),
)
Text("Trigger System Backup")
}
}
}
}
// System Stats (Mock structure check)
state.stats?.let { stats ->
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Performance Stats",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(8.dp))
stats.cpuPercent?.let {
Text(text = "CPU Usage: $it%", style = MaterialTheme.typography.bodyMedium)
}
stats.memoryPercent?.let {
Text(text = "Memory Usage: $it%", style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
// Doctor status
state.doctorReport?.let { report ->
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Doctor Diagnostics",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Diagnostics Status: ${if (report.ok) "Running" else "Failed"}",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color =
if (report.ok) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
},
)
report.pid?.let { pid ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Process PID: $pid",
style = MaterialTheme.typography.bodyMedium,
)
}
report.name?.let { name ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Process Name: $name",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
}
}
}
}
}
@@ -15,7 +15,6 @@ 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.material3.Card
import androidx.compose.material3.CardDefaults
@@ -24,11 +23,9 @@ 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.SuggestionChip
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
@@ -39,6 +36,9 @@ 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.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -61,126 +61,115 @@ fun ToolsetsScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Toolsets Manager") },
actions = {
IconButton(onClick = { viewModel.loadToolsets() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}
},
)
},
modifier = modifier,
HermesScaffold(
title = "Toolsets",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadToolsets() },
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading && state.toolsets.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null && state.toolsets.isEmpty()) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
IconButton(onClick = { viewModel.loadToolsets() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.toolsets) { toolset ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (toolset.enabled) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
when {
state.isLoading -> {
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadToolsets() },
modifier = Modifier.padding(paddingValues),
)
}
else ->
Box(Modifier.fillMaxSize()) {
if (state.isLoading && state.toolsets.isEmpty()) {
CircularProgressIndicator()
} else if (state.errorMessage != null && state.toolsets.isEmpty()) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = toolset.label ?: toolset.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
toolset.description?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
IconButton(onClick = { viewModel.loadToolsets() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.toolsets) { toolset ->
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (toolset.enabled) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text =
toolset.label
?: toolset.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
toolset.description?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
toolset.tools?.let { tools ->
if (tools.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
tools.forEach { tool ->
SuggestionChip(
onClick = {},
label = {
Text(
text = tool,
style = MaterialTheme.typography.labelSmall,
toolset.tools?.let { tools ->
if (tools.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
tools.forEach { tool ->
SuggestionChip(
onClick = {},
label = {
Text(
text = tool,
style = MaterialTheme.typography.labelSmall,
)
},
)
},
)
}
}
}
}
}
Switch(
checked = toolset.enabled,
onCheckedChange = { viewModel.toggleToolset(toolset) },
modifier = Modifier.padding(start = 16.dp),
)
}
}
Switch(
checked = toolset.enabled,
onCheckedChange = { viewModel.toggleToolset(toolset) },
modifier = Modifier.padding(start = 16.dp),
)
}
}
}
}
}
}
}
}
@@ -14,22 +14,15 @@ 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.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
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.SuggestionChip
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
@@ -41,6 +34,9 @@ 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.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -63,183 +59,171 @@ fun WebhooksScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("Webhooks Dispatcher") },
actions = {
IconButton(onClick = { viewModel.loadWebhooks() }) {
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}
},
)
},
modifier = modifier,
HermesScaffold(
title = "Webhooks",
onOpenDrawer = onOpenDrawer,
onRefresh = { viewModel.loadWebhooks() },
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (state.isLoading && state.baseUrl == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (state.errorMessage != null && state.baseUrl == null) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadWebhooks() }) {
Text("Retry")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Global Status Switch
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (state.enabled) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
when {
state.isLoading -> {
LoadingState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadWebhooks() },
modifier = Modifier.padding(paddingValues),
)
}
else ->
Box(Modifier.fillMaxSize()) {
if (state.isLoading && state.baseUrl == null) {
CircularProgressIndicator()
} else if (state.errorMessage != null && state.baseUrl == null) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Global Webhooks Status",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
state.baseUrl?.let {
Text(
text = "Local Server URL: $it",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Switch(
checked = state.enabled,
onCheckedChange = { viewModel.toggleWebhooks(it) },
)
}
}
}
// Subscriptions List Header
item {
Text(
text = "Subscriptions",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
if (state.subscriptions.isEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "No webhook subscriptions configured.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadWebhooks() }) {
Text("Retry")
}
}
} else {
items(state.subscriptions) { sub ->
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Global Status Switch
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (state.enabled) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Text(
text = sub.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "Target URL:",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
Text(
text = sub.url,
style =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
),
)
sub.events?.let { events ->
if (events.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Events Subscribed:",
text = "Global Webhooks Status",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
state.baseUrl?.let {
Text(
text = "Local Server URL: $it",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Switch(
checked = state.enabled,
onCheckedChange = { viewModel.toggleWebhooks(it) },
)
}
}
}
// Subscriptions List Header
item {
Text(
text = "Subscriptions",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
if (state.subscriptions.isEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "No webhook subscriptions configured.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
} else {
items(state.subscriptions) { sub ->
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = sub.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "Target URL:",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
events.forEach { event ->
SuggestionChip(
onClick = {},
label = {
Text(
text = event,
style = MaterialTheme.typography.labelSmall,
)
},
Text(
text = sub.url,
style =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
),
)
sub.events?.let { events ->
if (events.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Events Subscribed:",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
events.forEach { event ->
SuggestionChip(
onClick = {},
label = {
Text(
text = event,
style = MaterialTheme.typography.labelSmall,
)
},
)
}
}
}
}
}
@@ -249,7 +233,6 @@ fun WebhooksScreen(
}
}
}
}
}
}
}