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
}
}