Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -55,15 +60,16 @@ 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 ->
EditConfigValueDialog(
state = dialogState,
onValueChange = viewModel::onValueChanged,
onValueReset = viewModel::onValueReset,
onDismissRequest = viewModel::onEditDialogCloseClik
onDismissRequest = viewModel::onEditDialogCloseClicked
)
}
}
Expand All @@ -77,47 +83,119 @@ 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()) }
}
}
}
}

@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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<KonfeatureViewState> = _state.asStateFlow()

init {
debugPanelInterceptor
.valuesFlow
.onEach { updateItems() }
.launchIn(viewModelScope)
observeKonfeatureValues()
observeSearchQuery()
}

fun onValueChanged(key: String, value: Any) {
Expand Down Expand Up @@ -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<KonfeatureItem> {
return konfeature.spec.fold(mutableListOf<KonfeatureItem>()) { acc, configSpec ->
private fun getItems(konfeature: Konfeature): Pair<Map<String, KonfeatureItem.Config>, List<KonfeatureItem.Value>> {
val configs = mutableMapOf<String, KonfeatureItem.Config>()
val values = mutableListOf<KonfeatureItem.Value>()

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 {
Expand Down Expand Up @@ -152,6 +180,28 @@ internal class KonfeatureViewModel(
else -> "Unknown"
}
}

private suspend fun filterItems(
configs: Map<String, KonfeatureItem.Config>,
values: List<KonfeatureItem.Value>,
query: String
): List<KonfeatureItem> {
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
}
}
}
}
}
}


Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
package com.redmadrobot.debug.plugin.konfeature.ui.data

internal data class KonfeatureViewState(
val searchQuery: String = "",
val collapsedConfigs: Set<String> = emptySet(),
val items: List<KonfeatureItem> = emptyList(),
val configs: Map<String, KonfeatureItem.Config> = emptyMap(),
val values: List<KonfeatureItem.Value> = emptyList(),
val filteredItems: List<KonfeatureItem> = emptyList(),
val editDialogState: EditDialogState? = null
)
) {
val isSearchActive: Boolean
get() = searchQuery.isNotBlank()
val shouldShowEmptySearchItemsHint
get() = isSearchActive && filteredItems.none { it is KonfeatureItem.Value }
}
4 changes: 4 additions & 0 deletions plugins/plugin-konfeature/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,8 @@
<string name="konfeature_plugin_refresh">Refresh</string>
<string name="konfeature_plugin_collapse_all">Collapse All</string>
<string name="konfeature_plugin_reset_all">Reset All</string>

<string name="konfeature_plugin_search_hint">Search by key…</string>
<string name="konfeature_plugin_search_clear">Clear search</string>
<string name="konfeature_plugin_search_empty">No toggles found</string>
</resources>
Loading