From 20b0e2767fe89dcd85d629e6048c7f7375712dd2 Mon Sep 17 00:00:00 2001 From: "a.emogurov" Date: Tue, 3 Feb 2026 15:08:04 +0400 Subject: [PATCH 1/2] feature: add ability of searching toggles by key --- .../plugin/konfeature/ui/KonfeatureScreen.kt | 123 +++++++++++++++--- .../konfeature/ui/KonfeatureViewModel.kt | 32 ++++- .../konfeature/ui/data/KonfeatureViewState.kt | 9 +- .../src/main/res/values/strings.xml | 4 + 4 files changed, 143 insertions(+), 25 deletions(-) diff --git a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt index e7fbeca2..b8078445 100644 --- a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt +++ b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.Button @@ -14,8 +15,12 @@ import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.IconButton +import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.KeyboardArrowDown import androidx.compose.material.icons.outlined.KeyboardArrowUp @@ -24,6 +29,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -55,7 +61,8 @@ internal fun KonfeatureScreen( onResetAllClick = viewModel::onResetAllClick, onCollapseAllClick = viewModel::onCollapseAllClick, onHeaderClick = viewModel::onConfigHeaderClick, - onEditClick = viewModel::onEditClick + onEditClick = viewModel::onEditClick, + onSearchQueryChange = viewModel::onSearchQueryChanged, ) state.editDialogState?.let { dialogState -> @@ -63,7 +70,7 @@ internal fun KonfeatureScreen( state = dialogState, onValueChange = viewModel::onValueChanged, onValueReset = viewModel::onValueReset, - onDismissRequest = viewModel::onEditDialogCloseClik + onDismissRequest = viewModel::onEditDialogCloseClicked ) } } @@ -77,40 +84,42 @@ internal fun KonfeatureLayout( onCollapseAllClick: () -> Unit, onResetAllClick: () -> Unit, onHeaderClick: (String) -> Unit, + onSearchQueryChange: (String) -> Unit, ) { LazyColumn { stickyHeader { - Row( - modifier = Modifier - .background(colorResource(id = CoreR.color.super_light_gray)) - .padding(horizontal = 16.dp, vertical = 4.dp) - ) { - Button(onClick = onRefreshClick) { - Text(text = stringResource(id = R.string.konfeature_plugin_refresh)) - } - Spacer(modifier = Modifier.weight(1f)) - Button(onClick = onCollapseAllClick) { - Text(text = stringResource(id = R.string.konfeature_plugin_collapse_all)) - } - Spacer(modifier = Modifier.weight(1f)) - Button(onClick = onResetAllClick) { - Text(text = stringResource(id = R.string.konfeature_plugin_reset_all)) - } + KonfeatureHeader( + searchQuery = state.searchQuery, + onSearchQueryChange = onSearchQueryChange, + onRefreshClick = onRefreshClick, + onCollapseAllClick = onCollapseAllClick, + onResetAllClick = onResetAllClick, + ) + } + + if (state.shouldShowEmptySearchItemsHint) { + item { + Text( + text = stringResource(R.string.konfeature_plugin_search_empty), + modifier = Modifier.padding(16.dp) + ) } } - state.items.forEach { item -> + state.filteredItems.forEach { item -> if (item is KonfeatureItem.Config) { item(item.name) { ConfigItem( item = item, - isCollapsed = item.name in state.collapsedConfigs, + isCollapsed = !state.isSearchActive && item.name in state.collapsedConfigs, onHeaderClick = onHeaderClick ) } } - if (item is KonfeatureItem.Value && item.configName !in state.collapsedConfigs) { + val isExpanded = state.isSearchActive || item is KonfeatureItem.Value && + item.configName !in state.collapsedConfigs + if (item is KonfeatureItem.Value && isExpanded) { item(item.key) { ValueItem(item = item, onEditClick) } item { Divider(modifier = Modifier.fillMaxWidth()) } } @@ -118,6 +127,78 @@ internal fun KonfeatureLayout( } } +@Composable +private fun KonfeatureHeader( + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + onRefreshClick: () -> Unit, + onCollapseAllClick: () -> Unit, + onResetAllClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background(colorResource(id = CoreR.color.super_light_gray)) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(8.dp)) + KonfeatureSearchBar( + query = searchQuery, + onQueryChange = onSearchQueryChange, + modifier = Modifier + ) + Spacer(modifier = Modifier.height(8.dp)) + Row { + Button(onClick = onRefreshClick) { + Text(text = stringResource(id = R.string.konfeature_plugin_refresh)) + } + Spacer(modifier = Modifier.weight(1f)) + Button(onClick = onCollapseAllClick) { + Text(text = stringResource(id = R.string.konfeature_plugin_collapse_all)) + } + Spacer(modifier = Modifier.weight(1f)) + Button(onClick = onResetAllClick) { + Text(text = stringResource(id = R.string.konfeature_plugin_reset_all)) + } + } + } +} + +@Composable +private fun KonfeatureSearchBar( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier.fillMaxWidth(), + placeholder = { + Text(text = stringResource(R.string.konfeature_plugin_search_hint)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null + ) + }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.konfeature_plugin_search_clear) + ) + } + } + }, + singleLine = true, + colors = TextFieldDefaults.outlinedTextFieldColors( + backgroundColor = Color.White + ) + ) +} @Composable private fun ConfigItem( diff --git a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt index 4c5a44eb..66621020 100644 --- a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt +++ b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt @@ -25,7 +25,6 @@ internal class KonfeatureViewModel( private val konfeature: Konfeature, private val debugPanelInterceptor: KonfeatureDebugPanelInterceptor, ) : PluginViewModel() { - private val _state = MutableStateFlow(KonfeatureViewState()) val state: Flow = _state.asStateFlow() @@ -85,13 +84,23 @@ internal class KonfeatureViewModel( _state.update { it.copy(editDialogState = EditDialogState(key, value, isDebugSource)) } } - fun onEditDialogCloseClik() { + fun onEditDialogCloseClicked() { _state.update { it.copy(editDialogState = null) } } + fun onSearchQueryChanged(query: String) { + _state.update { state -> + val filteredItems = filterItems(state.items, query) + state.copy(searchQuery = query, filteredItems = filteredItems) + } + } + private suspend fun updateItems() { val items = withContext(Dispatchers.IO) { getItems(konfeature) } - _state.update { it.copy(items = items) } + _state.update { state -> + val filteredItems = filterItems(items, state.searchQuery) + state.copy(items = items, filteredItems = filteredItems) + } } private fun getItems(konfeature: Konfeature): List { @@ -152,6 +161,23 @@ internal class KonfeatureViewModel( else -> "Unknown" } } + + private fun filterItems(items: List, query: String): List { + if (query.isBlank()) return items + + val formattedQuery = query.lowercase() + val matchingItems = items + .filterIsInstance() + .filter { it.key.lowercase().contains(formattedQuery) } + val matchingConfigNames = matchingItems.map { it.configName }.toSet() + + return items.filter { item -> + when (item) { + is KonfeatureItem.Config -> item.name in matchingConfigNames + is KonfeatureItem.Value -> item.key.lowercase().contains(formattedQuery) + } + } + } } diff --git a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt index 7f978311..75c44de3 100644 --- a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt +++ b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt @@ -1,7 +1,14 @@ package com.redmadrobot.debug.plugin.konfeature.ui.data internal data class KonfeatureViewState( + val searchQuery: String = "", val collapsedConfigs: Set = emptySet(), val items: List = emptyList(), + val filteredItems: List = emptyList(), val editDialogState: EditDialogState? = null -) +) { + val isSearchActive: Boolean + get() = searchQuery.isNotBlank() + val shouldShowEmptySearchItemsHint + get() = isSearchActive && filteredItems.none { it is KonfeatureItem.Value } +} diff --git a/plugins/plugin-konfeature/src/main/res/values/strings.xml b/plugins/plugin-konfeature/src/main/res/values/strings.xml index 94797008..3fa00691 100644 --- a/plugins/plugin-konfeature/src/main/res/values/strings.xml +++ b/plugins/plugin-konfeature/src/main/res/values/strings.xml @@ -18,4 +18,8 @@ Refresh Collapse All Reset All + + Search by key… + Clear search + No toggles found From 2c682435aaebed9e7fd19108559cb09608675352 Mon Sep 17 00:00:00 2001 From: "a.emogurov" Date: Wed, 4 Feb 2026 13:35:33 +0400 Subject: [PATCH 2/2] review: review fixes --- .../plugin/konfeature/ui/KonfeatureScreen.kt | 5 +- .../konfeature/ui/KonfeatureViewModel.kt | 98 ++++++++++++------- .../konfeature/ui/data/KonfeatureViewState.kt | 3 +- 3 files changed, 64 insertions(+), 42 deletions(-) diff --git a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt index b8078445..b53115d6 100644 --- a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt +++ b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.Button @@ -141,13 +140,11 @@ private fun KonfeatureHeader( .background(colorResource(id = CoreR.color.super_light_gray)) .padding(horizontal = 16.dp) ) { - Spacer(modifier = Modifier.height(8.dp)) KonfeatureSearchBar( query = searchQuery, onQueryChange = onSearchQueryChange, - modifier = Modifier + modifier = Modifier.padding(vertical = 8.dp) ) - Spacer(modifier = Modifier.height(8.dp)) Row { Button(onClick = onRefreshClick) { Text(text = stringResource(id = R.string.konfeature_plugin_refresh)) diff --git a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt index 66621020..707b8aa1 100644 --- a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt +++ b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt @@ -12,28 +12,31 @@ import com.redmadrobot.konfeature.FeatureValueSpec import com.redmadrobot.konfeature.Konfeature import com.redmadrobot.konfeature.source.FeatureValueSource import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +private const val SEARCH_QUERY_DELAY_MILLIS = 500L + internal class KonfeatureViewModel( private val konfeature: Konfeature, private val debugPanelInterceptor: KonfeatureDebugPanelInterceptor, ) : PluginViewModel() { private val _state = MutableStateFlow(KonfeatureViewState()) + private val _searchQueryFlow = MutableStateFlow("") val state: Flow = _state.asStateFlow() init { - debugPanelInterceptor - .valuesFlow - .onEach { updateItems() } - .launchIn(viewModelScope) + observeKonfeatureValues() + observeSearchQuery() } fun onValueChanged(key: String, value: Any) { @@ -70,14 +73,7 @@ internal class KonfeatureViewModel( } fun onCollapseAllClick() { - _state.update { state -> - val collapsedConfigs = state.items - .asSequence() - .filterIsInstance(KonfeatureItem.Config::class.java) - .map { it.name } - .toSet() - state.copy(collapsedConfigs = collapsedConfigs) - } + _state.update { state -> state.copy(collapsedConfigs = state.configs.keys) } } fun onEditClick(key: String, value: Any, isDebugSource: Boolean) { @@ -89,33 +85,56 @@ internal class KonfeatureViewModel( } fun onSearchQueryChanged(query: String) { - _state.update { state -> - val filteredItems = filterItems(state.items, query) - state.copy(searchQuery = query, filteredItems = filteredItems) - } + _state.update { state -> state.copy(searchQuery = query) } + _searchQueryFlow.update { query } + } + + private fun observeKonfeatureValues() { + debugPanelInterceptor.valuesFlow + .onEach { updateItems() } + .launchIn(viewModelScope) + } + + @OptIn(FlowPreview::class) + private fun observeSearchQuery() { + _searchQueryFlow + .debounce(timeoutMillis = SEARCH_QUERY_DELAY_MILLIS) + .onEach { query -> + _state.update { state -> + state.copy(filteredItems = filterItems(state.configs, state.values, query)) + } + } + .launchIn(viewModelScope) } private suspend fun updateItems() { - val items = withContext(Dispatchers.IO) { getItems(konfeature) } + val (configs, values) = withContext(Dispatchers.IO) { getItems(konfeature) } + val searchQuery = _searchQueryFlow.value + val filteredItems = filterItems(configs, values, searchQuery) + _state.update { state -> - val filteredItems = filterItems(items, state.searchQuery) - state.copy(items = items, filteredItems = filteredItems) + state.copy(configs = configs, values = values, filteredItems = filteredItems) } } - private fun getItems(konfeature: Konfeature): List { - return konfeature.spec.fold(mutableListOf()) { acc, configSpec -> + private fun getItems(konfeature: Konfeature): Pair, List> { + val configs = mutableMapOf() + val values = mutableListOf() + + konfeature.spec.fold(configs to values) { acc, configSpec -> acc.apply { - add(createConfigItem(configSpec)) - addAll(configSpec.values.map { valueSpec -> + configs[configSpec.name] = createConfigItem(configSpec) + configSpec.values.mapTo(values) { valueSpec -> createConfigValueItem( configName = configSpec.name, valueSpec = valueSpec, konfeature = konfeature ) - }) + } } } + + return configs to values } private fun createConfigItem(config: FeatureConfigSpec): KonfeatureItem.Config { @@ -162,19 +181,24 @@ internal class KonfeatureViewModel( } } - private fun filterItems(items: List, query: String): List { - if (query.isBlank()) return items - - val formattedQuery = query.lowercase() - val matchingItems = items - .filterIsInstance() - .filter { it.key.lowercase().contains(formattedQuery) } - val matchingConfigNames = matchingItems.map { it.configName }.toSet() - - return items.filter { item -> - when (item) { - is KonfeatureItem.Config -> item.name in matchingConfigNames - is KonfeatureItem.Value -> item.key.lowercase().contains(formattedQuery) + private suspend fun filterItems( + configs: Map, + values: List, + query: String + ): List { + return withContext(Dispatchers.Default) { + buildList { + var previousValue: KonfeatureItem.Value? = null + + for (value in values) { + if (value.key.contains(query, ignoreCase = true)) { + if (previousValue?.configName != value.configName) { + configs[value.configName]?.let { config -> add(config) } + } + add(value) + previousValue = value + } + } } } } diff --git a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt index 75c44de3..3bdbadeb 100644 --- a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt +++ b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt @@ -3,7 +3,8 @@ package com.redmadrobot.debug.plugin.konfeature.ui.data internal data class KonfeatureViewState( val searchQuery: String = "", val collapsedConfigs: Set = emptySet(), - val items: List = emptyList(), + val configs: Map = emptyMap(), + val values: List = emptyList(), val filteredItems: List = emptyList(), val editDialogState: EditDialogState? = null ) {