refactor(skills): decompose SkillsScreen into focused UI components

Extract InstalledSkillsView, HubBrowseView, SkillCard, HubSkillCard,
SourceChip, SkillScanViews, and SkillPreviewDialog into components/.
This commit is contained in:
Hy4ri committed 2026-09-11 18:44:09 +03:00
1 parent 02d99e159f
commit 5ecc2e7d88
8 files changed
+1197 -1042

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,314 @@
package com.m57.hermescontrol.ui.skills.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
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.SkeletonListState
import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.skills.SkillsUiState
@Composable
fun HubBrowseView(
state: SkillsUiState,
hubQuery: String,
onHubQueryChange: (String) -> Unit,
onSearch: (String) -> Unit,
onClearSearch: () -> Unit,
onInstall: (String) -> Unit,
onPreviewHubSkill: (String) -> Unit,
onScanHubSkill: (String) -> Unit,
onClearHubScan: () -> Unit,
onLoadHubSources: () -> Unit,
isInstalling: Boolean,
installingSkillName: String?,
) {
var hubShowDetail by remember { mutableStateOf<HubSkill?>(null) }
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField(
value = hubQuery,
onValueChange = onHubQueryChange,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
placeholder = { Text(stringResource(R.string.skills_hub_search_placeholder)) },
trailingIcon = {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (hubQuery.isNotEmpty()) {
IconButton(onClick = {
onHubQueryChange("")
onClearSearch()
}) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.action_clear),
)
}
}
IconButton(
onClick = { onSearch(hubQuery) },
enabled = hubQuery.isNotBlank(),
) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = stringResource(R.string.skills_hub_search_button),
)
}
}
},
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions =
KeyboardActions(
onSearch = {
if (hubQuery.isNotBlank()) {
onSearch(hubQuery)
}
},
),
)
when {
state.isHubSearching -> {
SkeletonListState()
}
state.hubSearchError != null -> {
ErrorState(
message = state.hubSearchError,
onRetry = { onSearch(hubQuery) },
)
}
state.hubResults.isEmpty() && hubQuery.isNotBlank() && !state.isHubSearching -> {
EmptyState(
icon = Icons.Filled.Extension,
title = stringResource(R.string.skills_hub_empty_results),
)
}
state.hubResults.isNotEmpty() -> {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
verticalArrangement = listItemSpacing,
) {
itemsIndexed(
state.hubResults,
key = { index, hubSkill -> "${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index" },
) { _, hubSkill ->
HubSkillCard(
hubSkill = hubSkill,
onInstall = { onInstall(hubSkill.name) },
isInstalling = isInstalling && installingSkillName == hubSkill.name,
onClick = {
hubShowDetail = hubSkill
hubSkill.identifier?.let { onPreviewHubSkill(it) }
},
)
}
}
}
else -> {
HubLandingView(
state = state,
onLoadHubSources = onLoadHubSources,
onInstall = onInstall,
isInstalling = isInstalling,
installingSkillName = installingSkillName,
onOpenDetail = { skill ->
hubShowDetail = skill
skill.identifier?.let { onPreviewHubSkill(it) }
},
)
}
}
}
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 = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (previewReady && state.isHubPreviewing) {
CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp))
}
hubSkill.identifier?.let { identifier ->
SkillScanSection(
scanResult =
state.hubScanResult.takeIf {
state.hubScanIdentifier == identifier
},
isScanning =
state.isHubScanning &&
state.hubScanIdentifier == identifier,
scanError =
state.hubScanError.takeIf {
state.hubScanIdentifier == identifier
},
onScan = { onScanHubSkill(identifier) },
)
}
}
},
onDismiss = {
hubShowDetail = null
onClearHubScan()
},
)
}
}
@Composable
fun HubLandingView(
state: SkillsUiState,
onLoadHubSources: () -> Unit,
onInstall: (String) -> Unit,
isInstalling: Boolean,
installingSkillName: String?,
onOpenDetail: (HubSkill) -> Unit,
) {
when {
state.isHubSourcesLoading -> {
SkeletonListState()
}
state.hubSourcesError != null -> {
ErrorState(
message = state.hubSourcesError,
onRetry = onLoadHubSources,
)
}
state.hubSources.isEmpty() && state.hubFeatured.isEmpty() -> {
EmptyState(
icon = Icons.Filled.Extension,
title = stringResource(R.string.skills_hub_landing_hint),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
verticalArrangement = listItemSpacing,
) {
if (state.hubSources.isNotEmpty()) {
item(key = "hub_sources_header") {
Column(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp),
) {
Text(
text = stringResource(R.string.skills_hub_sources_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier =
Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
state.hubSources.forEach { source ->
SourceChip(source = source)
}
}
}
}
}
if (state.hubFeatured.isNotEmpty()) {
item(key = "hub_featured_header") {
Column(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp),
) {
Text(
text = stringResource(R.string.skills_hub_featured_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.skills_hub_featured_subtitle),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
itemsIndexed(
state.hubFeatured,
key = { index, hubSkill ->
"featured:${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index"
},
) { _, hubSkill ->
HubSkillCard(
hubSkill = hubSkill,
onInstall = { onInstall(hubSkill.name) },
isInstalling =
isInstalling && installingSkillName == hubSkill.name,
onClick = { onOpenDetail(hubSkill) },
)
}
}
}
}
}
}
@@ -0,0 +1,118 @@
package com.m57.hermescontrol.ui.skills.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.material.icons.Icons
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.font.FontWeight
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
@Composable
fun HubSkillCard(
hubSkill: HubSkill,
onInstall: () -> Unit,
isInstalling: Boolean,
onClick: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
) {
// ── Top row: name ──
Text(
text = hubSkill.name,
style =
MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
// ── Category ──
hubSkill.category?.let { cat ->
Spacer(modifier = Modifier.height(2.dp))
Text(
text = cat,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Description ──
hubSkill.description?.let { desc ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = desc,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
// ── Source badge + Install button ──
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
hubSkill.source?.let { source ->
SourceBadge(source = source)
Spacer(modifier = Modifier.width(8.dp))
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = onInstall,
enabled = !isInstalling,
) {
if (isInstalling) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Icon(
imageVector = Icons.Filled.CloudDownload,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.skills_hub_install))
}
}
}
}
}
}
@@ -0,0 +1,183 @@
package com.m57.hermescontrol.ui.skills.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material3.MaterialTheme
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.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Skill
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
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.SkeletonListState
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.SkillListFilter
import com.m57.hermescontrol.ui.skills.SkillsUiState
@Composable
fun InstalledSkillsView(
state: SkillsUiState,
query: String,
onQueryChange: (String) -> Unit,
selectedStatus: SkillFilter,
onStatusChange: (SkillFilter) -> Unit,
selectedCategory: String?,
onCategoryChange: (String) -> Unit,
onToggle: (Skill) -> Unit,
onEdit: (String) -> Unit,
onUninstall: (String) -> Unit,
onSetSourceFilter: (String?) -> Unit,
sourceFilter: String?,
isUninstalling: Boolean,
uninstallingSkillName: String?,
onRefresh: () -> Unit,
onBrowseHub: () -> Unit,
) {
var showDetail by remember { mutableStateOf<Skill?>(null) }
val categories =
remember(state.skills) {
SkillListFilter.extractCategories(state.skills)
}
val sources =
remember(state.skills) {
SkillListFilter.extractSources(state.skills)
}
val filteredSkills =
remember(state.skills, query, selectedStatus, selectedCategory, sourceFilter) {
SkillListFilter.filterSkills(
skills = state.skills,
query = query,
selectedStatus = selectedStatus,
selectedCategory = selectedCategory,
sourceFilter = sourceFilter,
)
}
Column(modifier = Modifier.fillMaxSize()) {
SearchBar(
query = query,
onQueryChange = onQueryChange,
placeholder = stringResource(R.string.skills_search_placeholder),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
)
FilterChipRow(
chips = SkillFilter.entries.toList(),
selectedChip = selectedStatus,
onChipSelected = onStatusChange,
chipLabel = { chip ->
Text(
text =
if (chip == SkillFilter.ALL_STATUSES) {
stringResource(R.string.skills_category_all)
} else {
stringResource(chip.labelRes)
},
)
},
)
if (categories.isNotEmpty() || sources.isNotEmpty()) {
FilterChipRow(
chips = listOf(stringResource(R.string.skills_category_all)) + categories,
selectedChip = selectedCategory,
onChipSelected = onCategoryChange,
)
if (sources.isNotEmpty()) {
FilterChipRow(
chips = listOf<String?>(null) + sources,
selectedChip = sourceFilter,
onChipSelected = onSetSourceFilter,
chipLabel = { source ->
Text(
text = source ?: stringResource(R.string.skills_source_all),
style = MaterialTheme.typography.labelSmall,
)
},
)
}
}
when {
state.isLoading -> {
SkeletonListState()
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage,
onRetry = onRefresh,
)
}
filteredSkills.isEmpty() -> {
EmptyState(
icon = Icons.Filled.Extension,
title = stringResource(R.string.skills_empty_message),
actionLabel = stringResource(R.string.empty_action_browse_hub),
onAction = onBrowseHub,
)
}
else -> {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
verticalArrangement = listItemSpacing,
) {
itemsIndexed(
filteredSkills,
key = { index, skill -> "${skill.name}:${skill.source ?: "unknown"}:$index" },
) { _, skill ->
SkillCard(
skill = skill,
onToggle = { onToggle(skill) },
onAction = { onEdit(skill.name) },
onUninstall =
if (skill.source == "hub") {
{ onUninstall(skill.name) }
} else {
null
},
isUninstalling = isUninstalling && uninstallingSkillName == skill.name,
onClick = { showDetail = skill },
)
}
}
}
}
}
showDetail?.let { skill ->
DetailDialog(
title = skill.name,
rows = skill.toDetailRows(),
onDismiss = { showDetail = null },
)
}
}
@@ -0,0 +1,161 @@
package com.m57.hermescontrol.ui.skills.components
import androidx.compose.foundation.BorderStroke
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.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.material.icons.Icons
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.theme.LocalHermesStatusColors
@Composable
fun SkillCard(
skill: Skill,
onToggle: () -> Unit,
onAction: () -> Unit,
onUninstall: (() -> Unit)?,
isUninstalling: Boolean,
onClick: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.weight(1f).padding(horizontal = 8.dp, vertical = 8.dp),
) {
// ── Top row: name + source badge + toggle ──
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = skill.name,
style =
MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
skill.source?.let { source ->
Spacer(modifier = Modifier.width(6.dp))
SourceBadge(source = source)
}
Spacer(modifier = Modifier.width(8.dp))
Switch(
checked = skill.enabled,
onCheckedChange = { onToggle() },
)
}
// ── Category ──
skill.category?.let { cat ->
Spacer(modifier = Modifier.height(2.dp))
Text(
text = cat,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Description ──
skill.description?.let { desc ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = desc,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
// ── Action buttons row ──
if (onUninstall != null) {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onAction) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = stringResource(R.string.content_desc_edit_skill),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(
onClick = onUninstall,
enabled = !isUninstalling,
) {
if (isUninstalling) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
} else {
val statusColors = LocalHermesStatusColors.current
Icon(
imageVector = Icons.Filled.CloudOff,
contentDescription = stringResource(R.string.content_desc_uninstall_skill),
tint = statusColors.error,
)
}
}
}
} else {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onAction) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = stringResource(R.string.content_desc_edit_skill),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
@@ -0,0 +1,92 @@
package com.m57.hermescontrol.ui.skills.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.NavIcon
@Composable
fun SkillPreviewDialog(
skillName: String,
isLoading: Boolean,
content: String?,
onDismiss: () -> Unit,
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Card(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
HermesScaffold(
title = { Text(stringResource(R.string.skills_preview_title, skillName)) },
navigationIcon = NavIcon.Back(onBack = onDismiss),
) { padding ->
Box(
modifier =
Modifier
.fillMaxSize(),
) {
when {
isLoading -> {
LoadingState()
}
content != null -> {
Column(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
) {
Text(
text = content,
style =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
),
)
}
}
else -> {
EmptyState(
icon = Icons.Filled.Extension,
title = stringResource(R.string.skills_preview_empty),
)
}
}
}
}
}
}
}
@@ -0,0 +1,270 @@
package com.m57.hermescontrol.ui.skills.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.horizontalScroll
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SkillScanFinding
import com.m57.hermescontrol.data.model.SkillScanResponse
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.skills.SkillScanPresentation
@Composable
fun SkillScanSection(
scanResult: SkillScanResponse?,
isScanning: Boolean,
scanError: String?,
onScan: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = onScan,
enabled = !isScanning,
) {
if (isScanning) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Icon(
imageVector = Icons.Filled.Shield,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.skills_hub_scan))
}
}
when {
isScanning -> {
Unit
}
scanError != null -> {
Surface(
color = LocalHermesStatusColors.current.errorContainer,
shape = RoundedCornerShape(8.dp),
) {
Text(
text = scanError,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp),
)
}
}
scanResult != null -> {
ScanResultCard(result = scanResult)
}
}
}
}
@Composable
fun ScanResultCard(result: SkillScanResponse) {
val statusColors = LocalHermesStatusColors.current
val verdictColor = SkillScanPresentation.resolveVerdictColor(result.verdict, statusColors)
val policyLabel =
SkillScanPresentation.resolvePolicyLabelRes(result.policy)?.let { stringResource(it) }
?: result.policy.orEmpty()
val policyColor = SkillScanPresentation.resolvePolicyColor(result.policy, statusColors)
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// ── Verdict header ──
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Shield,
contentDescription = null,
tint = verdictColor,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.skills_hub_scan_verdict_label, result.verdict.orEmpty()),
style = MaterialTheme.typography.titleSmall,
color = verdictColor,
modifier = Modifier.weight(1f),
)
Surface(
color = policyColor.copy(alpha = 0.15f),
shape = RoundedCornerShape(50),
) {
Text(
text = policyLabel,
style = MaterialTheme.typography.labelSmall,
color = policyColor,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
// ── Trust + finding count ──
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (!result.trustLevel.isNullOrBlank()) {
Text(
text = result.trustLevel,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text =
pluralStringResource(
R.plurals.skills_hub_scan_findings_count,
result.findings.size,
result.findings.size,
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Severity tally ──
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
listOf("critical", "high", "medium", "low").forEach { sev ->
val count = result.severityCounts[sev] ?: 0
if (count > 0) {
val sevColor = SkillScanPresentation.resolveSeverityColor(sev, statusColors)
Surface(
color = sevColor.copy(alpha = 0.15f),
shape = RoundedCornerShape(50),
) {
Text(
text = "$count $sev",
style = MaterialTheme.typography.labelSmall,
color = sevColor,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
}
if (result.findings.isEmpty()) {
Text(
text = stringResource(R.string.skills_hub_scan_no_findings),
style = MaterialTheme.typography.labelSmall,
color = statusColors.success,
)
}
}
// ── Policy reason ──
if (!result.policyReason.isNullOrBlank()) {
Text(
text = result.policyReason,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Findings ──
if (result.findings.isNotEmpty()) {
HorizontalDivider()
result.findings.forEach { finding ->
ScanFindingRow(finding = finding)
}
}
}
}
}
@Composable
fun ScanFindingRow(finding: SkillScanFinding) {
val statusColors = LocalHermesStatusColors.current
val severityColor = SkillScanPresentation.resolveSeverityColor(finding.severity, statusColors)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Surface(
color = severityColor.copy(alpha = 0.15f),
shape = RoundedCornerShape(4.dp),
) {
Text(
text = finding.severity,
style = MaterialTheme.typography.labelSmall,
color = severityColor,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 1.dp),
)
}
Text(
text = finding.category,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium,
)
val fileLocation =
buildString {
finding.file?.let { append(it) }
finding.line?.let { append(":").append(it) }
}
if (fileLocation.isNotEmpty()) {
Text(
text = fileLocation,
style =
MaterialTheme.typography.labelSmall.copy(
fontFamily = FontFamily.Monospace,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
finding.description?.let { description ->
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,56 @@
package com.m57.hermescontrol.ui.skills.components
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SkillHubSource
import com.m57.hermescontrol.theme.LocalHermesStatusColors
@Composable
fun SourceChip(source: SkillHubSource) {
val statusColors = LocalHermesStatusColors.current
val label =
buildString {
append(source.label ?: source.id)
when {
source.rateLimited == true -> {
append(" · ")
append(stringResource(R.string.skills_hub_source_rate_limited))
}
source.available == false -> {
append(" · ")
append(stringResource(R.string.skills_hub_source_offline))
}
source.searchable == false -> {
append(" · ")
append(stringResource(R.string.skills_hub_source_via_index))
}
}
}
val color =
when {
source.rateLimited == true -> statusColors.warning
source.available == false -> statusColors.error
else -> MaterialTheme.colorScheme.primary
}
Surface(
color = color.copy(alpha = 0.12f),
shape = RoundedCornerShape(50),
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = color,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
)
}
}