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..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 @@ -14,8 +14,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 +28,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 +60,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 +69,7 @@ internal fun KonfeatureScreen( state = dialogState, onValueChange = viewModel::onValueChanged, onValueReset = viewModel::onValueReset, - onDismissRequest = viewModel::onEditDialogCloseClik + onDismissRequest = viewModel::onEditDialogCloseClicked ) } } @@ -77,40 +83,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 +126,76 @@ 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) + ) { + KonfeatureSearchBar( + query = searchQuery, + onQueryChange = onSearchQueryChange, + modifier = Modifier.padding(vertical = 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..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,29 +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) { @@ -71,42 +73,68 @@ 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) { _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 -> 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) } - _state.update { it.copy(items = items) } + val (configs, values) = withContext(Dispatchers.IO) { getItems(konfeature) } + val searchQuery = _searchQueryFlow.value + val filteredItems = filterItems(configs, values, searchQuery) + + _state.update { state -> + 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 { @@ -152,6 +180,28 @@ internal class KonfeatureViewModel( else -> "Unknown" } } + + 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 7f978311..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 @@ -1,7 +1,15 @@ 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 -) +) { + 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