refactor(skills): decompose SkillsScreen into focused UI components
Extract InstalledSkillsView, HubBrowseView, SkillCard, HubSkillCard, SourceChip, SkillScanViews, and SkillPreviewDialog into components/.
This commit is contained in:
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user