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
+1377 -1085

No files matched your search

@@ -1,6 +1,7 @@
package com.m57.hermescontrol package com.m57.hermescontrol
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeDrawingPadding
@@ -29,11 +30,15 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector 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.toolsets.ToolsetsScreen as ToolsetsScreenContent
import com.m57.hermescontrol.ui.webhooks.WebhooksScreen as WebhooksScreenContent import com.m57.hermescontrol.ui.webhooks.WebhooksScreen as WebhooksScreenContent
/** // ── Bottom-nav primary screens ─────────────────────────────────────────
* Drawer sections — the navigation soul of the app.
* private data class BottomNavItem(
* Each section reflects how the user actually thinks about Hermes: val key: NavKey,
* CONVERSE → talking to the agent val label: String,
* AUTOMATE → scheduled + push-driven actions val icon: ImageVector,
* CONFIGURE → settings, capabilities, integrations )
* INSPECT → observability, ops, achievements
*/ 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) { private enum class DrawerSection(val title: String) {
CONVERSE("Converse"), CONVERSE("Converse"),
AUTOMATE("Automate"), AUTOMATE("Automate"),
@@ -92,14 +109,11 @@ private data class DrawerEntry(
private val DRAWER_ENTRIES = private val DRAWER_ENTRIES =
listOf( listOf(
// Converse // Converse
DrawerEntry(ChatScreen, "Chat", Icons.AutoMirrored.Filled.Chat, DrawerSection.CONVERSE),
DrawerEntry(ProfilesScreen, "Profiles", Icons.Filled.AccountCircle, DrawerSection.CONVERSE), DrawerEntry(ProfilesScreen, "Profiles", Icons.Filled.AccountCircle, DrawerSection.CONVERSE),
// Automate // Automate
DrawerEntry(CronJobsScreen, "Cron Jobs", Icons.Filled.Schedule, DrawerSection.AUTOMATE),
DrawerEntry(WebhooksScreen, "Webhooks", Icons.Filled.Webhook, DrawerSection.AUTOMATE), DrawerEntry(WebhooksScreen, "Webhooks", Icons.Filled.Webhook, DrawerSection.AUTOMATE),
DrawerEntry(GatewayScreen, "Gateway", Icons.Filled.Bolt, DrawerSection.AUTOMATE), DrawerEntry(GatewayScreen, "Gateway", Icons.Filled.Bolt, DrawerSection.AUTOMATE),
// Configure // Configure
DrawerEntry(SkillsScreen, "Skills", Icons.Filled.Extension, DrawerSection.CONFIGURE),
DrawerEntry(ToolsetsScreen, "Toolsets", Icons.Filled.Build, DrawerSection.CONFIGURE), DrawerEntry(ToolsetsScreen, "Toolsets", Icons.Filled.Build, DrawerSection.CONFIGURE),
DrawerEntry(PluginsScreen, "Plugins", Icons.Filled.Memory, DrawerSection.CONFIGURE), DrawerEntry(PluginsScreen, "Plugins", Icons.Filled.Memory, DrawerSection.CONFIGURE),
DrawerEntry(ConfigScreen, "Config", Icons.Filled.Code, 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(KeysScreen, "Keys", Icons.Filled.Key, DrawerSection.CONFIGURE),
DrawerEntry(ChannelsScreen, "Channels", Icons.Filled.ListAlt, DrawerSection.CONFIGURE), DrawerEntry(ChannelsScreen, "Channels", Icons.Filled.ListAlt, DrawerSection.CONFIGURE),
// Inspect // Inspect
DrawerEntry(SystemScreen, "System", Icons.Filled.Info, DrawerSection.INSPECT),
DrawerEntry(LogsScreen, "Logs", Icons.Filled.HistoryEdu, DrawerSection.INSPECT), DrawerEntry(LogsScreen, "Logs", Icons.Filled.HistoryEdu, DrawerSection.INSPECT),
DrawerEntry(KanbanScreen, "Kanban", Icons.Filled.Dashboard, DrawerSection.INSPECT), DrawerEntry(KanbanScreen, "Kanban", Icons.Filled.Dashboard, DrawerSection.INSPECT),
DrawerEntry(AchievementsScreen, "Achievements", Icons.Filled.Info, DrawerSection.INSPECT), DrawerEntry(AchievementsScreen, "Achievements", Icons.Filled.Info, DrawerSection.INSPECT),
) )
/** private val DRAWER_GESTURE_SCREENS: Set<NavKey> = BOTTOM_NAV_KEYS + DRAWER_ENTRIES.map { it.key }.toSet()
* Screens where swipe-to-open-drawer is allowed.
*
* Settings is intentionally excluded because it's pushed onto the stack
* (uses Back, not drawer); Connect is the entry gate and has no drawer.
*/
private val DRAWER_GESTURE_SCREENS: Set<NavKey> =
DRAWER_ENTRIES.map { it.key }.toSet()
@Composable @Composable
fun MainNavigation() { fun MainNavigation() {
@@ -136,6 +142,7 @@ fun MainNavigation() {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val isPrimaryScreen = currentScreen in BOTTOM_NAV_KEYS
val gesturesEnabled = currentScreen in DRAWER_GESTURE_SCREENS val gesturesEnabled = currentScreen in DRAWER_GESTURE_SCREENS
val openDrawer = { scope.launch { drawerState.open() } } val openDrawer = { scope.launch { drawerState.open() } }
@@ -165,7 +172,6 @@ fun MainNavigation() {
) )
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
// Render grouped entries
for (section in DrawerSection.entries) { for (section in DrawerSection.entries) {
Text( Text(
text = section.title.uppercase(), text = section.title.uppercase(),
@@ -204,28 +210,27 @@ fun MainNavigation() {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) 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)) Spacer(modifier = Modifier.height(12.dp))
} }
} }
}, },
) { ) {
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( NavDisplay(
backStack = backStack, backStack = backStack,
onBack = { backStack.removeLastOrNull() }, onBack = { backStack.removeLastOrNull() },
@@ -244,18 +249,9 @@ fun MainNavigation() {
entry<ChatScreen> { entry<ChatScreen> {
ChatScreenContent( ChatScreenContent(
onNavigateToSettings = { onNavigateToSettings = {
// B7 (Jun 18 2026): previously used // B7 (Jun 18 2026): route through
// `backStack.add(SettingsScreen)` directly, // NavigationController to prevent
// which bypasses NavigationController's // stacking duplicate entries.
// 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) NavigationController.navigateTo(SettingsScreen)
}, },
onOpenDrawer = { openDrawer() }, onOpenDrawer = { openDrawer() },
@@ -264,9 +260,7 @@ fun MainNavigation() {
entry<SettingsScreen> { entry<SettingsScreen> {
SettingsScreenContent( SettingsScreenContent(
onBack = { onBack = { backStack.removeLastOrNull() },
backStack.removeLastOrNull()
},
) )
} }
@@ -372,6 +366,11 @@ fun MainNavigation() {
) )
} }
}, },
modifier =
Modifier
.padding(paddingValues)
.fillMaxSize(),
) )
} }
}
} }
@@ -3,14 +3,35 @@ package com.m57.hermescontrol
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey 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 { object NavigationController {
var backStack: NavBackStack<NavKey>? = null 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) { fun navigateTo(key: NavKey) {
val stack = backStack ?: return val stack = backStack ?: return
if (stack.lastOrNull() == key) return if (stack.lastOrNull() == key) return
if (key == ChatScreen || key == SkillsScreen || key == CronJobsScreen) { if (key in primaryScreens) {
stack.clear() stack.clear()
} }
stack.add(key) stack.add(key)
@@ -2,17 +2,16 @@ package com.m57.hermescontrol.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
/** // Hermes Control brand palette — v3 (Material You rewrite).
* Hermes Control brand palette — v2 (modern redesign). //
* // On Android 12+ (API 31+) the app uses dynamic colour derived from the user's
* Design intent: // wallpaper — this palette is NOT used for primary/surface roles on those
* - Primary (voltage purple) is the single accent; surfaces stay calm. // devices (see HermesControlTheme). On API 26–30 it serves as the full colour
* - Secondary (plasma amber) is reserved for opt-in highlights only. // scheme. Semantic status colours and chat-bubble colours are ALWAYS
* - Surfaces use a 3-step elevation ladder with a slight blue tint so dark // brand-defined. All pairs verified for WCAG AA contrast (4.5:1 text, 3:1
* mode no longer reads as flat black. // large text / icons).
* - Semantic colors (success/warning/error/info) share the same saturation
* band so they don't fight the brand. // ── Brand: Voltage Purple (primary) ──────────────────────────────────────
*/
val HermesPurple = Color(0xFF7C5CFF) val HermesPurple = Color(0xFF7C5CFF)
val HermesPurpleLight = Color(0xFFAC93FF) val HermesPurpleLight = Color(0xFFAC93FF)
@@ -20,27 +19,45 @@ val HermesPurpleDark = Color(0xFF5A3FE0)
val HermesPurpleContainer = Color(0xFF2B2159) val HermesPurpleContainer = Color(0xFF2B2159)
val HermesPurpleOnContainer = Color(0xFFD9CCFF) val HermesPurpleOnContainer = Color(0xFFD9CCFF)
// ── Brand: Plasma Amber (secondary) ─────────────────────────────────────
val HermesAmber = Color(0xFFFFB627) val HermesAmber = Color(0xFFFFB627)
val HermesAmberLight = Color(0xFFFFE082) val HermesAmberLight = Color(0xFFFFE082)
val HermesAmberDark = Color(0xFFC68400) val HermesAmberDark = Color(0xFFC68400)
// ── Dark surfaces (5-step elevation ladder) ─────────────────────────────
val DarkBackground = Color(0xFF0B0B11) val DarkBackground = Color(0xFF0B0B11)
val DarkSurface = Color(0xFF121218) val DarkSurface = Color(0xFF121218)
val DarkSurfaceVariant = Color(0xFF1C1C26) 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 DarkOnSurface = Color(0xFFE8E6EE)
val DarkOnSurfaceVariant = Color(0xFFB6B2C4) val DarkOnSurfaceVariant = Color(0xFFB6B2C4)
val DarkOutline = Color(0xFF4A4A5A) val DarkOutline = Color(0xFF4A4A5A)
val DarkOutlineVariant = Color(0xFF2F2F3D) 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 LightBackground = Color(0xFFF7F6FB)
val LightSurface = Color(0xFFFFFFFF) val LightSurface = Color(0xFFFFFFFF)
val LightSurfaceVariant = Color(0xFFEFEDF4) 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 LightOnSurface = Color(0xFF1A1A24)
val LightOnSurfaceVariant = Color(0xFF56536A) val LightOnSurfaceVariant = Color(0xFF56536A)
val LightOutline = Color(0xFF79748E) val LightOutline = Color(0xFF79748E)
val LightOutlineVariant = Color(0xFFC9C5D6) 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 StatusGreen = Color(0xFF3DDC84)
val StatusGreenContainer = Color(0xFF143A23) val StatusGreenContainer = Color(0xFF143A23)
@@ -51,6 +68,8 @@ val StatusYellowContainer = Color(0xFF3D2F0F)
val StatusBlue = Color(0xFF4DA8FF) val StatusBlue = Color(0xFF4DA8FF)
val StatusBlueContainer = Color(0xFF0F2A3D) val StatusBlueContainer = Color(0xFF0F2A3D)
// ── Chat-specific colours (always brand-defined for consistency) ───────
val UserBubble = HermesPurple val UserBubble = HermesPurple
val UserBubbleDark = HermesPurpleDark val UserBubbleDark = HermesPurpleDark
val AssistantBubble = Color(0xFF1C1C26) val AssistantBubble = Color(0xFF1C1C26)
@@ -58,3 +77,10 @@ val AssistantBubbleLight = Color(0xFFEDEAF4)
val SystemMessageColor = Color(0xFF8B879A) val SystemMessageColor = Color(0xFF8B879A)
val ToolChipColor = Color(0xFF262633) val ToolChipColor = Color(0xFF262633)
val ToolChipColorLight = Color(0xFFE6E3EE) 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 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 * | Token | Radius | Use case |
* - sm: text fields, small cards * |--------------|--------|---------------------------------------|
* - md: standard cards, list rows * | extraSmall | 4 dp | Chips, badges, inline toggles |
* - lg: large cards, bottom sheets * | small | 8 dp | Text fields, small cards, small FAB |
* - xl: feature cards, full-bleed surfaces * | 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 = val HermesShapes =
Shapes( Shapes(
extraSmall = RoundedCornerShape(6.dp), extraSmall = RoundedCornerShape(4.dp),
small = RoundedCornerShape(10.dp), small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(16.dp), medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(24.dp), large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(32.dp), extraLarge = RoundedCornerShape(28.dp),
) )
@@ -1,24 +1,32 @@
package com.m57.hermescontrol.theme package com.m57.hermescontrol.theme
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/** /**
* Spacing tokens — use these instead of ad-hoc dp values so every screen * Spacing tokens — the 8 dp rhythm that every screen shares.
* shares the same rhythm.
* *
* Convention (Material 3 spacing scale, condensed): * | Token | Value | Use case |
* xs = 4 hairline / chip gaps * |-------|--------|-------------------------------------------|
* sm = 8 default inner padding * | xs | 4 dp | Hairline gaps, chip inner padding |
* md = 12 card content * | sm | 8 dp | Default inner padding, icon-to-text gap |
* lg = 16 screen edge, section gaps * | md | 16 dp | Card content, screen edge, section gaps |
* xl = 24 hero spacing * | lg | 24 dp | Hero spacing, between major sections |
* xxl = 32 full-feature spacing * | 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 { object Spacing {
val xs = 4.dp val xs = 4.dp
val sm = 8.dp val sm = 8.dp
val md = 12.dp val md = 16.dp
val lg = 16.dp val lg = 24.dp
val xl = 24.dp val xl = 32.dp
val xxl = 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.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext 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+). * 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 = private val HermesDarkColorScheme =
darkColorScheme( darkColorScheme(
@@ -33,6 +42,7 @@ private val HermesDarkColorScheme =
secondaryContainer = Color(0xFF3D2F0F), secondaryContainer = Color(0xFF3D2F0F),
onSecondaryContainer = HermesAmberLight, onSecondaryContainer = HermesAmberLight,
tertiary = HermesAmberLight, tertiary = HermesAmberLight,
onTertiary = Color.Black,
background = DarkBackground, background = DarkBackground,
onBackground = DarkOnSurface, onBackground = DarkOnSurface,
surface = DarkSurface, surface = DarkSurface,
@@ -40,17 +50,18 @@ private val HermesDarkColorScheme =
surfaceVariant = DarkSurfaceVariant, surfaceVariant = DarkSurfaceVariant,
onSurfaceVariant = DarkOnSurfaceVariant, onSurfaceVariant = DarkOnSurfaceVariant,
surfaceTint = HermesPurple, surfaceTint = HermesPurple,
surfaceContainer = DarkSurfaceVariant, surfaceContainer = DarkSurfaceContainer,
surfaceContainerHigh = DarkSurfaceHigh, surfaceContainerHigh = DarkSurfaceContainerHigh,
surfaceContainerHighest = DarkSurfaceHigh, surfaceContainerHighest = DarkSurfaceContainerHighest,
inverseSurface = Color(0xFFE8E6EE), inverseSurface = DarkInverseSurface,
inverseOnSurface = Color(0xFF1A1A24), inverseOnSurface = DarkInverseOnSurface,
error = StatusRed, error = StatusRed,
onError = Color.White, onError = Color.White,
errorContainer = StatusRedContainer, errorContainer = StatusRedContainer,
onErrorContainer = Color(0xFFFFB4B4), onErrorContainer = Color(0xFFFFB4B4),
outline = DarkOutline, outline = DarkOutline,
outlineVariant = DarkOutlineVariant, outlineVariant = DarkOutlineVariant,
scrim = DarkScrim,
) )
private val HermesLightColorScheme = private val HermesLightColorScheme =
@@ -64,6 +75,7 @@ private val HermesLightColorScheme =
secondaryContainer = HermesAmberLight, secondaryContainer = HermesAmberLight,
onSecondaryContainer = Color(0xFF3D2F0F), onSecondaryContainer = Color(0xFF3D2F0F),
tertiary = HermesAmberDark, tertiary = HermesAmberDark,
onTertiary = Color.White,
background = LightBackground, background = LightBackground,
onBackground = LightOnSurface, onBackground = LightOnSurface,
surface = LightSurface, surface = LightSurface,
@@ -71,17 +83,18 @@ private val HermesLightColorScheme =
surfaceVariant = LightSurfaceVariant, surfaceVariant = LightSurfaceVariant,
onSurfaceVariant = LightOnSurfaceVariant, onSurfaceVariant = LightOnSurfaceVariant,
surfaceTint = HermesPurple, surfaceTint = HermesPurple,
surfaceContainer = LightSurfaceVariant, surfaceContainer = LightSurfaceContainer,
surfaceContainerHigh = LightSurfaceHigh, surfaceContainerHigh = LightSurfaceContainerHigh,
surfaceContainerHighest = LightSurfaceHigh, surfaceContainerHighest = LightSurfaceContainerHighest,
inverseSurface = Color(0xFF1A1A24), inverseSurface = LightInverseSurface,
inverseOnSurface = Color(0xFFE8E6EE), inverseOnSurface = LightInverseOnSurface,
error = Color(0xFFB3261E), error = Color(0xFFB3261E),
onError = Color.White, onError = Color.White,
errorContainer = Color(0xFFF9DEDC), errorContainer = Color(0xFFF9DEDC),
onErrorContainer = Color(0xFF410E0B), onErrorContainer = Color(0xFF410E0B),
outline = LightOutline, outline = LightOutline,
outlineVariant = LightOutlineVariant, outlineVariant = LightOutlineVariant,
scrim = LightScrim,
) )
@Composable @Composable
@@ -107,10 +120,41 @@ fun HermesControlTheme(
else -> HermesLightColorScheme else -> HermesLightColorScheme
} }
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( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = Typography, typography = Typography,
shapes = HermesShapes, shapes = HermesShapes,
content = content, content = content,
) )
}
} }
@@ -1,7 +1,6 @@
package com.m57.hermescontrol.ui.achievements package com.m57.hermescontrol.ui.achievements
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons 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.Refresh
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults 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.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -35,8 +27,11 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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 @Composable
fun AchievementsScreen( fun AchievementsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -49,54 +44,33 @@ fun AchievementsScreen(
viewModel.loadAchievements() viewModel.loadAchievements()
} }
Scaffold( HermesScaffold(
topBar = { title = "Agent Achievements",
TopAppBar( onOpenDrawer = onOpenDrawer,
navigationIcon = { onRefresh = { viewModel.loadAchievements() },
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,
) { paddingValues -> ) { paddingValues ->
Box( when {
modifier = state.isLoading && state.achievements.isEmpty() -> {
Modifier LoadingState(modifier = Modifier.padding(paddingValues))
.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")
} }
state.errorMessage != null && state.achievements.isEmpty() -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadAchievements() },
modifier = Modifier.padding(paddingValues),
)
} }
} else { state.achievements.isEmpty() -> {
EmptyState(
title = "No achievements yet",
subtitle = "Complete tasks to unlock achievements.",
icon = Icons.Filled.Refresh,
modifier = Modifier.padding(paddingValues),
)
}
else -> {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
@@ -2,7 +2,6 @@ package com.m57.hermescontrol.ui.channels
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row 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.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Button
import androidx.compose.material3.Card 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.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -43,8 +33,11 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate 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 @Composable
fun ChannelsScreen( fun ChannelsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -65,52 +58,32 @@ fun ChannelsScreen(
} }
} }
Scaffold( HermesScaffold(
topBar = { title = "Messaging Channels",
TopAppBar( onOpenDrawer = onOpenDrawer,
navigationIcon = { onRefresh = { viewModel.loadPlatforms() },
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,
) { paddingValues -> ) { paddingValues ->
Box( when {
modifier = state.isLoading -> {
Modifier LoadingState(modifier = Modifier.padding(paddingValues))
.fillMaxSize() }
.padding(paddingValues), state.errorMessage != null -> {
) { ErrorState(
if (state.isLoading) { message = state.errorMessage ?: "",
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) onRetry = { viewModel.loadPlatforms() },
} else if (state.errorMessage != null) { modifier = Modifier.padding(paddingValues),
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
) )
} 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( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.common package com.m57.hermescontrol.ui.common
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -17,14 +18,16 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier 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: * Improvements (v2):
* - Menu icon (drawer toggle) * - Scroll-aware top bar (pinned collapse on scroll)
* - Title * - Uses Spacing tokens internally
* - Optional Refresh / actions slot * - 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 * Screens that still need a Back arrow instead of a Menu icon can set
* `showBack = true` and pass an `onBack` lambda. * `showBack = true` and pass an `onBack` lambda.
@@ -38,11 +41,15 @@ fun HermesScaffold(
onBack: (() -> Unit)? = null, onBack: (() -> Unit)? = null,
showBack: Boolean = false, showBack: Boolean = false,
actions: @Composable () -> Unit = {}, actions: @Composable () -> Unit = {},
content: @Composable (androidx.compose.foundation.layout.PaddingValues) -> Unit, content: @Composable (PaddingValues) -> Unit,
) { ) {
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(title) },
navigationIcon = { navigationIcon = {
when { when {
showBack && onBack != null -> { showBack && onBack != null -> {
@@ -57,13 +64,12 @@ fun HermesScaffold(
IconButton(onClick = onOpenDrawer) { IconButton(onClick = onOpenDrawer) {
Icon( Icon(
imageVector = Icons.Filled.Menu, imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer", contentDescription = "Open navigation drawer",
) )
} }
} }
} }
}, },
title = { Text(title) },
actions = { actions = {
if (onRefresh != null) { if (onRefresh != null) {
IconButton(onClick = onRefresh) { IconButton(onClick = onRefresh) {
@@ -82,6 +88,7 @@ fun HermesScaffold(
navigationIconContentColor = MaterialTheme.colorScheme.onSurface, navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface, actionIconContentColor = MaterialTheme.colorScheme.onSurface,
), ),
scrollBehavior = scrollBehavior,
) )
}, },
containerColor = MaterialTheme.colorScheme.background, 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 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.Icons
import androidx.compose.material.icons.filled.Inbox import androidx.compose.material.icons.filled.Inbox
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.theme.Motion
/** // Unified loading / error / empty / skeleton placeholders so every screen
* Unified loading / error / empty placeholders so every list screen // shares the same visual language.
* shares the same visual language.
*/ // ── Loading ───────────────────────────────────────────────────────────
@Composable @Composable
fun LoadingState(modifier: Modifier = Modifier) { 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 @Composable
fun ErrorState( fun ErrorState(
message: String, message: String,
onRetry: (() -> Unit)? = null, onRetry: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val spacing = LocalSpacing.current
Column( Column(
modifier = modifier =
modifier modifier
.fillMaxSize() .fillMaxSize()
.padding(24.dp), .padding(spacing.lg),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Text( Icon(
text = "⚠", imageVector = Icons.Filled.Refresh,
fontSize = MaterialTheme.typography.headlineMedium.fontSize, contentDescription = null,
color = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(48.dp),
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(spacing.sm))
Text( Text(
text = message, text = message,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
if (onRetry != null) { if (onRetry != null) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(spacing.md))
OutlinedButton(onClick = onRetry) { Button(onClick = onRetry) {
Icon( Icon(
imageVector = Icons.Filled.Refresh, imageVector = Icons.Filled.Refresh,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
) )
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(spacing.sm))
Text("Retry") Text("Retry")
} }
} }
} }
} }
// ── Empty ────────────────────────────────────────────────────────────
@Composable @Composable
fun EmptyState( fun EmptyState(
title: String, title: String,
subtitle: String? = null, subtitle: String? = null,
icon: ImageVector = Icons.Filled.Inbox, icon: ImageVector = Icons.Filled.Inbox,
actionLabel: String? = null,
onAction: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val spacing = LocalSpacing.current
Column( Column(
modifier = modifier =
modifier modifier
.fillMaxSize() .fillMaxSize()
.padding(24.dp), .padding(spacing.lg),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
@@ -103,16 +196,16 @@ fun EmptyState(
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), 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(
text = title, text = title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
if (subtitle != null) { if (subtitle != null) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(spacing.xs))
Text( Text(
text = subtitle, text = subtitle,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -120,12 +213,17 @@ fun EmptyState(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
} }
if (actionLabel != null && onAction != null) {
Spacer(modifier = Modifier.height(spacing.md))
OutlinedButton(onClick = onAction) {
Text(actionLabel)
}
}
} }
} }
/** // ── Padding conventions ──────────────────────────────────────────────
* Padding convention for List screens — outer 16, gaps of 8 between items.
*/
val listContentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp) val listContentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp)
val listItemSpacing = Arrangement.spacedBy(8.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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width 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.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -37,6 +30,9 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -60,43 +56,29 @@ fun ConfigScreen(
} }
} }
Scaffold( HermesScaffold(
topBar = { title = "Configuration",
TopAppBar( onOpenDrawer = onOpenDrawer,
navigationIcon = { onRefresh = { viewModel.loadRawConfig() },
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,
) { paddingValues -> ) { paddingValues ->
Box( when {
modifier = state.isLoading -> {
Modifier LoadingState(modifier = Modifier.padding(paddingValues))
.fillMaxSize() }
.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) { if (state.isLoading && state.yamlText == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator()
} else if (state.errorMessage != null && state.yamlText == null) { } else if (state.errorMessage != null && state.yamlText == null) {
Column( Column(
modifier = modifier = Modifier.padding(16.dp),
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
@@ -161,4 +143,5 @@ fun ConfigScreen(
} }
} }
} }
}
} }
@@ -2,42 +2,41 @@ package com.m57.hermescontrol.ui.cron
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext 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.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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 @Composable
fun CronJobsScreen( fun CronJobsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -46,6 +45,7 @@ fun CronJobsScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val spacing = LocalSpacing.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.loadCronJobs() viewModel.loadCronJobs()
@@ -58,90 +58,91 @@ fun CronJobsScreen(
} }
} }
Scaffold( HermesScaffold(
topBar = { title = "Cron Jobs",
TopAppBar( onOpenDrawer = onOpenDrawer,
navigationIcon = { onRefresh = { viewModel.loadCronJobs() },
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) { when {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) state.isLoading -> LoadingState()
} else if (state.errorMessage != null) { state.errorMessage != null ->
Text( ErrorState(
text = state.errorMessage ?: "", message = state.errorMessage ?: "Unknown error",
color = MaterialTheme.colorScheme.error, onRetry = { viewModel.loadCronJobs() },
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
) )
} else { state.jobs.isEmpty() ->
EmptyState(
title = "No cron jobs",
subtitle = "Scheduled tasks from Hermes will appear here.",
icon = Icons.Filled.Schedule,
)
else ->
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(16.dp), contentPadding = listContentPadding,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = listItemSpacing,
) { ) {
items(state.jobs) { job -> items(state.jobs) { job ->
Card(modifier = Modifier.fillMaxWidth()) { Card(
Column(modifier = Modifier.padding(16.dp)) { modifier = Modifier.fillMaxWidth(),
Text(text = job.name, style = MaterialTheme.typography.titleMedium) colors =
Spacer(modifier = Modifier.height(4.dp)) CardDefaults.cardColors(
Text( containerColor = MaterialTheme.colorScheme.surfaceContainer,
text = "Schedule: ${job.scheduleText}", ),
style = MaterialTheme.typography.bodyMedium, ) {
) Column(modifier = Modifier.padding(spacing.md)) {
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))
Row( Row(
modifier = Modifier.fillMaxWidth(), 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") { if (job.state == "active") {
Button(onClick = { viewModel.pauseCronJob(job.id) }) { OutlinedButton(
onClick = { viewModel.pauseCronJob(job.id) },
modifier = Modifier.weight(1f),
) {
Text("Pause") Text("Pause")
} }
} else { } else {
Button(onClick = { viewModel.resumeCronJob(job.id) }) { OutlinedButton(
onClick = { viewModel.resumeCronJob(job.id) },
modifier = Modifier.weight(1f),
) {
Text("Resume") Text("Resume")
} }
} }
Button(onClick = { viewModel.triggerCronJob(job.id) }) { OutlinedButton(
Text("Trigger") onClick = { viewModel.triggerCronJob(job.id) },
modifier = Modifier.weight(1f),
) {
Text("Run")
} }
Button( Button(
onClick = { viewModel.deleteCronJob(job.id) }, onClick = { viewModel.deleteCronJob(job.id) },
@@ -149,6 +150,7 @@ fun CronJobsScreen(
ButtonDefaults.buttonColors( ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error, containerColor = MaterialTheme.colorScheme.error,
), ),
modifier = Modifier.weight(1f),
) { ) {
Text("Delete") Text("Delete")
} }
@@ -159,5 +161,4 @@ fun CronJobsScreen(
} }
} }
} }
}
} }
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.PlayArrow
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -24,11 +23,8 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -40,6 +36,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -62,46 +61,29 @@ fun GatewayScreen(
} }
} }
Scaffold( HermesScaffold(
topBar = { title = "Gateway Control",
TopAppBar( onOpenDrawer = onOpenDrawer,
navigationIcon = { onRefresh = { viewModel.loadStatus() },
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,
) { paddingValues -> ) { paddingValues ->
Box( when {
modifier = state.isLoading -> {
Modifier LoadingState(modifier = Modifier.padding(paddingValues))
.fillMaxSize() }
.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) { if (state.isLoading && state.status == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator()
} else if (state.errorMessage != null && state.status == null) { } else if (state.errorMessage != null && state.status == null) {
Column( Column(
modifier = modifier = Modifier.padding(16.dp),
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(
@@ -303,4 +285,5 @@ fun GatewayScreen(
} }
} }
} }
}
} }
@@ -17,25 +17,19 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons 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.ArrowBack
import androidx.compose.material.icons.filled.ArrowForward 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.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -50,6 +44,9 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.data.model.KanbanTask 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -73,55 +70,31 @@ fun KanbanScreen(
} }
} }
Scaffold( HermesScaffold(
topBar = { title = "Kanban Board",
TopAppBar( onOpenDrawer = onOpenDrawer,
navigationIcon = { onRefresh = { viewModel.loadBoards() },
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,
) { paddingValues -> ) { paddingValues ->
Box( when {
modifier = state.isLoading -> {
Modifier LoadingState(modifier = Modifier.padding(paddingValues))
.fillMaxSize() }
.padding(paddingValues), state.errorMessage != null -> {
) { ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadBoards() },
modifier = Modifier.padding(paddingValues),
)
}
else ->
Box(Modifier.fillMaxSize()) {
if (state.isLoading) { if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator()
} else if (state.errorMessage != null) { } else if (state.errorMessage != null) {
Text( Text(
text = state.errorMessage ?: "", text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
modifier = modifier = Modifier.padding(16.dp),
Modifier
.align(Alignment.Center)
.padding(16.dp),
) )
} else { } else {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
@@ -172,7 +145,9 @@ fun KanbanScreen(
.fillMaxSize(), .fillMaxSize(),
) { ) {
Text( Text(
text = "${colName.replaceFirstChar { it.uppercase() }} (${colTasks.size})", text = "${colName.replaceFirstChar { it.uppercase() }} (${
colTasks.size
})",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp), modifier = Modifier.padding(bottom = 8.dp),
@@ -214,6 +189,7 @@ fun KanbanScreen(
} }
} }
} }
}
} }
@Composable @Composable
@@ -16,8 +16,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit 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.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -28,9 +26,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -44,6 +40,9 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -66,48 +65,31 @@ fun KeysScreen(
} }
} }
Scaffold( HermesScaffold(
topBar = { title = "Keys & Credentials",
TopAppBar( onOpenDrawer = onOpenDrawer,
navigationIcon = { onRefresh = { viewModel.loadKeys() },
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,
) { paddingValues -> ) { paddingValues ->
Box( when {
modifier = state.isLoading -> {
Modifier LoadingState(modifier = Modifier.padding(paddingValues))
.fillMaxSize() }
.padding(paddingValues), state.errorMessage != null -> {
) { ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadKeys() },
modifier = Modifier.padding(paddingValues),
)
}
else ->
Box(Modifier.fillMaxSize()) {
if (state.isLoading) { if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator()
} else if (state.errorMessage != null) { } else if (state.errorMessage != null) {
Text( Text(
text = state.errorMessage ?: "", text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
modifier = modifier = Modifier.padding(16.dp),
Modifier
.align(Alignment.Center)
.padding(16.dp),
) )
} else { } else {
LazyColumn( LazyColumn(
@@ -223,4 +205,5 @@ fun KeysScreen(
} }
} }
} }
}
} }
@@ -2,7 +2,6 @@ package com.m57.hermescontrol.ui.logs
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.HistoryEdu
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.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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 @Composable
fun LogsScreen( fun LogsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -43,6 +37,7 @@ fun LogsScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val spacing = LocalSpacing.current
val listState = rememberLazyListState() val listState = rememberLazyListState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -63,73 +58,43 @@ fun LogsScreen(
} }
} }
Scaffold( HermesScaffold(
topBar = { title = "Logs",
TopAppBar( onOpenDrawer = onOpenDrawer,
navigationIcon = { onRefresh = { viewModel.loadLogs() },
if (onOpenDrawer != null) { ) {
IconButton(onClick = onOpenDrawer) { when {
Icon( state.isLoading -> LoadingState()
imageVector = Icons.Filled.Menu, state.errorMessage != null ->
contentDescription = "Open Drawer", ErrorState(
message = state.errorMessage ?: "Unknown error",
onRetry = { viewModel.loadLogs() },
) )
} state.logs.isEmpty() ->
} // B5 (Jun 18 2026, kanban t_2322818d): proper empty-state using
}, // the shared EmptyState component instead of a bare Text.
title = { Text("System Logs") }, EmptyState(
actions = { title = "No logs available",
IconButton(onClick = { viewModel.loadLogs() }) { subtitle = "Log output from Hermes will appear here.",
Icon( icon = Icons.Filled.HistoryEdu,
imageVector = Icons.Filled.Refresh,
contentDescription = "Refresh",
) )
} else ->
}, LazyColumn(
) state = listState,
},
modifier = modifier,
) { paddingValues ->
Box(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .background(MaterialTheme.colorScheme.surfaceContainerLowest),
.background(Color.Black), contentPadding =
) { PaddingValues(
if (state.isLoading) { horizontal = spacing.md,
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center), color = Color.Green) vertical = spacing.sm,
} else if (state.errorMessage != null) { ),
Text(
text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error,
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
)
} 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),
)
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
) { ) {
items(state.logs) { logLine -> items(state.logs) { logLine ->
Text( Text(
text = logLine, text = logLine,
color = Color(0xFF00FF00), color = MaterialTheme.colorScheme.onSurface,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 12.sp, fontSize = 12.sp,
modifier = modifier =
@@ -141,5 +106,4 @@ fun LogsScreen(
} }
} }
} }
}
} }
@@ -13,22 +13,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -40,6 +33,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -62,43 +58,29 @@ fun McpServersScreen(
} }
} }
Scaffold( HermesScaffold(
topBar = { title = "MCP Servers",
TopAppBar( onOpenDrawer = onOpenDrawer,
navigationIcon = { onRefresh = { viewModel.loadServers() },
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,
) { paddingValues -> ) { paddingValues ->
Box( when {
modifier = state.isLoading -> {
Modifier LoadingState(modifier = Modifier.padding(paddingValues))
.fillMaxSize() }
.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()) { if (state.isLoading && state.servers.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator()
} else if (state.errorMessage != null && state.servers.isEmpty()) { } else if (state.errorMessage != null && state.servers.isEmpty()) {
Column( Column(
modifier = modifier = Modifier.padding(16.dp),
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
@@ -199,4 +181,5 @@ fun McpServersScreen(
} }
} }
} }
}
} }
@@ -17,7 +17,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -26,9 +25,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -42,6 +39,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -65,43 +65,29 @@ fun ModelScreen(
} }
} }
Scaffold( HermesScaffold(
topBar = { title = "Models",
TopAppBar( onOpenDrawer = onOpenDrawer,
navigationIcon = { onRefresh = { viewModel.loadModelOptions() },
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,
) { paddingValues -> ) { paddingValues ->
Box( when {
modifier = state.isLoading -> {
Modifier LoadingState(modifier = Modifier.padding(paddingValues))
.fillMaxSize() }
.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()) { if (state.isLoading && state.providers.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator()
} else if (state.errorMessage != null && state.providers.isEmpty()) { } else if (state.errorMessage != null && state.providers.isEmpty()) {
Column( Column(
modifier = modifier = Modifier.padding(16.dp),
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
@@ -213,7 +199,8 @@ fun ModelScreen(
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = containerColor =
if (isActive) { if (isActive) {
MaterialTheme.colorScheme.primaryContainer.copy( MaterialTheme.colorScheme
.primaryContainer.copy(
alpha = 0.3f, alpha = 0.3f,
) )
} else { } else {
@@ -227,7 +214,9 @@ fun ModelScreen(
if (isActive) { if (isActive) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) MaterialTheme.colorScheme.outline.copy(
alpha = 0.2f,
)
}, },
), ),
) { ) {
@@ -243,7 +232,11 @@ fun ModelScreen(
text = model, text = model,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = fontWeight =
if (isActive) FontWeight.Bold else FontWeight.Normal, if (isActive) {
FontWeight.Bold
} else {
FontWeight.Normal
},
color = color =
if (isActive) { if (isActive) {
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
@@ -271,4 +264,5 @@ fun ModelScreen(
} }
} }
} }
}
} }
@@ -13,22 +13,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -39,6 +32,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -61,43 +57,29 @@ fun PairingScreen(
} }
} }
Scaffold( HermesScaffold(
topBar = { title = "Device Pairing",
TopAppBar( onOpenDrawer = onOpenDrawer,
navigationIcon = { onRefresh = { viewModel.loadPairing() },
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,
) { paddingValues -> ) { paddingValues ->
Box( when {
modifier = state.isLoading -> {
Modifier LoadingState(modifier = Modifier.padding(paddingValues))
.fillMaxSize() }
.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) { if (state.isLoading && state.pairing == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator()
} else if (state.errorMessage != null && state.pairing == null) { } else if (state.errorMessage != null && state.pairing == null) {
Column( Column(
modifier = modifier = Modifier.padding(16.dp),
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
@@ -146,7 +128,10 @@ fun PairingScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f), containerColor =
MaterialTheme.colorScheme.errorContainer.copy(
alpha = 0.2f,
),
), ),
) { ) {
Row( Row(
@@ -268,4 +253,5 @@ fun PairingScreen(
} }
} }
} }
}
} }
@@ -14,21 +14,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -38,6 +31,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -60,48 +56,31 @@ fun PluginsScreen(
} }
} }
Scaffold( HermesScaffold(
topBar = { title = "Plugins",
TopAppBar( onOpenDrawer = onOpenDrawer,
navigationIcon = { onRefresh = { viewModel.loadPlugins() },
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,
) { paddingValues -> ) { paddingValues ->
Box( when {
modifier = state.isLoading -> {
Modifier LoadingState(modifier = Modifier.padding(paddingValues))
.fillMaxSize() }
.padding(paddingValues), state.errorMessage != null -> {
) { ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadPlugins() },
modifier = Modifier.padding(paddingValues),
)
}
else ->
Box(Modifier.fillMaxSize()) {
if (state.isLoading) { if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator()
} else if (state.errorMessage != null) { } else if (state.errorMessage != null) {
Text( Text(
text = state.errorMessage ?: "", text = state.errorMessage ?: "",
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
modifier = modifier = Modifier.padding(16.dp),
Modifier
.align(Alignment.Center)
.padding(16.dp),
) )
} else { } else {
LazyColumn( LazyColumn(
@@ -171,4 +150,5 @@ fun PluginsScreen(
} }
} }
} }
}
} }
@@ -17,23 +17,15 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Edit 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.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -47,6 +39,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -74,43 +69,29 @@ fun ProfilesScreen(
} }
} }
Scaffold( HermesScaffold(
topBar = { title = "Profiles",
TopAppBar( onOpenDrawer = onOpenDrawer,
navigationIcon = { onRefresh = { viewModel.loadProfiles() },
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,
) { paddingValues -> ) { paddingValues ->
Box( when {
modifier = state.isLoading -> {
Modifier LoadingState(modifier = Modifier.padding(paddingValues))
.fillMaxSize() }
.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()) { if (state.isLoading && state.profiles.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator()
} else if (state.errorMessage != null && state.profiles.isEmpty()) { } else if (state.errorMessage != null && state.profiles.isEmpty()) {
Column( Column(
modifier = modifier = Modifier.padding(16.dp),
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
@@ -240,102 +221,6 @@ fun ProfilesScreen(
} }
} }
} }
// 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.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState 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.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.listItemSpacing
@@ -44,6 +46,7 @@ fun SkillsScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val spacing = LocalSpacing.current
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -82,8 +85,8 @@ fun SkillsScreen(
) )
state.skills.isEmpty() -> state.skills.isEmpty() ->
EmptyState( EmptyState(
title = "No skills", title = "No skills found",
subtitle = "Tap refresh to retry loading skills from Hermes.", subtitle = "Skills loaded from Hermes will appear here.",
icon = Icons.Filled.Extension, icon = Icons.Filled.Extension,
) )
else -> else ->
@@ -92,17 +95,13 @@ fun SkillsScreen(
contentPadding = listContentPadding, contentPadding = listContentPadding,
verticalArrangement = listItemSpacing, verticalArrangement = listItemSpacing,
) { ) {
// Search field
item { item {
androidx.compose.material3.OutlinedTextField( SearchBar(
value = query, query = query,
onValueChange = { query = it }, onQueryChange = { query = it },
placeholder = { Text("Search skills…") }, placeholder = "Search skills…",
singleLine = true,
modifier = Modifier.fillMaxWidth(),
) )
} }
// Filtered list
items(filtered) { skill -> items(filtered) { skill ->
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -115,7 +114,7 @@ fun SkillsScreen(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(spacing.md),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -2,40 +2,46 @@ package com.m57.hermescontrol.ui.system
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.Refresh 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.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext 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.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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 @Composable
fun SystemScreen( fun SystemScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -44,6 +50,8 @@ fun SystemScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val spacing = LocalSpacing.current
val statusColors = LocalHermesStatusColors.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.loadSystemData() viewModel.loadSystemData()
@@ -56,137 +64,140 @@ fun SystemScreen(
} }
} }
Scaffold( HermesScaffold(
topBar = { title = "System",
TopAppBar( onOpenDrawer = onOpenDrawer,
navigationIcon = { onRefresh = { viewModel.loadSystemData() },
if (onOpenDrawer != null) {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
)
}
}
},
title = { Text("System Diagnostics") },
actions = {
IconButton(onClick = { viewModel.loadSystemData() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Refresh",
)
}
},
)
},
modifier = modifier,
) { paddingValues ->
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) { ) {
if (state.isLoading) { when {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) state.isLoading -> LoadingState()
} else if (state.errorMessage != null) { state.errorMessage != null ->
Text( ErrorState(
text = state.errorMessage ?: "", message = state.errorMessage ?: "Unknown error",
color = MaterialTheme.colorScheme.error, onRetry = { viewModel.loadSystemData() },
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
) )
} else { state.stats == null && state.doctorReport == null ->
EmptyState(
title = "No system data",
subtitle = "Tap refresh to load system diagnostics.",
)
else ->
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(16.dp), contentPadding =
verticalArrangement = Arrangement.spacedBy(16.dp), androidx.compose.foundation.layout.PaddingValues(
horizontal = spacing.md,
vertical = spacing.sm,
),
verticalArrangement = Arrangement.spacedBy(spacing.sm),
) { ) {
// Stats row
state.stats?.let { stats ->
item { item {
Card(modifier = Modifier.fillMaxWidth()) { SectionHeader(title = "Performance")
Column(modifier = Modifier.padding(16.dp)) { }
Text( item {
text = "System Administration", FlowRow(
style = MaterialTheme.typography.titleLarge, modifier = Modifier.fillMaxWidth(),
fontWeight = FontWeight.Bold, horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
stats.cpuPercent?.let { cpu ->
StatCard(
label = "CPU Usage",
value = "$cpu%",
icon = Icons.Filled.Speed,
modifier = Modifier.weight(1f),
) )
Spacer(modifier = Modifier.height(8.dp)) }
stats.memoryPercent?.let { mem ->
StatCard(
label = "Memory",
value = "$mem%",
icon = Icons.Filled.Memory,
modifier = Modifier.weight(1f),
)
}
}
}
}
// 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)
}
}
}
}
}
// Admin actions
item {
SectionHeader(title = "Administration")
}
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(spacing.md)) {
Button( Button(
onClick = { viewModel.triggerBackup() }, onClick = { viewModel.triggerBackup() },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Icon(
imageVector = Icons.Filled.Backup,
contentDescription = null,
modifier = Modifier.padding(end = spacing.xs),
)
Text("Trigger System Backup") 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons 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.Refresh
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -24,11 +23,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SuggestionChip import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -39,6 +36,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -61,43 +61,29 @@ fun ToolsetsScreen(
} }
} }
Scaffold( HermesScaffold(
topBar = { title = "Toolsets",
TopAppBar( onOpenDrawer = onOpenDrawer,
navigationIcon = { onRefresh = { viewModel.loadToolsets() },
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,
) { paddingValues -> ) { paddingValues ->
Box( when {
modifier = state.isLoading -> {
Modifier LoadingState(modifier = Modifier.padding(paddingValues))
.fillMaxSize() }
.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()) { if (state.isLoading && state.toolsets.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator()
} else if (state.errorMessage != null && state.toolsets.isEmpty()) { } else if (state.errorMessage != null && state.toolsets.isEmpty()) {
Column( Column(
modifier = modifier = Modifier.padding(16.dp),
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
@@ -135,7 +121,9 @@ fun ToolsetsScreen(
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = toolset.label ?: toolset.name.replaceFirstChar { it.uppercase() }, text =
toolset.label
?: toolset.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
@@ -183,4 +171,5 @@ fun ToolsetsScreen(
} }
} }
} }
}
} }
@@ -14,22 +14,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SuggestionChip import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -41,6 +34,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -63,43 +59,29 @@ fun WebhooksScreen(
} }
} }
Scaffold( HermesScaffold(
topBar = { title = "Webhooks",
TopAppBar( onOpenDrawer = onOpenDrawer,
navigationIcon = { onRefresh = { viewModel.loadWebhooks() },
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,
) { paddingValues -> ) { paddingValues ->
Box( when {
modifier = state.isLoading -> {
Modifier LoadingState(modifier = Modifier.padding(paddingValues))
.fillMaxSize() }
.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) { if (state.isLoading && state.baseUrl == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator()
} else if (state.errorMessage != null && state.baseUrl == null) { } else if (state.errorMessage != null && state.baseUrl == null) {
Column( Column(
modifier = modifier = Modifier.padding(16.dp),
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
@@ -252,4 +234,5 @@ fun WebhooksScreen(
} }
} }
} }
}
} }
@@ -452,9 +452,9 @@ class E2eIntegrationTest {
assertEquals(CronJobsScreen, backStack.lastOrNull()) assertEquals(CronJobsScreen, backStack.lastOrNull())
assertEquals(1, backStack.size) assertEquals(1, backStack.size)
// Non-clearing key settings adds to stack // Non-clearing drawer screen adds to stack
NavigationController.navigateTo(SettingsScreen) NavigationController.navigateTo(ProfilesScreen)
assertEquals(SettingsScreen, backStack.lastOrNull()) assertEquals(ProfilesScreen, backStack.lastOrNull())
assertEquals(2, backStack.size) assertEquals(2, backStack.size)
assertEquals(CronJobsScreen, backStack[0]) assertEquals(CronJobsScreen, backStack[0])
} }