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