From cdcecb97348a3bdf18e03a722151aba4fc2d0bef Mon Sep 17 00:00:00 2001 From: M57 Date: Sun, 30 Aug 2026 20:51:06 +0300 Subject: [PATCH] feat(profiles): add local on-device profile hiding and filtering --- .../data/config/ServerStoreState.kt | 2 + .../hermescontrol/data/local/AuthManager.kt | 22 +++++ .../hermescontrol/ui/bots/BotsViewModel.kt | 10 +- .../hermescontrol/ui/chat/ChatViewModel.kt | 5 +- .../ui/profiles/ProfilesScreen.kt | 98 +++++++++++++++++-- .../ui/profiles/ProfilesViewModel.kt | 46 ++++++++- app/src/main/res/values-ko/strings.xml | 6 ++ app/src/main/res/values-zh/strings.xml | 6 ++ app/src/main/res/values/strings.xml | 6 ++ .../ui/bots/BotsViewModelTest.kt | 4 + .../ui/profiles/ProfilesViewModelTest.kt | 45 +++++++++ 11 files changed, 240 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index 1de600f7..daa7a714 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -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 = 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). diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 4c06b7bf..a671101c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -357,6 +357,28 @@ object AuthManager { serverStore.update { it.copy(pinnedModels = pinned) } } + // ── Hidden Profiles (Local On-Device Filtering) ────────────────────── + + fun getHiddenProfiles(): List = 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) { + serverStore.update { it.copy(hiddenProfiles = hidden.distinct()) } + } + fun getProfileToken(profileId: String): String? = requirePrefs().getString("token_$profileId", null) fun setProfileToken( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt index c1a9764e..a0ad8fdc 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt @@ -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 = 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 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 = _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, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 29114bed..a461af90 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt index e79d8e12..cada9a40 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt @@ -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( 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 e02bd971..26e3cbd3 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 @@ -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 = 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 = emptyList(), val hubSearchResults: List = emptyList(), val isSearchingHub: Boolean = false, -) +) { + val hasHiddenProfiles: Boolean + get() = profiles.any { it.name in hiddenProfiles || it.botMeta()?.hidden == true } + + val displayProfiles: List + 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 = _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 { diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index d43eff7c..38f2446f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -453,6 +453,12 @@ 모델 설정 복제 설명 편집 + 프로필 숨기기 + 프로필 숨김 해제 + 숨겨진 프로필 표시 + 숨겨진 프로필 숨기기 + 숨김 + 모든 프로필이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 프로필을 표시하세요. 활성 프로필 소울 프로필 편집: %1$s 모델 설정: %1$s diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 6ee20c4a..9e66e094 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -596,6 +596,12 @@ 设置模型 克隆 编辑描述 + 隐藏配置文件 + 取消隐藏配置文件 + 显示隐藏的配置文件 + 隐藏已隐藏的配置文件 + 已隐藏 + 所有配置文件当前均已隐藏。点击下方以显示已隐藏的配置文件。 当前配置 编辑 Soul 配置:%1$s 设置模型:%1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d086f4ea..f47e7ff9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -682,6 +682,12 @@ Set Model Clone Edit Description + Hide Profile + Unhide Profile + Show hidden profiles + Hide hidden profiles + Hidden + All profiles are currently hidden. Tap below to show hidden profiles. Active Profile Edit Soul Profile: %1$s Set Model: %1$s diff --git a/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt index 74777d1f..e074c4a2 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.bots +import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.ActiveProfileResponse import com.m57.hermescontrol.data.model.BotAvatarMeta import com.m57.hermescontrol.data.model.BotRosterMeta @@ -45,6 +46,8 @@ class BotsViewModelTest { @Before fun setUp() { Dispatchers.setMain(testDispatcher) + mockkObject(AuthManager) + every { AuthManager.getHiddenProfiles() } returns emptyList() mockkObject(ApiClient) mockApi = mockk(relaxed = true) every { ApiClient.hermesApi } returns mockApi @@ -57,6 +60,7 @@ class BotsViewModelTest { @After fun tearDown() { + unmockkObject(AuthManager) unmockkObject(ApiClient) unmockkObject(HermesWsClient) Dispatchers.resetMain() diff --git a/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt index bfe1736b..28b7a7a8 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt @@ -56,6 +56,7 @@ class ProfilesViewModelTest { private var storedPinnedModels: MutableList = mutableListOf() private var storedSelectedProfile: String? = null private var storedProfileToken: String? = null + private var storedHiddenProfiles: MutableList = mutableListOf() private fun stubProfilesLoad() { coEvery { mockApi.getProfiles() } returns @@ -88,6 +89,15 @@ class ProfilesViewModelTest { every { AuthManager.setProfileToken(any(), any()) } answers { storedProfileToken = secondArg() } + every { AuthManager.getHiddenProfiles() } answers { storedHiddenProfiles.toList() } + every { AuthManager.hideProfile(any()) } answers { + val name = firstArg() + if (!storedHiddenProfiles.contains(name)) storedHiddenProfiles.add(name) + } + every { AuthManager.unhideProfile(any()) } answers { + val name = firstArg() + storedHiddenProfiles.remove(name) + } mockkObject(HermesWsClient) every { HermesWsClient.disconnect() } returns Unit @@ -345,4 +355,39 @@ class ProfilesViewModelTest { assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to clone profile")) assertFalse(vm.uiState.value.isLoading) } + + @Test + fun `hideProfile and unhideProfile update local hidden state and display filtering`() { + coEvery { mockApi.getProfiles() } returns + Response.success( + ProfilesResponse( + listOf( + ProfileInfo(name = "default", is_default = true), + ProfileInfo(name = "secret-bot"), + ), + ), + ) + + val vm = createViewModel() + vm.loadProfiles() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(2, vm.uiState.value.displayProfiles.size) + assertFalse(vm.uiState.value.hasHiddenProfiles) + + vm.hideProfile("secret-bot") + assertEquals(listOf("secret-bot"), storedHiddenProfiles) + assertTrue(vm.uiState.value.hasHiddenProfiles) + assertEquals(listOf("default"), vm.uiState.value.displayProfiles.map { it.name }) + + // Toggle show hidden + vm.toggleShowHidden() + assertTrue(vm.uiState.value.showHidden) + assertEquals(listOf("default", "secret-bot"), vm.uiState.value.displayProfiles.map { it.name }) + + // Unhide + vm.unhideProfile("secret-bot") + assertTrue(storedHiddenProfiles.isEmpty()) + assertFalse(vm.uiState.value.hasHiddenProfiles) + } }