From 6a57e77c7092040c24b3855b3a7ad3284792c538 Mon Sep 17 00:00:00 2001 From: "a.tabolin" Date: Wed, 7 Aug 2024 12:00:35 +0300 Subject: [PATCH 1/2] [PLUGINS] add konfeature plugin --- common/build.gradle.kts | 1 + gradle/libs.versions.toml | 5 + no-op/build.gradle.kts | 1 + .../KonfeatureDebugPanelInterceptor.kt | 13 ++ .../plugin/konfeature/KonfeaturePlugin.kt | 8 + plugins/konfeature/.gitignore | 1 + plugins/konfeature/build.gradle.kts | 54 ++++++ plugins/konfeature/consumer-rules.pro | 0 plugins/konfeature/library.properties | 3 + plugins/konfeature/proguard-rules.pro | 21 ++ .../KonfeatureDebugPanelInterceptor.kt | 115 +++++++++++ .../plugin/konfeature/KonfeaturePlugin.kt | 29 +++ .../konfeature/KonfeaturePluginContainer.kt | 15 ++ .../konfeature/ui/EditConfigValueDialog.kt | 183 ++++++++++++++++++ .../plugin/konfeature/ui/KonfeatureScreen.kt | 180 +++++++++++++++++ .../konfeature/ui/KonfeatureViewModel.kt | 138 +++++++++++++ .../konfeature/ui/data/EditDialogState.kt | 7 + .../konfeature/ui/data/KonfeatureItem.kt | 32 +++ .../konfeature/ui/data/KonfeatureViewState.kt | 6 + .../plugin/konfeature/util/JsonConverter.kt | 37 ++++ sample/build.gradle.kts | 9 +- .../com/redmadrobot/debug_sample/App.kt | 9 + .../storage/TestKonfeatureProvider.kt | 90 +++++++++ settings.gradle.kts | 3 +- 24 files changed, 952 insertions(+), 8 deletions(-) create mode 100644 gradle/libs.versions.toml create mode 100644 no-op/src/main/kotlin/com/redmadrobot/debug/noop/plugin/konfeature/KonfeatureDebugPanelInterceptor.kt create mode 100644 no-op/src/main/kotlin/com/redmadrobot/debug/noop/plugin/konfeature/KonfeaturePlugin.kt create mode 100644 plugins/konfeature/.gitignore create mode 100644 plugins/konfeature/build.gradle.kts create mode 100644 plugins/konfeature/consumer-rules.pro create mode 100644 plugins/konfeature/library.properties create mode 100644 plugins/konfeature/proguard-rules.pro create mode 100644 plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeatureDebugPanelInterceptor.kt create mode 100644 plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeaturePlugin.kt create mode 100644 plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeaturePluginContainer.kt create mode 100644 plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/EditConfigValueDialog.kt create mode 100644 plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt create mode 100644 plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt create mode 100644 plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/EditDialogState.kt create mode 100644 plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureItem.kt create mode 100644 plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt create mode 100644 plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/util/JsonConverter.kt create mode 100644 sample/src/main/kotlin/com/redmadrobot/debug_sample/storage/TestKonfeatureProvider.kt diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 1430970b..f8d63103 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -61,6 +61,7 @@ dependencies { api(androidx.room.runtime) api(rmr.flipper) api(rmr.itemsadapter.viewbinding) + api(libs.konfeature) api(stack.accompanist.themeadapter.core) api(stack.accompanist.themeadapter.material) api(stack.kotlinx.coroutines.android) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..0b520a20 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,5 @@ +[versions] +konfeature = "0.1.0" + +[libraries] +konfeature = { module = "com.redmadrobot.konfeature:konfeature", version.ref = "konfeature" } diff --git a/no-op/build.gradle.kts b/no-op/build.gradle.kts index 1759265a..fc42d5f1 100644 --- a/no-op/build.gradle.kts +++ b/no-op/build.gradle.kts @@ -43,5 +43,6 @@ dependencies { implementation(stack.okhttp) implementation(androidx.appcompat) implementation(rmr.flipper) + implementation(libs.konfeature) implementation(stack.kotlinx.coroutines.android) } diff --git a/no-op/src/main/kotlin/com/redmadrobot/debug/noop/plugin/konfeature/KonfeatureDebugPanelInterceptor.kt b/no-op/src/main/kotlin/com/redmadrobot/debug/noop/plugin/konfeature/KonfeatureDebugPanelInterceptor.kt new file mode 100644 index 00000000..d2ce5bc4 --- /dev/null +++ b/no-op/src/main/kotlin/com/redmadrobot/debug/noop/plugin/konfeature/KonfeatureDebugPanelInterceptor.kt @@ -0,0 +1,13 @@ +package com.redmadrobot.debug.plugin.konfeature + +import android.content.Context +import com.redmadrobot.konfeature.source.FeatureValueSource +import com.redmadrobot.konfeature.source.Interceptor + + +public class KonfeatureDebugPanelInterceptor(context: Context) : Interceptor { + + override val name: String = "NoopDebugPanelInterceptor" + + override fun intercept(valueSource: FeatureValueSource, key: String, value: Any): Any? = null +} diff --git a/no-op/src/main/kotlin/com/redmadrobot/debug/noop/plugin/konfeature/KonfeaturePlugin.kt b/no-op/src/main/kotlin/com/redmadrobot/debug/noop/plugin/konfeature/KonfeaturePlugin.kt new file mode 100644 index 00000000..f21cc0ee --- /dev/null +++ b/no-op/src/main/kotlin/com/redmadrobot/debug/noop/plugin/konfeature/KonfeaturePlugin.kt @@ -0,0 +1,8 @@ +package com.redmadrobot.debug.plugin.konfeature + +import com.redmadrobot.konfeature.Konfeature + +public class KonfeaturePlugin( + private val debugPanelInterceptor: KonfeatureDebugPanelInterceptor, + private val konfeature: Konfeature, +) diff --git a/plugins/konfeature/.gitignore b/plugins/konfeature/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/plugins/konfeature/.gitignore @@ -0,0 +1 @@ +/build diff --git a/plugins/konfeature/build.gradle.kts b/plugins/konfeature/build.gradle.kts new file mode 100644 index 00000000..df4f0796 --- /dev/null +++ b/plugins/konfeature/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + id(Plugins.Android.libraryPlagin) + kotlin(Plugins.Kotlin.androidPlugin) + kotlin(Plugins.Kotlin.kapt) + id("convention-publish") +} + +description = "Plugin for konfeature library integration" + +android { + compileSdk = Project.COMPILE_SDK + lint.targetSdk = Project.TARGET_SDK + + defaultConfig { + minSdk = Project.MIN_SDK + + consumerProguardFile("consumer-rules.pro") + } + + buildTypes { + getByName(Project.BuildTypes.release) { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile(Project.Proguard.androidOptimizedRules), + Project.Proguard.projectRules + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + freeCompilerArgs += "-Xexplicit-api=strict" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = androidx.versions.compose.compiler.get() + } + namespace = "com.redmadrobot.debug.plugin.konfeature" +} + +dependencies { + implementation(project(":core")) + implementation(project(":common")) + implementation(androidx.lifecycle.runtime) +} diff --git a/plugins/konfeature/consumer-rules.pro b/plugins/konfeature/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/plugins/konfeature/library.properties b/plugins/konfeature/library.properties new file mode 100644 index 00000000..d67a90a6 --- /dev/null +++ b/plugins/konfeature/library.properties @@ -0,0 +1,3 @@ +lib_name = plugin-flipper +lib_vcs=https://github.com/RedMadRobot/debug-panel-android.git +lib_issue_tracker=https://github.com/RedMadRobot/debug-panel-android/issues diff --git a/plugins/konfeature/proguard-rules.pro b/plugins/konfeature/proguard-rules.pro new file mode 100644 index 00000000..5b0d9eb8 --- /dev/null +++ b/plugins/konfeature/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts.kts.kts.kts.kts.kts.kts.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeatureDebugPanelInterceptor.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeatureDebugPanelInterceptor.kt new file mode 100644 index 00000000..0fb25f5f --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeatureDebugPanelInterceptor.kt @@ -0,0 +1,115 @@ +package com.redmadrobot.debug.plugin.konfeature + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import com.redmadrobot.debug.plugin.konfeature.util.JsonConverter +import com.redmadrobot.konfeature.source.FeatureValueSource +import com.redmadrobot.konfeature.source.Interceptor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.json.JSONObject +import timber.log.Timber + +public class KonfeatureDebugPanelInterceptor(context: Context) : Interceptor { + + private val preferences by lazy { + context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE) + } + + private val _valuesFlow = MutableStateFlow(emptyMap()) + + internal val valuesFlow = _valuesFlow.asStateFlow() + + private val mutex = Mutex() + + override val name: String = "DebugPanelInterceptor" + + init { + CoroutineScope(Dispatchers.IO).launch { fetchValues() } + } + + override fun intercept(valueSource: FeatureValueSource, key: String, value: Any): Any? { + return _valuesFlow.value[key] + ?.let { convertTypeIfNeeded(it, value) } + ?.takeIf { it != value } + } + + private suspend fun fetchValues() { + _valuesFlow.value = withContext(Dispatchers.IO) { + mutex.withLock { preferences.fetchValues() } + } + } + + private fun convertTypeIfNeeded(debugValue: Any, value: Any): Any { + var result = when { + debugValue is Int -> debugValue.toLong() + debugValue is Float -> debugValue.toDouble() + else -> debugValue + } + if (result is Long && value is Double) { + result = result.toDouble() + } + return result + } + + internal suspend fun setValue(key: String, value: Any) { + _valuesFlow.update { it + (key to value) } + updateValues(_valuesFlow.value) + } + + internal suspend fun resetValue(key: String) { + _valuesFlow.update { it - key } + updateValues(_valuesFlow.value) + } + + internal suspend fun resetAll() { + _valuesFlow.value = emptyMap() + updateValues(_valuesFlow.value) + } + + private suspend fun updateValues(map: Map) { + coroutineScope { + launch(Dispatchers.IO) { + mutex.withLock { preferences.updateValues(map) } + } + } + } + + private fun SharedPreferences.fetchValues(): Map { + return try { + val jsonValues = preferences.getString(KEY, EMPTY_MAP) ?: EMPTY_MAP + JsonConverter.toMap(JSONObject(jsonValues)) + } catch (error: Exception) { + Timber.tag(TAG).e(error, "cant fetch debug values") + preferences.edit(commit = true) { remove(KEY) } + emptyMap() + } + } + + private fun SharedPreferences.updateValues(map: Map) { + try { + val jsonValues = JSONObject(map).toString() + preferences.edit(commit = true) { + putString(KEY, jsonValues) + } + } catch (error: Exception) { + Timber.tag(TAG).e(error, "cant update debug values") + } + } + + private companion object { + private const val EMPTY_MAP = "{}" + private const val FILE_NAME = "debug_panel_interceptor_values" + private const val KEY = "values" + private const val TAG = "DebugPanelInterceptor" + } +} diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeaturePlugin.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeaturePlugin.kt new file mode 100644 index 00000000..91afaf73 --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeaturePlugin.kt @@ -0,0 +1,29 @@ +package com.redmadrobot.debug.plugin.konfeature + +import androidx.compose.runtime.Composable +import com.redmadrobot.debug.core.internal.CommonContainer +import com.redmadrobot.debug.core.internal.PluginDependencyContainer +import com.redmadrobot.debug.core.plugin.Plugin +import com.redmadrobot.debug.plugin.konfeaure.ui.KonfeatureScreen +import com.redmadrobot.konfeature.Konfeature + +public class KonfeaturePlugin( + private val debugPanelInterceptor: KonfeatureDebugPanelInterceptor, + private val konfeature: Konfeature, +) : Plugin() { + + public companion object { + private const val NAME = "KONFEATURE" + } + + override fun getName(): String = NAME + + override fun getPluginContainer(commonContainer: CommonContainer): PluginDependencyContainer { + return KonfeaturePluginContainer(konfeature, debugPanelInterceptor) + } + + @Composable + override fun content() { + KonfeatureScreen() + } +} diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeaturePluginContainer.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeaturePluginContainer.kt new file mode 100644 index 00000000..2d195378 --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeaturePluginContainer.kt @@ -0,0 +1,15 @@ +package com.redmadrobot.debug.plugin.konfeature + +import com.redmadrobot.debug.core.internal.PluginDependencyContainer +import com.redmadrobot.debug.plugin.konfeature.ui.KonfeatureViewModel +import com.redmadrobot.konfeature.Konfeature + +internal class KonfeaturePluginContainer( + private val konfeature: Konfeature, + private val debugPanelInterceptor: KonfeatureDebugPanelInterceptor, +) : PluginDependencyContainer { + + fun createKonfeatureViewModel(): KonfeatureViewModel { + return KonfeatureViewModel(konfeature, debugPanelInterceptor) + } +} diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/EditConfigValueDialog.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/EditConfigValueDialog.kt new file mode 100644 index 00000000..79769547 --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/EditConfigValueDialog.kt @@ -0,0 +1,183 @@ +package com.redmadrobot.debug.plugin.konfeature.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.Checkbox +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.redmadrobot.debug.core.R +import com.redmadrobot.debug.plugin.konfeature.ui.data.EditDialogState + +@Composable +internal fun EditConfigValueDialog( + state: EditDialogState, + onValueChanged: (key: String, value: Any) -> Unit, + onValueReset: (key: String) -> Unit, + onDismissRequest: () -> Unit, +) { + val initialValue = state.value + var value by remember { mutableStateOf(state.value) } + var isInputEmpty by remember { mutableStateOf(false) } + + AlertDialog( + backgroundColor = colorResource(id = R.color.super_light_gray), + title = { + Text(text = "Edit: ${state.key}") + }, + text = { + when (initialValue) { + is Boolean -> BooleanEditInput(initialValue, onValueChanged = { value = it }) + is Long -> LongEditInput( + initialValue, + onValueChanged = { value = it }, + onEmpty = { isInputEmpty = it } + ) + + is Double -> DoubleEditInput( + initialValue, + onValueChanged = { value = it }, + onEmpty = { isInputEmpty = it } + ) + + is String -> StringEditInput(initialValue, onValueChanged = { value = it }) + } + }, + onDismissRequest = onDismissRequest, + buttons = { + Row( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + ) { + Button(onClick = onDismissRequest) { + Text(text = "Close") + } + Spacer(modifier = Modifier.weight(1f)) + Button( + enabled = !isInputEmpty && initialValue != value, + onClick = { + onValueChanged.invoke(state.key, value) + onDismissRequest.invoke() + } + ) { + Text(text = "Save") + } + if (state.isDebugSource) { + Button( + modifier = Modifier.padding(start = 8.dp), + onClick = { + onValueReset.invoke(state.key) + onDismissRequest.invoke() + } + ) { + Text(text = "Reset") + } + } + } + } + ) +} + +@Composable +private fun BooleanEditInput( + value: Boolean, + onValueChanged: (Any) -> Unit +) { + var checked by remember { mutableStateOf(value) } + Row( + modifier = Modifier.fillMaxWidth() + ) { + Text( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + text = "Boolean value:" + ) + Checkbox( + checked = checked, + onCheckedChange = { + checked = it + onValueChanged.invoke(it) + } + ) + } +} + +@Composable +private fun LongEditInput( + value: Long, + onValueChanged: (Any) -> Unit, + onEmpty: (Boolean) -> Unit, +) { + var text by remember { mutableStateOf(value.toString(10)) } + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + label = { Text(text = "Long value:") }, + value = text, + onValueChange = { + val newValue = it.toLongOrNull() + if (newValue != null || it.isEmpty()) { + text = it + newValue?.let(onValueChanged) + onEmpty.invoke(it.isEmpty()) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) +} + +@Composable +private fun DoubleEditInput( + value: Double, + onValueChanged: (Any) -> Unit, + onEmpty: (Boolean) -> Unit, +) { + var text by remember { mutableStateOf(value.toBigDecimal().toPlainString()) } + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + label = { Text(text = "Double value:") }, + value = text, + onValueChange = { + val newValue = it.toDoubleOrNull() + if (newValue != null || it.isEmpty()) { + text = it + newValue?.let(onValueChanged) + onEmpty.invoke(it.isEmpty()) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal) + ) +} + +@Composable +private fun StringEditInput( + value: String, + onValueChanged: (Any) -> Unit, +) { + var text by remember { mutableStateOf(value) } + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + label = { Text(text = "String value:") }, + value = text, + onValueChange = { + text = it + onValueChanged.invoke(it) + }, + ) +} diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt new file mode 100644 index 00000000..37722e2c --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt @@ -0,0 +1,180 @@ +package com.redmadrobot.debug.plugin.konfeaure.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.Button +import androidx.compose.material.Checkbox +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material.icons.outlined.KeyboardArrowUp +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp +import com.redmadrobot.debug.core.extension.getPlugin +import com.redmadrobot.debug.core.extension.provideViewModel +import com.redmadrobot.debug.plugin.konfeature.KonfeaturePlugin +import com.redmadrobot.debug.plugin.konfeature.KonfeaturePluginContainer +import com.redmadrobot.debug.plugin.konfeature.ui.EditConfigValueDialog +import com.redmadrobot.debug.plugin.konfeature.ui.KonfeatureViewModel +import com.redmadrobot.debug.plugin.konfeature.ui.data.EditDialogState +import com.redmadrobot.debug.plugin.konfeature.ui.data.KonfeatureItem +import com.redmadrobot.debug.plugin.konfeature.ui.data.KonfeatureViewState +import com.redmadrobot.debug.core.R as CoreR + +@OptIn(ExperimentalMaterialApi::class) +@Composable +internal fun KonfeatureScreen( + viewModel: KonfeatureViewModel = provideViewModel { + getPlugin() + .getContainer() + .createKonfeatureViewModel() + }, +) { + val state by viewModel.state.collectAsState(KonfeatureViewState()) + + var editDialogState: EditDialogState? by remember { mutableStateOf(null) } + + KonfeatureLayout( + state = state, + onRefreshClicked = viewModel::onRefreshClicked, + onResetAllClicked = viewModel::onResetClicked, + onCollapseAllClicked = viewModel::onCollapseAllClicked, + onHeaderClicked = viewModel::onHeaderClicked, + onEditClciked = { key, value, isDebugSource -> + editDialogState = EditDialogState(key, value, isDebugSource) + } + ) + + editDialogState?.let { + EditConfigValueDialog( + state = it, + onValueChanged = viewModel::onValueChanged, + onValueReset = viewModel::onValueReset, + onDismissRequest = { editDialogState = null } + ) + } +} + +@Composable +internal fun KonfeatureLayout( + state: KonfeatureViewState, + onEditClciked: (String, Any, Boolean) -> Unit, + onRefreshClicked: () -> Unit, + onCollapseAllClicked: () -> Unit, + onResetAllClicked: () -> Unit, + onHeaderClicked: (String) -> Unit, +) { + LazyColumn { + item { + Row( + modifier = Modifier + .background(colorResource(id = CoreR.color.super_light_gray)) + .padding(horizontal = 16.dp, vertical = 4.dp) + ) { + Button(onClick = onRefreshClicked) { + Text(text = "Refresh") + } + Spacer(modifier = Modifier.weight(1f)) + Button(onClick = onCollapseAllClicked) { + Text(text = "Collapse All") + } + Spacer(modifier = Modifier.weight(1f)) + Button(onClick = onResetAllClicked) { + Text(text = "Reset All") + } + } + } + + for (item in state.items) { + if (item is KonfeatureItem.Config) { + item(item.name) + { + ConfigItem( + item = item, + isCollapsed = item.name in state.collapsedConfigs, + onHeaderClicked = onHeaderClicked + ) + } + } + + if (item is KonfeatureItem.Value && item.configName !in state.collapsedConfigs) { + item(item.key) { ValueItem(item = item, onEditClciked) } + item { Divider(modifier = Modifier.fillMaxWidth()) } + } + } + } +} + + +@Composable +private fun ConfigItem( + isCollapsed: Boolean, + item: KonfeatureItem.Config, + onHeaderClicked: (String) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onHeaderClicked.invoke(item.name) } + .background(colorResource(id = CoreR.color.super_light_gray)) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text( + modifier = Modifier.weight(1f), + text = item.description + ) + val icon = if (isCollapsed) Icons.Outlined.KeyboardArrowUp else Icons.Outlined.KeyboardArrowDown + + Icon( + imageVector = icon, + modifier = Modifier.align(Alignment.CenterVertically), + contentDescription = null + ) + } +} + +@Composable +internal fun ValueItem(item: KonfeatureItem.Value, onEditClciked: (String, Any, Boolean) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + ) { + Column(Modifier.weight(1f)) { + Text(text = item.description) + Text(text = "key: ${item.key}") + Text(text = "value: ${item.value}") + Text( + color = item.sourceColor, + text = "source: ${item.sourceName}" + ) + } + + if (item.editAvailable) { + IconButton( + modifier = Modifier.align(alignment = Alignment.CenterVertically), + onClick = { onEditClciked.invoke(item.key, item.value, item.isDebugSource) }) { + Icon(Icons.Outlined.Edit, contentDescription = null) + } + } + } +} diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt new file mode 100644 index 00000000..f7b1aa2f --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt @@ -0,0 +1,138 @@ +package com.redmadrobot.debug.plugin.konfeature.ui + +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.viewModelScope +import com.redmadrobot.debug.core.internal.PluginViewModel +import com.redmadrobot.debug.plugin.konfeature.KonfeatureDebugPanelInterceptor +import com.redmadrobot.debug.plugin.konfeature.ui.data.KonfeatureItem +import com.redmadrobot.debug.plugin.konfeature.ui.data.KonfeatureViewState +import com.redmadrobot.konfeature.Konfeature +import com.redmadrobot.konfeature.source.FeatureValueSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +internal class KonfeatureViewModel( + private val konfeature: Konfeature, + private val debugPanelInterceptor: KonfeatureDebugPanelInterceptor, +) : PluginViewModel() { + + private val _state = MutableStateFlow(KonfeatureViewState()) + + val state: Flow = _state.asStateFlow() + + init { + debugPanelInterceptor + .valuesFlow + .onEach { + val items = withContext(Dispatchers.Default) { konfeature.getItems() } + _state.update { it.copy(items = items) } + } + .launchIn(viewModelScope) + } + + fun onValueChanged(key: String, value: Any) { + viewModelScope.launch { + debugPanelInterceptor.setValue(key, value) + } + } + + fun onValueReset(key: String) { + viewModelScope.launch { + debugPanelInterceptor.resetValue(key) + } + } + + fun onHeaderClicked(configName: String) { + _state.update { + val newCollapsedConfigs = if (configName in it.collapsedConfigs) { + it.collapsedConfigs - configName + } else { + it.collapsedConfigs + configName + } + it.copy(collapsedConfigs = newCollapsedConfigs) + } + } + + fun onRefreshClicked() { + viewModelScope.launch { + val items = withContext(Dispatchers.Default) { konfeature.getItems() } + _state.update { it.copy(items = items) } + } + } + + fun onResetClicked() { + viewModelScope.launch { + debugPanelInterceptor.resetAll() + } + } + + fun onCollapseAllClicked() { + _state.update { + val collapsedConfigs = it.items + .asSequence() + .filterIsInstance(KonfeatureItem.Config::class.java) + .map { it.name } + .toSet() + it.copy(collapsedConfigs = collapsedConfigs) + } + } + + private fun Konfeature.getItems(): List { + val items = mutableListOf() + + spec.forEach { config -> + items.add( + KonfeatureItem.Config( + name = config.name, + description = config.description + ) + ) + + config.values.forEach { value -> + val configValue = getValue(value) + + val sourceColor = when (configValue.source) { + FeatureValueSource.Default -> Color.Gray + is FeatureValueSource.Interceptor -> Color.Red + is FeatureValueSource.Source -> Color.Green + } + + items.add( + KonfeatureItem.Value( + key = value.key, + value = configValue.value, + configName = config.name, + sourceName = getSourceName(configValue.source), + sourceColor = sourceColor, + description = value.description, + isDebugSource = configValue.source.isDebugSource(), + ) + ) + } + } + + return items + } + + private fun FeatureValueSource.isDebugSource(): Boolean { + return (this as? FeatureValueSource.Interceptor)?.name == debugPanelInterceptor.name + } + + private fun getSourceName(source: FeatureValueSource): String { + return when (source) { + FeatureValueSource.Default -> "Default" + is FeatureValueSource.Interceptor -> source.name + is FeatureValueSource.Source -> source.name + else -> "Unknown" + } + } +} + + diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/EditDialogState.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/EditDialogState.kt new file mode 100644 index 00000000..0a5fe92c --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/EditDialogState.kt @@ -0,0 +1,7 @@ +package com.redmadrobot.debug.plugin.konfeature.ui.data + +internal class EditDialogState( + val key: String, + val value: Any, + val isDebugSource: Boolean +) diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureItem.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureItem.kt new file mode 100644 index 00000000..1c497277 --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureItem.kt @@ -0,0 +1,32 @@ +package com.redmadrobot.debug.plugin.konfeature.ui.data + +import androidx.compose.ui.graphics.Color + +internal sealed interface KonfeatureItem { + + data class Config( + val name: String, + val description: String, + ) : KonfeatureItem + + data class Value( + val key: String, + val configName: String, + val value: Any, + val description: String, + val sourceName: String, + val sourceColor: Color, + val isDebugSource: Boolean + ) : KonfeatureItem { + + val editAvailable: Boolean + get() = when (value) { + is Boolean, + is String, + is Long, + is Double -> true + else -> false + } + + } +} diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt new file mode 100644 index 00000000..a589ac7a --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt @@ -0,0 +1,6 @@ +package com.redmadrobot.debug.plugin.konfeature.ui.data + +internal data class KonfeatureViewState( + val collapsedConfigs: Set = emptySet(), + val items: List = emptyList() +) diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/util/JsonConverter.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/util/JsonConverter.kt new file mode 100644 index 00000000..eb76ffd0 --- /dev/null +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/util/JsonConverter.kt @@ -0,0 +1,37 @@ +package com.redmadrobot.debug.plugin.konfeature.util + +import org.json.JSONArray +import org.json.JSONObject + +internal object JsonConverter { + + fun toMap(jsonobj: JSONObject): Map { + val map = mutableMapOf() + val keys = jsonobj.keys() + while (keys.hasNext()) { + val key = keys.next() + var value = jsonobj[key] + if (value is JSONArray) { + value = toList(value) + } else if (value is JSONObject) { + value = toMap(value) + } + map[key] = value + } + return map + } + + fun toList(array: JSONArray): List { + val list = mutableListOf() + for (i in 0 until array.length()) { + var value = array[i] + if (value is JSONArray) { + value = toList(value) + } else if (value is JSONObject) { + value = toMap(value) + } + list.add(value) + } + return list + } +} diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 593ba518..052d6735 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { implementation(stack.material) implementation(androidx.constraintlayout) implementation(rmr.flipper) + implementation(libs.konfeature) implementation(stack.timber) implementation(stack.kotlinx.coroutines.android) implementation(androidx.lifecycle.runtime) @@ -54,14 +55,8 @@ dependencies { debugImplementation(project(":plugins:app-settings")) debugImplementation(project(":plugins:flipper")) debugImplementation(project(":plugins:variable")) + debugImplementation(project(":plugins:konfeature")) releaseImplementation(project(":no-op")) -// debugImplementation("com.redmadrobot.debug:panel-core:${project.version}") -// debugImplementation("com.redmadrobot.debug:accounts-plugin:${project.version}") -// debugImplementation("com.redmadrobot.debug:servers-plugin:${project.version}") -// debugImplementation("com.redmadrobot.debug:app-settings-plugin:${project.version}") -// debugImplementation("com.redmadrobot.debug:flipper-plugin:${project.version}") -// debugImplementation("com.redmadrobot.debug:variable-plugin:${project.version}") -// releaseImplementation("com.redmadrobot.debug:panel-no-op:${project.version}") implementation(stack.retrofit) } diff --git a/sample/src/main/kotlin/com/redmadrobot/debug_sample/App.kt b/sample/src/main/kotlin/com/redmadrobot/debug_sample/App.kt index e0830914..77a5ef41 100644 --- a/sample/src/main/kotlin/com/redmadrobot/debug_sample/App.kt +++ b/sample/src/main/kotlin/com/redmadrobot/debug_sample/App.kt @@ -7,6 +7,8 @@ import com.redmadrobot.debug.plugin.accounts.AccountsPlugin import com.redmadrobot.debug.plugin.accounts.data.model.DebugAccount import com.redmadrobot.debug.plugin.appsettings.AppSettingsPlugin import com.redmadrobot.debug.plugin.flipper.FlipperPlugin +import com.redmadrobot.debug.plugin.konfeature.KonfeatureDebugPanelInterceptor +import com.redmadrobot.debug.plugin.konfeature.KonfeaturePlugin import com.redmadrobot.debug.plugin.servers.ServersPlugin import com.redmadrobot.debug.plugin.servers.data.model.DebugServer import com.redmadrobot.debug.plugin.variable.VariablePlugin @@ -16,11 +18,14 @@ import com.redmadrobot.debug_sample.debug_data.DebugFlipperFeaturesProvider import com.redmadrobot.debug_sample.debug_data.DebugServersProvider import com.redmadrobot.debug_sample.debug_data.DebugVariableWidgetsProvider import com.redmadrobot.debug_sample.storage.AppTestSettings +import com.redmadrobot.debug_sample.storage.TestKonfeatureProvider class App : Application() { override fun onCreate() { super.onCreate() + val debugPanelInterceptor = KonfeatureDebugPanelInterceptor(this) + DebugPanel.initialize( application = this, config = DebugPanelConfig(shakerMode = false), @@ -45,6 +50,10 @@ class App : Application() { VariablePlugin( customWidgets = DebugVariableWidgetsProvider().provideData() ), + KonfeaturePlugin( + debugPanelInterceptor = debugPanelInterceptor, + konfeature = TestKonfeatureProvider.create(debugPanelInterceptor), + ), ) ) } diff --git a/sample/src/main/kotlin/com/redmadrobot/debug_sample/storage/TestKonfeatureProvider.kt b/sample/src/main/kotlin/com/redmadrobot/debug_sample/storage/TestKonfeatureProvider.kt new file mode 100644 index 00000000..b7329d15 --- /dev/null +++ b/sample/src/main/kotlin/com/redmadrobot/debug_sample/storage/TestKonfeatureProvider.kt @@ -0,0 +1,90 @@ +package com.redmadrobot.debug_sample.storage + +import com.redmadrobot.debug.plugin.konfeature.KonfeatureDebugPanelInterceptor +import com.redmadrobot.konfeature.FeatureConfig +import com.redmadrobot.konfeature.Konfeature +import com.redmadrobot.konfeature.Logger +import com.redmadrobot.konfeature.builder.konfeature +import com.redmadrobot.konfeature.source.FeatureSource +import com.redmadrobot.konfeature.source.SourceSelectionStrategy +import timber.log.Timber + +internal object TestKonfeatureProvider { + + fun create(debugPanelInterceptor: KonfeatureDebugPanelInterceptor): Konfeature { + return konfeature { + register(FeatureConfig1()) + register(FeatureConfig2()) + addInterceptor(debugPanelInterceptor) + addSource(object : FeatureSource { + override val name: String = "SampleFeatureSource" + + override fun get(key: String): Any? { + return key == "boolean_feature_2" + } + }) + setLogger(object : Logger { + override fun log(severity: Logger.Severity, message: String) { + when (severity) { + Logger.Severity.WARNING -> Timber.tag("Konfeature").w(message) + Logger.Severity.INFO -> Timber.tag("Konfeature").i(message) + } + } + }) + } + } + + class FeatureConfig1 : FeatureConfig( + name = "FeatureConfig1", + description = "feature config number one" + ) { + val booleanFeature1 by toggle( + key = "boolean_feature_1", + description = "boolean feature one", + defaultValue = false, + ) + + val booleanFeature2 by toggle( + key = "boolean_feature_2", + description = "boolean feature two", + defaultValue = false, + sourceSelectionStrategy = SourceSelectionStrategy.Any + ) + + val doubleFeature1: Double by value( + key = "double_feature_1", + description = "double feature one", + defaultValue = 999.99, + ) + + val stringFeature1: String by value( + key = "string_feature_1", + description = "string feature one", + defaultValue = "String feature 1", + ) + } + + class FeatureConfig2 : FeatureConfig( + name = "FeatureConfig2", + description = "feature config number two" + ) { + val booleanFeature3 by toggle( + key = "boolean_feature_3", + description = "boolean feature three", + defaultValue = false, + ) + + val booleanFeature4 by toggle( + key = "boolean_feature_4", + description = "boolean feature foure", + defaultValue = false, + ) + + val longFeature1: Long by value( + key = "long_feature_1", + description = "long feature one", + defaultValue = 100, + ) + } + +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 4756dd6e..b4e91f41 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,7 +42,8 @@ include( ":plugins:servers", ":plugins:app-settings", ":plugins:flipper", - ":plugins:variable" + ":plugins:variable", + ":plugins:konfeature", ) include(":sample") From b6908b3af3ed6faa23bd8b7a6ad20b99c0292cf5 Mon Sep 17 00:00:00 2001 From: "a.tabolin" Date: Thu, 8 Aug 2024 15:07:44 +0300 Subject: [PATCH 2/2] [Plugin][Konfeature] refactroing after code review --- plugins/konfeature/library.properties | 2 +- .../KonfeatureDebugPanelInterceptor.kt | 24 ++-- .../plugin/konfeature/KonfeaturePlugin.kt | 2 +- .../konfeature/ui/EditConfigValueDialog.kt | 92 +++++++------ .../plugin/konfeature/ui/KonfeatureScreen.kt | 80 +++++------ .../konfeature/ui/KonfeatureViewModel.kt | 127 ++++++++++-------- .../konfeature/ui/data/KonfeatureViewState.kt | 3 +- .../src/main/res/values/strings.xml | 21 +++ .../storage/TestKonfeatureProvider.kt | 48 +++++++ 9 files changed, 244 insertions(+), 155 deletions(-) create mode 100644 plugins/konfeature/src/main/res/values/strings.xml diff --git a/plugins/konfeature/library.properties b/plugins/konfeature/library.properties index d67a90a6..35d2c9f3 100644 --- a/plugins/konfeature/library.properties +++ b/plugins/konfeature/library.properties @@ -1,3 +1,3 @@ -lib_name = plugin-flipper +lib_name = plugin-konfeature lib_vcs=https://github.com/RedMadRobot/debug-panel-android.git lib_issue_tracker=https://github.com/RedMadRobot/debug-panel-android/issues diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeatureDebugPanelInterceptor.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeatureDebugPanelInterceptor.kt index 0fb25f5f..bf8707c8 100644 --- a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeatureDebugPanelInterceptor.kt +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeatureDebugPanelInterceptor.kt @@ -15,7 +15,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext import org.json.JSONObject import timber.log.Timber @@ -34,7 +33,9 @@ public class KonfeatureDebugPanelInterceptor(context: Context) : Interceptor { override val name: String = "DebugPanelInterceptor" init { - CoroutineScope(Dispatchers.IO).launch { fetchValues() } + CoroutineScope(Dispatchers.IO).launch { + _valuesFlow.value = mutex.withLock { fetchValues(preferences) } + } } override fun intercept(valueSource: FeatureValueSource, key: String, value: Any): Any? { @@ -43,12 +44,11 @@ public class KonfeatureDebugPanelInterceptor(context: Context) : Interceptor { ?.takeIf { it != value } } - private suspend fun fetchValues() { - _valuesFlow.value = withContext(Dispatchers.IO) { - mutex.withLock { preferences.fetchValues() } - } - } - + /* + * map debugValue from Int to Long, + * from Float to Double, + * from Long to Double if value is Double + */ private fun convertTypeIfNeeded(debugValue: Any, value: Any): Any { var result = when { debugValue is Int -> debugValue.toLong() @@ -71,7 +71,7 @@ public class KonfeatureDebugPanelInterceptor(context: Context) : Interceptor { updateValues(_valuesFlow.value) } - internal suspend fun resetAll() { + internal suspend fun resetAllValues() { _valuesFlow.value = emptyMap() updateValues(_valuesFlow.value) } @@ -79,12 +79,12 @@ public class KonfeatureDebugPanelInterceptor(context: Context) : Interceptor { private suspend fun updateValues(map: Map) { coroutineScope { launch(Dispatchers.IO) { - mutex.withLock { preferences.updateValues(map) } + mutex.withLock { updateValues(preferences, map) } } } } - private fun SharedPreferences.fetchValues(): Map { + private fun fetchValues(preferences: SharedPreferences): Map { return try { val jsonValues = preferences.getString(KEY, EMPTY_MAP) ?: EMPTY_MAP JsonConverter.toMap(JSONObject(jsonValues)) @@ -95,7 +95,7 @@ public class KonfeatureDebugPanelInterceptor(context: Context) : Interceptor { } } - private fun SharedPreferences.updateValues(map: Map) { + private fun updateValues(preferences: SharedPreferences, map: Map) { try { val jsonValues = JSONObject(map).toString() preferences.edit(commit = true) { diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeaturePlugin.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeaturePlugin.kt index 91afaf73..d2f0de0b 100644 --- a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeaturePlugin.kt +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/KonfeaturePlugin.kt @@ -12,7 +12,7 @@ public class KonfeaturePlugin( private val konfeature: Konfeature, ) : Plugin() { - public companion object { + private companion object { private const val NAME = "KONFEATURE" } diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/EditConfigValueDialog.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/EditConfigValueDialog.kt index 79769547..23f7cd3b 100644 --- a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/EditConfigValueDialog.kt +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/EditConfigValueDialog.kt @@ -11,6 +11,7 @@ import androidx.compose.material.Checkbox import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -18,43 +19,48 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import com.redmadrobot.debug.core.R +import com.redmadrobot.debug.plugin.konfeature.R import com.redmadrobot.debug.plugin.konfeature.ui.data.EditDialogState +import com.redmadrobot.debug.core.R as CoreR @Composable internal fun EditConfigValueDialog( state: EditDialogState, - onValueChanged: (key: String, value: Any) -> Unit, + onValueChange: (key: String, value: Any) -> Unit, onValueReset: (key: String) -> Unit, onDismissRequest: () -> Unit, ) { val initialValue = state.value var value by remember { mutableStateOf(state.value) } var isInputEmpty by remember { mutableStateOf(false) } + val saveEnabled by remember { + derivedStateOf { !isInputEmpty && initialValue != value } + } AlertDialog( - backgroundColor = colorResource(id = R.color.super_light_gray), + backgroundColor = colorResource(id = CoreR.color.super_light_gray), title = { - Text(text = "Edit: ${state.key}") + Text(text = stringResource(id = R.string.konfeature_plugin_edit_dialog_title, state.key)) }, text = { when (initialValue) { - is Boolean -> BooleanEditInput(initialValue, onValueChanged = { value = it }) + is Boolean -> BooleanEditInput(initialValue, onValueChange = { value = it }) is Long -> LongEditInput( initialValue, - onValueChanged = { value = it }, - onEmpty = { isInputEmpty = it } + onValueChange = { value = it }, + onEmptyInput = { isInputEmpty = it } ) is Double -> DoubleEditInput( initialValue, - onValueChanged = { value = it }, - onEmpty = { isInputEmpty = it } + onValueChange = { value = it }, + onEmptyImput = { isInputEmpty = it } ) - is String -> StringEditInput(initialValue, onValueChanged = { value = it }) + is String -> StringEditInput(initialValue, onValueChange = { value = it }) } }, onDismissRequest = onDismissRequest, @@ -63,17 +69,17 @@ internal fun EditConfigValueDialog( modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp) ) { Button(onClick = onDismissRequest) { - Text(text = "Close") + Text(text = stringResource(id = R.string.konfeature_plugin_close)) } Spacer(modifier = Modifier.weight(1f)) Button( - enabled = !isInputEmpty && initialValue != value, + enabled = saveEnabled, onClick = { - onValueChanged.invoke(state.key, value) + onValueChange.invoke(state.key, value) onDismissRequest.invoke() } ) { - Text(text = "Save") + Text(text = stringResource(id = R.string.konfeature_plugin_save)) } if (state.isDebugSource) { Button( @@ -83,7 +89,7 @@ internal fun EditConfigValueDialog( onDismissRequest.invoke() } ) { - Text(text = "Reset") + Text(text = stringResource(id = R.string.konfeature_plugin_reset)) } } } @@ -94,7 +100,7 @@ internal fun EditConfigValueDialog( @Composable private fun BooleanEditInput( value: Boolean, - onValueChanged: (Any) -> Unit + onValueChange: (Any) -> Unit ) { var checked by remember { mutableStateOf(value) } Row( @@ -104,13 +110,13 @@ private fun BooleanEditInput( modifier = Modifier .weight(1f) .align(Alignment.CenterVertically), - text = "Boolean value:" + text = stringResource(id = R.string.konfeature_plugin_edit_dialog_hint_boolean) ) Checkbox( checked = checked, - onCheckedChange = { - checked = it - onValueChanged.invoke(it) + onCheckedChange = { newChecked -> + checked = newChecked + onValueChange.invoke(newChecked) } ) } @@ -119,21 +125,21 @@ private fun BooleanEditInput( @Composable private fun LongEditInput( value: Long, - onValueChanged: (Any) -> Unit, - onEmpty: (Boolean) -> Unit, + onValueChange: (Any) -> Unit, + onEmptyInput: (Boolean) -> Unit, ) { var text by remember { mutableStateOf(value.toString(10)) } OutlinedTextField( modifier = Modifier.fillMaxWidth(), - label = { Text(text = "Long value:") }, + label = { Text(text = stringResource(id = R.string.konfeature_plugin_edit_dialog_hint_long)) }, value = text, - onValueChange = { - val newValue = it.toLongOrNull() - if (newValue != null || it.isEmpty()) { - text = it - newValue?.let(onValueChanged) - onEmpty.invoke(it.isEmpty()) + onValueChange = { newText -> + val newValue = newText.toLongOrNull() + if (newValue != null || newText.isEmpty()) { + text = newText + newValue?.let(onValueChange) + onEmptyInput.invoke(newText.isEmpty()) } }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) @@ -143,21 +149,21 @@ private fun LongEditInput( @Composable private fun DoubleEditInput( value: Double, - onValueChanged: (Any) -> Unit, - onEmpty: (Boolean) -> Unit, + onValueChange: (Any) -> Unit, + onEmptyImput: (Boolean) -> Unit, ) { var text by remember { mutableStateOf(value.toBigDecimal().toPlainString()) } OutlinedTextField( modifier = Modifier.fillMaxWidth(), - label = { Text(text = "Double value:") }, + label = { Text(text = stringResource(id = R.string.konfeature_plugin_edit_dialog_hint_double)) }, value = text, - onValueChange = { - val newValue = it.toDoubleOrNull() - if (newValue != null || it.isEmpty()) { - text = it - newValue?.let(onValueChanged) - onEmpty.invoke(it.isEmpty()) + onValueChange = { newText -> + val newValue = newText.toDoubleOrNull() + if (newValue != null || newText.isEmpty()) { + text = newText + newValue?.let(onValueChange) + onEmptyImput.invoke(newText.isEmpty()) } }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal) @@ -167,17 +173,17 @@ private fun DoubleEditInput( @Composable private fun StringEditInput( value: String, - onValueChanged: (Any) -> Unit, + onValueChange: (Any) -> Unit, ) { var text by remember { mutableStateOf(value) } OutlinedTextField( modifier = Modifier.fillMaxWidth(), - label = { Text(text = "String value:") }, + label = { Text(text = stringResource(id = R.string.konfeature_plugin_edit_dialog_hint_string)) }, value = text, - onValueChange = { - text = it - onValueChanged.invoke(it) + onValueChange = { newText -> + text = newText + onValueChange.invoke(newText) }, ) } diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt index 37722e2c..e7fbeca2 100644 --- a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt @@ -1,5 +1,6 @@ package com.redmadrobot.debug.plugin.konfeaure.ui +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -9,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.Button -import androidx.compose.material.Checkbox import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon @@ -22,20 +22,18 @@ import androidx.compose.material.icons.outlined.KeyboardArrowUp import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.redmadrobot.debug.core.extension.getPlugin import com.redmadrobot.debug.core.extension.provideViewModel import com.redmadrobot.debug.plugin.konfeature.KonfeaturePlugin import com.redmadrobot.debug.plugin.konfeature.KonfeaturePluginContainer +import com.redmadrobot.debug.plugin.konfeature.R import com.redmadrobot.debug.plugin.konfeature.ui.EditConfigValueDialog import com.redmadrobot.debug.plugin.konfeature.ui.KonfeatureViewModel -import com.redmadrobot.debug.plugin.konfeature.ui.data.EditDialogState import com.redmadrobot.debug.plugin.konfeature.ui.data.KonfeatureItem import com.redmadrobot.debug.plugin.konfeature.ui.data.KonfeatureViewState import com.redmadrobot.debug.core.R as CoreR @@ -51,73 +49,69 @@ internal fun KonfeatureScreen( ) { val state by viewModel.state.collectAsState(KonfeatureViewState()) - var editDialogState: EditDialogState? by remember { mutableStateOf(null) } - KonfeatureLayout( state = state, - onRefreshClicked = viewModel::onRefreshClicked, - onResetAllClicked = viewModel::onResetClicked, - onCollapseAllClicked = viewModel::onCollapseAllClicked, - onHeaderClicked = viewModel::onHeaderClicked, - onEditClciked = { key, value, isDebugSource -> - editDialogState = EditDialogState(key, value, isDebugSource) - } + onRefreshClick = viewModel::onRefreshClick, + onResetAllClick = viewModel::onResetAllClick, + onCollapseAllClick = viewModel::onCollapseAllClick, + onHeaderClick = viewModel::onConfigHeaderClick, + onEditClick = viewModel::onEditClick ) - editDialogState?.let { + state.editDialogState?.let { dialogState -> EditConfigValueDialog( - state = it, - onValueChanged = viewModel::onValueChanged, + state = dialogState, + onValueChange = viewModel::onValueChanged, onValueReset = viewModel::onValueReset, - onDismissRequest = { editDialogState = null } + onDismissRequest = viewModel::onEditDialogCloseClik ) } } +@OptIn(ExperimentalFoundationApi::class) @Composable internal fun KonfeatureLayout( state: KonfeatureViewState, - onEditClciked: (String, Any, Boolean) -> Unit, - onRefreshClicked: () -> Unit, - onCollapseAllClicked: () -> Unit, - onResetAllClicked: () -> Unit, - onHeaderClicked: (String) -> Unit, + onEditClick: (String, Any, Boolean) -> Unit, + onRefreshClick: () -> Unit, + onCollapseAllClick: () -> Unit, + onResetAllClick: () -> Unit, + onHeaderClick: (String) -> Unit, ) { LazyColumn { - item { + stickyHeader { Row( modifier = Modifier .background(colorResource(id = CoreR.color.super_light_gray)) .padding(horizontal = 16.dp, vertical = 4.dp) ) { - Button(onClick = onRefreshClicked) { - Text(text = "Refresh") + Button(onClick = onRefreshClick) { + Text(text = stringResource(id = R.string.konfeature_plugin_refresh)) } Spacer(modifier = Modifier.weight(1f)) - Button(onClick = onCollapseAllClicked) { - Text(text = "Collapse All") + Button(onClick = onCollapseAllClick) { + Text(text = stringResource(id = R.string.konfeature_plugin_collapse_all)) } Spacer(modifier = Modifier.weight(1f)) - Button(onClick = onResetAllClicked) { - Text(text = "Reset All") + Button(onClick = onResetAllClick) { + Text(text = stringResource(id = R.string.konfeature_plugin_reset_all)) } } } - for (item in state.items) { + state.items.forEach { item -> if (item is KonfeatureItem.Config) { - item(item.name) - { + item(item.name) { ConfigItem( item = item, isCollapsed = item.name in state.collapsedConfigs, - onHeaderClicked = onHeaderClicked + onHeaderClick = onHeaderClick ) } } if (item is KonfeatureItem.Value && item.configName !in state.collapsedConfigs) { - item(item.key) { ValueItem(item = item, onEditClciked) } + item(item.key) { ValueItem(item = item, onEditClick) } item { Divider(modifier = Modifier.fillMaxWidth()) } } } @@ -129,18 +123,18 @@ internal fun KonfeatureLayout( private fun ConfigItem( isCollapsed: Boolean, item: KonfeatureItem.Config, - onHeaderClicked: (String) -> Unit + onHeaderClick: (String) -> Unit ) { Row( modifier = Modifier .fillMaxWidth() - .clickable { onHeaderClicked.invoke(item.name) } + .clickable { onHeaderClick.invoke(item.name) } .background(colorResource(id = CoreR.color.super_light_gray)) .padding(horizontal = 16.dp, vertical = 8.dp) ) { Text( modifier = Modifier.weight(1f), - text = item.description + text = item.description.takeIf { it.isNotEmpty() } ?: item.name ) val icon = if (isCollapsed) Icons.Outlined.KeyboardArrowUp else Icons.Outlined.KeyboardArrowDown @@ -153,7 +147,7 @@ private fun ConfigItem( } @Composable -internal fun ValueItem(item: KonfeatureItem.Value, onEditClciked: (String, Any, Boolean) -> Unit) { +internal fun ValueItem(item: KonfeatureItem.Value, onEditClick: (String, Any, Boolean) -> Unit) { Row( modifier = Modifier .fillMaxWidth() @@ -161,18 +155,18 @@ internal fun ValueItem(item: KonfeatureItem.Value, onEditClciked: (String, Any, ) { Column(Modifier.weight(1f)) { Text(text = item.description) - Text(text = "key: ${item.key}") - Text(text = "value: ${item.value}") + Text(text = stringResource(id = R.string.konfeature_plugin_item_key, item.key)) + Text(text = stringResource(id = R.string.konfeature_plugin_item_value, item.value.toString())) Text( color = item.sourceColor, - text = "source: ${item.sourceName}" + text = stringResource(id = R.string.konfeature_plugin_item_source, item.sourceName) ) } if (item.editAvailable) { IconButton( modifier = Modifier.align(alignment = Alignment.CenterVertically), - onClick = { onEditClciked.invoke(item.key, item.value, item.isDebugSource) }) { + onClick = { onEditClick.invoke(item.key, item.value, item.isDebugSource) }) { Icon(Icons.Outlined.Edit, contentDescription = null) } } diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt index f7b1aa2f..4c5a44eb 100644 --- a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt @@ -4,8 +4,11 @@ import androidx.compose.ui.graphics.Color import androidx.lifecycle.viewModelScope import com.redmadrobot.debug.core.internal.PluginViewModel import com.redmadrobot.debug.plugin.konfeature.KonfeatureDebugPanelInterceptor +import com.redmadrobot.debug.plugin.konfeature.ui.data.EditDialogState import com.redmadrobot.debug.plugin.konfeature.ui.data.KonfeatureItem import com.redmadrobot.debug.plugin.konfeature.ui.data.KonfeatureViewState +import com.redmadrobot.konfeature.FeatureConfigSpec +import com.redmadrobot.konfeature.FeatureValueSpec import com.redmadrobot.konfeature.Konfeature import com.redmadrobot.konfeature.source.FeatureValueSource import kotlinx.coroutines.Dispatchers @@ -30,10 +33,7 @@ internal class KonfeatureViewModel( init { debugPanelInterceptor .valuesFlow - .onEach { - val items = withContext(Dispatchers.Default) { konfeature.getItems() } - _state.update { it.copy(items = items) } - } + .onEach { updateItems() } .launchIn(viewModelScope) } @@ -49,80 +49,99 @@ internal class KonfeatureViewModel( } } - fun onHeaderClicked(configName: String) { - _state.update { - val newCollapsedConfigs = if (configName in it.collapsedConfigs) { - it.collapsedConfigs - configName + fun onConfigHeaderClick(configName: String) { + _state.update { state -> + val newCollapsedConfigs = if (configName in state.collapsedConfigs) { + state.collapsedConfigs - configName } else { - it.collapsedConfigs + configName + state.collapsedConfigs + configName } - it.copy(collapsedConfigs = newCollapsedConfigs) + state.copy(collapsedConfigs = newCollapsedConfigs) } } - fun onRefreshClicked() { - viewModelScope.launch { - val items = withContext(Dispatchers.Default) { konfeature.getItems() } - _state.update { it.copy(items = items) } - } + fun onRefreshClick() { + viewModelScope.launch { updateItems() } } - fun onResetClicked() { + fun onResetAllClick() { viewModelScope.launch { - debugPanelInterceptor.resetAll() + debugPanelInterceptor.resetAllValues() } } - fun onCollapseAllClicked() { - _state.update { - val collapsedConfigs = it.items + fun onCollapseAllClick() { + _state.update { state -> + val collapsedConfigs = state.items .asSequence() .filterIsInstance(KonfeatureItem.Config::class.java) .map { it.name } .toSet() - it.copy(collapsedConfigs = collapsedConfigs) + state.copy(collapsedConfigs = collapsedConfigs) } } - private fun Konfeature.getItems(): List { - val items = mutableListOf() - - spec.forEach { config -> - items.add( - KonfeatureItem.Config( - name = config.name, - description = config.description - ) - ) - - config.values.forEach { value -> - val configValue = getValue(value) - - val sourceColor = when (configValue.source) { - FeatureValueSource.Default -> Color.Gray - is FeatureValueSource.Interceptor -> Color.Red - is FeatureValueSource.Source -> Color.Green - } - - items.add( - KonfeatureItem.Value( - key = value.key, - value = configValue.value, - configName = config.name, - sourceName = getSourceName(configValue.source), - sourceColor = sourceColor, - description = value.description, - isDebugSource = configValue.source.isDebugSource(), + fun onEditClick(key: String, value: Any, isDebugSource: Boolean) { + _state.update { it.copy(editDialogState = EditDialogState(key, value, isDebugSource)) } + } + + fun onEditDialogCloseClik() { + _state.update { it.copy(editDialogState = null) } + } + + private suspend fun updateItems() { + val items = withContext(Dispatchers.IO) { getItems(konfeature) } + _state.update { it.copy(items = items) } + } + + private fun getItems(konfeature: Konfeature): List { + return konfeature.spec.fold(mutableListOf()) { acc, configSpec -> + acc.apply { + add(createConfigItem(configSpec)) + addAll(configSpec.values.map { valueSpec -> + createConfigValueItem( + configName = configSpec.name, + valueSpec = valueSpec, + konfeature = konfeature ) - ) + }) } } + } + + private fun createConfigItem(config: FeatureConfigSpec): KonfeatureItem.Config { + return KonfeatureItem.Config( + name = config.name, + description = config.description + ) + } + + private fun createConfigValueItem( + configName: String, + valueSpec: FeatureValueSpec, + konfeature: Konfeature, + ): KonfeatureItem.Value { + val configValue = konfeature.getValue(valueSpec) + + val sourceColor = when (configValue.source) { + FeatureValueSource.Default -> Color.Gray + is FeatureValueSource.Interceptor -> Color.Red + is FeatureValueSource.Source -> Color.Green + } - return items + return KonfeatureItem.Value( + key = valueSpec.key, + value = configValue.value, + configName = configName, + sourceName = getSourceName(configValue.source), + sourceColor = sourceColor, + description = valueSpec.description, + isDebugSource = isDebugSource(configValue.source), + ) } - private fun FeatureValueSource.isDebugSource(): Boolean { - return (this as? FeatureValueSource.Interceptor)?.name == debugPanelInterceptor.name + private fun isDebugSource(source: FeatureValueSource): Boolean { + return (source as? FeatureValueSource.Interceptor)?.name == debugPanelInterceptor.name } private fun getSourceName(source: FeatureValueSource): String { diff --git a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt index a589ac7a..7f978311 100644 --- a/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt +++ b/plugins/konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt @@ -2,5 +2,6 @@ package com.redmadrobot.debug.plugin.konfeature.ui.data internal data class KonfeatureViewState( val collapsedConfigs: Set = emptySet(), - val items: List = emptyList() + val items: List = emptyList(), + val editDialogState: EditDialogState? = null ) diff --git a/plugins/konfeature/src/main/res/values/strings.xml b/plugins/konfeature/src/main/res/values/strings.xml new file mode 100644 index 00000000..94797008 --- /dev/null +++ b/plugins/konfeature/src/main/res/values/strings.xml @@ -0,0 +1,21 @@ + + + Edit: %s + + Boolean value: + Long value: + Double value: + String value: + + key: %s + source: %s + value: %s + + Reset + Save + Close + + Refresh + Collapse All + Reset All + diff --git a/sample/src/main/kotlin/com/redmadrobot/debug_sample/storage/TestKonfeatureProvider.kt b/sample/src/main/kotlin/com/redmadrobot/debug_sample/storage/TestKonfeatureProvider.kt index b7329d15..c9a58a5c 100644 --- a/sample/src/main/kotlin/com/redmadrobot/debug_sample/storage/TestKonfeatureProvider.kt +++ b/sample/src/main/kotlin/com/redmadrobot/debug_sample/storage/TestKonfeatureProvider.kt @@ -15,6 +15,7 @@ internal object TestKonfeatureProvider { return konfeature { register(FeatureConfig1()) register(FeatureConfig2()) + register(FeatureConfig3()) addInterceptor(debugPanelInterceptor) addSource(object : FeatureSource { override val name: String = "SampleFeatureSource" @@ -87,4 +88,51 @@ internal object TestKonfeatureProvider { ) } + class FeatureConfig3 : FeatureConfig( + name = "FeatureConfig3", + description = "feature config number three" + ) { + val booleanFeature5 by toggle( + key = "boolean_feature_5", + description = "boolean feature five", + defaultValue = false, + ) + + val booleanFeature6 by toggle( + key = "boolean_feature_6", + description = "boolean feature six", + defaultValue = false, + ) + + val booleanFeature7 by toggle( + key = "boolean_feature_7", + description = "boolean feature seven", + defaultValue = false, + ) + + val booleanFeature8 by toggle( + key = "boolean_feature_8", + description = "boolean feature eight", + defaultValue = false, + ) + + val booleanFeature9 by toggle( + key = "boolean_feature_9", + description = "boolean feature nine", + defaultValue = false, + ) + + val booleanFeature10 by toggle( + key = "boolean_feature_10", + description = "boolean feature ten", + defaultValue = false, + ) + + val booleanFeature11 by toggle( + key = "boolean_feature_11", + description = "boolean feature eleven", + defaultValue = false, + ) + } + }