feat(profiles): add local on-device profile hiding and filtering

This commit is contained in:
M57 committed 2026-08-30 20:51:06 +03:00
1 parent 5c8ab7ed6c
commit cdcecb9734
11 files changed
+240 -10

No files matched your search

@@ -25,6 +25,8 @@ data class ServerStoreState(
// App display language. "system" = follow device locale; otherwise a BCP-47
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
val appLanguage: String = "system",
// Local on-device hidden profile names (filtered out from list views unless revealed).
val hiddenProfiles: List<String> = emptyList(),
// App version the silent update check (issue #867) last completed for.
// Null / mismatched with BuildConfig.VERSION_NAME → the About tab runs
// its one-time check again (re-check on app version bump).
@@ -357,6 +357,28 @@ object AuthManager {
serverStore.update { it.copy(pinnedModels = pinned) }
}
// ── Hidden Profiles (Local On-Device Filtering) ──────────────────────
fun getHiddenProfiles(): List<String> = serverStore.getLatestState().hiddenProfiles
fun isProfileHidden(name: String): Boolean = serverStore.getLatestState().hiddenProfiles.contains(name)
fun hideProfile(name: String) {
serverStore.update { s ->
s.copy(hiddenProfiles = (s.hiddenProfiles + name).distinct())
}
}
fun unhideProfile(name: String) {
serverStore.update { s ->
s.copy(hiddenProfiles = s.hiddenProfiles.filter { it != name })
}
}
fun setHiddenProfiles(hidden: List<String>) {
serverStore.update { it.copy(hiddenProfiles = hidden.distinct()) }
}
fun getProfileToken(profileId: String): String? = requirePrefs().getString("token_$profileId", null)
fun setProfileToken(
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.bots
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.BotAvatarMeta
import com.m57.hermescontrol.data.model.BotRosterMeta
import com.m57.hermescontrol.data.model.CreateProfileRequest
@@ -47,12 +48,13 @@ data class BotsUiState(
val activeProfileName: String? = null,
val searchQuery: String = "",
val showHidden: Boolean = false,
val hiddenProfiles: Set<String> = emptySet(),
val selectedTab: BotsTab = BotsTab.BOTS,
val errorMessage: String? = null,
val toastMessage: String? = null,
) {
val hasHiddenBots: Boolean
get() = profiles.any { it.isHidden }
get() = profiles.any { it.isHidden || it.name in hiddenProfiles }
val allGroups: List<GroupInfo>
get() {
@@ -185,7 +187,8 @@ data class BotsUiState(
val query = searchQuery.trim().lowercase()
return profiles
.filter { profile ->
if (!showHidden && profile.isHidden) return@filter false
val isHidden = profile.isHidden || profile.name in hiddenProfiles
if (!showHidden && isHidden) return@filter false
if (query.isBlank()) return@filter true
profile.name.lowercase().contains(query) ||
profile.effectiveTitle.lowercase().contains(query) ||
@@ -212,6 +215,7 @@ class BotsViewModel(
val uiState: StateFlow<BotsUiState> = _uiState.asStateFlow()
init {
_uiState.update { it.copy(hiddenProfiles = AuthManager.getHiddenProfiles().toSet()) }
if (autoLoad) {
loadBots()
}
@@ -262,6 +266,7 @@ class BotsViewModel(
isRefreshing = false,
profiles = profilesWithMeta,
activeProfileName = activeName ?: it.activeProfileName,
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
@@ -273,6 +278,7 @@ class BotsViewModel(
isRefreshing = false,
profiles = profilesResult.data.profiles.orEmpty(),
activeProfileName = activeName ?: it.activeProfileName,
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
errorMessage = null,
)
}
@@ -1901,7 +1901,10 @@ class ChatViewModel(
// Fetch available bot profiles for @ autocomplete
val profilesResult = safeApiCall { ApiClient.hermesApi.getProfiles() }
if (profilesResult is NetworkResult.Success) {
val profiles = profilesResult.data.profiles.orEmpty()
val profiles =
profilesResult.data.profiles.orEmpty().filter {
!AuthManager.isProfileHidden(it.name) && it.botMeta()?.hidden != true
}
_uiState.update { it.copy(availableBots = profiles) }
}
}
@@ -21,6 +21,8 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -49,6 +51,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
@@ -131,6 +134,29 @@ fun ProfilesScreen(
},
actions = {
if (!isBuildingProfile) {
if (state.hasHiddenProfiles) {
IconButton(
onClick = { viewModel.toggleShowHidden() },
modifier = Modifier.testTag("profiles_action_toggle_hidden"),
) {
Icon(
imageVector =
if (state.showHidden) {
Icons.Default.VisibilityOff
} else {
Icons.Default.Visibility
},
contentDescription =
stringResource(
if (state.showHidden) {
R.string.profiles_hide_hidden
} else {
R.string.profiles_show_hidden
},
),
)
}
}
IconButton(onClick = {
isBuildingProfile = true
viewModel.loadBuilderData()
@@ -173,6 +199,16 @@ fun ProfilesScreen(
)
}
state.displayProfiles.isEmpty() -> {
EmptyState(
title = stringResource(R.string.profiles_empty_title),
subtitle = stringResource(R.string.profiles_all_hidden_desc),
onAction = { viewModel.toggleShowHidden() },
actionLabel = stringResource(R.string.profiles_show_hidden),
modifier = Modifier.padding(paddingValues),
)
}
else -> {
Box(Modifier.fillMaxSize()) {
if (state.isLoading && state.profiles.isEmpty()) {
@@ -194,7 +230,7 @@ fun ProfilesScreen(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.profiles, key = { it.name }) { profile ->
items(state.displayProfiles, key = { it.name }) { profile ->
val isActive = profile.name == state.activeProfileName
Card(
modifier = Modifier.fillMaxWidth(),
@@ -225,11 +261,37 @@ fun ProfilesScreen(
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = profile.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = profile.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
val isProfileHidden =
profile.name in state.hiddenProfiles ||
profile.botMeta()?.hidden == true
if (isProfileHidden) {
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier =
Modifier
.clip(RoundedCornerShape(4.dp))
.background(
MaterialTheme.colorScheme.surfaceVariant,
).padding(horizontal = 6.dp, vertical = 2.dp),
) {
Text(
text =
stringResource(
R.string.profiles_hidden_badge,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
)
}
}
}
val descriptionText =
if (!profile.description.isNullOrBlank()) {
profile.description
@@ -376,6 +438,30 @@ fun ProfilesScreen(
setupCmdProfileName = profile.name
},
)
val isProfileHidden =
profile.name in state.hiddenProfiles ||
profile.botMeta()?.hidden == true
DropdownMenuItem(
text = {
Text(
stringResource(
if (isProfileHidden) {
R.string.profiles_action_unhide
} else {
R.string.profiles_action_hide
},
),
)
},
onClick = {
showMenu = false
if (isProfileHidden) {
viewModel.unhideProfile(profile.name)
} else {
viewModel.hideProfile(profile.name)
}
},
)
if (profile.is_default != true) {
HorizontalDivider()
DropdownMenuItem(
@@ -38,6 +38,8 @@ data class ProfilesUiState(
val isLoadingSoul: Boolean = false,
val errorMessage: String? = null,
val toastMessage: String? = null,
val showHidden: Boolean = false,
val hiddenProfiles: Set<String> = emptySet(),
// Rename / delete / auto-describe / setup-command states
val isAutoDescribing: Boolean = false,
val setupCommand: String? = null,
@@ -50,7 +52,20 @@ data class ProfilesUiState(
val availableSkills: List<Skill> = emptyList(),
val hubSearchResults: List<HubSkill> = emptyList(),
val isSearchingHub: Boolean = false,
)
) {
val hasHiddenProfiles: Boolean
get() = profiles.any { it.name in hiddenProfiles || it.botMeta()?.hidden == true }
val displayProfiles: List<ProfileInfo>
get() =
profiles.filter { profile ->
if (showHidden) {
true
} else {
!(profile.name in hiddenProfiles || profile.botMeta()?.hidden == true)
}
}
}
class ProfilesViewModel(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
@@ -59,6 +74,34 @@ class ProfilesViewModel(
private val _uiState = MutableStateFlow(ProfilesUiState())
val uiState: StateFlow<ProfilesUiState> = _uiState.asStateFlow()
init {
_uiState.update { it.copy(hiddenProfiles = AuthManager.getHiddenProfiles().toSet()) }
}
fun toggleShowHidden() {
_uiState.update { it.copy(showHidden = !it.showHidden) }
}
fun hideProfile(name: String) {
AuthManager.hideProfile(name)
_uiState.update {
it.copy(
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
toastMessage = "Profile $name hidden",
)
}
}
fun unhideProfile(name: String) {
AuthManager.unhideProfile(name)
_uiState.update {
it.copy(
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
toastMessage = "Profile $name unhidden",
)
}
}
fun loadProfiles() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
@@ -77,6 +120,7 @@ class ProfilesViewModel(
isLoading = false,
profiles = profilesResult.data.profiles.orEmpty(),
activeProfileName = activeResult.data.active,
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
)
}
} else {