diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt index 8c52332d..9e58ccb2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt @@ -1,96 +1,37 @@ package com.m57.hermescontrol.ui.skills -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box 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.layout.size -import androidx.compose.foundation.layout.width -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.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CloudDownload -import androidx.compose.material.icons.filled.CloudOff -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Extension -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Shield -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R -import com.m57.hermescontrol.data.model.HubSkill -import com.m57.hermescontrol.data.model.Skill -import com.m57.hermescontrol.data.model.SkillHubSource -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.common.DetailDialog -import com.m57.hermescontrol.ui.common.DetailRow -import com.m57.hermescontrol.ui.common.EmptyState -import com.m57.hermescontrol.ui.common.ErrorState -import com.m57.hermescontrol.ui.common.FilterChipRow import com.m57.hermescontrol.ui.common.HermesScaffold -import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.NavIcon -import com.m57.hermescontrol.ui.common.SearchBar -import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.ToastEffect -import com.m57.hermescontrol.ui.common.listItemSpacing -import com.m57.hermescontrol.ui.common.toDetailRows +import com.m57.hermescontrol.ui.skills.components.HubBrowseView +import com.m57.hermescontrol.ui.skills.components.InstalledSkillsView import com.m57.hermescontrol.ui.skills.components.SkillEditorDialog -import com.m57.hermescontrol.ui.skills.components.SourceBadge +import com.m57.hermescontrol.ui.skills.components.SkillPreviewDialog internal const val CATEGORY_ALL = "All" @@ -230,983 +171,3 @@ fun SkillsScreen( onClearToast = viewModel::clearToast, ) } - -// ── Installed Skills View ─────────────────────────────────────────────── - -@Composable -private 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(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(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 }, - ) - } -} - -// ── Skill Card ───────────────────────────────────────────────────────── - -@Composable -private 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, - ) { - IconButton(onClick = onAction) { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = stringResource(R.string.content_desc_edit_skill), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } - } -} - -// ── Hub Browse View ───────────────────────────────────────────────────── - -@Composable -private 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(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() - }, - ) - } -} - -// ── Hub Landing (sources + featured) ─────────────────────────────────── - -@Composable -private 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) }, - ) - } - } - } - } - } -} - -@Composable -private 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), - ) - } -} - -// ── Hub security scan ────────────────────────────────────────────────── - -@Composable -private 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 -private 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 -private 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 = 2.dp), - ) - } - Text( - text = finding.category, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - ) - val location = - buildString { - finding.file?.let { append(it) } - finding.line?.let { append(":").append(it) } - } - if (location.isNotEmpty()) { - Text( - text = location, - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - finding.description?.let { description -> - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -// ── Hub Skill Card ───────────────────────────────────────────────────── - -@Composable -private 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)) - } - } - } - } - } -} - -// ── Skill Preview Dialog ─────────────────────────────────────────────── - -@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), - ) - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubBrowseView.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubBrowseView.kt new file mode 100644 index 00000000..7d31f04a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubBrowseView.kt @@ -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(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) }, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillCard.kt new file mode 100644 index 00000000..11414133 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillCard.kt @@ -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)) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/InstalledSkillsView.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/InstalledSkillsView.kt new file mode 100644 index 00000000..abd94af1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/InstalledSkillsView.kt @@ -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(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(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 }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillCard.kt new file mode 100644 index 00000000..fcedce17 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillCard.kt @@ -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, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillPreviewDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillPreviewDialog.kt new file mode 100644 index 00000000..a26bb55c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillPreviewDialog.kt @@ -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), + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillScanViews.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillScanViews.kt new file mode 100644 index 00000000..daecd21c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillScanViews.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SourceChip.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SourceChip.kt new file mode 100644 index 00000000..4412308d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SourceChip.kt @@ -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), + ) + } +}