This commit is contained in:
8 files changed
+840
-71
No files matched your search
@@ -80,9 +80,63 @@ data class PluginsHubResponse(
|
||||
|
||||
@Serializable
|
||||
data class AgentPluginInstallBody(
|
||||
val identifier: String,
|
||||
val identifier: String = "",
|
||||
val force: Boolean = false,
|
||||
val enable: Boolean = true,
|
||||
@SerialName("catalog_name") val catalogName: String? = null,
|
||||
val ref: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PluginCatalogCapabilities(
|
||||
@SerialName("provides_tools") val providesTools: List<String> = emptyList(),
|
||||
@SerialName("provides_hooks") val providesHooks: List<String> = emptyList(),
|
||||
@SerialName("provides_middleware") val providesMiddleware: List<String> = emptyList(),
|
||||
@SerialName("requires_env") val requiresEnv: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PluginCatalogRemovedEntry(
|
||||
val name: String? = null,
|
||||
val repo: String? = null,
|
||||
val reason: String? = null,
|
||||
val date: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PluginCatalogEntry(
|
||||
val name: String,
|
||||
@SerialName("catalog_name") val catalogName: String? = null,
|
||||
val repo: String? = null,
|
||||
val sha: String? = null,
|
||||
@SerialName("sha_short") val shaShort: String? = null,
|
||||
val description: String? = null,
|
||||
val maintainer: String? = null,
|
||||
val tier: String? = null,
|
||||
val category: String? = null,
|
||||
@SerialName("requires_hermes") val requiresHermes: String? = null,
|
||||
val subdir: String? = null,
|
||||
@SerialName("docs_url") val docsUrl: String? = null,
|
||||
val platforms: List<String> = emptyList(),
|
||||
val capabilities: PluginCatalogCapabilities? = null,
|
||||
@SerialName("capability_summary") val capabilitySummary: String? = null,
|
||||
val installed: Boolean = false,
|
||||
@SerialName("installed_sha") val installedSha: String? = null,
|
||||
@SerialName("update_available") val updateAvailable: Boolean = false,
|
||||
@SerialName("runtime_status") val runtimeStatus: String? = null,
|
||||
) {
|
||||
val displayName: String
|
||||
get() = catalogName?.takeIf { it.isNotBlank() } ?: name
|
||||
|
||||
val isOfficial: Boolean
|
||||
get() = tier?.equals("official", ignoreCase = true) == true
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PluginCatalogResponse(
|
||||
val entries: List<PluginCatalogEntry> = emptyList(),
|
||||
val removed: List<PluginCatalogRemovedEntry> = emptyList(),
|
||||
@SerialName("generated_at") val generatedAt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -80,6 +80,7 @@ import com.m57.hermescontrol.data.model.OAuthSubmitResponse
|
||||
import com.m57.hermescontrol.data.model.PairingApproveRequest
|
||||
import com.m57.hermescontrol.data.model.PairingResponse
|
||||
import com.m57.hermescontrol.data.model.PairingRevokeRequest
|
||||
import com.m57.hermescontrol.data.model.PluginCatalogResponse
|
||||
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
|
||||
import com.m57.hermescontrol.data.model.PluginsHubResponse
|
||||
import com.m57.hermescontrol.data.model.PortalResponse
|
||||
@@ -625,6 +626,9 @@ interface HermesApiService {
|
||||
@GET("api/dashboard/plugins/hub")
|
||||
suspend fun getPlugins(): Response<PluginsHubResponse>
|
||||
|
||||
@GET("api/dashboard/plugins/catalog")
|
||||
suspend fun getPluginCatalog(): Response<PluginCatalogResponse>
|
||||
|
||||
@POST("api/dashboard/agent-plugins/install")
|
||||
suspend fun installPlugin(
|
||||
@Body body: AgentPluginInstallBody,
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.HubSkill
|
||||
import com.m57.hermescontrol.data.model.McpServer
|
||||
import com.m57.hermescontrol.data.model.PluginCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.PluginInfo
|
||||
import com.m57.hermescontrol.data.model.Skill
|
||||
import com.m57.hermescontrol.data.model.Toolset
|
||||
@@ -68,6 +69,26 @@ fun PluginInfo.toDetailRows(): List<DetailRow> =
|
||||
DetailRow(stringResource(R.string.detail_dialog_description), description),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PluginCatalogEntry.toDetailRows(): List<DetailRow> =
|
||||
listOfNotNull(
|
||||
tier?.let { DetailRow(stringResource(R.string.detail_dialog_tier), it) },
|
||||
maintainer?.let { DetailRow(stringResource(R.string.detail_dialog_maintainer), it) },
|
||||
category?.let { DetailRow(stringResource(R.string.detail_dialog_category), it) },
|
||||
repo?.let { DetailRow(stringResource(R.string.detail_dialog_repository), it) },
|
||||
shaShort?.let { DetailRow(stringResource(R.string.detail_dialog_commit), it) },
|
||||
requiresHermes?.takeIf { it.isNotBlank() }?.let {
|
||||
DetailRow(stringResource(R.string.detail_dialog_requires_hermes), it)
|
||||
},
|
||||
docsUrl?.takeIf { it.isNotBlank() }?.let {
|
||||
DetailRow(stringResource(R.string.detail_dialog_docs), it)
|
||||
},
|
||||
capabilitySummary?.takeIf { it.isNotBlank() }?.let {
|
||||
DetailRow(stringResource(R.string.detail_dialog_capabilities), it)
|
||||
},
|
||||
description?.let { DetailRow(stringResource(R.string.detail_dialog_description), it) },
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun Toolset.toDetailRows(): List<DetailRow> =
|
||||
listOf(
|
||||
|
||||
@@ -29,6 +29,9 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -47,6 +50,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.MemoryProviderDetailKey
|
||||
import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.PluginCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.PluginInfo
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.common.DetailDialog
|
||||
@@ -61,6 +65,7 @@ import com.m57.hermescontrol.ui.common.ToastEffect
|
||||
import com.m57.hermescontrol.ui.common.listContentPadding
|
||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||
import com.m57.hermescontrol.ui.common.toDetailRows
|
||||
import com.m57.hermescontrol.ui.plugins.components.CatalogPluginsView
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -73,6 +78,7 @@ fun PluginsScreen(
|
||||
|
||||
var query by remember { mutableStateOf("") }
|
||||
var showDetail by remember { mutableStateOf<PluginInfo?>(null) }
|
||||
var showCatalogDetail by remember { mutableStateOf<PluginCatalogEntry?>(null) }
|
||||
|
||||
val filteredPlugins =
|
||||
remember(query, state.plugins) {
|
||||
@@ -82,6 +88,29 @@ fun PluginsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val filteredCatalogEntries =
|
||||
remember(state.catalogEntries, state.catalogQuery, state.catalogTierFilter) {
|
||||
state.catalogEntries.filter { entry ->
|
||||
val matchesQuery =
|
||||
state.catalogQuery.isBlank() ||
|
||||
entry.displayName.contains(state.catalogQuery, ignoreCase = true) ||
|
||||
entry.description?.contains(state.catalogQuery, ignoreCase = true) == true ||
|
||||
entry.maintainer?.contains(state.catalogQuery, ignoreCase = true) == true ||
|
||||
entry.capabilities?.providesTools?.any {
|
||||
it.contains(
|
||||
state.catalogQuery,
|
||||
ignoreCase = true,
|
||||
)
|
||||
} == true
|
||||
|
||||
val matchesTier =
|
||||
state.catalogTierFilter == null ||
|
||||
entry.tier?.equals(state.catalogTierFilter, ignoreCase = true) == true
|
||||
|
||||
matchesQuery && matchesTier
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadPlugins()
|
||||
}
|
||||
@@ -120,85 +149,130 @@ fun PluginsScreen(
|
||||
HermesScaffold(
|
||||
title = { Text(stringResource(R.string.screen_plugins)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadPlugins() },
|
||||
) { paddingValues ->
|
||||
when {
|
||||
state.isLoading && state.plugins.isEmpty() -> {
|
||||
SkeletonListState(modifier = Modifier.padding(paddingValues))
|
||||
isRefreshing = if (state.selectedTab == PluginsTab.INSTALLED) state.isLoading else state.isCatalogLoading,
|
||||
onRefresh = {
|
||||
if (state.selectedTab == PluginsTab.INSTALLED) {
|
||||
viewModel.loadPlugins()
|
||||
} else {
|
||||
viewModel.loadCatalog(isRefresh = true)
|
||||
}
|
||||
|
||||
state.errorMessage != null -> {
|
||||
ErrorState(
|
||||
message = state.errorMessage ?: "",
|
||||
onRetry = { viewModel.loadPlugins() },
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
|
||||
state.plugins.isEmpty() && state.orphanPlugins.isEmpty() -> {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.plugins_empty_title),
|
||||
subtitle = stringResource(R.string.plugins_empty_desc),
|
||||
onAction = { viewModel.loadPlugins() },
|
||||
actionLabel = stringResource(R.string.content_desc_refresh),
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = listContentPadding,
|
||||
verticalArrangement = listItemSpacing,
|
||||
},
|
||||
) { _ ->
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
SingleChoiceSegmentedButtonRow(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
SegmentedButton(
|
||||
selected = state.selectedTab == PluginsTab.INSTALLED,
|
||||
onClick = { viewModel.setTab(PluginsTab.INSTALLED) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
|
||||
) {
|
||||
// Provider selection section
|
||||
if (state.memoryOptions.isNotEmpty() || state.contextOptions.isNotEmpty()) {
|
||||
item(key = "providers") {
|
||||
ProviderSelectionSection(state, viewModel)
|
||||
Text(stringResource(R.string.plugins_tab_installed))
|
||||
}
|
||||
SegmentedButton(
|
||||
selected = state.selectedTab == PluginsTab.CATALOG,
|
||||
onClick = { viewModel.setTab(PluginsTab.CATALOG) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
|
||||
) {
|
||||
Text(stringResource(R.string.plugins_tab_catalog))
|
||||
}
|
||||
}
|
||||
|
||||
when (state.selectedTab) {
|
||||
PluginsTab.INSTALLED -> {
|
||||
when {
|
||||
state.isLoading && state.plugins.isEmpty() -> {
|
||||
SkeletonListState()
|
||||
}
|
||||
}
|
||||
|
||||
// Install section
|
||||
item(key = "install") {
|
||||
InstallSection(state, viewModel)
|
||||
}
|
||||
|
||||
// Search bar
|
||||
item(key = "search") {
|
||||
SearchBar(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
placeholder = stringResource(R.string.plugins_search_placeholder),
|
||||
)
|
||||
}
|
||||
|
||||
// Plugin list
|
||||
items(filteredPlugins, key = { it.name }) { plugin ->
|
||||
PluginCard(
|
||||
plugin = plugin,
|
||||
state = state,
|
||||
viewModel = viewModel,
|
||||
onClick = { showDetail = plugin },
|
||||
)
|
||||
}
|
||||
|
||||
// Orphan dashboard plugins section
|
||||
if (state.orphanPlugins.isNotEmpty()) {
|
||||
item(key = "orphan-header") {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_orphan_heading),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
state.errorMessage != null && state.plugins.isEmpty() -> {
|
||||
ErrorState(
|
||||
message = state.errorMessage ?: "",
|
||||
onRetry = { viewModel.loadPlugins() },
|
||||
)
|
||||
}
|
||||
items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin ->
|
||||
OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin })
|
||||
|
||||
state.plugins.isEmpty() && state.orphanPlugins.isEmpty() -> {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.plugins_empty_title),
|
||||
subtitle = stringResource(R.string.plugins_empty_desc),
|
||||
onAction = { viewModel.loadPlugins() },
|
||||
actionLabel = stringResource(R.string.content_desc_refresh),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = listContentPadding,
|
||||
verticalArrangement = listItemSpacing,
|
||||
) {
|
||||
// Provider selection section
|
||||
if (state.memoryOptions.isNotEmpty() || state.contextOptions.isNotEmpty()) {
|
||||
item(key = "providers") {
|
||||
ProviderSelectionSection(state, viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
// Install section
|
||||
item(key = "install") {
|
||||
InstallSection(state, viewModel)
|
||||
}
|
||||
|
||||
// Search bar
|
||||
item(key = "search") {
|
||||
SearchBar(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
placeholder = stringResource(R.string.plugins_search_placeholder),
|
||||
)
|
||||
}
|
||||
|
||||
// Plugin list
|
||||
items(filteredPlugins, key = { it.name }) { plugin ->
|
||||
PluginCard(
|
||||
plugin = plugin,
|
||||
state = state,
|
||||
viewModel = viewModel,
|
||||
onClick = { showDetail = plugin },
|
||||
)
|
||||
}
|
||||
|
||||
// Orphan dashboard plugins section
|
||||
if (state.orphanPlugins.isNotEmpty()) {
|
||||
item(key = "orphan-header") {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_orphan_heading),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin ->
|
||||
OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PluginsTab.CATALOG -> {
|
||||
CatalogPluginsView(
|
||||
state = state,
|
||||
filteredEntries = filteredCatalogEntries,
|
||||
onQueryChange = viewModel::setCatalogQuery,
|
||||
onTierFilterChange = viewModel::setCatalogTierFilter,
|
||||
onInstall = { viewModel.installCatalogPlugin(it) },
|
||||
onUpdate = { viewModel.installCatalogPlugin(it, force = true) },
|
||||
onShowDetail = { showCatalogDetail = it },
|
||||
onRetry = { viewModel.loadCatalog(isRefresh = true) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,6 +284,14 @@ fun PluginsScreen(
|
||||
onDismiss = { showDetail = null },
|
||||
)
|
||||
}
|
||||
|
||||
showCatalogDetail?.let { entry ->
|
||||
DetailDialog(
|
||||
title = entry.displayName,
|
||||
rows = entry.toDetailRows(),
|
||||
onDismiss = { showCatalogDetail = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.m57.hermescontrol.ui.plugins
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
|
||||
import com.m57.hermescontrol.data.model.PluginCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.PluginInfo
|
||||
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
|
||||
import com.m57.hermescontrol.data.model.ProviderOption
|
||||
@@ -9,6 +11,7 @@ import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchAction
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -18,7 +21,13 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
enum class PluginsTab {
|
||||
INSTALLED,
|
||||
CATALOG,
|
||||
}
|
||||
|
||||
data class PluginsUiState(
|
||||
val selectedTab: PluginsTab = PluginsTab.INSTALLED,
|
||||
val isLoading: Boolean = false,
|
||||
val plugins: List<PluginInfo> = emptyList(),
|
||||
val orphanPlugins: List<PluginInfo> = emptyList(),
|
||||
@@ -41,6 +50,13 @@ data class PluginsUiState(
|
||||
val removeConfirmPlugin: String? = null,
|
||||
// Rescan
|
||||
val rescanBusy: Boolean = false,
|
||||
// Catalog state
|
||||
val catalogEntries: List<PluginCatalogEntry> = emptyList(),
|
||||
val isCatalogLoading: Boolean = false,
|
||||
val catalogErrorMessage: String? = null,
|
||||
val catalogInstallingName: String? = null,
|
||||
val catalogQuery: String = "",
|
||||
val catalogTierFilter: String? = null,
|
||||
) {
|
||||
/** Built-in memory provider sentinel — empty string means "use config defaults" */
|
||||
val isMemoryBuiltin: Boolean
|
||||
@@ -382,6 +398,93 @@ class PluginsViewModel :
|
||||
_uiState.update { if (it.rowBusy == name) it.copy(rowBusy = null) else it }
|
||||
}
|
||||
|
||||
fun setTab(tab: PluginsTab) {
|
||||
_uiState.update { it.copy(selectedTab = tab) }
|
||||
if (tab == PluginsTab.CATALOG && _uiState.value.catalogEntries.isEmpty()) {
|
||||
loadCatalog()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadCatalog(isRefresh: Boolean = false) {
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getPluginCatalog() } },
|
||||
onStart = {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isCatalogLoading = true,
|
||||
catalogErrorMessage = if (isRefresh) it.catalogErrorMessage else null,
|
||||
)
|
||||
}
|
||||
},
|
||||
onSuccess = { response ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isCatalogLoading = false,
|
||||
catalogEntries = response.entries,
|
||||
catalogErrorMessage = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isCatalogLoading = false,
|
||||
catalogErrorMessage = "Failed to load catalog: $errorMsg",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun setCatalogQuery(query: String) {
|
||||
_uiState.update { it.copy(catalogQuery = query) }
|
||||
}
|
||||
|
||||
fun setCatalogTierFilter(tier: String?) {
|
||||
_uiState.update { it.copy(catalogTierFilter = tier) }
|
||||
}
|
||||
|
||||
fun installCatalogPlugin(
|
||||
entry: PluginCatalogEntry,
|
||||
force: Boolean = false,
|
||||
) {
|
||||
val catalogName = entry.name
|
||||
safeLaunchAction(
|
||||
onStart = { _uiState.update { it.copy(catalogInstallingName = catalogName) } },
|
||||
apiCall = {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.installPlugin(
|
||||
AgentPluginInstallBody(
|
||||
identifier = entry.repo ?: "",
|
||||
catalogName = catalogName,
|
||||
force = force,
|
||||
enable = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
onSuccess = {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
toastMessage = "Plugin \"${entry.displayName}\" installed successfully",
|
||||
)
|
||||
}
|
||||
loadPlugins()
|
||||
loadCatalog(isRefresh = true)
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
toastMessage = "Failed to install \"${entry.displayName}\": $errorMsg",
|
||||
)
|
||||
}
|
||||
},
|
||||
onComplete = {
|
||||
_uiState.update { it.copy(catalogInstallingName = null) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package com.m57.hermescontrol.ui.plugins.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.InstallDesktop
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.PluginCatalogEntry
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.FilterChipRow
|
||||
import com.m57.hermescontrol.ui.common.SearchBar
|
||||
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||
import com.m57.hermescontrol.ui.common.listContentPadding
|
||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||
import com.m57.hermescontrol.ui.plugins.PluginsUiState
|
||||
|
||||
@Composable
|
||||
fun CatalogPluginsView(
|
||||
state: PluginsUiState,
|
||||
filteredEntries: List<PluginCatalogEntry>,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onTierFilterChange: (String?) -> Unit,
|
||||
onInstall: (PluginCatalogEntry) -> Unit,
|
||||
onUpdate: (PluginCatalogEntry) -> Unit,
|
||||
onShowDetail: (PluginCatalogEntry) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
SearchBar(
|
||||
query = state.catalogQuery,
|
||||
onQueryChange = onQueryChange,
|
||||
placeholder = stringResource(R.string.plugins_catalog_search_placeholder),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
|
||||
FilterChipRow(
|
||||
chips = listOf(null, "official", "community"),
|
||||
selectedChip = state.catalogTierFilter,
|
||||
onChipSelected = onTierFilterChange,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
chipLabel = { tier ->
|
||||
Text(
|
||||
text =
|
||||
when (tier) {
|
||||
null -> stringResource(R.string.plugins_catalog_filter_all)
|
||||
"official" -> stringResource(R.string.plugins_catalog_filter_official)
|
||||
"community" -> stringResource(R.string.plugins_catalog_filter_community)
|
||||
else -> tier
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
when {
|
||||
state.isCatalogLoading && state.catalogEntries.isEmpty() -> {
|
||||
SkeletonListState()
|
||||
}
|
||||
|
||||
state.catalogErrorMessage != null && state.catalogEntries.isEmpty() -> {
|
||||
ErrorState(
|
||||
message = state.catalogErrorMessage,
|
||||
onRetry = onRetry,
|
||||
)
|
||||
}
|
||||
|
||||
filteredEntries.isEmpty() -> {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.plugins_catalog_empty_title),
|
||||
subtitle = stringResource(R.string.plugins_catalog_empty_desc),
|
||||
onAction = onRetry,
|
||||
actionLabel = stringResource(R.string.content_desc_refresh),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = listContentPadding,
|
||||
verticalArrangement = listItemSpacing,
|
||||
) {
|
||||
items(filteredEntries, key = { it.name }) { entry ->
|
||||
CatalogPluginCard(
|
||||
entry = entry,
|
||||
isInstalling = state.catalogInstallingName == entry.name,
|
||||
onInstall = { onInstall(entry) },
|
||||
onUpdate = { onUpdate(entry) },
|
||||
onClick = { onShowDetail(entry) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CatalogPluginCard(
|
||||
entry: PluginCatalogEntry,
|
||||
isInstalling: Boolean,
|
||||
onInstall: () -> Unit,
|
||||
onUpdate: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Header row: Title + Maintainer on left, Tier badge on right
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = entry.displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
if (!entry.maintainer.isNullOrBlank()) {
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_catalog_maintainer, entry.maintainer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val isOfficial = entry.isOfficial
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color =
|
||||
if (isOfficial) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
if (isOfficial) {
|
||||
stringResource(R.string.plugins_catalog_tier_official)
|
||||
} else {
|
||||
stringResource(R.string.plugins_catalog_tier_community)
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color =
|
||||
if (isOfficial) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// Description
|
||||
entry.description?.let { desc ->
|
||||
Text(
|
||||
text = desc,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Bottom row: Status badge on left, Action button on right
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (entry.installed) {
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_catalog_installed_badge),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = statusColors.success,
|
||||
)
|
||||
}
|
||||
if (entry.updateAvailable) {
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_catalog_update),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = statusColors.warning,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.installed && entry.updateAvailable) {
|
||||
OutlinedButton(
|
||||
onClick = onUpdate,
|
||||
enabled = !isInstalling,
|
||||
) {
|
||||
if (isInstalling) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(stringResource(R.string.plugins_catalog_update))
|
||||
}
|
||||
} else if (entry.installed) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = statusColors.success,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_catalog_installed_badge),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = statusColors.success,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = onInstall,
|
||||
enabled = !isInstalling,
|
||||
) {
|
||||
if (isInstalling) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.InstallDesktop,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(stringResource(R.string.plugins_action_install))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user