* 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:
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user