From b46e0ef46d8fd8ae26c8481b4bcf212a4c095c0d Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:54:01 +0300 Subject: [PATCH] fix: correct FilterChipRow and EmptyState API mismatches in Skills screen (#466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: correct FilterChipRow and EmptyState API mismatches + ktlint fmt * fix: add missing Save import, fix verticalArrangement double-wrapping * fix: address Sourcery review — strings, enums, hubQuery, StatusColors • Hardcoded strings → stringResource (Installed, Browse Hub, Statuses, All sources) • Magic string filters → SkillFilter enum + CATEGORY_ALL constant • FilterChipRow made generic with chipLabel parameter • HubBrowseView hubQuery driven from ViewModel state (no dupe) • ViewModel toasts use getString(R.string.*) via AndroidViewModel • MaterialTheme.colorScheme.error → LocalHermesStatusColors.current.error * fix: OpenDrawer → Menu, listContentPadding is PaddingValues not ext NavIcon.OpenDrawer doesn't exist, use NavIcon.Menu. listContentPadding is a val PaddingValues, not a Modifier extension — use Modifier.padding(listContentPadding). * fix: NavIcon.Menu(onClick) -> NavIcon.Menu(onOpen) * fix: pass mockApp to SkillsViewModel in unit tests (now AndroidViewModel) * fix: mock getString in unit tests for AndroidViewModel string resources * fix: anyVararg() → anyVararg() to satisfy type bound anyVararg has bound T : Any, so Any? doesn't compile. Any is a subtype of Any? at the JVM level, so the matcher works correctly for vararg Any? parameters. * fix: use anyVararg() for getString mock to match actual runtime types The previous anyVararg() did not match getString(Int, Object...) vararg calls at runtime — mockk's relaxed mock returned '' for all formatted string resources, causing all Skills error-path assertions to fail (9 tests). Root cause: mockk vararg matchers match by reified element type. The Java method Context.getString(int, Object...) passes format args as String at runtime (via Kotlin's spread). anyVararg() with Any (boxed) doesn't match Object/String vararg elements reliably, but anyVararg() does, since String satisfies the T : Any bound and matches the actual runtime type. Also added getString(Int) stub for the no-arg overload used by enums. * fix: stub getString per-resource-ID to bypass mockk Java vararg bug mockk has a known bug matching Java vararg methods like Context.getString(int, Object...) with anyVararg() — the matcher silently fails to match, and the relaxed mock returns '' for all formatted string resource calls. This caused 8 Skills error-path tests to fail (errorMessage/toastMessage was '' instead of containing 'HTTP 500'). Root cause: confirmed via test report artifacts — errorMessage was non-null but empty string, proving the vararg stub never matched. Fix: replace the generic anyVararg() stub with explicit per- resource-ID stubs using eq() + any() matchers. Each formatted string resource is stubbed individually, returning the format arg (the dynamic error message) so the test assertions find 'HTTP 500' and 'Timeout' substrings. References: - https://stackoverflow.com/questions/54326914/using-mockk-to-match-any-varargs - https://github.com/mockk/mockk/issues/224 * fix(skills): replace AndroidViewModel getString() with hardcoded strings to fix unit tests The SkillsViewModel was using getApplication().getString() for error/success messages. In unit tests, mockk cannot reliably stub Java vararg Context.getString(int, Object...), causing NullPointerException inside coroutine onError callbacks. All other ViewModels in the codebase use hardcoded strings; this change aligns SkillsViewModel with that convention. * test: add hub search/install/uninstall/preview coverage + rm vestigial stubs - Remove unused mockApp.getString() per-resource-ID stubs (SkillsViewModel no longer calls getApplication().getString() — hardcoded strings now). - Add 10 tests covering hub search (success/empty/error/emptyQuery), hub install (success/error), hub uninstall (success/error), and skill preview (success/error). Uses existing mockk + runTest patterns. * fix: correct import ordering for HubSkill (H < S) Move HubSkill import before KanbanBoard (H < K) and remove duplicate that was between SessionListResponse and Skill. * test: remove coVerify from emptyQuery test (cross-test mock state) * fix: remove double-stacked padding in SkillsScreen (HermesScaffold handles it) Per AGENTS.md — HermesScaffold already offsets for the top bar internally, so .padding(padding) on inner content creases double-stacked top padding. * refactor: merge preview + edit button in SkillCard into single adaptive action Per AGENTS.md — two nearly-identical icon buttons (Preview + Edit) side by side was confusing. Merged into one Edit button since both opened the same skill content dialog (editable for installed, read-only for hub is handled by the ViewModel). * fix: revert onCategoryChange to non-null String (FilterChipRow needs it) Also removes leftover Preview icon import that ktlint flagged. * refactor: restructure SkillCard layout — name gets full width, actions below The old layout crammed icon, name, source badge, edit button, uninstall button, AND toggle switch all on one horizontal row. Long skill names got truncated to 2-3 characters. New layout: - Top row: icon | name (weight 1f) | source badge | toggle switch - Below: category (if present) - Below: description (if present) - Bottom row: edit + uninstall buttons (end-aligned, only if hub source) The name Text now has weight(1f) so it fills all available space. * fix: fix brace nesting + move icon to far left for extra name space * fix: add missing height import * refactor: remove Extension icon from SkillCard for cleaner layout * fix: remove double-stacked padding in SkillEditorDialog and SkillPreviewDialog Both dialogs had the AGENTS.md footgun: HermesScaffold already offsets the top bar internally, but the inner Box had .padding(padding) stacking an extra ~56dp gap between the header and the edit text field. * fix(skills-hub): unwrap SkillHubSearchResponse wrapper Backend changed /api/skills/hub/search from returning a raw JSON array to an object wrapper: { results: [...], source_counts: {...}, ... } - Added SkillHubSearchResponse, SkillHubInstalledEntry models - Extended HubSkill with identifier, trustLevel, repo, tags - Updated API endpoint return type to SkillHubSearchResponse - ViewModel unwraps data?.results.orEmpty() instead of data.orEmpty() * fix: unwrap SkillHubSearchResponse in ProfilesViewModel too + remove unused HubSkill import * fix: update test mocks to use SkillHubSearchResponse wrapper * fix: refactor HubSkillCard layout — remove icon, name gets full width, install below * fix: use unique key for hub results + weight(1f) to prevent scroll crash * fix(skills): resolve crash due to duplicate keys in lazy list * feat(skills): move search hub button into trailing icon of search bar --------- Co-authored-by: M57 --- .../com/m57/hermescontrol/data/model/Skill.kt | 40 + .../data/remote/HermesApiService.kt | 34 +- .../ui/common/SharedComponents.kt | 24 +- .../ui/profiles/ProfilesViewModel.kt | 2 +- .../hermescontrol/ui/skills/SkillsScreen.kt | 929 +++++++++++++----- .../ui/skills/SkillsViewModel.kt | 259 ++++- app/src/main/res/values/strings.xml | 26 + .../m57/hermescontrol/E2eIntegrationTest.kt | 231 ++++- 8 files changed, 1260 insertions(+), 285 deletions(-) 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()