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