feat(plugins): browse and install official plugins from catalog (#1088) (#1096)

This commit is contained in:
M57 authored and GitHub committed 2026-09-12 22:32:52 +03:00
1 parent 47b6955c9a
commit d774a63663
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))
}
}
}
}
}
}