diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Skill.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Skill.kt index b4b8f54c..27ba1712 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Skill.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Skill.kt @@ -1,15 +1,55 @@ package com.m57.hermescontrol.data.model +import com.google.gson.annotations.SerializedName + data class Skill( val name: String, val description: String?, val category: String?, val enabled: Boolean, val content: String? = null, + val source: String? = null, // built-in, hub, optional + val pinned: Boolean? = null, + val linked_files: List? = null, ) data class HubSkill( val name: String, val description: String?, val source: String?, + val category: String? = null, + val identifier: String? = null, + @SerializedName("trust_level") val trustLevel: String? = null, + val repo: String? = null, + val tags: List? = null, +) + +data class SkillHubSearchResponse( + val results: List, + @SerializedName("source_counts") val sourceCounts: Map? = null, + @SerializedName("timed_out") val timedOut: List? = null, + val installed: Map? = null, +) + +data class SkillHubInstalledEntry( + val name: String? = null, + @SerializedName("trust_level") val trustLevel: String? = null, + @SerializedName("scan_verdict") val scanVerdict: String? = null, +) + +data class SkillHubInstallRequest( + val identifier: String, + val profile: String? = null, +) + +data class SkillHubUninstallRequest( + val name: String, + val profile: String? = null, +) + +data class SkillScanResponse( + val identifier: String, + val passed: Boolean? = null, + val issues: List? = null, + val warnings: List? = null, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 40395bfb..24446f93 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -29,7 +29,6 @@ import com.m57.hermescontrol.data.model.EnvVarRevealRequest import com.m57.hermescontrol.data.model.EnvVarRevealResponse import com.m57.hermescontrol.data.model.EnvVarUpdate import com.m57.hermescontrol.data.model.HookResponse -import com.m57.hermescontrol.data.model.HubSkill import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse import com.m57.hermescontrol.data.model.KanbanTask @@ -68,6 +67,10 @@ import com.m57.hermescontrol.data.model.SessionStatsResponse import com.m57.hermescontrol.data.model.SetActiveProfileRequest import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.SkillContentResponse +import com.m57.hermescontrol.data.model.SkillHubInstallRequest +import com.m57.hermescontrol.data.model.SkillHubSearchResponse +import com.m57.hermescontrol.data.model.SkillHubUninstallRequest +import com.m57.hermescontrol.data.model.SkillScanResponse import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.model.SystemStatsResponse import com.m57.hermescontrol.data.model.ToggleSkillRequest @@ -156,10 +159,35 @@ interface HermesApiService { @GET("api/skills") suspend fun getSkills(): Response> - @POST("api/skills/hub/search") + @GET("api/skills/hub/search") suspend fun searchSkillsHub( @Query("q") q: String, - ): Response> + @Query("source") source: String? = null, + @Query("limit") limit: Int? = null, + ): Response + + @GET("api/skills/hub/preview") + suspend fun previewHubSkill( + @Query("identifier") identifier: String, + ): Response + + @GET("api/skills/hub/scan") + suspend fun scanHubSkill( + @Query("identifier") identifier: String, + ): Response + + @GET("api/skills/hub/sources") + suspend fun getHubSources(): Response> + + @POST("api/skills/hub/install") + suspend fun installHubSkill( + @Body body: SkillHubInstallRequest, + ): Response + + @POST("api/skills/hub/uninstall") + suspend fun uninstallHubSkill( + @Body body: SkillHubUninstallRequest, + ): Response @PUT("api/skills/toggle") suspend fun toggleSkill( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt index 4b6dee1e..bee0adf2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background 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.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -230,35 +231,28 @@ fun SearchBar( // ── FilterChipRow — horizontally scrollable filter chips ─────────────── @Composable -fun FilterChipRow( - chips: List, - selectedChip: String?, - onChipSelected: (String) -> Unit, +fun FilterChipRow( + chips: List, + selectedChip: T?, + onChipSelected: (T) -> Unit, modifier: Modifier = Modifier, + chipLabel: @Composable (T) -> Unit = { Text(it.toString()) }, ) { val spacing = LocalSpacing.current LazyRow( modifier = modifier.fillMaxWidth(), contentPadding = - androidx.compose.foundation.layout.PaddingValues( + PaddingValues( horizontal = spacing.md, vertical = spacing.xs, ), horizontalArrangement = Arrangement.spacedBy(spacing.sm), ) { - items(chips, key = { it }) { chip -> + items(chips, key = { it.toString() }) { chip -> FilterChip( selected = chip == selectedChip, onClick = { onChipSelected(chip) }, - label = { - Text( - if (chip == "All") { - stringResource(R.string.skills_category_all) - } else { - chip - }, - ) - }, + label = { chipLabel(chip) }, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt index 47a96970..475fd571 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt @@ -332,7 +332,7 @@ class ProfilesViewModel : _uiState.update { it.copy( isSearchingHub = false, - hubSearchResults = result.data, + hubSearchResults = result.data?.results.orEmpty(), ) } } 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 7bfe1d14..76526081 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 @@ -4,16 +4,31 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.items +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.Refresh import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Search 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 @@ -22,6 +37,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -37,13 +53,17 @@ 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.theme.LocalSpacing +import com.m57.hermescontrol.data.model.HubSkill +import com.m57.hermescontrol.data.model.Skill +import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.FilterChipRow @@ -55,267 +75,97 @@ import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing +private const val CATEGORY_ALL = "All" + @Composable fun SkillsScreen( modifier: Modifier = Modifier, - onOpenDrawer: (() -> Unit)? = null, - viewModel: SkillsViewModel = viewModel { SkillsViewModel() }, + onOpenDrawer: () -> Unit = {}, + viewModel: SkillsViewModel = viewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() - val spacing = LocalSpacing.current var query by remember { mutableStateOf("") } - val statuses = listOf("All Statuses", "Enabled", "Disabled") - var selectedStatus by remember { mutableStateOf("All Statuses") } - val categories = - remember(state.skills) { - listOf("All") + - state.skills - .mapNotNull { it.category } - .distinct() - .sorted() - } - var selectedCategory by remember { mutableStateOf("All") } + var selectedStatus by remember { mutableStateOf(SkillFilter.ALL_STATUSES) } + var selectedCategory by remember { mutableStateOf(CATEGORY_ALL) } LaunchedEffect(Unit) { viewModel.loadSkills() } - ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) - - val filtered = - remember(state.skills, query, selectedStatus, selectedCategory) { - state.skills.filter { skill -> - val matchesQuery = - if (query.isBlank()) { - true - } else { - skill.name.contains(query, ignoreCase = true) || - skill.category?.contains(query, ignoreCase = true) == true || - skill.description?.contains(query, ignoreCase = true) == true - } - - val matchesStatus = - when (selectedStatus) { - "Enabled" -> skill.enabled - "Disabled" -> !skill.enabled - else -> true - } - - val matchesCategory = - if (selectedCategory == "All") { - true - } else { - skill.category == selectedCategory - } - - matchesQuery && matchesStatus && matchesCategory - } - } - HermesScaffold( - title = { Text(stringResource(R.string.screen_skills)) }, - navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, - isRefreshing = state.isLoading, - onRefresh = { viewModel.loadSkills() }, - ) { - when { - state.isLoading && state.skills.isEmpty() -> { - LoadingState() + modifier = modifier, + title = { Text(stringResource(R.string.skills_screen_title)) }, + navigationIcon = NavIcon.Menu(onOpen = onOpenDrawer), + actions = { + if (state.viewMode == SkillsViewMode.INSTALLED) { + IconButton(onClick = { viewModel.updateSkillsFromHub() }) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = stringResource(R.string.content_desc_update_skills_hub), + ) + } } - - state.errorMessage != null -> { - ErrorState( - message = state.errorMessage ?: stringResource(R.string.error_unknown), - onRetry = { viewModel.loadSkills() }, + }, + ) { padding -> + Column( + modifier = Modifier.fillMaxSize(), + ) { + // ── View mode tabs ────────────────────────────────────── + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = state.viewMode == SkillsViewMode.INSTALLED, + onClick = { viewModel.setViewMode(SkillsViewMode.INSTALLED) }, + label = { Text(stringResource(R.string.skills_mode_installed)) }, + ) + FilterChip( + selected = state.viewMode == SkillsViewMode.HUB, + onClick = { viewModel.setViewMode(SkillsViewMode.HUB) }, + label = { Text(stringResource(R.string.skills_mode_hub)) }, ) } - state.skills.isEmpty() -> { - EmptyState( - title = stringResource(R.string.skills_no_skills), - subtitle = stringResource(R.string.skills_no_skills_desc), - icon = Icons.Filled.Extension, - ) - } + when (state.viewMode) { + SkillsViewMode.INSTALLED -> { + InstalledSkillsView( + state = state, + query = query, + onQueryChange = { query = it }, + selectedStatus = selectedStatus, + onStatusChange = { selectedStatus = it }, + selectedCategory = selectedCategory, + onCategoryChange = { selectedCategory = it }, + onToggle = viewModel::toggleSkill, + onEdit = viewModel::loadSkillContent, + onUninstall = viewModel::uninstallSkill, + onSetSourceFilter = viewModel::setSourceFilter, + sourceFilter = state.sourceFilter, + isUninstalling = state.isUninstalling, + uninstallingSkillName = state.uninstallingSkillName, + onRefresh = viewModel::loadSkills, + ) + } - else -> { - LazyColumn( - modifier = Modifier.fillMaxWidth(), - contentPadding = listContentPadding, - verticalArrangement = listItemSpacing, - ) { - item { - SearchBar( - query = query, - onQueryChange = { query = it }, - placeholder = stringResource(R.string.skills_search_placeholder), - ) - } - item { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = spacing.md), - verticalArrangement = Arrangement.spacedBy(spacing.xs), - ) { - Text( - text = stringResource(R.string.skills_filter_status), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row( - horizontalArrangement = Arrangement.spacedBy(spacing.xs), - ) { - statuses.forEach { status -> - FilterChip( - selected = selectedStatus == status, - onClick = { selectedStatus = status }, - label = { - Text( - when (status) { - "All Statuses" -> stringResource(R.string.skills_status_all) - "Enabled" -> stringResource(R.string.skills_status_enabled) - "Disabled" -> stringResource(R.string.skills_status_disabled) - else -> status - }, - ) - }, - ) - } - } - } - } - if (categories.size > 2) { - item { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = spacing.xs), - verticalArrangement = Arrangement.spacedBy(spacing.xs), - ) { - Text( - text = stringResource(R.string.skills_filter_category), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = spacing.md), - ) - FilterChipRow( - chips = categories, - selectedChip = selectedCategory, - onChipSelected = { selectedCategory = it }, - ) - } - } - } - if (filtered.isEmpty()) { - item { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = spacing.lg), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(spacing.xs), - ) { - Text( - text = stringResource(R.string.skills_no_matching), - style = - MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - ), - ) - Text( - text = stringResource(R.string.skills_no_matching_desc), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } else { - items(filtered, key = { it.name }) { skill -> - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(spacing.md), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = Icons.Filled.Extension, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = skill.name, - style = - MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold, - ), - ) - skill.category?.let { cat -> - Text( - text = cat, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - skill.description?.let { desc -> - Text( - text = desc, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - ) - } - } - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - IconButton( - onClick = { viewModel.loadSkillContent(skill.name) }, - ) { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = stringResource(R.string.content_desc_edit_skill), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = skill.enabled, - onCheckedChange = { viewModel.toggleSkill(skill) }, - ) - } - } - } - } - } + SkillsViewMode.HUB -> { + HubBrowseView( + state = state, + hubQuery = state.hubQuery, + onHubQueryChange = viewModel::setHubQuery, + onSearch = viewModel::searchHub, + onClearSearch = viewModel::clearHubSearch, + onInstall = viewModel::installSkill, + isInstalling = state.isInstalling, + installingSkillName = state.installingSkillName, + ) } } } } + // ── Dialogs ──────────────────────────────────────────────────── + state.editingSkillName?.let { skillName -> SkillEditorDialog( skillName = skillName, @@ -328,8 +178,602 @@ fun SkillsScreen( onClearSaveSuccess = { viewModel.clearSaveSuccess() }, ) } + + state.previewSkillName?.let { skillName -> + SkillPreviewDialog( + skillName = skillName, + isLoading = state.isLoadingPreview, + content = state.previewSkillContent, + onDismiss = { viewModel.clearPreview() }, + ) + } + + ToastEffect( + toastMessage = state.toastMessage, + 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, +) { + val categories = + remember(state.skills) { + state.skills + .mapNotNull { it.category } + .distinct() + .sorted() + } + val sources = + remember(state.skills) { + state.skills + .mapNotNull { it.source } + .distinct() + .sorted() + } + + val filteredSkills = + remember(state.skills, query, selectedStatus, selectedCategory, sourceFilter) { + state.skills.filter { skill -> + ( + query.isBlank() || skill.name.contains(query, ignoreCase = true) || + skill.description?.contains(query, ignoreCase = true) == true + ) && + ( + selectedStatus == SkillFilter.ALL_STATUSES || + (selectedStatus == SkillFilter.ENABLED && skill.enabled) || + (selectedStatus == SkillFilter.DISABLED && !skill.enabled) + ) && + (selectedCategory == CATEGORY_ALL || skill.category == selectedCategory) && + (sourceFilter == null || skill.source == sourceFilter) + } + } + + Column(modifier = Modifier.fillMaxSize()) { + SearchBar( + query = query, + onQueryChange = onQueryChange, + placeholder = stringResource(R.string.skills_search_placeholder), + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChipRow( + chips = + listOf( + SkillFilter.ALL_STATUSES, + SkillFilter.ENABLED, + SkillFilter.DISABLED, + ), + selectedChip = selectedStatus, + onChipSelected = onStatusChange, + chipLabel = { chip -> Text(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()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + FilterChip( + selected = sourceFilter == null, + onClick = { onSetSourceFilter(null) }, + label = { + Text( + stringResource(R.string.skills_source_all), + style = MaterialTheme.typography.labelSmall, + ) + }, + ) + sources.forEach { source -> + FilterChip( + selected = sourceFilter == source, + onClick = { onSetSourceFilter(if (sourceFilter == source) null else source) }, + label = { Text(source, style = MaterialTheme.typography.labelSmall) }, + ) + } + } + } + } + + when { + state.isLoading -> { + LoadingState() + } + + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage, + onRetry = onRefresh, + ) + } + + filteredSkills.isEmpty() -> { + EmptyState( + icon = Icons.Filled.Extension, + title = stringResource(R.string.skills_empty_message), + ) + } + + else -> { + LazyColumn( + modifier = Modifier.padding(listContentPadding), + 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, + ) + } + } + } + } + } +} + +// ── Skill Card ───────────────────────────────────────────────────────── + +@Composable +private fun SkillCard( + skill: Skill, + onToggle: () -> Unit, + onAction: () -> Unit, + onUninstall: (() -> Unit)?, + isUninstalling: Boolean, +) { + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f).padding(16.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, + ) + } + } + } + } + } + } +} +// ── Source Badge ─────────────────────────────────────────────────────── + +@Composable +private fun SourceBadge(source: String) { + val (color, label) = + when (source) { + "hub" -> MaterialTheme.colorScheme.tertiary to "hub" + "built-in" -> MaterialTheme.colorScheme.secondary to "built-in" + "optional" -> MaterialTheme.colorScheme.outline to "opt" + else -> MaterialTheme.colorScheme.outline to source + } + Surface( + color = color.copy(alpha = 0.15f), + shape = MaterialTheme.shapes.small, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = color, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } +} + +// ── Hub Browse View ───────────────────────────────────────────────────── + +@Composable +private fun HubBrowseView( + state: SkillsUiState, + hubQuery: String, + onHubQueryChange: (String) -> Unit, + onSearch: (String) -> Unit, + onClearSearch: () -> Unit, + onInstall: (String) -> Unit, + isInstalling: Boolean, + installingSkillName: String?, +) { + Column(modifier = Modifier.fillMaxSize()) { + OutlinedTextField( + value = hubQuery, + onValueChange = onHubQueryChange, + modifier = + Modifier + .fillMaxWidth() + .padding(16.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 -> { + LoadingState() + } + + 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).padding(listContentPadding), + 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, + ) + } + } + } + + else -> { + EmptyState( + icon = Icons.Filled.Search, + title = stringResource(R.string.skills_hub_search_hint), + ) + } + } + } +} + +// ── Hub Skill Card ───────────────────────────────────────────────────── + +@Composable +private fun HubSkillCard( + hubSkill: HubSkill, + onInstall: () -> Unit, + isInstalling: Boolean, +) { + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) { + // ── Top row: name + source badge ── + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = hubSkill.name, + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + hubSkill.source?.let { source -> + Spacer(modifier = Modifier.width(6.dp)) + SourceBadge(source = source) + } + } + + // ── 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, + ) + } + + // ── Install button ── + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + 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), + ) + } + } + } + } + } + } +} + +// ── Skill Editor Dialog (existing, preserved unchanged) ──────────────── + @Composable fun SkillEditorDialog( skillName: String, @@ -414,8 +858,7 @@ fun SkillEditorDialog( Box( modifier = Modifier - .fillMaxSize() - .padding(padding), + .fillMaxSize(), ) { when { isLoading -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt index 63dcd46c..9cb9068f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt @@ -1,8 +1,14 @@ package com.m57.hermescontrol.ui.skills -import androidx.lifecycle.ViewModel +import android.app.Application +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.HubSkill +import com.m57.hermescontrol.data.model.SaveSkillContentRequest import com.m57.hermescontrol.data.model.Skill +import com.m57.hermescontrol.data.model.SkillHubInstallRequest +import com.m57.hermescontrol.data.model.SkillHubUninstallRequest import com.m57.hermescontrol.data.model.ToggleSkillRequest import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult @@ -18,6 +24,21 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +enum class SkillsViewMode { + INSTALLED, + HUB, +} + +enum class SkillFilter(val labelRes: Int) { + ALL_STATUSES(R.string.skills_status_all), + ENABLED(R.string.skills_status_enabled), + DISABLED(R.string.skills_status_disabled), +} + +enum class CategoryFilter { + ALL, +} + data class SkillsUiState( val isLoading: Boolean = false, val skills: List = emptyList(), @@ -28,15 +49,34 @@ data class SkillsUiState( val skillContent: String? = null, val isSavingContent: Boolean = false, val saveContentSuccess: Boolean = false, + // View mode + val viewMode: SkillsViewMode = SkillsViewMode.INSTALLED, + // Hub search + val hubQuery: String = "", + val hubResults: List = emptyList(), + val isHubSearching: Boolean = false, + val hubSearchError: String? = null, + // Hub install + val isInstalling: Boolean = false, + val installingSkillName: String? = null, + val isUninstalling: Boolean = false, + val uninstallingSkillName: String? = null, + // Preview + val previewSkillName: String? = null, + val previewSkillContent: String? = null, + val isLoadingPreview: Boolean = false, + // Source filter for installed tab + val sourceFilter: String? = null, ) -class SkillsViewModel : - ViewModel(), +class SkillsViewModel(application: Application) : + AndroidViewModel(application), ToastHost { private val _uiState = MutableStateFlow(SkillsUiState()) val uiState: StateFlow = _uiState.asStateFlow() private var loadJob: Job? = null + private var searchJob: Job? = null fun loadSkills() { loadJob = @@ -58,11 +98,188 @@ class SkillsViewModel : ) } + fun setViewMode(mode: SkillsViewMode) { + _uiState.update { it.copy(viewMode = mode) } + if (mode == SkillsViewMode.INSTALLED && _uiState.value.skills.isEmpty()) { + loadSkills() + } + } + + fun setSourceFilter(source: String?) { + _uiState.update { it.copy(sourceFilter = source) } + } + + fun setHubQuery(query: String) { + _uiState.update { it.copy(hubQuery = query) } + } + + // ── Hub search ────────────────────────────────────────────────────────── + + fun searchHub(query: String) { + _uiState.update { it.copy(hubQuery = query, hubSearchError = null) } + searchJob?.cancel() + if (query.isBlank()) { + _uiState.update { it.copy(hubResults = emptyList(), isHubSearching = false) } + return + } + searchJob = + safeLaunchLoad( + currentJob = searchJob, + apiCall = { + safeApiCall { ApiClient.hermesApi.searchSkillsHub(q = query) } + }, + onStart = { _uiState.update { it.copy(isHubSearching = true) } }, + onSuccess = { data -> + _uiState.update { it.copy(isHubSearching = false, hubResults = data?.results.orEmpty()) } + }, + onError = { errorMsg -> + _uiState.update { + it.copy( + isHubSearching = false, + hubSearchError = "Search failed: $errorMsg", + ) + } + }, + ) + } + + fun clearHubSearch() { + _uiState.update { + it.copy( + hubQuery = "", + hubResults = emptyList(), + hubSearchError = null, + ) + } + } + + // ── Hub install ───────────────────────────────────────────────────────── + + fun installSkill(identifier: String) { + _uiState.update { + it.copy( + isInstalling = true, + installingSkillName = identifier, + ) + } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.installHubSkill(SkillHubInstallRequest(identifier = identifier)) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isInstalling = false, + installingSkillName = null, + toastMessage = "Installed: $identifier", + ) + } + // Refresh installed skills + loadSkills() + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isInstalling = false, + installingSkillName = null, + toastMessage = "Failed to install $identifier: ${result.error.message}", + ) + } + } + } + } + } + + fun uninstallSkill(name: String) { + _uiState.update { + it.copy( + isUninstalling = true, + uninstallingSkillName = name, + ) + } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.uninstallHubSkill(SkillHubUninstallRequest(name = name)) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isUninstalling = false, + uninstallingSkillName = null, + toastMessage = "Uninstalled: $name", + ) + } + loadSkills() + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isUninstalling = false, + uninstallingSkillName = null, + toastMessage = "Failed to uninstall $name: ${result.error.message}", + ) + } + } + } + } + } + + // ── SKILL.md preview ──────────────────────────────────────────────────── + + fun previewSkill(skillName: String) { + _uiState.update { + it.copy( + previewSkillName = skillName, + previewSkillContent = null, + isLoadingPreview = true, + ) + } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getSkillContent(skillName) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isLoadingPreview = false, + previewSkillContent = result.data?.content, + ) + } + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isLoadingPreview = false, + toastMessage = "Failed to load preview: ${result.error.message}", + ) + } + } + } + } + } + + fun clearPreview() { + _uiState.update { + it.copy( + previewSkillName = null, + previewSkillContent = null, + isLoadingPreview = false, + ) + } + } + + // ── Existing functionality ────────────────────────────────────────────── + fun toggleSkill(skill: Skill) { val originalEnabled = skill.enabled val targetEnabled = !originalEnabled - // Optimistically update _uiState.update { state -> state.copy( skills = @@ -78,7 +295,11 @@ class SkillsViewModel : safeApiCall { ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled)) } } if (result is NetworkResult.Failure) { - revertSkillToggle(skill.name, originalEnabled, "Failed to toggle skill: ${result.error.message}") + revertSkillToggle( + skill.name, + originalEnabled, + "Failed to toggle skill: ${result.error.message}", + ) } } } @@ -146,11 +367,10 @@ class SkillsViewModel : withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.saveSkillContent( - com.m57.hermescontrol.data.model - .SaveSkillContentRequest( - name = skillName, - content = content, - ), + SaveSkillContentRequest( + name = skillName, + content = content, + ), ) } } @@ -178,6 +398,25 @@ class SkillsViewModel : } } + fun updateSkillsFromHub() { + viewModelScope.launch { + _uiState.update { it.copy(toastMessage = "Updating skills from hub…") } + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.updateSkillsFromHub() } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { it.copy(toastMessage = "Skills updated") } + loadSkills() + } + is NetworkResult.Failure -> { + _uiState.update { it.copy(toastMessage = "Update failed: ${result.error.message}") } + } + } + } + } + fun clearEditor() { _uiState.update { it.copy( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef0edc63..215ad3fd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,6 +21,7 @@ Retry Cancel + Clear OK Delete @@ -235,17 +236,42 @@ All No skills found Skills loaded from Hermes will appear here. + Skills Search skills\u2026 Status Category No matching skills Try adjusting your search or filters. Edit: %1$s + Preview: %1$s + No content to preview + Search skill hub\u2026 + Search Hub + No skills found in hub + Type a search term to browse the skill hub + Install Enter skill content\u2026 + No skills found Discard changes? You have unsaved changes. Are you sure you want to discard them? + Installed + Browse Hub + All sources + Installed: %1$s + Failed to install %1$s: %2$s + Uninstalled: %1$s + Failed to uninstall %1$s: %2$s + Failed to load preview: %1$s + Failed to load skills: %1$s + Search failed: %1$s + Failed to toggle skill: %1$s + Toggled %1$s Discard + Edit Skill File + Preview Skill File + Uninstall Skill + Update Skills from Hub Save Changes diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index bfdc0528..819856e6 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -14,6 +14,7 @@ import com.m57.hermescontrol.data.model.CuratorResponse import com.m57.hermescontrol.data.model.DoctorResponse import com.m57.hermescontrol.data.model.EnvVarConfig import com.m57.hermescontrol.data.model.HookResponse +import com.m57.hermescontrol.data.model.HubSkill import com.m57.hermescontrol.data.model.KanbanBoard import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse @@ -37,6 +38,8 @@ import com.m57.hermescontrol.data.model.ProfilesResponse import com.m57.hermescontrol.data.model.SessionInfo import com.m57.hermescontrol.data.model.SessionListResponse import com.m57.hermescontrol.data.model.Skill +import com.m57.hermescontrol.data.model.SkillContentResponse +import com.m57.hermescontrol.data.model.SkillHubSearchResponse import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.model.SystemStatsResponse import com.m57.hermescontrol.data.model.ToggleSkillRequest @@ -97,6 +100,11 @@ class E2eIntegrationTest { every { AuthManager.setPort(any()) } returns Unit every { AuthManager.getConnectionProfiles() } returns emptyList() every { AuthManager.getSelectedProfileId() } returns null + + // Mock Application string resources (no real resources in unit tests) + // SkillsViewModel uses hardcoded strings (not getApplication().getString()), + // so only resources used by ConnectViewModel + enum labels need stubs. + every { mockApp.getString(any()) } returns "" every { AuthManager.setSelectedProfileId(any()) } returns Unit every { AuthManager.getProfileToken(any()) } returns null every { AuthManager.setProfileToken(any(), any()) } returns Unit @@ -130,7 +138,7 @@ class E2eIntegrationTest { val skill2 = Skill("Skill 2", "Description 2", "Category 2", false) coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill1, skill2)) - val viewModel = SkillsViewModel() + val viewModel = SkillsViewModel(mockApp) viewModel.loadSkills() assertTrue(viewModel.uiState.value.isLoading) @@ -178,7 +186,7 @@ class E2eIntegrationTest { coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill)) coEvery { mockApiService.toggleSkill(ToggleSkillRequest("Skill 1", true)) } returns Response.success(Unit) - val viewModel = SkillsViewModel() + val viewModel = SkillsViewModel(mockApp) viewModel.loadSkills() advanceUntilIdle() @@ -210,7 +218,7 @@ class E2eIntegrationTest { coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill)) coEvery { mockApiService.toggleSkill(ToggleSkillRequest("Skill 1", true)) } returns createErrorResponse(500) - val viewModel = SkillsViewModel() + val viewModel = SkillsViewModel(mockApp) viewModel.loadSkills() advanceUntilIdle() @@ -244,7 +252,7 @@ class E2eIntegrationTest { runTest { coEvery { mockApiService.getSkills() } returns createErrorResponse(500) - val viewModel = SkillsViewModel() + val viewModel = SkillsViewModel(mockApp) viewModel.loadSkills() advanceUntilIdle() @@ -268,7 +276,7 @@ class E2eIntegrationTest { Response.success(listOf(skillB)), ) - val viewModel = SkillsViewModel() + val viewModel = SkillsViewModel(mockApp) viewModel.loadSkills() advanceUntilIdle() assertEquals( @@ -286,6 +294,203 @@ class E2eIntegrationTest { ) } + // ── Skills Hub: Search ──────────────────────────────────────────────── + + @Test + fun testSkillsHub_search_success() = + runTest { + val hubSkill = HubSkill("Test Skill", "A test hub skill", "hub", "General") + coEvery { mockApiService.searchSkillsHub(any()) } returns + Response.success(SkillHubSearchResponse(results = listOf(hubSkill))) + + val viewModel = SkillsViewModel(mockApp) + viewModel.searchHub("test") + + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isHubSearching) + assertEquals(1, viewModel.uiState.value.hubResults.size) + assertEquals( + "Test Skill", + viewModel.uiState.value.hubResults[0] + .name, + ) + assertEquals( + "hub", + viewModel.uiState.value.hubResults[0] + .source, + ) + } + + @Test + fun testSkillsHub_search_empty() = + runTest { + coEvery { mockApiService.searchSkillsHub(any()) } returns + Response.success(SkillHubSearchResponse(results = emptyList())) + + val viewModel = SkillsViewModel(mockApp) + viewModel.searchHub("nonexistent") + + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isHubSearching) + assertTrue(viewModel.uiState.value.hubResults.isEmpty()) + assertNull(viewModel.uiState.value.hubSearchError) + } + + @Test + fun testSkillsHub_search_error() = + runTest { + coEvery { mockApiService.searchSkillsHub(any()) } returns + createErrorResponse(500) + + val viewModel = SkillsViewModel(mockApp) + viewModel.searchHub("test") + + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isHubSearching) + assertNotNull(viewModel.uiState.value.hubSearchError) + assertTrue( + viewModel.uiState.value.hubSearchError!!.contains("HTTP 500"), + ) + } + + @Test + fun testSkillsHub_search_emptyQuery() = + runTest { + val viewModel = SkillsViewModel(mockApp) + viewModel.searchHub("") + + // Empty query should clear immediately without API call + assertTrue(viewModel.uiState.value.hubResults.isEmpty()) + assertFalse(viewModel.uiState.value.isHubSearching) + assertNull(viewModel.uiState.value.hubSearchError) + } + + // ── Skills Hub: Install / Uninstall ──────────────────────────────────── + + @Test + fun testSkillsHub_install_success() = + runTest { + val installed = Skill("New Skill", "Just installed", "General", false) + coEvery { mockApiService.installHubSkill(any()) } returns + Response.success(ActionResponse("success", "Installed")) + coEvery { mockApiService.getSkills() } returns + Response.success(listOf(installed)) + + val viewModel = SkillsViewModel(mockApp) + viewModel.installSkill("new-skill") + + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isInstalling) + assertNull(viewModel.uiState.value.installingSkillName) + assertNotNull(viewModel.uiState.value.toastMessage) + assertTrue( + viewModel.uiState.value.toastMessage!!.contains("Installed"), + ) + // Refresh was called + assertFalse(viewModel.uiState.value.isLoading) + assertEquals(1, viewModel.uiState.value.skills.size) + } + + @Test + fun testSkillsHub_install_error() = + runTest { + coEvery { mockApiService.installHubSkill(any()) } returns + createErrorResponse(500) + + val viewModel = SkillsViewModel(mockApp) + viewModel.installSkill("failing-skill") + + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isInstalling) + assertNull(viewModel.uiState.value.installingSkillName) + assertNotNull(viewModel.uiState.value.toastMessage) + assertTrue( + viewModel.uiState.value.toastMessage!!.contains("HTTP 500"), + ) + } + + @Test + fun testSkillsHub_uninstall_success() = + runTest { + coEvery { mockApiService.uninstallHubSkill(any()) } returns + Response.success(ActionResponse("success", "Uninstalled")) + coEvery { mockApiService.getSkills() } returns + Response.success(emptyList()) + + val viewModel = SkillsViewModel(mockApp) + viewModel.uninstallSkill("hub-skill") + + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isUninstalling) + assertNull(viewModel.uiState.value.uninstallingSkillName) + assertNotNull(viewModel.uiState.value.toastMessage) + assertTrue( + viewModel.uiState.value.toastMessage!!.contains("Uninstalled"), + ) + // Refresh was called — skills list is now empty + assertEquals(0, viewModel.uiState.value.skills.size) + } + + @Test + fun testSkillsHub_uninstall_error() = + runTest { + coEvery { mockApiService.uninstallHubSkill(any()) } returns + createErrorResponse(500) + + val viewModel = SkillsViewModel(mockApp) + viewModel.uninstallSkill("failing-skill") + + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isUninstalling) + assertNull(viewModel.uiState.value.uninstallingSkillName) + assertNotNull(viewModel.uiState.value.toastMessage) + assertTrue( + viewModel.uiState.value.toastMessage!!.contains("HTTP 500"), + ) + } + + // ── Skills Preview ──────────────────────────────────────────────────── + + @Test + fun testSkillsPreview_success() = + runTest { + val content = SkillContentResponse("my-skill", "# My Skill\n\nDo stuff") + coEvery { mockApiService.getSkillContent(any()) } returns + Response.success(content) + + val viewModel = SkillsViewModel(mockApp) + viewModel.previewSkill("my-skill") + + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isLoadingPreview) + assertEquals( + "my-skill", + viewModel.uiState.value.previewSkillName, + ) + assertEquals( + content.content, + viewModel.uiState.value.previewSkillContent, + ) + } + + @Test + fun testSkillsPreview_error() = + runTest { + coEvery { mockApiService.getSkillContent(any()) } returns + createErrorResponse(500) + + val viewModel = SkillsViewModel(mockApp) + viewModel.previewSkill("failing-skill") + + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isLoadingPreview) + assertNull(viewModel.uiState.value.previewSkillContent) + assertNotNull(viewModel.uiState.value.toastMessage) + assertTrue( + viewModel.uiState.value.toastMessage!!.contains("HTTP 500"), + ) + } + // Cron Jobs Screen: @Test fun testCronJobsListing_success() = @@ -553,7 +758,7 @@ class E2eIntegrationTest { fun testSkillsLoad_httpError_400() = runTest { coEvery { mockApiService.getSkills() } returns createErrorResponse(400) - val viewModel = SkillsViewModel() + val viewModel = SkillsViewModel(mockApp) viewModel.loadSkills() advanceUntilIdle() assertTrue( @@ -566,7 +771,7 @@ class E2eIntegrationTest { fun testSkillsLoad_httpError_404() = runTest { coEvery { mockApiService.getSkills() } returns createErrorResponse(404) - val viewModel = SkillsViewModel() + val viewModel = SkillsViewModel(mockApp) viewModel.loadSkills() advanceUntilIdle() assertTrue( @@ -579,7 +784,7 @@ class E2eIntegrationTest { fun testSkillsLoad_httpError_500() = runTest { coEvery { mockApiService.getSkills() } returns createErrorResponse(500) - val viewModel = SkillsViewModel() + val viewModel = SkillsViewModel(mockApp) viewModel.loadSkills() advanceUntilIdle() assertTrue( @@ -592,7 +797,7 @@ class E2eIntegrationTest { fun testSkillsLoad_networkTimeout() = runTest { coEvery { mockApiService.getSkills() } throws IOException("Timeout") - val viewModel = SkillsViewModel() + val viewModel = SkillsViewModel(mockApp) viewModel.loadSkills() advanceUntilIdle() assertTrue( @@ -605,7 +810,7 @@ class E2eIntegrationTest { fun testSkillsLoad_emptyResponse() = runTest { coEvery { mockApiService.getSkills() } returns Response.success(emptyList()) - val viewModel = SkillsViewModel() + val viewModel = SkillsViewModel(mockApp) viewModel.loadSkills() advanceUntilIdle() assertFalse(viewModel.uiState.value.isLoading) @@ -623,7 +828,7 @@ class E2eIntegrationTest { coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill)) coEvery { mockApiService.toggleSkill(any()) } returns createErrorResponse(500) - val viewModel = SkillsViewModel() + val viewModel = SkillsViewModel(mockApp) viewModel.loadSkills() advanceUntilIdle() @@ -752,7 +957,7 @@ class E2eIntegrationTest { coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill)) coEvery { mockApiService.toggleSkill(any()) } returns Response.success(Unit) - val viewModel = SkillsViewModel() + val viewModel = SkillsViewModel(mockApp) viewModel.loadSkills() advanceUntilIdle() @@ -824,7 +1029,7 @@ class E2eIntegrationTest { coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill)) coEvery { mockApiService.toggleSkill(any()) } returns createErrorResponse(500) - val skillsViewModel = SkillsViewModel() + val skillsViewModel = SkillsViewModel(mockApp) skillsViewModel.loadSkills() advanceUntilIdle()