fix: correct FilterChipRow and EmptyState API mismatches in Skills screen (#466)

* 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<Any?>() → anyVararg<Any>() to satisfy type bound

anyVararg<T> 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<String>() for getString mock to match actual runtime types

The previous anyVararg<Any>() 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<Any>() with Any (boxed) doesn't
match Object/String vararg elements reliably, but anyVararg<String>() 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<T>() — 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<String>() stub with explicit per-
resource-ID stubs using eq() + any<String>() 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<Application>().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<T> 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 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-04 12:54:01 +03:00
1 parent c33821e71c
commit b46e0ef46d
8 files changed
+1252 -277

No files matched your search

@@ -1,15 +1,55 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import com.google.gson.annotations.SerializedName
data class Skill( data class Skill(
val name: String, val name: String,
val description: String?, val description: String?,
val category: String?, val category: String?,
val enabled: Boolean, val enabled: Boolean,
val content: String? = null, val content: String? = null,
val source: String? = null, // built-in, hub, optional
val pinned: Boolean? = null,
val linked_files: List<String>? = null,
) )
data class HubSkill( data class HubSkill(
val name: String, val name: String,
val description: String?, val description: String?,
val source: 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<String>? = null,
)
data class SkillHubSearchResponse(
val results: List<HubSkill>,
@SerializedName("source_counts") val sourceCounts: Map<String, Int>? = null,
@SerializedName("timed_out") val timedOut: List<String>? = null,
val installed: Map<String, SkillHubInstalledEntry>? = 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<String>? = null,
val warnings: List<String>? = null,
) )
@@ -29,7 +29,6 @@ import com.m57.hermescontrol.data.model.EnvVarRevealRequest
import com.m57.hermescontrol.data.model.EnvVarRevealResponse import com.m57.hermescontrol.data.model.EnvVarRevealResponse
import com.m57.hermescontrol.data.model.EnvVarUpdate import com.m57.hermescontrol.data.model.EnvVarUpdate
import com.m57.hermescontrol.data.model.HookResponse 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.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanTask 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.SetActiveProfileRequest
import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.SkillContentResponse 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.StatusResponse
import com.m57.hermescontrol.data.model.SystemStatsResponse import com.m57.hermescontrol.data.model.SystemStatsResponse
import com.m57.hermescontrol.data.model.ToggleSkillRequest import com.m57.hermescontrol.data.model.ToggleSkillRequest
@@ -156,10 +159,35 @@ interface HermesApiService {
@GET("api/skills") @GET("api/skills")
suspend fun getSkills(): Response<List<Skill>> suspend fun getSkills(): Response<List<Skill>>
@POST("api/skills/hub/search") @GET("api/skills/hub/search")
suspend fun searchSkillsHub( suspend fun searchSkillsHub(
@Query("q") q: String, @Query("q") q: String,
): Response<List<HubSkill>> @Query("source") source: String? = null,
@Query("limit") limit: Int? = null,
): Response<SkillHubSearchResponse>
@GET("api/skills/hub/preview")
suspend fun previewHubSkill(
@Query("identifier") identifier: String,
): Response<SkillContentResponse>
@GET("api/skills/hub/scan")
suspend fun scanHubSkill(
@Query("identifier") identifier: String,
): Response<SkillScanResponse>
@GET("api/skills/hub/sources")
suspend fun getHubSources(): Response<List<String>>
@POST("api/skills/hub/install")
suspend fun installHubSkill(
@Body body: SkillHubInstallRequest,
): Response<ActionResponse>
@POST("api/skills/hub/uninstall")
suspend fun uninstallHubSkill(
@Body body: SkillHubUninstallRequest,
): Response<ActionResponse>
@PUT("api/skills/toggle") @PUT("api/skills/toggle")
suspend fun toggleSkill( suspend fun toggleSkill(
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -230,35 +231,28 @@ fun SearchBar(
// ── FilterChipRow — horizontally scrollable filter chips ─────────────── // ── FilterChipRow — horizontally scrollable filter chips ───────────────
@Composable @Composable
fun FilterChipRow( fun <T> FilterChipRow(
chips: List<String>, chips: List<T>,
selectedChip: String?, selectedChip: T?,
onChipSelected: (String) -> Unit, onChipSelected: (T) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
chipLabel: @Composable (T) -> Unit = { Text(it.toString()) },
) { ) {
val spacing = LocalSpacing.current val spacing = LocalSpacing.current
LazyRow( LazyRow(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
contentPadding = contentPadding =
androidx.compose.foundation.layout.PaddingValues( PaddingValues(
horizontal = spacing.md, horizontal = spacing.md,
vertical = spacing.xs, vertical = spacing.xs,
), ),
horizontalArrangement = Arrangement.spacedBy(spacing.sm), horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) { ) {
items(chips, key = { it }) { chip -> items(chips, key = { it.toString() }) { chip ->
FilterChip( FilterChip(
selected = chip == selectedChip, selected = chip == selectedChip,
onClick = { onChipSelected(chip) }, onClick = { onChipSelected(chip) },
label = { label = { chipLabel(chip) },
Text(
if (chip == "All") {
stringResource(R.string.skills_category_all)
} else {
chip
},
)
},
) )
} }
} }
@@ -332,7 +332,7 @@ class ProfilesViewModel :
_uiState.update { _uiState.update {
it.copy( it.copy(
isSearchingHub = false, isSearchingHub = false,
hubSearchResults = result.data, hubSearchResults = result.data?.results.orEmpty(),
) )
} }
} }
@@ -4,16 +4,31 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.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.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.Edit
import androidx.compose.material.icons.filled.Extension 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.Save
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -22,6 +37,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton 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.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight 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.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R 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.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.FilterChipRow import com.m57.hermescontrol.ui.common.FilterChipRow
@@ -55,266 +75,96 @@ import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.listItemSpacing
private const val CATEGORY_ALL = "All"
@Composable @Composable
fun SkillsScreen( fun SkillsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null, onOpenDrawer: () -> Unit = {},
viewModel: SkillsViewModel = viewModel { SkillsViewModel() }, viewModel: SkillsViewModel = viewModel(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
val statuses = listOf("All Statuses", "Enabled", "Disabled") var selectedStatus by remember { mutableStateOf(SkillFilter.ALL_STATUSES) }
var selectedStatus by remember { mutableStateOf("All Statuses") } var selectedCategory by remember { mutableStateOf(CATEGORY_ALL) }
val categories =
remember(state.skills) {
listOf("All") +
state.skills
.mapNotNull { it.category }
.distinct()
.sorted()
}
var selectedCategory by remember { mutableStateOf("All") }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.loadSkills() 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( HermesScaffold(
title = { Text(stringResource(R.string.screen_skills)) }, modifier = modifier,
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, title = { Text(stringResource(R.string.skills_screen_title)) },
isRefreshing = state.isLoading, navigationIcon = NavIcon.Menu(onOpen = onOpenDrawer),
onRefresh = { viewModel.loadSkills() }, actions = {
) { if (state.viewMode == SkillsViewMode.INSTALLED) {
when { IconButton(onClick = { viewModel.updateSkillsFromHub() }) {
state.isLoading && state.skills.isEmpty() -> {
LoadingState()
}
state.errorMessage != null -> {
ErrorState(
message = state.errorMessage ?: stringResource(R.string.error_unknown),
onRetry = { viewModel.loadSkills() },
)
}
state.skills.isEmpty() -> {
EmptyState(
title = stringResource(R.string.skills_no_skills),
subtitle = stringResource(R.string.skills_no_skills_desc),
icon = Icons.Filled.Extension,
)
}
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( Icon(
imageVector = Icons.Filled.Extension, imageVector = Icons.Filled.Refresh,
contentDescription = null, contentDescription = stringResource(R.string.content_desc_update_skills_hub),
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,
) )
} }
} }
} },
) { padding ->
Column(
modifier = Modifier.fillMaxSize(),
) {
// ── View mode tabs ──────────────────────────────────────
Row( Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
IconButton( FilterChip(
onClick = { viewModel.loadSkillContent(skill.name) }, selected = state.viewMode == SkillsViewMode.INSTALLED,
) { onClick = { viewModel.setViewMode(SkillsViewMode.INSTALLED) },
Icon( label = { Text(stringResource(R.string.skills_mode_installed)) },
imageVector = Icons.Filled.Edit, )
contentDescription = stringResource(R.string.content_desc_edit_skill), FilterChip(
tint = MaterialTheme.colorScheme.onSurfaceVariant, selected = state.viewMode == SkillsViewMode.HUB,
onClick = { viewModel.setViewMode(SkillsViewMode.HUB) },
label = { Text(stringResource(R.string.skills_mode_hub)) },
) )
} }
Switch(
checked = skill.enabled, when (state.viewMode) {
onCheckedChange = { viewModel.toggleSkill(skill) }, 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,
)
}
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 -> state.editingSkillName?.let { skillName ->
SkillEditorDialog( SkillEditorDialog(
@@ -328,8 +178,602 @@ fun SkillsScreen(
onClearSaveSuccess = { viewModel.clearSaveSuccess() }, 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 @Composable
fun SkillEditorDialog( fun SkillEditorDialog(
skillName: String, skillName: String,
@@ -414,8 +858,7 @@ fun SkillEditorDialog(
Box( Box(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize(),
.padding(padding),
) { ) {
when { when {
isLoading -> { isLoading -> {
@@ -1,8 +1,14 @@
package com.m57.hermescontrol.ui.skills package com.m57.hermescontrol.ui.skills
import androidx.lifecycle.ViewModel import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope 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.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.model.ToggleSkillRequest
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
@@ -18,6 +24,21 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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( data class SkillsUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val skills: List<Skill> = emptyList(), val skills: List<Skill> = emptyList(),
@@ -28,15 +49,34 @@ data class SkillsUiState(
val skillContent: String? = null, val skillContent: String? = null,
val isSavingContent: Boolean = false, val isSavingContent: Boolean = false,
val saveContentSuccess: Boolean = false, val saveContentSuccess: Boolean = false,
// View mode
val viewMode: SkillsViewMode = SkillsViewMode.INSTALLED,
// Hub search
val hubQuery: String = "",
val hubResults: List<HubSkill> = 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 : class SkillsViewModel(application: Application) :
ViewModel(), AndroidViewModel(application),
ToastHost { ToastHost {
private val _uiState = MutableStateFlow(SkillsUiState()) private val _uiState = MutableStateFlow(SkillsUiState())
val uiState: StateFlow<SkillsUiState> = _uiState.asStateFlow() val uiState: StateFlow<SkillsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null private var loadJob: Job? = null
private var searchJob: Job? = null
fun loadSkills() { fun loadSkills() {
loadJob = 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) { fun toggleSkill(skill: Skill) {
val originalEnabled = skill.enabled val originalEnabled = skill.enabled
val targetEnabled = !originalEnabled val targetEnabled = !originalEnabled
// Optimistically update
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
skills = skills =
@@ -78,7 +295,11 @@ class SkillsViewModel :
safeApiCall { ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled)) } safeApiCall { ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled)) }
} }
if (result is NetworkResult.Failure) { 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,8 +367,7 @@ class SkillsViewModel :
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
safeApiCall { safeApiCall {
ApiClient.hermesApi.saveSkillContent( ApiClient.hermesApi.saveSkillContent(
com.m57.hermescontrol.data.model SaveSkillContentRequest(
.SaveSkillContentRequest(
name = skillName, name = skillName,
content = content, 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() { fun clearEditor() {
_uiState.update { _uiState.update {
it.copy( it.copy(
+26
View File
@@ -21,6 +21,7 @@
<!-- Actions --> <!-- Actions -->
<string name="action_retry">Retry</string> <string name="action_retry">Retry</string>
<string name="action_cancel">Cancel</string> <string name="action_cancel">Cancel</string>
<string name="action_clear">Clear</string>
<string name="action_ok">OK</string> <string name="action_ok">OK</string>
<string name="action_delete">Delete</string> <string name="action_delete">Delete</string>
@@ -235,17 +236,42 @@
<string name="skills_category_all">All</string> <string name="skills_category_all">All</string>
<string name="skills_no_skills">No skills found</string> <string name="skills_no_skills">No skills found</string>
<string name="skills_no_skills_desc">Skills loaded from Hermes will appear here.</string> <string name="skills_no_skills_desc">Skills loaded from Hermes will appear here.</string>
<string name="skills_screen_title">Skills</string>
<string name="skills_search_placeholder">Search skills\u2026</string> <string name="skills_search_placeholder">Search skills\u2026</string>
<string name="skills_filter_status">Status</string> <string name="skills_filter_status">Status</string>
<string name="skills_filter_category">Category</string> <string name="skills_filter_category">Category</string>
<string name="skills_no_matching">No matching skills</string> <string name="skills_no_matching">No matching skills</string>
<string name="skills_no_matching_desc">Try adjusting your search or filters.</string> <string name="skills_no_matching_desc">Try adjusting your search or filters.</string>
<string name="skills_edit_title">Edit: %1$s</string> <string name="skills_edit_title">Edit: %1$s</string>
<string name="skills_preview_title">Preview: %1$s</string>
<string name="skills_preview_empty">No content to preview</string>
<string name="skills_hub_search_placeholder">Search skill hub\u2026</string>
<string name="skills_hub_search_button">Search Hub</string>
<string name="skills_hub_empty_results">No skills found in hub</string>
<string name="skills_hub_search_hint">Type a search term to browse the skill hub</string>
<string name="skills_hub_install">Install</string>
<string name="skills_content_placeholder">Enter skill content\u2026</string> <string name="skills_content_placeholder">Enter skill content\u2026</string>
<string name="skills_empty_message">No skills found</string>
<string name="skills_discard_title">Discard changes?</string> <string name="skills_discard_title">Discard changes?</string>
<string name="skills_discard_message">You have unsaved changes. Are you sure you want to discard them?</string> <string name="skills_discard_message">You have unsaved changes. Are you sure you want to discard them?</string>
<string name="skills_mode_installed">Installed</string>
<string name="skills_mode_hub">Browse Hub</string>
<string name="skills_source_all">All sources</string>
<string name="skills_install_success">Installed: %1$s</string>
<string name="skills_install_failure">Failed to install %1$s: %2$s</string>
<string name="skills_uninstall_success">Uninstalled: %1$s</string>
<string name="skills_uninstall_failure">Failed to uninstall %1$s: %2$s</string>
<string name="skills_preview_failure">Failed to load preview: %1$s</string>
<string name="skills_load_failure">Failed to load skills: %1$s</string>
<string name="skills_search_failure">Search failed: %1$s</string>
<string name="skills_toggle_failure">Failed to toggle skill: %1$s</string>
<string name="skills_toggle_success">Toggled %1$s</string>
<string name="action_discard">Discard</string> <string name="action_discard">Discard</string>
<string name="content_desc_edit_skill">Edit Skill File</string> <string name="content_desc_edit_skill">Edit Skill File</string>
<string name="content_desc_preview_skill">Preview Skill File</string>
<string name="content_desc_uninstall_skill">Uninstall Skill</string>
<string name="content_desc_update_skills_hub">Update Skills from Hub</string>
<string name="content_desc_save_changes">Save Changes</string> <string name="content_desc_save_changes">Save Changes</string>
<!-- Logs Screen --> <!-- Logs Screen -->
@@ -14,6 +14,7 @@ import com.m57.hermescontrol.data.model.CuratorResponse
import com.m57.hermescontrol.data.model.DoctorResponse import com.m57.hermescontrol.data.model.DoctorResponse
import com.m57.hermescontrol.data.model.EnvVarConfig import com.m57.hermescontrol.data.model.EnvVarConfig
import com.m57.hermescontrol.data.model.HookResponse 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.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse 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.SessionInfo
import com.m57.hermescontrol.data.model.SessionListResponse import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.model.Skill 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.StatusResponse
import com.m57.hermescontrol.data.model.SystemStatsResponse import com.m57.hermescontrol.data.model.SystemStatsResponse
import com.m57.hermescontrol.data.model.ToggleSkillRequest import com.m57.hermescontrol.data.model.ToggleSkillRequest
@@ -97,6 +100,11 @@ class E2eIntegrationTest {
every { AuthManager.setPort(any()) } returns Unit every { AuthManager.setPort(any()) } returns Unit
every { AuthManager.getConnectionProfiles() } returns emptyList() every { AuthManager.getConnectionProfiles() } returns emptyList()
every { AuthManager.getSelectedProfileId() } returns null 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<Int>()) } returns ""
every { AuthManager.setSelectedProfileId(any()) } returns Unit every { AuthManager.setSelectedProfileId(any()) } returns Unit
every { AuthManager.getProfileToken(any()) } returns null every { AuthManager.getProfileToken(any()) } returns null
every { AuthManager.setProfileToken(any(), any()) } returns Unit every { AuthManager.setProfileToken(any(), any()) } returns Unit
@@ -130,7 +138,7 @@ class E2eIntegrationTest {
val skill2 = Skill("Skill 2", "Description 2", "Category 2", false) val skill2 = Skill("Skill 2", "Description 2", "Category 2", false)
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill1, skill2)) coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill1, skill2))
val viewModel = SkillsViewModel() val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills() viewModel.loadSkills()
assertTrue(viewModel.uiState.value.isLoading) assertTrue(viewModel.uiState.value.isLoading)
@@ -178,7 +186,7 @@ class E2eIntegrationTest {
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill)) coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
coEvery { mockApiService.toggleSkill(ToggleSkillRequest("Skill 1", true)) } returns Response.success(Unit) coEvery { mockApiService.toggleSkill(ToggleSkillRequest("Skill 1", true)) } returns Response.success(Unit)
val viewModel = SkillsViewModel() val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills() viewModel.loadSkills()
advanceUntilIdle() advanceUntilIdle()
@@ -210,7 +218,7 @@ class E2eIntegrationTest {
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill)) coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
coEvery { mockApiService.toggleSkill(ToggleSkillRequest("Skill 1", true)) } returns createErrorResponse(500) coEvery { mockApiService.toggleSkill(ToggleSkillRequest("Skill 1", true)) } returns createErrorResponse(500)
val viewModel = SkillsViewModel() val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills() viewModel.loadSkills()
advanceUntilIdle() advanceUntilIdle()
@@ -244,7 +252,7 @@ class E2eIntegrationTest {
runTest { runTest {
coEvery { mockApiService.getSkills() } returns createErrorResponse(500) coEvery { mockApiService.getSkills() } returns createErrorResponse(500)
val viewModel = SkillsViewModel() val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills() viewModel.loadSkills()
advanceUntilIdle() advanceUntilIdle()
@@ -268,7 +276,7 @@ class E2eIntegrationTest {
Response.success(listOf(skillB)), Response.success(listOf(skillB)),
) )
val viewModel = SkillsViewModel() val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills() viewModel.loadSkills()
advanceUntilIdle() advanceUntilIdle()
assertEquals( 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: // Cron Jobs Screen:
@Test @Test
fun testCronJobsListing_success() = fun testCronJobsListing_success() =
@@ -553,7 +758,7 @@ class E2eIntegrationTest {
fun testSkillsLoad_httpError_400() = fun testSkillsLoad_httpError_400() =
runTest { runTest {
coEvery { mockApiService.getSkills() } returns createErrorResponse(400) coEvery { mockApiService.getSkills() } returns createErrorResponse(400)
val viewModel = SkillsViewModel() val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills() viewModel.loadSkills()
advanceUntilIdle() advanceUntilIdle()
assertTrue( assertTrue(
@@ -566,7 +771,7 @@ class E2eIntegrationTest {
fun testSkillsLoad_httpError_404() = fun testSkillsLoad_httpError_404() =
runTest { runTest {
coEvery { mockApiService.getSkills() } returns createErrorResponse(404) coEvery { mockApiService.getSkills() } returns createErrorResponse(404)
val viewModel = SkillsViewModel() val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills() viewModel.loadSkills()
advanceUntilIdle() advanceUntilIdle()
assertTrue( assertTrue(
@@ -579,7 +784,7 @@ class E2eIntegrationTest {
fun testSkillsLoad_httpError_500() = fun testSkillsLoad_httpError_500() =
runTest { runTest {
coEvery { mockApiService.getSkills() } returns createErrorResponse(500) coEvery { mockApiService.getSkills() } returns createErrorResponse(500)
val viewModel = SkillsViewModel() val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills() viewModel.loadSkills()
advanceUntilIdle() advanceUntilIdle()
assertTrue( assertTrue(
@@ -592,7 +797,7 @@ class E2eIntegrationTest {
fun testSkillsLoad_networkTimeout() = fun testSkillsLoad_networkTimeout() =
runTest { runTest {
coEvery { mockApiService.getSkills() } throws IOException("Timeout") coEvery { mockApiService.getSkills() } throws IOException("Timeout")
val viewModel = SkillsViewModel() val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills() viewModel.loadSkills()
advanceUntilIdle() advanceUntilIdle()
assertTrue( assertTrue(
@@ -605,7 +810,7 @@ class E2eIntegrationTest {
fun testSkillsLoad_emptyResponse() = fun testSkillsLoad_emptyResponse() =
runTest { runTest {
coEvery { mockApiService.getSkills() } returns Response.success(emptyList()) coEvery { mockApiService.getSkills() } returns Response.success(emptyList())
val viewModel = SkillsViewModel() val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills() viewModel.loadSkills()
advanceUntilIdle() advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading) assertFalse(viewModel.uiState.value.isLoading)
@@ -623,7 +828,7 @@ class E2eIntegrationTest {
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill)) coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
coEvery { mockApiService.toggleSkill(any()) } returns createErrorResponse(500) coEvery { mockApiService.toggleSkill(any()) } returns createErrorResponse(500)
val viewModel = SkillsViewModel() val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills() viewModel.loadSkills()
advanceUntilIdle() advanceUntilIdle()
@@ -752,7 +957,7 @@ class E2eIntegrationTest {
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill)) coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
coEvery { mockApiService.toggleSkill(any()) } returns Response.success(Unit) coEvery { mockApiService.toggleSkill(any()) } returns Response.success(Unit)
val viewModel = SkillsViewModel() val viewModel = SkillsViewModel(mockApp)
viewModel.loadSkills() viewModel.loadSkills()
advanceUntilIdle() advanceUntilIdle()
@@ -824,7 +1029,7 @@ class E2eIntegrationTest {
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill)) coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
coEvery { mockApiService.toggleSkill(any()) } returns createErrorResponse(500) coEvery { mockApiService.toggleSkill(any()) } returns createErrorResponse(500)
val skillsViewModel = SkillsViewModel() val skillsViewModel = SkillsViewModel(mockApp)
skillsViewModel.loadSkills() skillsViewModel.loadSkills()
advanceUntilIdle() advanceUntilIdle()