feat(#500): tap-to-reveal detail dialog for truncated item descriptions (#501)

* feat(#500): tap-to-reveal detail dialog for truncated cards

Add shared DetailDialog composable and wire tap-to-open full metadata
dialogs across Skills (installed + hub), MCP servers, Plugins, Toolsets,
and Webhooks screens. Action buttons stay on the card (sticky); tapping
the card body reveals untruncated description + key metadata.

Refs: #500

* fix(#500): resolve compile errors from missing runtime imports + dialog scope

- add missing androidx.compose.runtime.mutableStateOf/remember imports
  in InstalledSkillsTab + HubSkillsTab (caused 'Unresolved reference'
  cascade on compileDebugKotlin)
- move PluginsScreen detail dialog inside the composable function scope
  (was nested in OrphanPluginCard -> showDetail unresolved)
- DetailDialog rows type: Pair<String?,String?> -> Pair<String,String?>
  (key always non-null; fixes type-inference failure)
- drop unnecessary safe call on non-null label

Verified locally with the Android SDK:
  ./gradlew :app:compileDebugKotlin  -> BUILD SUCCESSFUL
  ./gradlew :app:testDebugUnitTest   -> BUILD SUCCESSFUL

Refs: #500

* refactor(#500): extract toDetailRows() mappers + semantic status tones; wire SkillsScreen hub browse

- add DetailRows.kt with @Composable extension mappers for Skill/HubSkill/
  McpServer/PluginInfo/Toolset/WebhookSubscription -> List<DetailRow>
- DetailDialog now renders status rows via LocalHermesStatusColors (review #2):
  SUCCESS/NEUTRAL/WARNING/ERROR/INFO tones instead of plain onSurfaceVariant
- wire SkillsScreen.HubBrowseView/HubSkillCard tap -> DetailDialog (same UX as
  other screens) — user request
- remove JVM-signature-clashing Pair overload; drop unused Color import
- add detail_dialog_trust_level string (HubSkill has no version field)
- verified: compileDebugKotlin + testDebugUnitTest PASS locally w/ ANDROID_HOME

* fix(#500): full hub description in dialog + no name truncation

- HubBrowseView (both SkillsScreen + HubSkillsTab): on card tap, fetch
  previewHubSkill(identifier) to get the FULL SkillContentResponse.content
  (search snippet was truncated); dialog shows full content once loaded,
  with a spinner while previewing. Falls back to snippet if no identifier.
- SkillsViewModel: add hubPreviewContent/isHubPreviewing/hubPreviewError
  state + previewHubSkill()/clearHubPreview() (uses existing preview endpoint)
- DetailDialog: title now wraps fully (removed maxLines=2 ellipsis cap) so
  long skill names are never cut off
- verified: compileDebugKotlin PASS + ktlint clean (local ANDROID_HOME)
This commit is contained in:
M57 authored and GitHub committed 2026-07-08 22:50:03 +03:00
1 parent 7f29cb3660
commit cbbda06157
11 files changed
+522 -13

No files matched your search

@@ -0,0 +1,124 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.m57.hermescontrol.theme.LocalHermesStatusColors
/**
* Semantic tone for a detail row's value text.
* NONEl uses the default on-surface variant; the rest pull from
* [LocalHermesStatusColors] so status feedback stays brand-correct
* regardless of the wallpaper-derived Material palette.
*/
enum class DetailRowTone {
NONE,
SUCCESS,
WARNING,
ERROR,
INFO,
NEUTRAL,
}
/**
* A single label/value row shown in [DetailDialog].
*
* @param label Non-null resource string (already resolved by the mapper).
* @param value Display value; null/blank rows are skipped at render time.
* @param tone Semantic tone for the value text (defaults to [DetailRowTone.NONE]).
*/
data class DetailRow(
val label: String,
val value: String?,
val tone: DetailRowTone = DetailRowTone.NONE,
)
/**
* Reusable tap-to-reveal detail dialog: a title plus a scrollable list of
* label/value rows. Null/blank rows are skipped automatically. An optional
* [actions] slot renders after the rows (inside the scroll area) so primary
* actions stay visible without the user dismissing the dialog.
*/
@Composable
fun DetailDialog(
title: String,
rows: List<DetailRow>,
onDismiss: () -> Unit,
actions: @Composable (() -> Unit)? = null,
) {
val statusColors = LocalHermesStatusColors.current
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
HermesScaffold(
title = { Text(title, style = MaterialTheme.typography.titleLarge) },
navigationIcon = NavIcon.Back(onDismiss),
isRefreshing = false,
) { padding ->
val visibleRows = rows.filter { !it.value.isNullOrBlank() }
Column(
modifier =
Modifier
.padding(padding)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
visibleRows.forEachIndexed { index, (label, value, tone) ->
if (index > 0) {
HorizontalDivider()
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value ?: "",
style = MaterialTheme.typography.bodyMedium,
color =
when (tone) {
DetailRowTone.SUCCESS -> statusColors.success
DetailRowTone.WARNING -> statusColors.warning
DetailRowTone.ERROR -> statusColors.error
DetailRowTone.INFO -> statusColors.info
DetailRowTone.NEUTRAL -> statusColors.neutral
DetailRowTone.NONE -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
if (actions != null) {
if (visibleRows.isNotEmpty()) {
HorizontalDivider()
}
actions()
}
}
}
}
}
}
@@ -0,0 +1,122 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.runtime.Composable
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.PluginInfo
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.Toolset
import com.m57.hermescontrol.data.model.WebhookSubscription
/**
* Mappers that turn a domain model into the rows shown by [DetailDialog].
* Keeping these here (instead of inline in each screen) avoids the
* showDetail + DetailDialog wiring being copied across six screens.
*/
@Composable
fun Skill.toDetailRows(): List<DetailRow> =
listOf(
DetailRow(stringResource(R.string.detail_dialog_category), category),
DetailRow(stringResource(R.string.detail_dialog_source), source),
DetailRow(stringResource(R.string.detail_dialog_description), description),
)
@Composable
fun HubSkill.toDetailRows(): List<DetailRow> =
listOf(
DetailRow(stringResource(R.string.detail_dialog_category), category),
DetailRow(stringResource(R.string.detail_dialog_source), source),
DetailRow(stringResource(R.string.detail_dialog_description), description),
DetailRow(stringResource(R.string.detail_dialog_tags), tags.orEmpty().joinToString(", ")),
DetailRow(stringResource(R.string.detail_dialog_trust_level), trustLevel),
)
@Composable
fun McpServer.toDetailRows(): List<DetailRow> {
val commandSummary =
buildString {
if (command?.isNotBlank() == true) append(command)
if (!args.isNullOrEmpty()) append(" " + args.joinToString(" "))
}.trim().ifBlank { null }
return listOf(
DetailRow(stringResource(R.string.detail_dialog_transport), transport),
DetailRow(stringResource(R.string.detail_dialog_status), status, toneForStatus(status)),
DetailRow(stringResource(R.string.detail_dialog_url), url),
DetailRow(stringResource(R.string.detail_dialog_command), commandSummary),
DetailRow(
stringResource(R.string.detail_dialog_env_vars),
env?.entries?.joinToString("\n") { "${it.key}=${it.value}" },
),
DetailRow(stringResource(R.string.detail_dialog_error), error),
)
}
@Composable
fun PluginInfo.toDetailRows(): List<DetailRow> =
listOf(
DetailRow(stringResource(R.string.detail_dialog_version), version),
DetailRow(stringResource(R.string.detail_dialog_source), source),
DetailRow(
stringResource(R.string.detail_dialog_status),
runtimeStatus,
toneForStatus(runtimeStatus),
),
DetailRow(stringResource(R.string.detail_dialog_description), description),
)
@Composable
fun Toolset.toDetailRows(): List<DetailRow> =
listOf(
DetailRow(stringResource(R.string.detail_dialog_label), label),
DetailRow(stringResource(R.string.detail_dialog_description), description),
DetailRow(stringResource(R.string.detail_dialog_tools), tools.orEmpty().joinToString(", ")),
DetailRow(
stringResource(R.string.detail_dialog_status),
if (enabled) {
stringResource(R.string.detail_dialog_status_enabled)
} else {
stringResource(R.string.detail_dialog_status_disabled)
},
if (enabled) DetailRowTone.SUCCESS else DetailRowTone.NEUTRAL,
),
)
@Composable
fun WebhookSubscription.toDetailRows(): List<DetailRow> =
listOf(
DetailRow(
stringResource(R.string.detail_dialog_status),
if (enabled == true) {
stringResource(R.string.detail_dialog_status_enabled)
} else {
stringResource(R.string.detail_dialog_status_disabled)
},
if (enabled == true) DetailRowTone.SUCCESS else DetailRowTone.NEUTRAL,
),
DetailRow(stringResource(R.string.detail_dialog_deliver), deliver),
DetailRow(stringResource(R.string.detail_dialog_description), description),
DetailRow(stringResource(R.string.detail_dialog_events), events.orEmpty().joinToString(", ")),
DetailRow(stringResource(R.string.detail_dialog_prompt), prompt),
DetailRow(stringResource(R.string.detail_dialog_skills), skills.orEmpty().joinToString(", ")),
DetailRow(stringResource(R.string.detail_dialog_url), url),
DetailRow(stringResource(R.string.detail_dialog_created_at), created_at),
)
/**
* Map a free-form status string to a semantic tone. Recognises the app's
* common status vocabulary (enabled/active/running/ok → success, etc.).
*/
private fun toneForStatus(status: String?): DetailRowTone {
if (status.isNullOrBlank()) return DetailRowTone.NONE
return when (status.lowercase()) {
"enabled", "active", "running", "ok", "online", "connected" -> DetailRowTone.SUCCESS
"disabled", "inactive", "offline", "disconnected" -> DetailRowTone.NEUTRAL
"error", "failed", "unreachable" -> DetailRowTone.ERROR
"warning", "degraded" -> DetailRowTone.WARNING
else -> DetailRowTone.NONE
}
}
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.mcp
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -57,6 +58,7 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.McpCatalogEntry
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.DetailDialog
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -69,6 +71,7 @@ import com.m57.hermescontrol.ui.common.StatusBadgeType
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
@Composable
fun McpServersScreen(
@@ -79,6 +82,7 @@ fun McpServersScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current
var query by remember { mutableStateOf("") }
var showDetail by remember { mutableStateOf<McpServer?>(null) }
val filteredServers =
remember(query, state.servers) {
@@ -162,7 +166,13 @@ fun McpServersScreen(
}
items(filteredServers, key = { "server:${it.name}" }) { server ->
ServerCard(server = server, state = state, viewModel = viewModel, spacing = spacing)
ServerCard(
server = server,
state = state,
viewModel = viewModel,
spacing = spacing,
onClick = { showDetail = server },
)
}
// ── 4. Catalog ──────────────────────────────────────
@@ -178,6 +188,14 @@ fun McpServersScreen(
}
}
}
showDetail?.let { server ->
DetailDialog(
title = server.name,
rows = server.toDetailRows(),
onDismiss = { showDetail = null },
)
}
}
// ── Sections ──────────────────────────────────────────────────────
@@ -279,11 +297,12 @@ private fun ServerCard(
state: McpServersUiState,
viewModel: McpServersViewModel,
spacing: com.m57.hermescontrol.theme.Spacing,
onClick: () -> Unit,
) {
var showEnv by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor =
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.plugins
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -51,6 +52,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.DetailDialog
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -60,6 +62,7 @@ import com.m57.hermescontrol.ui.common.SearchBar
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -71,6 +74,7 @@ fun PluginsScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var showDetail by remember { mutableStateOf<PluginInfo?>(null) }
val filteredPlugins =
remember(query, state.plugins) {
@@ -176,7 +180,12 @@ fun PluginsScreen(
// Plugin list
items(filteredPlugins, key = { it.name }) { plugin ->
PluginCard(plugin = plugin, state = state, viewModel = viewModel)
PluginCard(
plugin = plugin,
state = state,
viewModel = viewModel,
onClick = { showDetail = plugin },
)
}
// Orphan dashboard plugins section
@@ -191,7 +200,7 @@ fun PluginsScreen(
)
}
items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin ->
OrphanPluginCard(plugin = plugin)
OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin })
}
}
}
@@ -199,6 +208,14 @@ fun PluginsScreen(
}
}
}
showDetail?.let { plugin ->
DetailDialog(
title = plugin.name,
rows = plugin.toDetailRows(),
onDismiss = { showDetail = null },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -422,11 +439,12 @@ private fun PluginCard(
plugin: PluginInfo,
state: PluginsUiState,
viewModel: PluginsViewModel,
onClick: () -> Unit,
) {
val busy = state.rowBusy == plugin.name
val statusColors = LocalHermesStatusColors.current
Card(modifier = Modifier.fillMaxWidth()) {
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Column(modifier = Modifier.padding(16.dp)) {
// Header row: name + toggle
Row(
@@ -588,8 +606,11 @@ private fun PluginCard(
}
@Composable
private fun OrphanPluginCard(plugin: PluginInfo) {
Card(modifier = Modifier.fillMaxWidth()) {
private fun OrphanPluginCard(
plugin: PluginInfo,
onClick: () -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.skills
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -64,6 +65,8 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.DetailDialog
import com.m57.hermescontrol.ui.common.DetailRow
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.FilterChipRow
@@ -74,6 +77,7 @@ import com.m57.hermescontrol.ui.common.SearchBar
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
internal const val CATEGORY_ALL = "All"
@@ -156,6 +160,7 @@ fun SkillsScreen(
onSearch = viewModel::searchHub,
onClearSearch = viewModel::clearHubSearch,
onInstall = viewModel::installSkill,
onPreviewHubSkill = viewModel::previewHubSkill,
isInstalling = state.isInstalling,
installingSkillName = state.installingSkillName,
)
@@ -214,6 +219,8 @@ private fun InstalledSkillsView(
uninstallingSkillName: String?,
onRefresh: () -> Unit,
) {
var showDetail by remember { mutableStateOf<Skill?>(null) }
val categories =
remember(state.skills) {
state.skills
@@ -347,12 +354,21 @@ private fun InstalledSkillsView(
null
},
isUninstalling = isUninstalling && uninstallingSkillName == skill.name,
onClick = { showDetail = skill },
)
}
}
}
}
}
showDetail?.let { skill ->
DetailDialog(
title = skill.name,
rows = skill.toDetailRows(),
onDismiss = { showDetail = null },
)
}
}
// ── Skill Card ─────────────────────────────────────────────────────────
@@ -364,9 +380,10 @@ private fun SkillCard(
onAction: () -> Unit,
onUninstall: (() -> Unit)?,
isUninstalling: Boolean,
onClick: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
@@ -514,9 +531,12 @@ private fun HubBrowseView(
onSearch: (String) -> Unit,
onClearSearch: () -> Unit,
onInstall: (String) -> Unit,
onPreviewHubSkill: (String) -> Unit,
isInstalling: Boolean,
installingSkillName: String?,
) {
var hubShowDetail by remember { mutableStateOf<HubSkill?>(null) }
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField(
value = hubQuery,
@@ -597,6 +617,10 @@ private fun HubBrowseView(
hubSkill = hubSkill,
onInstall = { onInstall(hubSkill.name) },
isInstalling = isInstalling && installingSkillName == hubSkill.name,
onClick = {
hubShowDetail = hubSkill
hubSkill.identifier?.let { onPreviewHubSkill(it) }
},
)
}
}
@@ -610,6 +634,34 @@ private fun HubBrowseView(
}
}
}
hubShowDetail?.let { hubSkill ->
val previewReady = state.hubPreviewIdentifier == hubSkill.identifier
val fullContent =
if (previewReady) {
state.hubPreviewContent ?: hubSkill.description
} else {
hubSkill.description
}
DetailDialog(
title = hubSkill.name,
rows =
listOf(
DetailRow(stringResource(R.string.detail_dialog_category), hubSkill.category),
DetailRow(stringResource(R.string.detail_dialog_source), hubSkill.source),
DetailRow(stringResource(R.string.detail_dialog_description), fullContent),
DetailRow(stringResource(R.string.detail_dialog_tags), hubSkill.tags.orEmpty().joinToString(", ")),
DetailRow(stringResource(R.string.detail_dialog_trust_level), hubSkill.trustLevel),
),
actions =
if (previewReady && state.isHubPreviewing) {
{ CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) }
} else {
null
},
onDismiss = { hubShowDetail = null },
)
}
}
// ── Hub Skill Card ─────────────────────────────────────────────────────
@@ -619,9 +671,10 @@ private fun HubSkillCard(
hubSkill: HubSkill,
onInstall: () -> Unit,
isInstalling: Boolean,
onClick: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
@@ -56,6 +56,11 @@ data class SkillsUiState(
val hubResults: List<HubSkill> = emptyList(),
val isHubSearching: Boolean = false,
val hubSearchError: String? = null,
// Hub detail preview (full description/content via preview endpoint)
val hubPreviewIdentifier: String? = null,
val hubPreviewContent: String? = null,
val isHubPreviewing: Boolean = false,
val hubPreviewError: String? = null,
// Hub install
val isInstalling: Boolean = false,
val installingSkillName: String? = null,
@@ -153,6 +158,60 @@ class SkillsViewModel(application: Application) :
}
}
// ── Hub detail preview ──────────────────────────────────────────────────
/**
* Fetches the full skill content via the preview endpoint so the detail
* dialog can show the complete description (search results only carry a
* truncated snippet).
*/
fun previewHubSkill(identifier: String) {
if (identifier.isBlank()) return
_uiState.update {
it.copy(
hubPreviewIdentifier = identifier,
isHubPreviewing = true,
hubPreviewError = null,
hubPreviewContent = null,
)
}
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.previewHubSkill(identifier = identifier) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isHubPreviewing = false,
hubPreviewContent = result.data.content,
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isHubPreviewing = false,
hubPreviewError = result.error.message,
)
}
}
}
}
}
fun clearHubPreview() {
_uiState.update {
it.copy(
hubPreviewIdentifier = null,
hubPreviewContent = null,
isHubPreviewing = false,
hubPreviewError = null,
)
}
}
// ── Hub install ─────────────────────────────────────────────────────────
fun installSkill(identifier: String) {
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.skills.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -31,6 +32,8 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -41,6 +44,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.ui.common.DetailDialog
import com.m57.hermescontrol.ui.common.DetailRow
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.LoadingState
@@ -56,9 +61,12 @@ internal fun HubBrowseView(
onSearch: (String) -> Unit,
onClearSearch: () -> Unit,
onInstall: (String) -> Unit,
onPreviewHubSkill: (String) -> Unit,
isInstalling: Boolean,
installingSkillName: String?,
) {
var showDetail by remember { mutableStateOf<HubSkill?>(null) }
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField(
value = hubQuery,
@@ -139,6 +147,10 @@ internal fun HubBrowseView(
hubSkill = hubSkill,
onInstall = { onInstall(hubSkill.name) },
isInstalling = isInstalling && installingSkillName == hubSkill.name,
onClick = {
showDetail = hubSkill
hubSkill.identifier?.let { onPreviewHubSkill(it) }
},
)
}
}
@@ -152,6 +164,34 @@ internal fun HubBrowseView(
}
}
}
showDetail?.let { hubSkill ->
val previewReady = state.hubPreviewIdentifier == hubSkill.identifier
val fullContent =
if (previewReady) {
state.hubPreviewContent ?: hubSkill.description
} else {
hubSkill.description
}
DetailDialog(
title = hubSkill.name,
rows =
listOf(
DetailRow(stringResource(R.string.detail_dialog_category), hubSkill.category),
DetailRow(stringResource(R.string.detail_dialog_source), hubSkill.source),
DetailRow(stringResource(R.string.detail_dialog_description), fullContent),
DetailRow(stringResource(R.string.detail_dialog_tags), hubSkill.tags.orEmpty().joinToString(", ")),
DetailRow(stringResource(R.string.detail_dialog_trust_level), hubSkill.trustLevel),
),
actions =
if (previewReady && state.isHubPreviewing) {
{ CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) }
} else {
null
},
onDismiss = { showDetail = null },
)
}
}
@Composable
@@ -159,9 +199,10 @@ private fun HubSkillCard(
hubSkill: HubSkill,
onInstall: () -> Unit,
isInstalling: Boolean,
onClick: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.skills.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -30,6 +31,7 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -44,6 +46,7 @@ import androidx.compose.ui.window.DialogProperties
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.DetailDialog
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.FilterChipRow
@@ -53,6 +56,7 @@ import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar
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.skills.CATEGORY_ALL
import com.m57.hermescontrol.ui.skills.SkillFilter
import com.m57.hermescontrol.ui.skills.SkillsUiState
@@ -75,6 +79,8 @@ internal fun InstalledSkillsView(
uninstallingSkillName: String?,
onRefresh: () -> Unit,
) {
var showDetail by remember { mutableStateOf<Skill?>(null) }
val categories =
remember(state.skills) {
state.skills
@@ -208,12 +214,23 @@ internal fun InstalledSkillsView(
null
},
isUninstalling = isUninstalling && uninstallingSkillName == skill.name,
onClick = {
showDetail = skill
},
)
}
}
}
}
}
showDetail?.let { skill ->
DetailDialog(
title = skill.name,
rows = skill.toDetailRows(),
onDismiss = { showDetail = null },
)
}
}
@Composable
@@ -223,9 +240,10 @@ private fun SkillCard(
onAction: () -> Unit,
onUninstall: (() -> Unit)?,
isUninstalling: Boolean,
onClick: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.toolsets
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -38,6 +39,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Toolset
import com.m57.hermescontrol.ui.common.DetailDialog
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -47,6 +50,7 @@ import com.m57.hermescontrol.ui.common.SearchBar
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -58,6 +62,7 @@ fun ToolsetsScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var showDetail by remember { mutableStateOf<Toolset?>(null) }
val filteredToolsets =
remember(query, state.toolsets) {
@@ -136,7 +141,7 @@ fun ToolsetsScreen(
}
items(filteredToolsets, key = { it.name }) { toolset ->
Card(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().clickable(onClick = { showDetail = toolset }),
colors =
CardDefaults.cardColors(
containerColor =
@@ -208,5 +213,13 @@ fun ToolsetsScreen(
}
}
}
showDetail?.let { toolset ->
DetailDialog(
title = toolset.label ?: toolset.name,
rows = toolset.toDetailRows(),
onDismiss = { showDetail = null },
)
}
}
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.webhooks
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
@@ -48,6 +49,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.WebhookSubscription
import com.m57.hermescontrol.ui.common.DetailDialog
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -59,6 +61,7 @@ import com.m57.hermescontrol.ui.common.StatusBadgeType
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -70,6 +73,7 @@ fun WebhooksScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var showDetail by remember { mutableStateOf<WebhookSubscription?>(null) }
val filteredSubscriptions =
remember(query, state.subscriptions) {
@@ -356,6 +360,7 @@ fun WebhooksScreen(
isToggling = state.togglingName == sub.name,
onToggle = { enabled -> viewModel.toggleSubscription(sub.name, enabled) },
onDelete = { viewModel.promptDeleteSubscription(sub) },
onClick = { showDetail = sub },
)
}
}
@@ -363,6 +368,14 @@ fun WebhooksScreen(
}
}
}
showDetail?.let { sub ->
DetailDialog(
title = sub.name,
rows = sub.toDetailRows(),
onDismiss = { showDetail = null },
)
}
}
@Composable
@@ -371,11 +384,12 @@ private fun SubscriptionCard(
isToggling: Boolean,
onToggle: (Boolean) -> Unit,
onDelete: () -> Unit,
onClick: () -> Unit,
) {
val isEnabled = sub.enabled ?: true
Card(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor =