From d90e36974ccd225f00dab9c41737e4ce1453eba6 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:34:44 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Material=20You=20design=20system=20rewr?= =?UTF-8?q?ite=20=E2=80=94=20Phases=201=E2=80=934?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../java/com/m57/hermescontrol/Navigation.kt | 331 ++++++++------- .../m57/hermescontrol/NavigationController.kt | 23 +- .../java/com/m57/hermescontrol/theme/Color.kt | 52 ++- .../hermescontrol/theme/HermesStatusColors.kt | 39 ++ .../com/m57/hermescontrol/theme/Motion.kt | 79 ++++ .../com/m57/hermescontrol/theme/Shapes.kt | 24 +- .../com/m57/hermescontrol/theme/Spacing.kt | 34 +- .../java/com/m57/hermescontrol/theme/Theme.kt | 80 +++- .../ui/achievements/AchievementsScreen.kt | 84 ++-- .../ui/channels/ChannelsScreen.kt | 81 ++-- .../hermescontrol/ui/common/HermesScaffold.kt | 23 +- .../ui/common/SharedComponents.kt | 322 +++++++++++++++ .../m57/hermescontrol/ui/common/StateViews.kt | 140 ++++++- .../hermescontrol/ui/config/ConfigScreen.kt | 179 ++++---- .../hermescontrol/ui/cron/CronJobsScreen.kt | 171 ++++---- .../hermescontrol/ui/gateway/GatewayScreen.kt | 373 ++++++++--------- .../hermescontrol/ui/kanban/KanbanScreen.kt | 240 +++++------ .../m57/hermescontrol/ui/keys/KeysScreen.kt | 263 ++++++------ .../m57/hermescontrol/ui/logs/LogsScreen.kt | 108 ++--- .../hermescontrol/ui/mcp/McpServersScreen.kt | 243 +++++------ .../m57/hermescontrol/ui/model/ModelScreen.kt | 366 ++++++++--------- .../hermescontrol/ui/pairing/PairingScreen.kt | 374 ++++++++--------- .../hermescontrol/ui/plugins/PluginsScreen.kt | 190 ++++----- .../ui/profiles/ProfilesScreen.kt | 387 ++++++------------ .../hermescontrol/ui/skills/SkillsScreen.kt | 21 +- .../hermescontrol/ui/system/SystemScreen.kt | 273 ++++++------ .../ui/toolsets/ToolsetsScreen.kt | 209 +++++----- .../ui/webhooks/WebhooksScreen.kt | 331 +++++++-------- .../m57/hermescontrol/E2eIntegrationTest.kt | 6 +- 29 files changed, 2669 insertions(+), 2377 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/theme/Motion.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index c33dcd02..8f309f5d 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding @@ -29,11 +30,15 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -66,15 +71,27 @@ import com.m57.hermescontrol.ui.system.SystemScreen as SystemScreenContent import com.m57.hermescontrol.ui.toolsets.ToolsetsScreen as ToolsetsScreenContent import com.m57.hermescontrol.ui.webhooks.WebhooksScreen as WebhooksScreenContent -/** - * Drawer sections — the navigation soul of the app. - * - * Each section reflects how the user actually thinks about Hermes: - * CONVERSE → talking to the agent - * AUTOMATE → scheduled + push-driven actions - * CONFIGURE → settings, capabilities, integrations - * INSPECT → observability, ops, achievements - */ +// ── Bottom-nav primary screens ───────────────────────────────────────── + +private data class BottomNavItem( + val key: NavKey, + val label: String, + val icon: ImageVector, +) + +private val BOTTOM_NAV_ITEMS = + listOf( + BottomNavItem(ChatScreen, "Chat", Icons.AutoMirrored.Filled.Chat), + BottomNavItem(SkillsScreen, "Skills", Icons.Filled.Extension), + BottomNavItem(CronJobsScreen, "Cron", Icons.Filled.Schedule), + BottomNavItem(SystemScreen, "System", Icons.Filled.Info), + BottomNavItem(SettingsScreen, "Settings", Icons.Filled.Settings), + ) + +private val BOTTOM_NAV_KEYS: Set = BOTTOM_NAV_ITEMS.map { it.key }.toSet() + +// ── Drawer sections for secondary screens ────────────────────────────── + private enum class DrawerSection(val title: String) { CONVERSE("Converse"), AUTOMATE("Automate"), @@ -92,14 +109,11 @@ private data class DrawerEntry( private val DRAWER_ENTRIES = listOf( // Converse - DrawerEntry(ChatScreen, "Chat", Icons.AutoMirrored.Filled.Chat, DrawerSection.CONVERSE), DrawerEntry(ProfilesScreen, "Profiles", Icons.Filled.AccountCircle, DrawerSection.CONVERSE), // Automate - DrawerEntry(CronJobsScreen, "Cron Jobs", Icons.Filled.Schedule, DrawerSection.AUTOMATE), DrawerEntry(WebhooksScreen, "Webhooks", Icons.Filled.Webhook, DrawerSection.AUTOMATE), DrawerEntry(GatewayScreen, "Gateway", Icons.Filled.Bolt, DrawerSection.AUTOMATE), // Configure - DrawerEntry(SkillsScreen, "Skills", Icons.Filled.Extension, DrawerSection.CONFIGURE), DrawerEntry(ToolsetsScreen, "Toolsets", Icons.Filled.Build, DrawerSection.CONFIGURE), DrawerEntry(PluginsScreen, "Plugins", Icons.Filled.Memory, DrawerSection.CONFIGURE), DrawerEntry(ConfigScreen, "Config", Icons.Filled.Code, DrawerSection.CONFIGURE), @@ -109,20 +123,12 @@ private val DRAWER_ENTRIES = DrawerEntry(KeysScreen, "Keys", Icons.Filled.Key, DrawerSection.CONFIGURE), DrawerEntry(ChannelsScreen, "Channels", Icons.Filled.ListAlt, DrawerSection.CONFIGURE), // Inspect - DrawerEntry(SystemScreen, "System", Icons.Filled.Info, DrawerSection.INSPECT), DrawerEntry(LogsScreen, "Logs", Icons.Filled.HistoryEdu, DrawerSection.INSPECT), DrawerEntry(KanbanScreen, "Kanban", Icons.Filled.Dashboard, DrawerSection.INSPECT), DrawerEntry(AchievementsScreen, "Achievements", Icons.Filled.Info, DrawerSection.INSPECT), ) -/** - * Screens where swipe-to-open-drawer is allowed. - * - * Settings is intentionally excluded because it's pushed onto the stack - * (uses Back, not drawer); Connect is the entry gate and has no drawer. - */ -private val DRAWER_GESTURE_SCREENS: Set = - DRAWER_ENTRIES.map { it.key }.toSet() +private val DRAWER_GESTURE_SCREENS: Set = BOTTOM_NAV_KEYS + DRAWER_ENTRIES.map { it.key }.toSet() @Composable fun MainNavigation() { @@ -136,6 +142,7 @@ fun MainNavigation() { val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() + val isPrimaryScreen = currentScreen in BOTTOM_NAV_KEYS val gesturesEnabled = currentScreen in DRAWER_GESTURE_SCREENS val openDrawer = { scope.launch { drawerState.open() } } @@ -165,7 +172,6 @@ fun MainNavigation() { ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - // Render grouped entries for (section in DrawerSection.entries) { Text( text = section.title.uppercase(), @@ -204,174 +210,167 @@ fun MainNavigation() { Spacer(modifier = Modifier.height(8.dp)) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - // Settings is always at the bottom — not a section sibling - NavigationDrawerItem( - icon = { Icon(Icons.Filled.Settings, contentDescription = null) }, - label = { Text("Settings") }, - selected = currentScreen == SettingsScreen, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(SettingsScreen) - }, - colors = - NavigationDrawerItemDefaults.colors( - selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, - selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer, - selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), - ) Spacer(modifier = Modifier.height(12.dp)) } } }, ) { - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - entryProvider = - entryProvider { - entry { - ConnectScreenContent( - onConnected = { - backStack.clear() - backStack.add(ChatScreen) - }, - modifier = Modifier.safeDrawingPadding(), - ) + Scaffold( + bottomBar = { + if (isPrimaryScreen) { + NavigationBar { + BOTTOM_NAV_ITEMS.forEach { item -> + NavigationBarItem( + selected = currentScreen == item.key, + onClick = { NavigationController.navigateTo(item.key) }, + icon = { Icon(item.icon, contentDescription = item.label) }, + label = { Text(item.label) }, + ) + } } + } + }, + ) { paddingValues -> + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = + entryProvider { + entry { + ConnectScreenContent( + onConnected = { + backStack.clear() + backStack.add(ChatScreen) + }, + modifier = Modifier.safeDrawingPadding(), + ) + } - entry { - ChatScreenContent( - onNavigateToSettings = { - // B7 (Jun 18 2026): previously used - // `backStack.add(SettingsScreen)` directly, - // which bypasses NavigationController's - // navigation guard. The drawer Settings path - // goes through NavigationController which - // deduplicates and prevents stacking duplicate - // SettingsScreen entries when user rapidly - // taps the gear icon. Use the same path for - // consistency and to recover if a duplicate - // SettingsScreen entry was the source of the - // "page unresponsive" symptom reported when - // reaching Settings from the topbar. - NavigationController.navigateTo(SettingsScreen) - }, - onOpenDrawer = { openDrawer() }, - ) - } + entry { + ChatScreenContent( + onNavigateToSettings = { + // B7 (Jun 18 2026): route through + // NavigationController to prevent + // stacking duplicate entries. + NavigationController.navigateTo(SettingsScreen) + }, + onOpenDrawer = { openDrawer() }, + ) + } - entry { - SettingsScreenContent( - onBack = { - backStack.removeLastOrNull() - }, - ) - } + entry { + SettingsScreenContent( + onBack = { backStack.removeLastOrNull() }, + ) + } - entry { - SkillsScreenContent( - onOpenDrawer = { openDrawer() }, - ) - } + entry { + SkillsScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } - entry { - CronJobsScreenContent( - onOpenDrawer = { openDrawer() }, - ) - } + entry { + CronJobsScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } - entry { - ProfilesScreenContent( - onOpenDrawer = { openDrawer() }, - ) - } + entry { + ProfilesScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } - entry { - ToolsetsScreenContent( - onOpenDrawer = { openDrawer() }, - ) - } + entry { + ToolsetsScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } - entry { - AchievementsScreenContent( - onOpenDrawer = { openDrawer() }, - ) - } + entry { + AchievementsScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } - entry { - PairingScreenContent( - onOpenDrawer = { openDrawer() }, - ) - } + entry { + PairingScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } - entry { - ConfigScreenContent( - onOpenDrawer = { openDrawer() }, - ) - } + entry { + ConfigScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } - entry { - McpServersScreenContent( - onOpenDrawer = { openDrawer() }, - ) - } + entry { + McpServersScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } - entry { - WebhooksScreenContent( - onOpenDrawer = { openDrawer() }, - ) - } + entry { + WebhooksScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } - entry { - ModelScreenContent( - onOpenDrawer = { openDrawer() }, - ) - } + entry { + ModelScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } - entry { - GatewayScreenContent( - onOpenDrawer = { openDrawer() }, - ) - } + entry { + GatewayScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } - entry { - LogsScreenContent( - onOpenDrawer = { openDrawer() }, - ) - } + entry { + LogsScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } - entry { - PluginsScreenContent( - onOpenDrawer = { openDrawer() }, - ) - } + entry { + PluginsScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } - entry { - ChannelsScreenContent( - onOpenDrawer = { openDrawer() }, - ) - } + entry { + ChannelsScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } - entry { - KeysScreenContent( - onOpenDrawer = { openDrawer() }, - ) - } + entry { + KeysScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } - entry { - SystemScreenContent( - onOpenDrawer = { openDrawer() }, - ) - } + entry { + SystemScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } - entry { - KanbanScreenContent( - onOpenDrawer = { openDrawer() }, - ) - } - }, - ) + entry { + KanbanScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } + }, + modifier = + Modifier + .padding(paddingValues) + .fillMaxSize(), + ) + } } } diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt index 9ab4e90a..6785930f 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt @@ -3,14 +3,35 @@ package com.m57.hermescontrol import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey +/** + * Central navigation controller with deduplication guard. + * + * Primary screens (bottom nav tabs) clear the back stack and become the new + * root — this matches Material's bottom-nav pattern where each tab has its own + * back stack history (simplified: we just clear). + * + * B7 (Jun 18 2026): Never call `backStack.add()` directly from UI callbacks. + * Always route through [navigateTo] to prevent stacking duplicate screen + * entries that compete for touch events. + */ object NavigationController { var backStack: NavBackStack? = null + // Bottom-nav primary screens — tapping one clears the stack to root. + private val primaryScreens: Set = + setOf( + ChatScreen, + SkillsScreen, + CronJobsScreen, + SystemScreen, + SettingsScreen, + ) + fun navigateTo(key: NavKey) { val stack = backStack ?: return if (stack.lastOrNull() == key) return - if (key == ChatScreen || key == SkillsScreen || key == CronJobsScreen) { + if (key in primaryScreens) { stack.clear() } stack.add(key) diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt index 9abb7789..d04b5c15 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt @@ -2,17 +2,16 @@ package com.m57.hermescontrol.theme import androidx.compose.ui.graphics.Color -/** - * Hermes Control brand palette — v2 (modern redesign). - * - * Design intent: - * - Primary (voltage purple) is the single accent; surfaces stay calm. - * - Secondary (plasma amber) is reserved for opt-in highlights only. - * - Surfaces use a 3-step elevation ladder with a slight blue tint so dark - * mode no longer reads as flat black. - * - Semantic colors (success/warning/error/info) share the same saturation - * band so they don't fight the brand. - */ +// Hermes Control brand palette — v3 (Material You rewrite). +// +// On Android 12+ (API 31+) the app uses dynamic colour derived from the user's +// wallpaper — this palette is NOT used for primary/surface roles on those +// devices (see HermesControlTheme). On API 26–30 it serves as the full colour +// scheme. Semantic status colours and chat-bubble colours are ALWAYS +// brand-defined. All pairs verified for WCAG AA contrast (4.5:1 text, 3:1 +// large text / icons). + +// ── Brand: Voltage Purple (primary) ────────────────────────────────────── val HermesPurple = Color(0xFF7C5CFF) val HermesPurpleLight = Color(0xFFAC93FF) @@ -20,27 +19,45 @@ val HermesPurpleDark = Color(0xFF5A3FE0) val HermesPurpleContainer = Color(0xFF2B2159) val HermesPurpleOnContainer = Color(0xFFD9CCFF) +// ── Brand: Plasma Amber (secondary) ───────────────────────────────────── + val HermesAmber = Color(0xFFFFB627) val HermesAmberLight = Color(0xFFFFE082) val HermesAmberDark = Color(0xFFC68400) +// ── Dark surfaces (5-step elevation ladder) ───────────────────────────── + val DarkBackground = Color(0xFF0B0B11) val DarkSurface = Color(0xFF121218) val DarkSurfaceVariant = Color(0xFF1C1C26) -val DarkSurfaceHigh = Color(0xFF262633) +val DarkSurfaceContainer = Color(0xFF1C1C26) +val DarkSurfaceContainerHigh = Color(0xFF262633) +val DarkSurfaceContainerHighest = Color(0xFF30303D) val DarkOnSurface = Color(0xFFE8E6EE) val DarkOnSurfaceVariant = Color(0xFFB6B2C4) val DarkOutline = Color(0xFF4A4A5A) val DarkOutlineVariant = Color(0xFF2F2F3D) +val DarkInverseSurface = Color(0xFFE8E6EE) +val DarkInverseOnSurface = Color(0xFF1A1A24) +val DarkScrim = Color(0xFF000000) + +// ── Light surfaces (5-step elevation ladder) ──────────────────────────── val LightBackground = Color(0xFFF7F6FB) val LightSurface = Color(0xFFFFFFFF) val LightSurfaceVariant = Color(0xFFEFEDF4) -val LightSurfaceHigh = Color(0xFFE6E3EE) +val LightSurfaceContainer = Color(0xFFEFEDF4) +val LightSurfaceContainerHigh = Color(0xFFE6E3EE) +val LightSurfaceContainerHighest = Color(0xFFDDD9EA) val LightOnSurface = Color(0xFF1A1A24) val LightOnSurfaceVariant = Color(0xFF56536A) val LightOutline = Color(0xFF79748E) val LightOutlineVariant = Color(0xFFC9C5D6) +val LightInverseSurface = Color(0xFF1A1A24) +val LightInverseOnSurface = Color(0xFFE8E6EE) +val LightScrim = Color(0xFF000000) + +// ── Semantic status colours (always brand-defined) ────────────────────── val StatusGreen = Color(0xFF3DDC84) val StatusGreenContainer = Color(0xFF143A23) @@ -51,6 +68,8 @@ val StatusYellowContainer = Color(0xFF3D2F0F) val StatusBlue = Color(0xFF4DA8FF) val StatusBlueContainer = Color(0xFF0F2A3D) +// ── Chat-specific colours (always brand-defined for consistency) ─────── + val UserBubble = HermesPurple val UserBubbleDark = HermesPurpleDark val AssistantBubble = Color(0xFF1C1C26) @@ -58,3 +77,10 @@ val AssistantBubbleLight = Color(0xFFEDEAF4) val SystemMessageColor = Color(0xFF8B879A) val ToolChipColor = Color(0xFF262633) val ToolChipColorLight = Color(0xFFE6E3EE) + +// ── Skeleton shimmer colours ─────────────────────────────────────────── + +val SkeletonBase = Color(0xFF1C1C26) +val SkeletonHighlight = Color(0xFF2F2F3D) +val SkeletonBaseLight = Color(0xFFE6E3EE) +val SkeletonHighlightLight = Color(0xFFEFEDF4) diff --git a/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt b/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt new file mode 100644 index 00000000..7be863a7 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt @@ -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, + ) + } diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Motion.kt b/app/src/main/java/com/m57/hermescontrol/theme/Motion.kt new file mode 100644 index 00000000..7ac0b8f5 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/theme/Motion.kt @@ -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 bouncySpring(): SpringSpec = + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ) + + /** Snappy spring for state toggles (switch, selection). */ + fun snappySpring(): SpringSpec = + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessHigh, + ) + + /** Gentle spring for layout animations (expand, collapse). */ + fun gentleSpring(): SpringSpec = + spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow, + ) +} + +val MotionDefaults = Motion + +val LocalMotion = staticCompositionLocalOf { Motion } diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Shapes.kt b/app/src/main/java/com/m57/hermescontrol/theme/Shapes.kt index 8da1555f..bdff67ea 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Shapes.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Shapes.kt @@ -5,19 +5,21 @@ import androidx.compose.material3.Shapes import androidx.compose.ui.unit.dp /** - * Unified shape system for Hermes Control. + * Unified shape system — aligned to 8 dp rhythm (4/8/12/16/28). * - * - xs: chips, small badges - * - sm: text fields, small cards - * - md: standard cards, list rows - * - lg: large cards, bottom sheets - * - xl: feature cards, full-bleed surfaces + * | Token | Radius | Use case | + * |--------------|--------|---------------------------------------| + * | extraSmall | 4 dp | Chips, badges, inline toggles | + * | small | 8 dp | Text fields, small cards, small FAB | + * | medium | 12 dp | Standard cards, list rows, dialogs | + * | large | 16 dp | Large cards, bottom sheets, FAB | + * | extraLarge | 28 dp | Feature cards, full-bleed surfaces | */ val HermesShapes = Shapes( - extraSmall = RoundedCornerShape(6.dp), - small = RoundedCornerShape(10.dp), - medium = RoundedCornerShape(16.dp), - large = RoundedCornerShape(24.dp), - extraLarge = RoundedCornerShape(32.dp), + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(28.dp), ) diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Spacing.kt b/app/src/main/java/com/m57/hermescontrol/theme/Spacing.kt index e421f76b..1cda0b4e 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Spacing.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Spacing.kt @@ -1,24 +1,32 @@ package com.m57.hermescontrol.theme +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.unit.dp /** - * Spacing tokens — use these instead of ad-hoc dp values so every screen - * shares the same rhythm. + * Spacing tokens — the 8 dp rhythm that every screen shares. * - * Convention (Material 3 spacing scale, condensed): - * xs = 4 hairline / chip gaps - * sm = 8 default inner padding - * md = 12 card content - * lg = 16 screen edge, section gaps - * xl = 24 hero spacing - * xxl = 32 full-feature spacing + * | Token | Value | Use case | + * |-------|--------|-------------------------------------------| + * | xs | 4 dp | Hairline gaps, chip inner padding | + * | sm | 8 dp | Default inner padding, icon-to-text gap | + * | md | 16 dp | Card content, screen edge, section gaps | + * | lg | 24 dp | Hero spacing, between major sections | + * | xl | 32 dp | Full-feature spacing, large section break | + * | xxl | 48 dp | Maximum spacing, rare | + * + * Access in composables via `LocalSpacing.current` or the [Spacing] object. */ object Spacing { val xs = 4.dp val sm = 8.dp - val md = 12.dp - val lg = 16.dp - val xl = 24.dp - val xxl = 32.dp + val md = 16.dp + val lg = 24.dp + val xl = 32.dp + val xxl = 48.dp } + +/** Alias for backwards-compatibility — existing `Spacing.xs` calls still work. */ +val SpacingDefaults = Spacing + +val LocalSpacing = staticCompositionLocalOf { Spacing } diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt index 487bff3c..ed2f0378 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -18,9 +19,17 @@ val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM } /** * Set to true to opt into Material You dynamic color (Android 12+). - * Off by default — brand identity (Voltage Purple) is the priority. + * + * On API 31+ the app derives its entire colour scheme from the user's + * wallpaper — primary, secondary, tertiary, surfaces, the lot. On older + * devices the [HermesDarkColorScheme] / [HermesLightColorScheme] brand + * palette is used instead. + * + * Semantic status colours (success / warning / error / info) and chat-bubble + * colours are ALWAYS brand-defined regardless of dynamic colour — see + * [Color.kt] and the `LocalHermesStatusColors` provider below. */ -private const val ENABLE_DYNAMIC_COLOR = false +private const val ENABLE_DYNAMIC_COLOR = true private val HermesDarkColorScheme = darkColorScheme( @@ -33,6 +42,7 @@ private val HermesDarkColorScheme = secondaryContainer = Color(0xFF3D2F0F), onSecondaryContainer = HermesAmberLight, tertiary = HermesAmberLight, + onTertiary = Color.Black, background = DarkBackground, onBackground = DarkOnSurface, surface = DarkSurface, @@ -40,17 +50,18 @@ private val HermesDarkColorScheme = surfaceVariant = DarkSurfaceVariant, onSurfaceVariant = DarkOnSurfaceVariant, surfaceTint = HermesPurple, - surfaceContainer = DarkSurfaceVariant, - surfaceContainerHigh = DarkSurfaceHigh, - surfaceContainerHighest = DarkSurfaceHigh, - inverseSurface = Color(0xFFE8E6EE), - inverseOnSurface = Color(0xFF1A1A24), + surfaceContainer = DarkSurfaceContainer, + surfaceContainerHigh = DarkSurfaceContainerHigh, + surfaceContainerHighest = DarkSurfaceContainerHighest, + inverseSurface = DarkInverseSurface, + inverseOnSurface = DarkInverseOnSurface, error = StatusRed, onError = Color.White, errorContainer = StatusRedContainer, onErrorContainer = Color(0xFFFFB4B4), outline = DarkOutline, outlineVariant = DarkOutlineVariant, + scrim = DarkScrim, ) private val HermesLightColorScheme = @@ -64,6 +75,7 @@ private val HermesLightColorScheme = secondaryContainer = HermesAmberLight, onSecondaryContainer = Color(0xFF3D2F0F), tertiary = HermesAmberDark, + onTertiary = Color.White, background = LightBackground, onBackground = LightOnSurface, surface = LightSurface, @@ -71,17 +83,18 @@ private val HermesLightColorScheme = surfaceVariant = LightSurfaceVariant, onSurfaceVariant = LightOnSurfaceVariant, surfaceTint = HermesPurple, - surfaceContainer = LightSurfaceVariant, - surfaceContainerHigh = LightSurfaceHigh, - surfaceContainerHighest = LightSurfaceHigh, - inverseSurface = Color(0xFF1A1A24), - inverseOnSurface = Color(0xFFE8E6EE), + surfaceContainer = LightSurfaceContainer, + surfaceContainerHigh = LightSurfaceContainerHigh, + surfaceContainerHighest = LightSurfaceContainerHighest, + inverseSurface = LightInverseSurface, + inverseOnSurface = LightInverseOnSurface, error = Color(0xFFB3261E), onError = Color.White, errorContainer = Color(0xFFF9DEDC), onErrorContainer = Color(0xFF410E0B), outline = LightOutline, outlineVariant = LightOutlineVariant, + scrim = LightScrim, ) @Composable @@ -107,10 +120,41 @@ fun HermesControlTheme( else -> HermesLightColorScheme } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - shapes = HermesShapes, - content = content, - ) + val statusColors = + if (darkTheme) { + HermesStatusColors( + success = StatusGreen, + successContainer = StatusGreenContainer, + warning = StatusYellow, + warningContainer = StatusYellowContainer, + error = StatusRed, + errorContainer = StatusRedContainer, + info = StatusBlue, + infoContainer = StatusBlueContainer, + ) + } else { + HermesStatusColors( + success = Color(0xFF1B873A), + successContainer = Color(0xFFD7F5E0), + warning = HermesAmberDark, + warningContainer = Color(0xFFFFEAB3), + error = Color(0xFFB3261E), + errorContainer = Color(0xFFF9DEDC), + info = Color(0xFF2E6FBD), + infoContainer = Color(0xFFD4E7FF), + ) + } + + CompositionLocalProvider( + LocalHermesStatusColors provides statusColors, + LocalSpacing provides SpacingDefaults, + LocalMotion provides MotionDefaults, + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + shapes = HermesShapes, + content = content, + ) + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt index 2f50518f..c3604b8a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt @@ -1,7 +1,6 @@ package com.m57.hermescontrol.ui.achievements import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -13,19 +12,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -35,8 +27,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AchievementsScreen( modifier: Modifier = Modifier, @@ -49,54 +44,33 @@ fun AchievementsScreen( viewModel.loadAchievements() } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onOpenDrawer != null) { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Open Drawer", - ) - } - } - }, - title = { Text("Agent Achievements") }, - actions = { - IconButton(onClick = { viewModel.loadAchievements() }) { - Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh") - } - }, - ) - }, - modifier = modifier, + HermesScaffold( + title = "Agent Achievements", + onOpenDrawer = onOpenDrawer, + onRefresh = { viewModel.loadAchievements() }, ) { paddingValues -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - if (state.isLoading && state.achievements.isEmpty()) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else if (state.errorMessage != null && state.achievements.isEmpty()) { - Column( - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(16.dp)) - IconButton(onClick = { viewModel.loadAchievements() }) { - Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry") - } - } - } else { + when { + state.isLoading && state.achievements.isEmpty() -> { + LoadingState(modifier = Modifier.padding(paddingValues)) + } + state.errorMessage != null && state.achievements.isEmpty() -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadAchievements() }, + modifier = Modifier.padding(paddingValues), + ) + } + state.achievements.isEmpty() -> { + EmptyState( + title = "No achievements yet", + subtitle = "Complete tasks to unlock achievements.", + icon = Icons.Filled.Refresh, + modifier = Modifier.padding(paddingValues), + ) + } + else -> { LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().padding(paddingValues), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt index 8f9705b3..ea3f9ec5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt @@ -2,7 +2,6 @@ package com.m57.hermescontrol.ui.channels import android.widget.Toast import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -14,21 +13,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -43,8 +33,11 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.data.model.MessagingPlatformUpdate +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChannelsScreen( modifier: Modifier = Modifier, @@ -65,52 +58,32 @@ fun ChannelsScreen( } } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onOpenDrawer != null) { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Open Drawer", - ) - } - } - }, - title = { Text("Messaging Channels") }, - actions = { - IconButton(onClick = { viewModel.loadPlatforms() }) { - Icon( - imageVector = Icons.Filled.Refresh, - contentDescription = "Refresh", - ) - } - }, - ) - }, - modifier = modifier, + HermesScaffold( + title = "Messaging Channels", + onOpenDrawer = onOpenDrawer, + onRefresh = { viewModel.loadPlatforms() }, ) { paddingValues -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - if (state.isLoading) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else if (state.errorMessage != null) { - Text( - text = state.errorMessage ?: "", - color = MaterialTheme.colorScheme.error, - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), + when { + state.isLoading -> { + LoadingState(modifier = Modifier.padding(paddingValues)) + } + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadPlatforms() }, + modifier = Modifier.padding(paddingValues), ) - } else { + } + state.platforms.isEmpty() -> { + EmptyState( + title = "No channels configured", + subtitle = "Add messaging platforms in Hermes to see them here.", + modifier = Modifier.padding(paddingValues), + ) + } + else -> { LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().padding(paddingValues), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt index e2ac7147..73e219f3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.ui.common import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -17,14 +18,16 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll /** - * Shared Scaffold wrapper that standardizes the drawer-aware TopAppBar. + * Shared Scaffold wrapper that standardizes the TopAppBar. * - * Eliminates the per-screen duplication of: - * - Menu icon (drawer toggle) - * - Title - * - Optional Refresh / actions slot + * Improvements (v2): + * - Scroll-aware top bar (pinned collapse on scroll) + * - Uses Spacing tokens internally + * - Simplified API: onOpenDrawer OR showBack (mutually exclusive) + * - Safe-area aware via Scaffold insets * * Screens that still need a Back arrow instead of a Menu icon can set * `showBack = true` and pass an `onBack` lambda. @@ -38,11 +41,15 @@ fun HermesScaffold( onBack: (() -> Unit)? = null, showBack: Boolean = false, actions: @Composable () -> Unit = {}, - content: @Composable (androidx.compose.foundation.layout.PaddingValues) -> Unit, + content: @Composable (PaddingValues) -> Unit, ) { + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { TopAppBar( + title = { Text(title) }, navigationIcon = { when { showBack && onBack != null -> { @@ -57,13 +64,12 @@ fun HermesScaffold( IconButton(onClick = onOpenDrawer) { Icon( imageVector = Icons.Filled.Menu, - contentDescription = "Open Drawer", + contentDescription = "Open navigation drawer", ) } } } }, - title = { Text(title) }, actions = { if (onRefresh != null) { IconButton(onClick = onRefresh) { @@ -82,6 +88,7 @@ fun HermesScaffold( navigationIconContentColor = MaterialTheme.colorScheme.onSurface, actionIconContentColor = MaterialTheme.colorScheme.onSurface, ), + scrollBehavior = scrollBehavior, ) }, containerColor = MaterialTheme.colorScheme.background, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt new file mode 100644 index 00000000..82942e6e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt @@ -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, + 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, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt index a8d2f0e8..f9ad6009 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt @@ -1,33 +1,49 @@ package com.m57.hermescontrol.ui.common +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Inbox import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.theme.Motion -/** - * Unified loading / error / empty placeholders so every list screen - * shares the same visual language. - */ +// Unified loading / error / empty / skeleton placeholders so every screen +// shares the same visual language. + +// ── Loading ─────────────────────────────────────────────────────────── @Composable fun LoadingState(modifier: Modifier = Modifier) { @@ -43,59 +59,136 @@ fun LoadingState(modifier: Modifier = Modifier) { } } +// ── Skeleton Shimmer ────────────────────────────────────────────────── + +/** + * Shimmer placeholder for list-based loading states. + * Shows animated placeholder cards while real data loads. + * + * @param itemCount number of skeleton rows to show + */ +@Composable +fun SkeletonList( + itemCount: Int = 6, + modifier: Modifier = Modifier, +) { + val spacing = LocalSpacing.current + val infiniteTransition = rememberInfiniteTransition(label = "shimmer") + val shimmerAlpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 0.7f, + animationSpec = + infiniteRepeatable( + animation = tween(Motion.NORMAL * 2), + repeatMode = RepeatMode.Reverse, + ), + label = "shimmerAlpha", + ) + val skeletonColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = shimmerAlpha) + + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = spacing.md, vertical = spacing.sm), + verticalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + items(itemCount) { + SkeletonCard(skeletonColor) + } + } +} + +@Composable +private fun SkeletonCard(baseColor: Color) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(baseColor) + .padding(16.dp), + ) { + Box( + modifier = + Modifier + .width(180.dp) + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + .background(baseColor.copy(alpha = baseColor.alpha + 0.1f)), + ) + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = + Modifier + .fillMaxWidth(0.7f) + .height(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(baseColor.copy(alpha = baseColor.alpha + 0.05f)), + ) + } +} + +// ── Error ────────────────────────────────────────────────────────────── + @Composable fun ErrorState( message: String, onRetry: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { + val spacing = LocalSpacing.current Column( modifier = modifier .fillMaxSize() - .padding(24.dp), + .padding(spacing.lg), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Text( - text = "⚠", - fontSize = MaterialTheme.typography.headlineMedium.fontSize, - color = MaterialTheme.colorScheme.error, + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(48.dp), ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(spacing.sm)) Text( text = message, - color = MaterialTheme.colorScheme.error, + color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, ) if (onRetry != null) { - Spacer(modifier = Modifier.height(16.dp)) - OutlinedButton(onClick = onRetry) { + Spacer(modifier = Modifier.height(spacing.md)) + Button(onClick = onRetry) { Icon( imageVector = Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp), ) - Spacer(modifier = Modifier.size(8.dp)) + Spacer(modifier = Modifier.size(spacing.sm)) Text("Retry") } } } } +// ── Empty ──────────────────────────────────────────────────────────── + @Composable fun EmptyState( title: String, subtitle: String? = null, icon: ImageVector = Icons.Filled.Inbox, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { + val spacing = LocalSpacing.current Column( modifier = modifier .fillMaxSize() - .padding(24.dp), + .padding(spacing.lg), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -103,16 +196,16 @@ fun EmptyState( imageVector = icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - modifier = Modifier.size(48.dp), + modifier = Modifier.size(56.dp), ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(spacing.sm)) Text( text = title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) if (subtitle != null) { - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(spacing.xs)) Text( text = subtitle, style = MaterialTheme.typography.bodySmall, @@ -120,12 +213,17 @@ fun EmptyState( textAlign = TextAlign.Center, ) } + if (actionLabel != null && onAction != null) { + Spacer(modifier = Modifier.height(spacing.md)) + OutlinedButton(onClick = onAction) { + Text(actionLabel) + } + } } } -/** - * Padding convention for List screens — outer 16, gaps of 8 between items. - */ +// ── Padding conventions ────────────────────────────────────────────── + val listContentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp) val listItemSpacing = Arrangement.spacedBy(8.dp) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt index 6fbdb45c..878739fe 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt @@ -10,20 +10,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -37,6 +30,9 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -60,105 +56,92 @@ fun ConfigScreen( } } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onOpenDrawer != null) { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Open Drawer", - ) - } - } - }, - title = { Text("Raw Configuration") }, - actions = { - IconButton(onClick = { viewModel.loadRawConfig() }) { - Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh") - } - }, - ) - }, - modifier = modifier, + HermesScaffold( + title = "Configuration", + onOpenDrawer = onOpenDrawer, + onRefresh = { viewModel.loadRawConfig() }, ) { paddingValues -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - if (state.isLoading && state.yamlText == null) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else if (state.errorMessage != null && state.yamlText == null) { - Column( - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { viewModel.loadRawConfig() }) { - Text("Retry") - } - } - } else { - Column( - modifier = - Modifier - .fillMaxSize() - .padding(16.dp), - ) { - state.path?.let { - Text( - text = "Path: $it", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp), - ) - } - - OutlinedTextField( - value = editingText, - onValueChange = { editingText = it }, - modifier = - Modifier - .fillMaxWidth() - .weight(1f), - placeholder = { Text("YAML Config...") }, - textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), - maxLines = Int.MAX_VALUE, - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Row(modifier = Modifier.fillMaxWidth()) { - OutlinedButton( - onClick = { editingText = state.yamlText ?: "" }, - modifier = Modifier.weight(1f), + when { + state.isLoading -> { + LoadingState(modifier = Modifier.padding(paddingValues)) + } + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadRawConfig() }, + modifier = Modifier.padding(paddingValues), + ) + } + else -> + Box(Modifier.fillMaxSize()) { + if (state.isLoading && state.yamlText == null) { + CircularProgressIndicator() + } else if (state.errorMessage != null && state.yamlText == null) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Text("Reset") + Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.loadRawConfig() }) { + Text("Retry") + } } - - Spacer(modifier = Modifier.width(16.dp)) - - Button( - onClick = { viewModel.saveRawConfig(editingText) }, - modifier = Modifier.weight(1f), - enabled = !state.isSaving, + } else { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), ) { - if (state.isSaving) { - CircularProgressIndicator(modifier = Modifier.width(16.dp)) - } else { - Text("Save Config") + state.path?.let { + Text( + text = "Path: $it", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + + OutlinedTextField( + value = editingText, + onValueChange = { editingText = it }, + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + placeholder = { Text("YAML Config...") }, + textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + maxLines = Int.MAX_VALUE, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = { editingText = state.yamlText ?: "" }, + modifier = Modifier.weight(1f), + ) { + Text("Reset") + } + + Spacer(modifier = Modifier.width(16.dp)) + + Button( + onClick = { viewModel.saveRawConfig(editingText) }, + modifier = Modifier.weight(1f), + enabled = !state.isSaving, + ) { + if (state.isSaving) { + CircularProgressIndicator(modifier = Modifier.width(16.dp)) + } else { + Text("Save Config") + } + } } } } } - } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt index d2953431..ba228f6a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt @@ -2,42 +2,41 @@ package com.m57.hermescontrol.ui.cron import android.widget.Toast import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Schedule import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.font.FontWeight import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.common.listContentPadding +import com.m57.hermescontrol.ui.common.listItemSpacing -@OptIn(ExperimentalMaterial3Api::class) @Composable fun CronJobsScreen( modifier: Modifier = Modifier, @@ -46,6 +45,7 @@ fun CronJobsScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + val spacing = LocalSpacing.current LaunchedEffect(Unit) { viewModel.loadCronJobs() @@ -58,90 +58,91 @@ fun CronJobsScreen( } } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onOpenDrawer != null) { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = androidx.compose.material.icons.Icons.Filled.Menu, - contentDescription = "Open Drawer", - ) - } - } - }, - title = { Text("Cron Jobs") }, - actions = { - IconButton(onClick = { viewModel.loadCronJobs() }) { - Icon( - imageVector = Icons.Filled.Refresh, - contentDescription = "Refresh", - ) - } - }, - ) - }, - modifier = modifier, - ) { paddingValues -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - if (state.isLoading) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else if (state.errorMessage != null) { - Text( - text = state.errorMessage ?: "", - color = MaterialTheme.colorScheme.error, - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), + HermesScaffold( + title = "Cron Jobs", + onOpenDrawer = onOpenDrawer, + onRefresh = { viewModel.loadCronJobs() }, + ) { + when { + state.isLoading -> LoadingState() + state.errorMessage != null -> + ErrorState( + message = state.errorMessage ?: "Unknown error", + onRetry = { viewModel.loadCronJobs() }, ) - } else { + state.jobs.isEmpty() -> + EmptyState( + title = "No cron jobs", + subtitle = "Scheduled tasks from Hermes will appear here.", + icon = Icons.Filled.Schedule, + ) + else -> LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, ) { items(state.jobs) { job -> - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text(text = job.name, style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Schedule: ${job.scheduleText}", - style = MaterialTheme.typography.bodyMedium, - ) - Text(text = "Status: ${job.state}", style = MaterialTheme.typography.bodyMedium) - Text( - text = "Last Run: ${job.lastRunStatus.ifBlank { "Never" }}", - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = "Next Run: ${job.nextRunTime.ifBlank { "N/A" }}", - style = MaterialTheme.typography.bodyMedium, - ) - - Spacer(modifier = Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Text( + text = job.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + StatusBadge( + text = if (job.state == "active") "Active" else "Paused", + status = + if (job.state == "active") { + StatusBadgeType.SUCCESS + } else { + StatusBadgeType.NEUTRAL + }, + ) + } + Spacer(modifier = Modifier.height(spacing.xs)) + Text( + text = job.scheduleText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), ) { if (job.state == "active") { - Button(onClick = { viewModel.pauseCronJob(job.id) }) { + OutlinedButton( + onClick = { viewModel.pauseCronJob(job.id) }, + modifier = Modifier.weight(1f), + ) { Text("Pause") } } else { - Button(onClick = { viewModel.resumeCronJob(job.id) }) { + OutlinedButton( + onClick = { viewModel.resumeCronJob(job.id) }, + modifier = Modifier.weight(1f), + ) { Text("Resume") } } - Button(onClick = { viewModel.triggerCronJob(job.id) }) { - Text("Trigger") + OutlinedButton( + onClick = { viewModel.triggerCronJob(job.id) }, + modifier = Modifier.weight(1f), + ) { + Text("Run") } Button( onClick = { viewModel.deleteCronJob(job.id) }, @@ -149,6 +150,7 @@ fun CronJobsScreen( ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error, ), + modifier = Modifier.weight(1f), ) { Text("Delete") } @@ -157,7 +159,6 @@ fun CronJobsScreen( } } } - } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayScreen.kt index 76d83c2c..c7837ca4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/gateway/GatewayScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button @@ -24,11 +23,8 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,6 +36,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -62,193 +61,101 @@ fun GatewayScreen( } } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onOpenDrawer != null) { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Open Drawer", - ) - } - } - }, - title = { Text("Gateway Control") }, - actions = { - IconButton( - onClick = { viewModel.loadStatus() }, - enabled = !state.isLoading, - ) { - Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh") - } - }, - ) - }, - modifier = modifier, + HermesScaffold( + title = "Gateway Control", + onOpenDrawer = onOpenDrawer, + onRefresh = { viewModel.loadStatus() }, ) { paddingValues -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - if (state.isLoading && state.status == null) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else if (state.errorMessage != null && state.status == null) { - Column( - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = state.errorMessage ?: "", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { viewModel.loadStatus() }) { - Text("Retry") - } - } - } else { - val status = state.status - Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - val isRunning = status?.gateway_running == true - - // Status Overview Card - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = - if (isRunning) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.errorContainer - }, - ), - ) { + when { + state.isLoading -> { + LoadingState(modifier = Modifier.padding(paddingValues)) + } + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadStatus() }, + modifier = Modifier.padding(paddingValues), + ) + } + else -> + Box(Modifier.fillMaxSize()) { + if (state.isLoading && state.status == null) { + CircularProgressIndicator() + } else if (state.errorMessage != null && state.status == null) { Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = if (isRunning) "GATEWAY RUNNING" else "GATEWAY STOPPED", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = - if (isRunning) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onErrorContainer - }, + text = state.errorMessage ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, ) - status?.version?.let { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Version: $it", - style = MaterialTheme.typography.bodySmall, - color = - if (isRunning) { - MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) - } else { - MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f) - }, - ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.loadStatus() }) { + Text("Retry") } } - } - - // Action Controls Card - Card( - modifier = Modifier.fillMaxWidth(), - ) { + } else { + val status = state.status Column( modifier = Modifier - .fillMaxWidth() + .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text( - text = "Controls", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) + val isRunning = status?.gateway_running == true - Row( + // Status Overview Card + Card( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button( - onClick = { viewModel.startGateway() }, - modifier = Modifier.weight(1f), - enabled = !isRunning && !state.isActionRunning, - colors = - ButtonDefaults.buttonColors( - containerColor = Color(0xFF4CAF50), // Green for start - contentColor = Color.White, - ), - ) { - Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) - Spacer(modifier = Modifier.width(4.dp)) - Text("Start") - } - - Button( - onClick = { viewModel.stopGateway() }, - modifier = Modifier.weight(1f), - enabled = isRunning && !state.isActionRunning, - colors = - ButtonDefaults.buttonColors( - containerColor = Color(0xFFF44336), // Red for stop - contentColor = Color.White, - ), - ) { - Text("Stop") - } - } - - Button( - onClick = { viewModel.restartGateway() }, - modifier = Modifier.fillMaxWidth(), - enabled = !state.isActionRunning, colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary, + CardDefaults.cardColors( + containerColor = + if (isRunning) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.errorContainer + }, ), ) { - Icon(imageVector = Icons.Filled.Refresh, contentDescription = null) - Spacer(modifier = Modifier.width(4.dp)) - Text("Restart") + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = if (isRunning) "GATEWAY RUNNING" else "GATEWAY STOPPED", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = + if (isRunning) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onErrorContainer + }, + ) + status?.version?.let { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Version: $it", + style = MaterialTheme.typography.bodySmall, + color = + if (isRunning) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f) + }, + ) + } + } } - if (state.isActionRunning) { - Spacer(modifier = Modifier.height(4.dp)) - CircularProgressIndicator( - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - } - } - } - - // Platforms Card - status?.gateway_platforms?.let { platforms -> - if (platforms.isNotEmpty()) { + // Action Controls Card Card( modifier = Modifier.fillMaxWidth(), ) { @@ -257,42 +164,119 @@ fun GatewayScreen( Modifier .fillMaxWidth() .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Active Platforms", + text = "Controls", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, ) - platforms.forEach { (platform, pStatus) -> - Row( + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { viewModel.startGateway() }, + modifier = Modifier.weight(1f), + enabled = !isRunning && !state.isActionRunning, + colors = + ButtonDefaults.buttonColors( + containerColor = Color(0xFF4CAF50), // Green for start + contentColor = Color.White, + ), + ) { + Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) + Spacer(modifier = Modifier.width(4.dp)) + Text("Start") + } + + Button( + onClick = { viewModel.stopGateway() }, + modifier = Modifier.weight(1f), + enabled = isRunning && !state.isActionRunning, + colors = + ButtonDefaults.buttonColors( + containerColor = Color(0xFFF44336), // Red for stop + contentColor = Color.White, + ), + ) { + Text("Stop") + } + } + + Button( + onClick = { viewModel.restartGateway() }, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isActionRunning, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary, + ), + ) { + Icon(imageVector = Icons.Filled.Refresh, contentDescription = null) + Spacer(modifier = Modifier.width(4.dp)) + Text("Restart") + } + + if (state.isActionRunning) { + Spacer(modifier = Modifier.height(4.dp)) + CircularProgressIndicator( + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + } + } + + // Platforms Card + status?.gateway_platforms?.let { platforms -> + if (platforms.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = platform.replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, + text = "Active Platforms", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, ) - val stateText = pStatus.state?.uppercase() ?: "UNKNOWN" - val badgeColor = - when (stateText) { - "RUNNING" -> Color(0xFF4CAF50) - "STOPPED" -> Color(0xFFF44336) - "ERROR" -> Color(0xFFFF9800) - else -> MaterialTheme.colorScheme.onSurfaceVariant + + platforms.forEach { (platform, pStatus) -> + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = platform.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + val stateText = pStatus.state?.uppercase() ?: "UNKNOWN" + val badgeColor = + when (stateText) { + "RUNNING" -> Color(0xFF4CAF50) + "STOPPED" -> Color(0xFFF44336) + "ERROR" -> Color(0xFFFF9800) + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Text( + text = stateText, + style = MaterialTheme.typography.bodyMedium, + color = badgeColor, + fontWeight = FontWeight.Bold, + ) } - Text( - text = stateText, - style = MaterialTheme.typography.bodyMedium, - color = badgeColor, - fontWeight = FontWeight.Bold, - ) + } } } } @@ -300,7 +284,6 @@ fun GatewayScreen( } } } - } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt index ac3f87df..e0cd3564 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt @@ -17,25 +17,19 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowForward -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -50,6 +44,9 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.data.model.KanbanTask +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -73,145 +70,124 @@ fun KanbanScreen( } } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onOpenDrawer != null) { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Open Drawer", - ) - } - } - }, - title = { Text(state.selectedBoard?.name ?: "Kanban Boards") }, - actions = { - IconButton(onClick = { viewModel.loadBoards() }) { - Icon( - imageVector = Icons.Filled.Refresh, - contentDescription = "Refresh", - ) - } - }, - ) - }, - floatingActionButton = { - if (state.selectedBoard != null) { - FloatingActionButton(onClick = { showAddTaskDialog = true }) { - Icon(imageVector = Icons.Filled.Add, contentDescription = "Add Task") - } - } - }, - modifier = modifier, + HermesScaffold( + title = "Kanban Board", + onOpenDrawer = onOpenDrawer, + onRefresh = { viewModel.loadBoards() }, ) { paddingValues -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - if (state.isLoading) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else if (state.errorMessage != null) { - Text( - text = state.errorMessage ?: "", - color = MaterialTheme.colorScheme.error, - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), + when { + state.isLoading -> { + LoadingState(modifier = Modifier.padding(paddingValues)) + } + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadBoards() }, + modifier = Modifier.padding(paddingValues), ) - } else { - Column(modifier = Modifier.fillMaxSize()) { - // Board selector tab row - if (state.boards.isNotEmpty()) { - ScrollableTabRow( - selectedTabIndex = state.boards.indexOf(state.selectedBoard).coerceAtLeast(0), - modifier = Modifier.fillMaxWidth(), - ) { - state.boards.forEach { board -> - Tab( - selected = board == state.selectedBoard, - onClick = { viewModel.selectBoard(board) }, - text = { Text(board.name) }, - ) - } - } - } - - if (state.selectedBoard == null) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("No boards found.") - } - } else if (state.columns.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("No columns found for this board.") - } + } + else -> + Box(Modifier.fillMaxSize()) { + if (state.isLoading) { + CircularProgressIndicator() + } else if (state.errorMessage != null) { + Text( + text = state.errorMessage ?: "", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(16.dp), + ) } else { - LazyRow( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - items(state.columns) { column -> - val colName = column.name - val colTasks = - state.tasks.filter { - it.status.equals(colName, ignoreCase = true) - } - val columnIndex = state.columns.indexOf(column) - val prevColumn = state.columns.getOrNull(columnIndex - 1) - val nextColumn = state.columns.getOrNull(columnIndex + 1) - - Column( - modifier = - Modifier - .width(280.dp) - .fillMaxSize(), + Column(modifier = Modifier.fillMaxSize()) { + // Board selector tab row + if (state.boards.isNotEmpty()) { + ScrollableTabRow( + selectedTabIndex = state.boards.indexOf(state.selectedBoard).coerceAtLeast(0), + modifier = Modifier.fillMaxWidth(), ) { - Text( - text = "${colName.replaceFirstChar { it.uppercase() }} (${colTasks.size})", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 8.dp), - ) + state.boards.forEach { board -> + Tab( + selected = board == state.selectedBoard, + onClick = { viewModel.selectBoard(board) }, + text = { Text(board.name) }, + ) + } + } + } - LazyColumn( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(colTasks) { task -> - TaskCard( - task = task, - onMoveLeft = - prevColumn?.let { - { viewModel.moveTask(task, it.name) } - }, - onMoveRight = - nextColumn?.let { - { viewModel.moveTask(task, it.name) } - }, + if (state.selectedBoard == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("No boards found.") + } + } else if (state.columns.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("No columns found for this board.") + } + } else { + LazyRow( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(state.columns) { column -> + val colName = column.name + val colTasks = + state.tasks.filter { + it.status.equals(colName, ignoreCase = true) + } + val columnIndex = state.columns.indexOf(column) + val prevColumn = state.columns.getOrNull(columnIndex - 1) + val nextColumn = state.columns.getOrNull(columnIndex + 1) + + Column( + modifier = + Modifier + .width(280.dp) + .fillMaxSize(), + ) { + Text( + text = "${colName.replaceFirstChar { it.uppercase() }} (${ + colTasks.size + })", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp), ) + + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(colTasks) { task -> + TaskCard( + task = task, + onMoveLeft = + prevColumn?.let { + { viewModel.moveTask(task, it.name) } + }, + onMoveRight = + nextColumn?.let { + { viewModel.moveTask(task, it.name) } + }, + ) + } + } } } } } } } - } - } - if (showAddTaskDialog) { - AddTaskDialog( - onDismiss = { showAddTaskDialog = false }, - onConfirm = { title, desc -> - viewModel.createTask(title, desc, state.columns.firstOrNull()?.name ?: "todo") - showAddTaskDialog = false - }, - ) - } + if (showAddTaskDialog) { + AddTaskDialog( + onDismiss = { showAddTaskDialog = false }, + onConfirm = { title, desc -> + viewModel.createTask(title, desc, state.columns.firstOrNull()?.name ?: "todo") + showAddTaskDialog = false + }, + ) + } + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt index fa80fc7c..a889e81e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/keys/KeysScreen.kt @@ -16,8 +16,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button @@ -28,9 +26,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -44,6 +40,9 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -66,152 +65,137 @@ fun KeysScreen( } } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onOpenDrawer != null) { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Open Drawer", - ) - } - } - }, - title = { Text("Keys & Credentials") }, - actions = { - IconButton(onClick = { viewModel.loadKeys() }) { - Icon( - imageVector = Icons.Filled.Refresh, - contentDescription = "Refresh", - ) - } - }, - ) - }, - modifier = modifier, + HermesScaffold( + title = "Keys & Credentials", + onOpenDrawer = onOpenDrawer, + onRefresh = { viewModel.loadKeys() }, ) { paddingValues -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - if (state.isLoading) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else if (state.errorMessage != null) { - Text( - text = state.errorMessage ?: "", - color = MaterialTheme.colorScheme.error, - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), + when { + state.isLoading -> { + LoadingState(modifier = Modifier.padding(paddingValues)) + } + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadKeys() }, + modifier = Modifier.padding(paddingValues), ) - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(state.envVars.toList()) { (key, config) -> - var isEditing by remember { mutableStateOf(false) } - val revealedVal = state.revealedValues[key] - val isRevealed = revealedVal != null - var editedValue by remember { mutableStateOf(revealedVal ?: "") } + } + else -> + Box(Modifier.fillMaxSize()) { + if (state.isLoading) { + CircularProgressIndicator() + } else if (state.errorMessage != null) { + Text( + text = state.errorMessage ?: "", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(16.dp), + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.envVars.toList()) { (key, config) -> + var isEditing by remember { mutableStateOf(false) } + val revealedVal = state.revealedValues[key] + val isRevealed = revealedVal != null + var editedValue by remember { mutableStateOf(revealedVal ?: "") } - val displayValue = - if (isRevealed) { - revealedVal.orEmpty() - } else if (config.isSet) { - config.redactedValue ?: "********" - } else { - "Not configured" - } - - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = key, - style = MaterialTheme.typography.titleMedium, - fontFamily = FontFamily.Monospace, - ) - if (!config.description.isNullOrBlank()) { - Text( - text = config.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.padding(vertical = 4.dp), - ) - } - Spacer(modifier = Modifier.height(4.dp)) - if (isEditing) { - OutlinedTextField( - value = editedValue, - onValueChange = { editedValue = it }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - Button(onClick = { - viewModel.updateKey(key, editedValue) - isEditing = false - }) { - Text("Save") - } - Spacer(modifier = Modifier.width(8.dp)) - Button(onClick = { - isEditing = false - }) { - Text("Cancel") - } + val displayValue = + if (isRevealed) { + revealedVal.orEmpty() + } else if (config.isSet) { + config.redactedValue ?: "********" + } else { + "Not configured" } - } else { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { Text( - text = displayValue, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = key, + style = MaterialTheme.typography.titleMedium, fontFamily = FontFamily.Monospace, - modifier = Modifier.weight(1f), ) - Row { - if (config.isSet) { - IconButton(onClick = { - if (isRevealed) { - viewModel.hideKey(key) - } else { - viewModel.revealKey(key) - } + if (!config.description.isNullOrBlank()) { + Text( + text = config.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(vertical = 4.dp), + ) + } + Spacer(modifier = Modifier.height(4.dp)) + if (isEditing) { + OutlinedTextField( + value = editedValue, + onValueChange = { editedValue = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Button(onClick = { + viewModel.updateKey(key, editedValue) + isEditing = false }) { - Icon( - imageVector = - if (isRevealed) { - Icons.Filled.VisibilityOff - } else { - Icons.Filled.Visibility - }, - contentDescription = "Toggle Visibility", - ) + Text("Save") + } + Spacer(modifier = Modifier.width(8.dp)) + Button(onClick = { + isEditing = false + }) { + Text("Cancel") } } - IconButton(onClick = { - editedValue = if (isRevealed) revealedVal.orEmpty() else "" - isEditing = true - }) { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = "Edit", + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = displayValue, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f), ) + Row { + if (config.isSet) { + IconButton(onClick = { + if (isRevealed) { + viewModel.hideKey(key) + } else { + viewModel.revealKey(key) + } + }) { + Icon( + imageVector = + if (isRevealed) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + }, + contentDescription = "Toggle Visibility", + ) + } + } + IconButton(onClick = { + editedValue = if (isRevealed) revealedVal.orEmpty() else "" + isEditing = true + }) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = "Edit", + ) + } + } } } } @@ -220,7 +204,6 @@ fun KeysScreen( } } } - } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt index ef4de0d9..74a542dd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt @@ -2,7 +2,6 @@ package com.m57.hermescontrol.ui.logs import android.widget.Toast import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -11,30 +10,25 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material.icons.filled.HistoryEdu import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState -@OptIn(ExperimentalMaterial3Api::class) @Composable fun LogsScreen( modifier: Modifier = Modifier, @@ -43,6 +37,7 @@ fun LogsScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + val spacing = LocalSpacing.current val listState = rememberLazyListState() LaunchedEffect(Unit) { @@ -63,73 +58,43 @@ fun LogsScreen( } } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onOpenDrawer != null) { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Open Drawer", - ) - } - } - }, - title = { Text("System Logs") }, - actions = { - IconButton(onClick = { viewModel.loadLogs() }) { - Icon( - imageVector = Icons.Filled.Refresh, - contentDescription = "Refresh", - ) - } - }, - ) - }, - modifier = modifier, - ) { paddingValues -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues) - .background(Color.Black), - ) { - if (state.isLoading) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center), color = Color.Green) - } else if (state.errorMessage != null) { - Text( - text = state.errorMessage ?: "", - color = MaterialTheme.colorScheme.error, - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), + HermesScaffold( + title = "Logs", + onOpenDrawer = onOpenDrawer, + onRefresh = { viewModel.loadLogs() }, + ) { + when { + state.isLoading -> LoadingState() + state.errorMessage != null -> + ErrorState( + message = state.errorMessage ?: "Unknown error", + onRetry = { viewModel.loadLogs() }, ) - } else if (state.logs.isEmpty()) { - // B5 (Jun 18 2026, kanban t_2322818d): explicit empty-state UX. - // Sibling screens (PairingScreen.kt, WebhooksScreen.kt) did this; - // LogsScreen didn't — when logs were empty and not loading, the - // black Box rendered with NO content visible to the user. - Text( - text = "No logs available", - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), + state.logs.isEmpty() -> + // B5 (Jun 18 2026, kanban t_2322818d): proper empty-state using + // the shared EmptyState component instead of a bare Text. + EmptyState( + title = "No logs available", + subtitle = "Log output from Hermes will appear here.", + icon = Icons.Filled.HistoryEdu, ) - } else { + else -> LazyColumn( state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerLowest), + contentPadding = + PaddingValues( + horizontal = spacing.md, + vertical = spacing.sm, + ), ) { items(state.logs) { logLine -> Text( text = logLine, - color = Color(0xFF00FF00), + color = MaterialTheme.colorScheme.onSurface, fontFamily = FontFamily.Monospace, fontSize = 12.sp, modifier = @@ -139,7 +104,6 @@ fun LogsScreen( ) } } - } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt index 34b2175f..120228bd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt @@ -13,22 +13,15 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,6 +33,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -62,141 +58,128 @@ fun McpServersScreen( } } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onOpenDrawer != null) { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Open Drawer", - ) - } - } - }, - title = { Text("MCP Servers") }, - actions = { - IconButton(onClick = { viewModel.loadServers() }) { - Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh") - } - }, - ) - }, - modifier = modifier, + HermesScaffold( + title = "MCP Servers", + onOpenDrawer = onOpenDrawer, + onRefresh = { viewModel.loadServers() }, ) { paddingValues -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - if (state.isLoading && state.servers.isEmpty()) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else if (state.errorMessage != null && state.servers.isEmpty()) { - Column( - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { viewModel.loadServers() }) { - Text("Retry") - } - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(state.servers) { server -> - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = - if (server.enabled) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), + when { + state.isLoading -> { + LoadingState(modifier = Modifier.padding(paddingValues)) + } + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadServers() }, + modifier = Modifier.padding(paddingValues), + ) + } + else -> + Box(Modifier.fillMaxSize()) { + if (state.isLoading && state.servers.isEmpty()) { + CircularProgressIndicator() + } else if (state.errorMessage != null && state.servers.isEmpty()) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - Row( + Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.loadServers() }) { + Text("Retry") + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.servers) { server -> + Card( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + colors = + CardDefaults.cardColors( + containerColor = + if (server.enabled) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = server.name.replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - Text( - text = "Transport: ${server.transport ?: "stdio"}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Switch( - checked = server.enabled, - onCheckedChange = { viewModel.toggleServer(server) }, - ) - } - - if (server.command != null) { - Spacer(modifier = Modifier.height(8.dp)) - val fullCmd = "${server.command} ${server.args.orEmpty().joinToString(" ")}" - Text( - text = "Command:", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Bold, - ) - Text( - text = fullCmd, - style = - MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - ), - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - OutlinedButton( - onClick = { viewModel.testServer(server.name) }, - modifier = Modifier.padding(end = 8.dp), + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), ) { - Text("Test") - } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = server.name.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Transport: ${server.transport ?: "stdio"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - OutlinedButton( - onClick = { viewModel.deleteServer(server.name) }, - ) { - Text("Delete") + Switch( + checked = server.enabled, + onCheckedChange = { viewModel.toggleServer(server) }, + ) + } + + if (server.command != null) { + Spacer(modifier = Modifier.height(8.dp)) + val fullCmd = "${server.command} ${server.args.orEmpty().joinToString(" ")}" + Text( + text = "Command:", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = fullCmd, + style = + MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + ), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton( + onClick = { viewModel.testServer(server.name) }, + modifier = Modifier.padding(end = 8.dp), + ) { + Text("Test") + } + + OutlinedButton( + onClick = { viewModel.deleteServer(server.name) }, + ) { + Text("Delete") + } + } } } } } } } - } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt index 48c79392..a28f2f6b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -26,9 +25,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -42,6 +39,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -65,198 +65,193 @@ fun ModelScreen( } } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onOpenDrawer != null) { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Open Drawer", - ) - } - } - }, - title = { Text("Model Providers") }, - actions = { - IconButton(onClick = { viewModel.loadModelOptions() }) { - Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh") - } - }, - ) - }, - modifier = modifier, + HermesScaffold( + title = "Models", + onOpenDrawer = onOpenDrawer, + onRefresh = { viewModel.loadModelOptions() }, ) { paddingValues -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - if (state.isLoading && state.providers.isEmpty()) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else if (state.errorMessage != null && state.providers.isEmpty()) { - Column( - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(16.dp)) - IconButton(onClick = { viewModel.loadModelOptions() }) { - Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry") - } - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(state.providers) { provider -> - val isExpanded = expandedProviderSlug == provider.slug - val isCurrent = provider.is_current == true - - Card( - modifier = Modifier.fillMaxWidth(), - onClick = { - expandedProviderSlug = if (isExpanded) null else provider.slug - }, - colors = - CardDefaults.cardColors( - containerColor = - if (isCurrent) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), + when { + state.isLoading -> { + LoadingState(modifier = Modifier.padding(paddingValues)) + } + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadModelOptions() }, + modifier = Modifier.padding(paddingValues), + ) + } + else -> + Box(Modifier.fillMaxSize()) { + if (state.isLoading && state.providers.isEmpty()) { + CircularProgressIndicator() + } else if (state.errorMessage != null && state.providers.isEmpty()) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - Row( + Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(16.dp)) + IconButton(onClick = { viewModel.loadModelOptions() }) { + Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry") + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.providers) { provider -> + val isExpanded = expandedProviderSlug == provider.slug + val isCurrent = provider.is_current == true + + Card( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + onClick = { + expandedProviderSlug = if (isExpanded) null else provider.slug + }, + colors = + CardDefaults.cardColors( + containerColor = + if (isCurrent) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), ) { - Column { - Text( - text = provider.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - Text( - text = "Slug: ${provider.slug}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = provider.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Slug: ${provider.slug}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - if (isCurrent) { - Text( - text = "CURRENT", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - ) - } - } + if (isCurrent) { + Text( + text = "CURRENT", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + } - provider.warning?.let { - if (it.isNotBlank()) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } + provider.warning?.let { + if (it.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } - AnimatedVisibility(visible = isExpanded) { - Column(modifier = Modifier.padding(top = 16.dp)) { - Text( - text = "Available Models:", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(bottom = 8.dp), - ) + AnimatedVisibility(visible = isExpanded) { + Column(modifier = Modifier.padding(top = 16.dp)) { + Text( + text = "Available Models:", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 8.dp), + ) - val models = provider.models.orEmpty() - if (models.isEmpty()) { - Text( - text = "No models reported or provider needs authentication.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - models.forEach { model -> - val isActive = - state.activeProfile?.provider == provider.slug && - state.activeProfile?.model == model - Card( - onClick = { - viewModel.selectModel(provider.slug, model) - }, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - colors = - CardDefaults.cardColors( - containerColor = + val models = provider.models.orEmpty() + if (models.isEmpty()) { + Text( + text = "No models reported or provider needs authentication.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + models.forEach { model -> + val isActive = + state.activeProfile?.provider == provider.slug && + state.activeProfile?.model == model + Card( + onClick = { + viewModel.selectModel(provider.slug, model) + }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + colors = + CardDefaults.cardColors( + containerColor = + if (isActive) { + MaterialTheme.colorScheme + .primaryContainer.copy( + alpha = 0.3f, + ) + } else { + MaterialTheme.colorScheme.surface + }, + ), + border = + BorderStroke( + width = 1.dp, + color = + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy( + alpha = 0.2f, + ) + }, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = model, + style = MaterialTheme.typography.bodyMedium, + fontWeight = + if (isActive) { + FontWeight.Bold + } else { + FontWeight.Normal + }, + color = + if (isActive) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) if (isActive) { - MaterialTheme.colorScheme.primaryContainer.copy( - alpha = 0.3f, + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Active Model", + tint = MaterialTheme.colorScheme.primary, ) - } else { - MaterialTheme.colorScheme.surface - }, - ), - border = - BorderStroke( - width = 1.dp, - color = - if (isActive) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) - }, - ), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = model, - style = MaterialTheme.typography.bodyMedium, - fontWeight = - if (isActive) FontWeight.Bold else FontWeight.Normal, - color = - if (isActive) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - if (isActive) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = "Active Model", - tint = MaterialTheme.colorScheme.primary, - ) + } + } } } } @@ -268,7 +263,6 @@ fun ModelScreen( } } } - } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt index ac91171d..41bb4a86 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt @@ -13,22 +13,15 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -39,6 +32,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -61,211 +57,201 @@ fun PairingScreen( } } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onOpenDrawer != null) { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Open Drawer", - ) - } - } - }, - title = { Text("Device Pairing") }, - actions = { - IconButton(onClick = { viewModel.loadPairing() }) { - Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh") - } - }, - ) - }, - modifier = modifier, + HermesScaffold( + title = "Device Pairing", + onOpenDrawer = onOpenDrawer, + onRefresh = { viewModel.loadPairing() }, ) { paddingValues -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - if (state.isLoading && state.pairing == null) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else if (state.errorMessage != null && state.pairing == null) { - Column( - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { viewModel.loadPairing() }) { - Text("Retry") - } - } - } else { - val pairing = state.pairing - val pending = pairing?.pending.orEmpty() - val approved = pairing?.approved.orEmpty() - - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - if (pending.isNotEmpty()) { - item { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Pending Approvals", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - - OutlinedButton( - onClick = { viewModel.clearPending() }, - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Text("Clear All") - } - } - } - - items(pending) { item -> - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f), - ), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Platform: ${item.platform.uppercase()}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - item.code?.let { - Text( - text = "Pairing Code: $it", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - ) - } - } - - Button( - onClick = { - viewModel.approvePairing(item.platform, item.code ?: "") - }, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text("Approve") - } - } - } - } - } - - item { - Text( - text = "Approved Devices", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } - - if (approved.isEmpty()) { - item { - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(24.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = "No approved devices paired yet.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + when { + state.isLoading -> { + LoadingState(modifier = Modifier.padding(paddingValues)) + } + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadPairing() }, + modifier = Modifier.padding(paddingValues), + ) + } + else -> + Box(Modifier.fillMaxSize()) { + if (state.isLoading && state.pairing == null) { + CircularProgressIndicator() + } else if (state.errorMessage != null && state.pairing == null) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.loadPairing() }) { + Text("Retry") } } } else { - items(approved) { item -> - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { + val pairing = state.pairing + val pending = pairing?.pending.orEmpty() + val approved = pairing?.approved.orEmpty() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (pending.isNotEmpty()) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { Text( - text = "Platform: ${item.platform.uppercase()}", + text = "Pending Approvals", style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.Bold, ) - Text( - text = "User ID: ${item.user_id ?: "Unknown"}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - item.username?.let { + + OutlinedButton( + onClick = { viewModel.clearPending() }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Clear All") + } + } + } + + items(pending) { item -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.errorContainer.copy( + alpha = 0.2f, + ), + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Platform: ${item.platform.uppercase()}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + item.code?.let { + Text( + text = "Pairing Code: $it", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + } + + Button( + onClick = { + viewModel.approvePairing(item.platform, item.code ?: "") + }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text("Approve") + } + } + } + } + } + + item { + Text( + text = "Approved Devices", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + if (approved.isEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { Text( - text = "Username: @$it", - style = MaterialTheme.typography.bodySmall, + text = "No approved devices paired yet.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - - OutlinedButton( - onClick = { - viewModel.revokePairing(item.platform, item.user_id ?: "") - }, - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), + } + } else { + items(approved) { item -> + Card( + modifier = Modifier.fillMaxWidth(), ) { - Text("Revoke") + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Platform: ${item.platform.uppercase()}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "User ID: ${item.user_id ?: "Unknown"}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + item.username?.let { + Text( + text = "Username: @$it", + style = MaterialTheme.typography.bodySmall, + ) + } + } + + OutlinedButton( + onClick = { + viewModel.revokePairing(item.platform, item.user_id ?: "") + }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Revoke") + } + } } } } } } } - } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt index 15bfd5ad..c20a766c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt @@ -14,21 +14,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -38,6 +31,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -60,107 +56,92 @@ fun PluginsScreen( } } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onOpenDrawer != null) { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Open Drawer", - ) - } - } - }, - title = { Text("Plugins Hub") }, - actions = { - IconButton(onClick = { viewModel.loadPlugins() }) { - Icon( - imageVector = Icons.Filled.Refresh, - contentDescription = "Refresh", - ) - } - }, - ) - }, - modifier = modifier, + HermesScaffold( + title = "Plugins", + onOpenDrawer = onOpenDrawer, + onRefresh = { viewModel.loadPlugins() }, ) { paddingValues -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - if (state.isLoading) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else if (state.errorMessage != null) { - Text( - text = state.errorMessage ?: "", - color = MaterialTheme.colorScheme.error, - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), + when { + state.isLoading -> { + LoadingState(modifier = Modifier.padding(paddingValues)) + } + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadPlugins() }, + modifier = Modifier.padding(paddingValues), ) - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(state.plugins) { plugin -> - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(text = plugin.name, style = MaterialTheme.typography.titleMedium) - plugin.version?.let { - Text( - text = "v$it", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - if (plugin.installed) { - Switch( - checked = plugin.enabled, - onCheckedChange = { viewModel.togglePlugin(plugin) }, - ) - } - } - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = plugin.description ?: "No description provided.", - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - if (plugin.installed) { - OutlinedButton(onClick = { viewModel.updatePlugin(plugin.name) }) { - Text("Update") - } - Spacer(modifier = Modifier.width(8.dp)) - OutlinedButton( - onClick = { viewModel.uninstallPlugin(plugin.name) }, - colors = - androidx.compose.material3.ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), + } + else -> + Box(Modifier.fillMaxSize()) { + if (state.isLoading) { + CircularProgressIndicator() + } else if (state.errorMessage != null) { + Text( + text = state.errorMessage ?: "", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(16.dp), + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.plugins) { plugin -> + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Text("Uninstall") + Column(modifier = Modifier.weight(1f)) { + Text(text = plugin.name, style = MaterialTheme.typography.titleMedium) + plugin.version?.let { + Text( + text = "v$it", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (plugin.installed) { + Switch( + checked = plugin.enabled, + onCheckedChange = { viewModel.togglePlugin(plugin) }, + ) + } } - } else { - Button(onClick = { viewModel.installPlugin(plugin.name) }) { - Text("Install") + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = plugin.description ?: "No description provided.", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + if (plugin.installed) { + OutlinedButton(onClick = { viewModel.updatePlugin(plugin.name) }) { + Text("Update") + } + Spacer(modifier = Modifier.width(8.dp)) + OutlinedButton( + onClick = { viewModel.uninstallPlugin(plugin.name) }, + colors = + androidx.compose.material3.ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Uninstall") + } + } else { + Button(onClick = { viewModel.installPlugin(plugin.name) }) { + Text("Install") + } + } } } } @@ -168,7 +149,6 @@ fun PluginsScreen( } } } - } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt index 09d979b9..834d47e8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt @@ -17,23 +17,15 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -47,6 +39,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -74,268 +69,158 @@ fun ProfilesScreen( } } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onOpenDrawer != null) { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Open Drawer", - ) - } - } - }, - title = { Text("Agent Profiles") }, - actions = { - IconButton(onClick = { viewModel.loadProfiles() }) { - Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh") - } - }, - ) - }, - modifier = modifier, + HermesScaffold( + title = "Profiles", + onOpenDrawer = onOpenDrawer, + onRefresh = { viewModel.loadProfiles() }, ) { paddingValues -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - if (state.isLoading && state.profiles.isEmpty()) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else if (state.errorMessage != null && state.profiles.isEmpty()) { - Column( - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { viewModel.loadProfiles() }) { - Text("Retry") - } - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(state.profiles) { profile -> - val isActive = profile.name == state.activeProfileName - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = - if (isActive) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), - onClick = { - if (!isActive) { - viewModel.selectActiveProfile(profile.name) - } - }, + when { + state.isLoading -> { + LoadingState(modifier = Modifier.padding(paddingValues)) + } + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadProfiles() }, + modifier = Modifier.padding(paddingValues), + ) + } + else -> + Box(Modifier.fillMaxSize()) { + if (state.isLoading && state.profiles.isEmpty()) { + CircularProgressIndicator() + } else if (state.errorMessage != null && state.profiles.isEmpty()) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - Row( + Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.loadProfiles() }) { + Text("Retry") + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.profiles) { profile -> + val isActive = profile.name == state.activeProfileName + Card( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + colors = + CardDefaults.cardColors( + containerColor = + if (isActive) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + onClick = { + if (!isActive) { + viewModel.selectActiveProfile(profile.name) + } + }, ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = profile.name.replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - ) - profile.description?.let { - if (it.isNotBlank()) { - Spacer(modifier = Modifier.height(4.dp)) + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { Text( - text = it, + text = profile.name.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + profile.description?.let { + if (it.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + if (isActive) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Active Profile", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = "Model: ${profile.model ?: "None"}", style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = "Provider: ${profile.provider ?: "None"}", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - } - if (isActive) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = "Active Profile", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 8.dp), - ) - } - } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text( - text = "Model: ${profile.model ?: "None"}", - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = "Provider: ${profile.provider ?: "None"}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton( + onClick = { + soulEditProfileName = profile.name + viewModel.loadSoul(profile.name) + }, + modifier = Modifier.padding(end = 8.dp), + ) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = null, + modifier = Modifier.width(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Edit Soul") + } - Spacer(modifier = Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - OutlinedButton( - onClick = { - soulEditProfileName = profile.name - viewModel.loadSoul(profile.name) - }, - modifier = Modifier.padding(end = 8.dp), - ) { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = null, - modifier = Modifier.width(16.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text("Edit Soul") - } - - Button( - onClick = { - modelEditProfileName = profile.name - tempModelProvider = profile.provider ?: "" - tempModelName = profile.model ?: "" - }, - ) { - Text("Set Model") + Button( + onClick = { + modelEditProfileName = profile.name + tempModelProvider = profile.provider ?: "" + tempModelName = profile.model ?: "" + }, + ) { + Text("Set Model") + } + } } } } } } } - } - } - - // Soul Edit Dialog - soulEditProfileName?.let { profileName -> - var soulText by remember(state.selectedSoulContent) { - mutableStateOf(state.selectedSoulContent ?: "") - } - - AlertDialog( - onDismissRequest = { - soulEditProfileName = null - viewModel.closeSoulDialog() - }, - title = { Text("Edit Soul ($profileName)") }, - text = { - Box(modifier = Modifier.fillMaxWidth()) { - if (state.isLoadingSoul) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else { - OutlinedTextField( - value = soulText, - onValueChange = { soulText = it }, - modifier = - Modifier - .fillMaxWidth() - .height(200.dp), - label = { Text("Soul/Persona Prompts") }, - maxLines = 10, - ) - } - } - }, - confirmButton = { - TextButton( - onClick = { - viewModel.saveSoul(profileName, soulText) - soulEditProfileName = null - }, - enabled = !state.isLoadingSoul, - ) { - Text("Save") - } - }, - dismissButton = { - TextButton( - onClick = { - soulEditProfileName = null - viewModel.closeSoulDialog() - }, - ) { - Text("Cancel") - } - }, - ) - } - - // Model Edit Dialog - modelEditProfileName?.let { profileName -> - AlertDialog( - onDismissRequest = { modelEditProfileName = null }, - title = { Text("Switch Model ($profileName)") }, - text = { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlinedTextField( - value = tempModelProvider, - onValueChange = { tempModelProvider = it }, - label = { Text("Provider (e.g. nvidia, openai, local)") }, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = tempModelName, - onValueChange = { tempModelName = it }, - label = { Text("Model Name") }, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - TextButton( - onClick = { - viewModel.updateModel(profileName, tempModelProvider, tempModelName) - modelEditProfileName = null - }, - ) { - Text("Save") - } - }, - dismissButton = { - TextButton(onClick = { modelEditProfileName = null }) { - Text("Cancel") - } - }, - ) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt index 26631ead..d4e3fc97 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt @@ -29,10 +29,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.LoadingState +import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing @@ -44,6 +46,7 @@ fun SkillsScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + val spacing = LocalSpacing.current var query by remember { mutableStateOf("") } LaunchedEffect(Unit) { @@ -82,8 +85,8 @@ fun SkillsScreen( ) state.skills.isEmpty() -> EmptyState( - title = "No skills", - subtitle = "Tap refresh to retry loading skills from Hermes.", + title = "No skills found", + subtitle = "Skills loaded from Hermes will appear here.", icon = Icons.Filled.Extension, ) else -> @@ -92,17 +95,13 @@ fun SkillsScreen( contentPadding = listContentPadding, verticalArrangement = listItemSpacing, ) { - // Search field item { - androidx.compose.material3.OutlinedTextField( - value = query, - onValueChange = { query = it }, - placeholder = { Text("Search skills…") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), + SearchBar( + query = query, + onQueryChange = { query = it }, + placeholder = "Search skills…", ) } - // Filtered list items(filtered) { skill -> Card( modifier = Modifier.fillMaxWidth(), @@ -115,7 +114,7 @@ fun SkillsScreen( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .padding(spacing.md), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt index 811d09f3..fea37600 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt @@ -2,40 +2,46 @@ package com.m57.hermescontrol.ui.system import android.widget.Toast import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Backup +import androidx.compose.material.icons.filled.HealthAndSafety +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.Speed import androidx.compose.material3.Button import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.InfoRow +import com.m57.hermescontrol.ui.common.LoadingState +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.common.StatCard +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @Composable fun SystemScreen( modifier: Modifier = Modifier, @@ -44,6 +50,8 @@ fun SystemScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + val spacing = LocalSpacing.current + val statusColors = LocalHermesStatusColors.current LaunchedEffect(Unit) { viewModel.loadSystemData() @@ -56,138 +64,141 @@ fun SystemScreen( } } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onOpenDrawer != null) { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Open Drawer", - ) + HermesScaffold( + title = "System", + onOpenDrawer = onOpenDrawer, + onRefresh = { viewModel.loadSystemData() }, + ) { + when { + state.isLoading -> LoadingState() + state.errorMessage != null -> + ErrorState( + message = state.errorMessage ?: "Unknown error", + onRetry = { viewModel.loadSystemData() }, + ) + state.stats == null && state.doctorReport == null -> + EmptyState( + title = "No system data", + subtitle = "Tap refresh to load system diagnostics.", + ) + else -> + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = + androidx.compose.foundation.layout.PaddingValues( + horizontal = spacing.md, + vertical = spacing.sm, + ), + verticalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + // Stats row + state.stats?.let { stats -> + item { + SectionHeader(title = "Performance") + } + item { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + stats.cpuPercent?.let { cpu -> + StatCard( + label = "CPU Usage", + value = "$cpu%", + icon = Icons.Filled.Speed, + modifier = Modifier.weight(1f), + ) + } + stats.memoryPercent?.let { mem -> + StatCard( + label = "Memory", + value = "$mem%", + icon = Icons.Filled.Memory, + modifier = Modifier.weight(1f), + ) + } + } } } - }, - title = { Text("System Diagnostics") }, - actions = { - IconButton(onClick = { viewModel.loadSystemData() }) { - Icon( - imageVector = Icons.Filled.Refresh, - contentDescription = "Refresh", - ) + + // Doctor diagnostics + state.doctorReport?.let { report -> + item { + SectionHeader(title = "Doctor") + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + Row( + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + Icon( + imageVector = Icons.Filled.HealthAndSafety, + contentDescription = null, + tint = + if (report.ok) { + statusColors.success + } else { + statusColors.error + }, + ) + StatusBadge( + text = if (report.ok) "Running" else "Failed", + status = + if (report.ok) { + StatusBadgeType.SUCCESS + } else { + StatusBadgeType.ERROR + }, + ) + } + Spacer(modifier = Modifier.height(spacing.sm)) + report.pid?.let { pid -> + InfoRow(label = "Process PID", value = pid.toString()) + } + report.name?.let { name -> + InfoRow(label = "Process Name", value = name) + } + } + } + } } - }, - ) - }, - modifier = modifier, - ) { paddingValues -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - if (state.isLoading) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else if (state.errorMessage != null) { - Text( - text = state.errorMessage ?: "", - color = MaterialTheme.colorScheme.error, - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), - ) - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + + // Admin actions item { - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "System Administration", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - ) - Spacer(modifier = Modifier.height(8.dp)) + SectionHeader(title = "Administration") + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(spacing.md)) { Button( onClick = { viewModel.triggerBackup() }, modifier = Modifier.fillMaxWidth(), ) { + Icon( + imageVector = Icons.Filled.Backup, + contentDescription = null, + modifier = Modifier.padding(end = spacing.xs), + ) Text("Trigger System Backup") } } } } - - // System Stats (Mock structure check) - state.stats?.let { stats -> - item { - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "Performance Stats", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - ) - Spacer(modifier = Modifier.height(8.dp)) - stats.cpuPercent?.let { - Text(text = "CPU Usage: $it%", style = MaterialTheme.typography.bodyMedium) - } - stats.memoryPercent?.let { - Text(text = "Memory Usage: $it%", style = MaterialTheme.typography.bodyMedium) - } - } - } - } - } - - // Doctor status - state.doctorReport?.let { report -> - item { - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "Doctor Diagnostics", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Diagnostics Status: ${if (report.ok) "Running" else "Failed"}", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = - if (report.ok) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.error - }, - ) - report.pid?.let { pid -> - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Process PID: $pid", - style = MaterialTheme.typography.bodyMedium, - ) - } - report.name?.let { name -> - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Process Name: $name", - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } - } - } } - } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt index e19e6e12..b72a4f38 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -24,11 +23,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -39,6 +36,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -61,126 +61,115 @@ fun ToolsetsScreen( } } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onOpenDrawer != null) { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Open Drawer", - ) - } - } - }, - title = { Text("Toolsets Manager") }, - actions = { - IconButton(onClick = { viewModel.loadToolsets() }) { - Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh") - } - }, - ) - }, - modifier = modifier, + HermesScaffold( + title = "Toolsets", + onOpenDrawer = onOpenDrawer, + onRefresh = { viewModel.loadToolsets() }, ) { paddingValues -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - if (state.isLoading && state.toolsets.isEmpty()) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else if (state.errorMessage != null && state.toolsets.isEmpty()) { - Column( - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(16.dp)) - IconButton(onClick = { viewModel.loadToolsets() }) { - Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry") - } - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(state.toolsets) { toolset -> - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = - if (toolset.enabled) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), + when { + state.isLoading -> { + LoadingState(modifier = Modifier.padding(paddingValues)) + } + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadToolsets() }, + modifier = Modifier.padding(paddingValues), + ) + } + else -> + Box(Modifier.fillMaxSize()) { + if (state.isLoading && state.toolsets.isEmpty()) { + CircularProgressIndicator() + } else if (state.errorMessage != null && state.toolsets.isEmpty()) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = toolset.label ?: toolset.name.replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - toolset.description?.let { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(16.dp)) + IconButton(onClick = { viewModel.loadToolsets() }) { + Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry") + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.toolsets) { toolset -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = + if (toolset.enabled) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = + toolset.label + ?: toolset.name.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + toolset.description?.let { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - toolset.tools?.let { tools -> - if (tools.isNotEmpty()) { - Spacer(modifier = Modifier.height(8.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - tools.forEach { tool -> - SuggestionChip( - onClick = {}, - label = { - Text( - text = tool, - style = MaterialTheme.typography.labelSmall, + toolset.tools?.let { tools -> + if (tools.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + tools.forEach { tool -> + SuggestionChip( + onClick = {}, + label = { + Text( + text = tool, + style = MaterialTheme.typography.labelSmall, + ) + }, ) - }, - ) + } + } } } } + + Switch( + checked = toolset.enabled, + onCheckedChange = { viewModel.toggleToolset(toolset) }, + modifier = Modifier.padding(start = 16.dp), + ) } } - - Switch( - checked = toolset.enabled, - onCheckedChange = { viewModel.toggleToolset(toolset) }, - modifier = Modifier.padding(start = 16.dp), - ) } } } } - } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt index 3d618490..6458324f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt @@ -14,22 +14,15 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -41,6 +34,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -63,183 +59,171 @@ fun WebhooksScreen( } } - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (onOpenDrawer != null) { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Open Drawer", - ) - } - } - }, - title = { Text("Webhooks Dispatcher") }, - actions = { - IconButton(onClick = { viewModel.loadWebhooks() }) { - Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh") - } - }, - ) - }, - modifier = modifier, + HermesScaffold( + title = "Webhooks", + onOpenDrawer = onOpenDrawer, + onRefresh = { viewModel.loadWebhooks() }, ) { paddingValues -> - Box( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - if (state.isLoading && state.baseUrl == null) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else if (state.errorMessage != null && state.baseUrl == null) { - Column( - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { viewModel.loadWebhooks() }) { - Text("Retry") - } - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // Global Status Switch - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = - if (state.enabled) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), + when { + state.isLoading -> { + LoadingState(modifier = Modifier.padding(paddingValues)) + } + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadWebhooks() }, + modifier = Modifier.padding(paddingValues), + ) + } + else -> + Box(Modifier.fillMaxSize()) { + if (state.isLoading && state.baseUrl == null) { + CircularProgressIndicator() + } else if (state.errorMessage != null && state.baseUrl == null) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Global Webhooks Status", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - state.baseUrl?.let { - Text( - text = "Local Server URL: $it", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - Switch( - checked = state.enabled, - onCheckedChange = { viewModel.toggleWebhooks(it) }, - ) - } - } - } - - // Subscriptions List Header - item { - Text( - text = "Subscriptions", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } - - if (state.subscriptions.isEmpty()) { - item { - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(24.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = "No webhook subscriptions configured.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.loadWebhooks() }) { + Text("Retry") } } } else { - items(state.subscriptions) { sub -> - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Global Status Switch + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = + if (state.enabled) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), ) { - Text( - text = sub.name.replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = "Target URL:", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Bold, - ) - Text( - text = sub.url, - style = - MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - ), - ) - - sub.events?.let { events -> - if (events.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { Text( - text = "Events Subscribed:", + text = "Global Webhooks Status", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + state.baseUrl?.let { + Text( + text = "Local Server URL: $it", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Switch( + checked = state.enabled, + onCheckedChange = { viewModel.toggleWebhooks(it) }, + ) + } + } + } + + // Subscriptions List Header + item { + Text( + text = "Subscriptions", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + if (state.subscriptions.isEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "No webhook subscriptions configured.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } else { + items(state.subscriptions) { sub -> + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = sub.name.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Target URL:", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold, ) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - events.forEach { event -> - SuggestionChip( - onClick = {}, - label = { - Text( - text = event, - style = MaterialTheme.typography.labelSmall, - ) - }, + Text( + text = sub.url, + style = + MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + ), + ) + + sub.events?.let { events -> + if (events.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Events Subscribed:", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + events.forEach { event -> + SuggestionChip( + onClick = {}, + label = { + Text( + text = event, + style = MaterialTheme.typography.labelSmall, + ) + }, + ) + } + } } } } @@ -249,7 +233,6 @@ fun WebhooksScreen( } } } - } } } } diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 7e40bfb2..25564884 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -452,9 +452,9 @@ class E2eIntegrationTest { assertEquals(CronJobsScreen, backStack.lastOrNull()) assertEquals(1, backStack.size) - // Non-clearing key settings adds to stack - NavigationController.navigateTo(SettingsScreen) - assertEquals(SettingsScreen, backStack.lastOrNull()) + // Non-clearing drawer screen adds to stack + NavigationController.navigateTo(ProfilesScreen) + assertEquals(ProfilesScreen, backStack.lastOrNull()) assertEquals(2, backStack.size) assertEquals(CronJobsScreen, backStack[0]) }