Updated on 2026-08-14
This commit is contained in:
parent
e3d2e7f2e1
commit
4fbb427a1d
16 changed files with 367 additions and 125 deletions
|
|
@ -0,0 +1,14 @@
|
|||
package com.tangem.core.configtoggle.feature
|
||||
|
||||
/**
|
||||
* Feature toggle information exposed by [MutableFeatureTogglesManager].
|
||||
*
|
||||
* @property name raw toggle name
|
||||
* @property version release version from local config ("undefined" for permanently disabled toggles)
|
||||
* @property isEnabled current toggle state (may differ from default if overridden locally)
|
||||
*/
|
||||
data class FeatureToggleInfo(
|
||||
val name: String,
|
||||
val version: String,
|
||||
val isEnabled: Boolean,
|
||||
)
|
||||
|
|
@ -2,6 +2,9 @@ package com.tangem.core.configtoggle.feature
|
|||
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
|
||||
/** Version value marking a feature toggle that has no planned release (permanently disabled). */
|
||||
const val DISABLED_FEATURE_TOGGLE_VERSION = "undefined"
|
||||
|
||||
/**
|
||||
* Component for getting information about the availability of feature toggles
|
||||
*
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ interface MutableFeatureTogglesManager : FeatureTogglesManager {
|
|||
/** Check if the current state of the feature toggles matches the local config state. */
|
||||
fun isMatchLocalConfig(): Boolean
|
||||
|
||||
/** Get feature toggles */
|
||||
fun getFeatureToggles(): Map<String, Boolean>
|
||||
/** Get feature toggles with version info */
|
||||
fun getFeatureToggles(): List<FeatureToggleInfo>
|
||||
|
||||
/** Change availability [isEnabled] of toggle with name [name] */
|
||||
suspend fun changeToggle(name: String, isEnabled: Boolean)
|
||||
|
|
|
|||
|
|
@ -2,14 +2,16 @@ package com.tangem.core.configtoggle.feature.impl
|
|||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureToggleInfo
|
||||
import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager
|
||||
import com.tangem.core.configtoggle.feature.provider.FeatureTogglesProvider
|
||||
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
|
||||
import com.tangem.core.configtoggle.utils.defineTogglesAvailability
|
||||
import com.tangem.core.configtoggle.utils.toTableString
|
||||
import com.tangem.core.configtoggle.version.VersionProvider
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
/**
|
||||
* Feature toggles manager implementation in dev or mocked build
|
||||
|
|
@ -24,54 +26,62 @@ internal class DevFeatureTogglesManager(
|
|||
private val featureTogglesLocalStorage: LocalTogglesStorage,
|
||||
) : MutableFeatureTogglesManager {
|
||||
|
||||
private val fileFeatureTogglesMap: Map<String, Boolean> = getFileFeatureToggles()
|
||||
private val fileFeatureToggles: List<FeatureToggleInfo> = buildFileFeatureToggles()
|
||||
|
||||
@Suppress("DoubleMutabilityForCollection")
|
||||
private var featureTogglesMap: MutableMap<String, Boolean> by Delegates.notNull()
|
||||
private val currentToggles: MutableStateFlow<List<FeatureToggleInfo>> = MutableStateFlow(buildInitialToggles())
|
||||
|
||||
init {
|
||||
val savedFeatureToggles = runBlocking { featureTogglesLocalStorage.getSyncOrEmpty() }
|
||||
|
||||
featureTogglesMap = fileFeatureTogglesMap
|
||||
.mapValues { resultToggle ->
|
||||
savedFeatureToggles[resultToggle.key] ?: resultToggle.value
|
||||
}
|
||||
.toMutableMap()
|
||||
}
|
||||
|
||||
override fun isFeatureEnabled(toggle: FeatureToggles): Boolean = featureTogglesMap[toggle.rawName] == true
|
||||
override fun isFeatureEnabled(toggle: FeatureToggles): Boolean =
|
||||
currentToggles.value.any { it.name == toggle.rawName && it.isEnabled }
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
|
||||
fun isFeatureEnabledByName(name: String): Boolean = featureTogglesMap[name] == true
|
||||
fun isFeatureEnabledByName(name: String): Boolean = currentToggles.value.any { it.name == name && it.isEnabled }
|
||||
|
||||
override fun getFeatureToggles(): Map<String, Boolean> = featureTogglesMap
|
||||
override fun getFeatureToggles(): List<FeatureToggleInfo> = currentToggles.value
|
||||
|
||||
override fun isMatchLocalConfig(): Boolean = featureTogglesMap == fileFeatureTogglesMap
|
||||
override fun isMatchLocalConfig(): Boolean =
|
||||
currentToggles.value.associateBy { it.name } == fileFeatureToggles.associateBy { it.name }
|
||||
|
||||
override suspend fun changeToggle(name: String, isEnabled: Boolean) {
|
||||
featureTogglesMap[name] ?: return
|
||||
featureTogglesMap[name] = isEnabled
|
||||
featureTogglesLocalStorage.store(value = featureTogglesMap)
|
||||
if (currentToggles.value.none { it.name == name }) return
|
||||
currentToggles.update { toggles ->
|
||||
toggles.map { toggle ->
|
||||
if (toggle.name == name) toggle.copy(isEnabled = isEnabled) else toggle
|
||||
}
|
||||
}
|
||||
featureTogglesLocalStorage.store(value = currentToggles.value.toAvailabilityMap())
|
||||
}
|
||||
|
||||
override suspend fun recoverLocalConfig() {
|
||||
featureTogglesMap = fileFeatureTogglesMap.toMutableMap()
|
||||
featureTogglesLocalStorage.store(value = fileFeatureTogglesMap)
|
||||
currentToggles.value = fileFeatureToggles
|
||||
featureTogglesLocalStorage.store(value = currentToggles.value.toAvailabilityMap())
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return featureTogglesMap.toTableString(tableName = this@DevFeatureTogglesManager::class.java.simpleName)
|
||||
}
|
||||
|
||||
private fun getFileFeatureToggles(): Map<String, Boolean> {
|
||||
val appVersion = versionProvider.get()
|
||||
|
||||
return featureTogglesProvider.getToggles()
|
||||
.defineTogglesAvailability(appVersion = appVersion)
|
||||
return currentToggles.value.toAvailabilityMap()
|
||||
.toTableString(tableName = this@DevFeatureTogglesManager::class.java.simpleName)
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
|
||||
fun setFeatureToggles(map: MutableMap<String, Boolean>) {
|
||||
featureTogglesMap = map
|
||||
currentToggles.value = map.map { (name, isEnabled) ->
|
||||
val version = fileFeatureToggles.firstOrNull { it.name == name }?.version.orEmpty()
|
||||
FeatureToggleInfo(name = name, version = version, isEnabled = isEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildInitialToggles(): List<FeatureToggleInfo> {
|
||||
val savedFeatureToggles = runBlocking { featureTogglesLocalStorage.getSyncOrEmpty() }
|
||||
return fileFeatureToggles.map { it.copy(isEnabled = savedFeatureToggles[it.name] ?: it.isEnabled) }
|
||||
}
|
||||
|
||||
private fun buildFileFeatureToggles(): List<FeatureToggleInfo> {
|
||||
val rawToggles = featureTogglesProvider.getToggles()
|
||||
val availability = rawToggles.defineTogglesAvailability(appVersion = versionProvider.get())
|
||||
return rawToggles.map { (name, version) ->
|
||||
FeatureToggleInfo(name = name, version = version, isEnabled = availability.getValue(name))
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<FeatureToggleInfo>.toAvailabilityMap(): Map<String, Boolean> =
|
||||
associate { it.name to it.isEnabled }
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
package com.tangem.core.configtoggle.feature.impl
|
||||
|
||||
internal object FeatureTogglesConstants {
|
||||
|
||||
const val LOCAL_CONFIG_PATH: String = "configs/feature_toggles_config"
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import com.tangem.utils.logging.TangemLogger
|
|||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class Version private constructor(value: String) : Comparable<Version> {
|
||||
class Version private constructor(value: String) : Comparable<Version> {
|
||||
|
||||
private val major: Int
|
||||
private val minor: Int
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.tangem.core.configtoggle.version
|
||||
|
||||
import com.tangem.core.configtoggle.feature.DISABLED_FEATURE_TOGGLE_VERSION
|
||||
|
||||
/**
|
||||
* Version contract to evaluate availability of feature toggle
|
||||
*
|
||||
|
|
@ -7,8 +9,6 @@ package com.tangem.core.configtoggle.version
|
|||
*/
|
||||
internal object VersionAvailabilityContract {
|
||||
|
||||
private const val DISABLED_FEATURE_TOGGLE_VERSION = "undefined"
|
||||
|
||||
/** Evaluate availability of feature toggles using [currentVersion] and [localVersion] */
|
||||
operator fun invoke(currentVersion: String, localVersion: String): Boolean {
|
||||
if (localVersion == DISABLED_FEATURE_TOGGLE_VERSION) return false
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.core.configtoggle.manager
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.core.configtoggle.feature.FeatureToggleInfo
|
||||
import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
|
||||
import com.tangem.core.configtoggle.feature.provider.FeatureTogglesProvider
|
||||
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
|
||||
|
|
@ -28,6 +29,14 @@ internal class DevFeatureTogglesManagerTest {
|
|||
version != "undefined" && !appVersion.isNullOrEmpty()
|
||||
}
|
||||
|
||||
private fun Map<String, Boolean>.toToggleInfoList(): List<FeatureToggleInfo> = map { (name, isEnabled) ->
|
||||
FeatureToggleInfo(
|
||||
name = name,
|
||||
version = testToggles.getValue(name),
|
||||
isEnabled = isEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class Initialization {
|
||||
|
|
@ -60,7 +69,7 @@ internal class DevFeatureTogglesManagerTest {
|
|||
|
||||
// Assert
|
||||
val expected = savedToggles
|
||||
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
|
||||
Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
|
||||
|
||||
coVerifyOrder {
|
||||
versionProvider.get()
|
||||
|
|
@ -86,7 +95,7 @@ internal class DevFeatureTogglesManagerTest {
|
|||
|
||||
// Assert
|
||||
val expected = savedToggles
|
||||
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
|
||||
Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
|
||||
|
||||
coVerifyOrder {
|
||||
versionProvider.get()
|
||||
|
|
@ -112,7 +121,7 @@ internal class DevFeatureTogglesManagerTest {
|
|||
|
||||
// Assert
|
||||
val expected = savedToggles
|
||||
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
|
||||
Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
|
||||
|
||||
coVerifyOrder {
|
||||
versionProvider.get()
|
||||
|
|
@ -159,7 +168,7 @@ internal class DevFeatureTogglesManagerTest {
|
|||
|
||||
// Assert
|
||||
val expected = getExpectedFileToggles(appVersion)
|
||||
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
|
||||
Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
|
||||
|
||||
coVerifyOrder {
|
||||
versionProvider.get()
|
||||
|
|
@ -185,7 +194,7 @@ internal class DevFeatureTogglesManagerTest {
|
|||
|
||||
// Assert
|
||||
val expected = fileToggles
|
||||
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
|
||||
Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
|
||||
|
||||
coVerifyOrder {
|
||||
versionProvider.get()
|
||||
|
|
@ -302,6 +311,59 @@ internal class DevFeatureTogglesManagerTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isMatchLocalConfig is true when toggles match but order differs`() = runTest {
|
||||
// Arrange
|
||||
every { versionProvider.get() } returns "1.0.0"
|
||||
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap()
|
||||
|
||||
// Same toggles and values as the local config, but in reversed order
|
||||
val reorderedToggles = getExpectedFileToggles(appVersion = "1.0.0")
|
||||
.entries.reversed()
|
||||
.associate { it.key to it.value }
|
||||
.toMutableMap()
|
||||
val manager = DevFeatureTogglesManager(
|
||||
versionProvider,
|
||||
featureTogglesProvider,
|
||||
featureTogglesLocalStorage,
|
||||
).apply {
|
||||
setFeatureToggles(reorderedToggles)
|
||||
}
|
||||
|
||||
// Act
|
||||
val actual = manager.isMatchLocalConfig()
|
||||
|
||||
// Assert
|
||||
Truth.assertThat(actual).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isMatchLocalConfig is false when a value differs despite reversed order`() = runTest {
|
||||
// Arrange
|
||||
every { versionProvider.get() } returns "1.0.0"
|
||||
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap()
|
||||
|
||||
// Reversed order AND one toggle value flipped → must not match
|
||||
val reorderedChangedToggles = getExpectedFileToggles(appVersion = "1.0.0")
|
||||
.entries.reversed()
|
||||
.associate { it.key to it.value }
|
||||
.toMutableMap()
|
||||
.apply { this["ENABLED_TOGGLE"] = false }
|
||||
val manager = DevFeatureTogglesManager(
|
||||
versionProvider,
|
||||
featureTogglesProvider,
|
||||
featureTogglesLocalStorage,
|
||||
).apply {
|
||||
setFeatureToggles(reorderedChangedToggles)
|
||||
}
|
||||
|
||||
// Act
|
||||
val actual = manager.isMatchLocalConfig()
|
||||
|
||||
// Assert
|
||||
Truth.assertThat(actual).isFalse()
|
||||
}
|
||||
|
||||
private fun provideTestModels(): List<IsMatchLocalConfigModel> {
|
||||
val appVersion = "1.0.0"
|
||||
val fileToggles = getExpectedFileToggles(appVersion)
|
||||
|
|
@ -369,7 +431,7 @@ internal class DevFeatureTogglesManagerTest {
|
|||
|
||||
// Assert
|
||||
val expected = fileToggles + savedToggles
|
||||
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
|
||||
Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
|
||||
|
||||
coVerifyOrder {
|
||||
versionProvider.get()
|
||||
|
|
@ -405,7 +467,7 @@ internal class DevFeatureTogglesManagerTest {
|
|||
val actual = manager.getFeatureToggles()
|
||||
|
||||
// Assert
|
||||
Truth.assertThat(actual).containsExactlyEntriesIn(model.expectedToggles)
|
||||
Truth.assertThat(actual).containsExactlyElementsIn(model.expectedToggles.toToggleInfoList())
|
||||
|
||||
coVerifyOrder {
|
||||
versionProvider.get()
|
||||
|
|
@ -478,7 +540,7 @@ internal class DevFeatureTogglesManagerTest {
|
|||
|
||||
// Assert
|
||||
val expected = getExpectedFileToggles(appVersion)
|
||||
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
|
||||
Truth.assertThat(actual).containsExactlyElementsIn(expected.toToggleInfoList())
|
||||
|
||||
coVerifyOrder {
|
||||
versionProvider.get()
|
||||
|
|
|
|||
|
|
@ -20,4 +20,14 @@ fun DividerWithPadding(start: Dp = 0.dp, end: Dp = 0.dp, top: Dp = 0.dp, bottom:
|
|||
thickness = TangemTheme.dimens.size1,
|
||||
color = TangemTheme.colors.stroke.primary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DividerWithPadding(horizontal: Dp = 0.dp, vertical: Dp = 0.dp) {
|
||||
DividerWithPadding(
|
||||
start = horizontal,
|
||||
end = horizontal,
|
||||
top = vertical,
|
||||
bottom = vertical,
|
||||
)
|
||||
}
|
||||
|
|
@ -126,7 +126,8 @@ internal class TesterActivity : ComposeActivity() {
|
|||
setupInteractions(innerTesterRouter, appFinisher)
|
||||
}
|
||||
|
||||
FeatureTogglesScreen(state = viewModel.uiState)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
FeatureTogglesScreen(state = state)
|
||||
}
|
||||
|
||||
composable(route = TesterScreen.ENVIRONMENTS_TOGGLES.name) {
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
package com.tangem.feature.tester.presentation.featuretoggles.models
|
||||
|
||||
/**
|
||||
* Presentation model of feature toggle
|
||||
*
|
||||
* @property name name
|
||||
* @property isEnabled availability
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal data class TesterFeatureToggle(
|
||||
val name: String,
|
||||
val isEnabled: Boolean,
|
||||
)
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.tangem.feature.tester.presentation.featuretoggles.state
|
||||
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* Group of feature toggles sharing the same release [status]
|
||||
*
|
||||
* @property status release status common for all [toggles]
|
||||
* @property toggles toggles of this group
|
||||
*/
|
||||
internal data class FeatureToggleGroupUM(
|
||||
val status: TesterFeatureToggleUM.Status,
|
||||
val toggles: ImmutableList<TesterFeatureToggleUM>,
|
||||
)
|
||||
|
|
@ -1,22 +1,21 @@
|
|||
package com.tangem.feature.tester.presentation.featuretoggles.state
|
||||
|
||||
import com.tangem.feature.tester.presentation.common.components.appbar.TopBarWithRefreshUM
|
||||
import com.tangem.feature.tester.presentation.featuretoggles.models.TesterFeatureToggle
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* Content state of feature toggles screen
|
||||
*
|
||||
* @property topBar top bar state
|
||||
* @property topBar top bar state
|
||||
* @property appVersion app version
|
||||
* @property featureToggles feature toggles list
|
||||
* @property featureToggleGroups feature toggles grouped by release status
|
||||
* @property onToggleValueChange the lambda to be invoked when switch button is pressed
|
||||
* @property onRestartAppClick the lambda to be invoked when restart app button is pressed
|
||||
*/
|
||||
internal data class FeatureTogglesContentState(
|
||||
internal data class FeatureTogglesScreenUM(
|
||||
val topBar: TopBarWithRefreshUM,
|
||||
val appVersion: String,
|
||||
val featureToggles: ImmutableList<TesterFeatureToggle>,
|
||||
val featureToggleGroups: ImmutableList<FeatureToggleGroupUM>,
|
||||
val onToggleValueChange: (String, Boolean) -> Unit,
|
||||
val onRestartAppClick: () -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.tangem.feature.tester.presentation.featuretoggles.state
|
||||
|
||||
/**
|
||||
* Presentation model of feature toggle
|
||||
*
|
||||
* @property name name
|
||||
* @property version release version ("undefined" for permanently disabled toggles)
|
||||
* @property status release status relative to the current app version
|
||||
* @property isEnabled availability
|
||||
*/
|
||||
internal data class TesterFeatureToggleUM(
|
||||
val name: String,
|
||||
val version: String,
|
||||
val status: Status,
|
||||
val isEnabled: Boolean,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Release status relative to the current app version.
|
||||
*
|
||||
* Declaration order defines the display order of groups on the screen
|
||||
* (most interesting first, least interesting last).
|
||||
*/
|
||||
enum class Status(val title: String, val emoji: String) {
|
||||
PENDING("Planned for current release", "⏳"),
|
||||
PLANNED("Planned for next releases", "🗓️"),
|
||||
UNDEFINED("Not planned yet", "❓"),
|
||||
RELEASED("Released", "✅"),
|
||||
}
|
||||
}
|
||||
|
|
@ -13,8 +13,10 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastForEachIndexed
|
||||
import com.tangem.core.ui.components.PrimaryButton
|
||||
import com.tangem.core.ui.components.TangemSwitch
|
||||
import com.tangem.core.ui.components.divider.DividerWithPadding
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
|
|
@ -25,9 +27,12 @@ import com.tangem.feature.tester.presentation.common.components.appbar.TopBarWit
|
|||
import com.tangem.feature.tester.presentation.common.components.appbar.TopBarWithRefreshUM
|
||||
import com.tangem.feature.tester.presentation.common.components.notification.CustomSetupNotification
|
||||
import com.tangem.feature.tester.presentation.common.components.notification.InitialSetupNotification
|
||||
import com.tangem.feature.tester.presentation.featuretoggles.models.TesterFeatureToggle
|
||||
import com.tangem.feature.tester.presentation.featuretoggles.state.FeatureTogglesContentState
|
||||
import com.tangem.feature.tester.presentation.featuretoggles.state.FeatureToggleGroupUM
|
||||
import com.tangem.feature.tester.presentation.featuretoggles.state.TesterFeatureToggleUM
|
||||
import com.tangem.feature.tester.presentation.featuretoggles.state.FeatureTogglesScreenUM
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
/**
|
||||
* Screen with feature toggles list
|
||||
|
|
@ -36,7 +41,7 @@ import kotlinx.collections.immutable.persistentListOf
|
|||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
internal fun FeatureTogglesScreen(state: FeatureTogglesContentState) {
|
||||
internal fun FeatureTogglesScreen(state: FeatureTogglesScreenUM) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -70,15 +75,27 @@ internal fun FeatureTogglesScreen(state: FeatureTogglesContentState) {
|
|||
}
|
||||
}
|
||||
|
||||
items(
|
||||
items = state.featureToggles,
|
||||
key = TesterFeatureToggle::name,
|
||||
contentType = { "feature_toggle_item" },
|
||||
) { featureToggle ->
|
||||
FeatureToggleItem(
|
||||
toggle = featureToggle,
|
||||
onCheckedChange = { isChange -> state.onToggleValueChange(featureToggle.name, isChange) },
|
||||
)
|
||||
state.featureToggleGroups.fastForEachIndexed { index, group ->
|
||||
if (index != 0) {
|
||||
item(key = "divider_${group.status.name}", contentType = "group_divider") {
|
||||
DividerWithPadding(horizontal = 16.dp, vertical = 8.dp)
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "header_${group.status.name}", contentType = "group_header") {
|
||||
GroupHeader(title = "${group.status.emoji} ${group.status.title}")
|
||||
}
|
||||
|
||||
items(
|
||||
items = group.toggles,
|
||||
key = TesterFeatureToggleUM::name,
|
||||
contentType = { "feature_toggle_item" },
|
||||
) { featureToggle ->
|
||||
FeatureToggleItem(
|
||||
toggle = featureToggle,
|
||||
onCheckedChange = { isChange -> state.onToggleValueChange(featureToggle.name, isChange) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
|
|
@ -94,7 +111,22 @@ internal fun FeatureTogglesScreen(state: FeatureTogglesContentState) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureToggleItem(toggle: TesterFeatureToggle, onCheckedChange: (Boolean) -> Unit) {
|
||||
private fun GroupHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = TangemTheme.dimens.spacing18,
|
||||
vertical = TangemTheme.dimens.spacing8,
|
||||
),
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
style = TangemTheme.typography.h3,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureToggleItem(toggle: TesterFeatureToggleUM, onCheckedChange: (Boolean) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -105,14 +137,35 @@ private fun FeatureToggleItem(toggle: TesterFeatureToggle, onCheckedChange: (Boo
|
|||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = toggle.name,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
style = TangemTheme.typography.body2,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = toggle.name,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
style = TangemTheme.typography.body2,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = toggle.version,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
style = TangemTheme.typography.caption2,
|
||||
)
|
||||
if (toggle.status == TesterFeatureToggleUM.Status.RELEASED && !toggle.isEnabled) {
|
||||
Text(
|
||||
text = "Disabled",
|
||||
color = TangemTheme.colors.text.warning,
|
||||
maxLines = 1,
|
||||
style = TangemTheme.typography.caption2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TangemSwitch(onCheckedChange = onCheckedChange, checked = toggle.isEnabled)
|
||||
}
|
||||
|
|
@ -126,7 +179,7 @@ private fun PreviewFeatureTogglesScreen() {
|
|||
var isCustomSetup by remember { mutableStateOf(value = true) }
|
||||
|
||||
FeatureTogglesScreen(
|
||||
state = FeatureTogglesContentState(
|
||||
state = FeatureTogglesScreenUM(
|
||||
topBar = TopBarWithRefreshUM(
|
||||
titleResId = R.string.feature_toggles,
|
||||
onBackClick = {},
|
||||
|
|
@ -135,14 +188,34 @@ private fun PreviewFeatureTogglesScreen() {
|
|||
onRefreshClick = { isCustomSetup = false },
|
||||
),
|
||||
),
|
||||
appVersion = "5.15",
|
||||
featureToggles = persistentListOf(
|
||||
TesterFeatureToggle(name = "FEATURE_TOGGLE_1", isEnabled = true),
|
||||
TesterFeatureToggle(name = "FEATURE_TOGGLE_2", isEnabled = false),
|
||||
),
|
||||
appVersion = "5.38",
|
||||
featureToggleGroups = previewFeatureToggleGroups(),
|
||||
onToggleValueChange = { _, _ -> isCustomSetup = true },
|
||||
onRestartAppClick = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun previewFeatureToggleGroups(): ImmutableList<FeatureToggleGroupUM> {
|
||||
fun group(status: TesterFeatureToggleUM.Status, vararg toggles: Triple<String, String, Boolean>) =
|
||||
FeatureToggleGroupUM(
|
||||
status = status,
|
||||
toggles = toggles
|
||||
.map { (name, version, isEnabled) ->
|
||||
TesterFeatureToggleUM(name = name, version = version, status = status, isEnabled = isEnabled)
|
||||
}
|
||||
.toImmutableList(),
|
||||
)
|
||||
|
||||
return persistentListOf(
|
||||
group(TesterFeatureToggleUM.Status.PENDING, Triple("FEATURE_TOGGLE_2", "5.38", false)),
|
||||
group(TesterFeatureToggleUM.Status.PLANNED, Triple("FEATURE_TOGGLE_1", "5.40", true)),
|
||||
group(TesterFeatureToggleUM.Status.UNDEFINED, Triple("FEATURE_TOGGLE_4", "undefined", false)),
|
||||
group(
|
||||
TesterFeatureToggleUM.Status.RELEASED,
|
||||
Triple("FEATURE_TOGGLE_3", "5.15", true),
|
||||
Triple("FEATURE_TOGGLE_3_OFF", "5.15", false),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,23 +1,27 @@
|
|||
package com.tangem.feature.tester.presentation.featuretoggles.viewmodels
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tangem.core.configtoggle.feature.DISABLED_FEATURE_TOGGLE_VERSION
|
||||
import com.tangem.core.configtoggle.feature.FeatureToggleInfo
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager
|
||||
import com.tangem.core.configtoggle.version.Version
|
||||
import com.tangem.core.navigation.finisher.AppFinisher
|
||||
import com.tangem.feature.tester.impl.R
|
||||
import com.tangem.feature.tester.presentation.common.components.appbar.TopBarWithRefreshUM
|
||||
import com.tangem.feature.tester.presentation.featuretoggles.models.TesterFeatureToggle
|
||||
import com.tangem.feature.tester.presentation.featuretoggles.state.FeatureTogglesContentState
|
||||
import com.tangem.feature.tester.presentation.featuretoggles.state.FeatureToggleGroupUM
|
||||
import com.tangem.feature.tester.presentation.featuretoggles.state.TesterFeatureToggleUM
|
||||
import com.tangem.feature.tester.presentation.featuretoggles.state.FeatureTogglesScreenUM
|
||||
import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter
|
||||
import com.tangem.utils.info.AppInfoProvider
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -35,9 +39,11 @@ internal class FeatureTogglesViewModel @Inject constructor(
|
|||
private val appInfoProvider: AppInfoProvider,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Current ui state */
|
||||
var uiState: FeatureTogglesContentState by mutableStateOf(initState())
|
||||
private set
|
||||
// Declared before `state` so it is initialized before `initState()` runs in the field initializer.
|
||||
private val appVersion: Version? = Version.create(appInfoProvider.appVersion)
|
||||
|
||||
val state: StateFlow<FeatureTogglesScreenUM>
|
||||
field = MutableStateFlow(initState())
|
||||
|
||||
private val mutableFeatureTogglesManager: MutableFeatureTogglesManager
|
||||
get() = requireNotNull(featureTogglesManager as? MutableFeatureTogglesManager) {
|
||||
|
|
@ -46,17 +52,19 @@ internal class FeatureTogglesViewModel @Inject constructor(
|
|||
|
||||
/** Setup navigation state property by router [router] and provides app restart method by [appFinisher] */
|
||||
fun setupInteractions(router: InnerTesterRouter, appFinisher: AppFinisher) {
|
||||
uiState = uiState.copy(
|
||||
topBar = uiState.topBar.copy(onBackClick = router::back),
|
||||
onRestartAppClick = appFinisher::restart,
|
||||
)
|
||||
state.update { current ->
|
||||
current.copy(
|
||||
topBar = current.topBar.copy(onBackClick = router::back),
|
||||
onRestartAppClick = appFinisher::restart,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initState(): FeatureTogglesContentState {
|
||||
return FeatureTogglesContentState(
|
||||
private fun initState(): FeatureTogglesScreenUM {
|
||||
return FeatureTogglesScreenUM(
|
||||
topBar = getConfigSetupState(isPrimarySetup = true),
|
||||
appVersion = appInfoProvider.appVersion,
|
||||
featureToggles = mutableFeatureTogglesManager.getTesterFeatureToggles(),
|
||||
featureToggleGroups = mutableFeatureTogglesManager.getTesterFeatureToggleGroups(),
|
||||
onToggleValueChange = ::onToggleValueChange,
|
||||
onRestartAppClick = {},
|
||||
)
|
||||
|
|
@ -75,8 +83,9 @@ internal class FeatureTogglesViewModel @Inject constructor(
|
|||
),
|
||||
)
|
||||
} else {
|
||||
uiState.topBar.copy(
|
||||
refreshButton = uiState.topBar.refreshButton.copy(isVisible = !isMatchLocalConfig),
|
||||
val topBar = state.value.topBar
|
||||
topBar.copy(
|
||||
refreshButton = topBar.refreshButton.copy(isVisible = !isMatchLocalConfig),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -85,12 +94,14 @@ internal class FeatureTogglesViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
mutableFeatureTogglesManager.changeToggle(name = name, isEnabled = isEnabled)
|
||||
|
||||
uiState = uiState.copy(featureToggles = mutableFeatureTogglesManager.getTesterFeatureToggles())
|
||||
val groups = mutableFeatureTogglesManager.getTesterFeatureToggleGroups()
|
||||
state.update { it.copy(featureToggleGroups = groups) }
|
||||
|
||||
// delay for smoothly update animations
|
||||
delay(timeMillis = 300)
|
||||
|
||||
uiState = uiState.copy(topBar = getConfigSetupState(isPrimarySetup = false))
|
||||
val topBar = getConfigSetupState(isPrimarySetup = false)
|
||||
state.update { it.copy(topBar = topBar) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -98,17 +109,52 @@ internal class FeatureTogglesViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
mutableFeatureTogglesManager.recoverLocalConfig()
|
||||
|
||||
uiState = uiState.copy(
|
||||
topBar = getConfigSetupState(isPrimarySetup = false),
|
||||
featureToggles = mutableFeatureTogglesManager.getTesterFeatureToggles(),
|
||||
)
|
||||
val topBar = getConfigSetupState(isPrimarySetup = false)
|
||||
val groups = mutableFeatureTogglesManager.getTesterFeatureToggleGroups()
|
||||
state.update { it.copy(topBar = topBar, featureToggleGroups = groups) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableFeatureTogglesManager.getTesterFeatureToggles(): ImmutableList<TesterFeatureToggle> {
|
||||
return this
|
||||
.getFeatureToggles()
|
||||
.map { TesterFeatureToggle(it.key, it.value) }
|
||||
private fun MutableFeatureTogglesManager.getTesterFeatureToggleGroups(): ImmutableList<FeatureToggleGroupUM> {
|
||||
val togglesByStatus = getFeatureToggles()
|
||||
.sortedWith(featureToggleComparator)
|
||||
.map { info ->
|
||||
TesterFeatureToggleUM(
|
||||
name = info.name,
|
||||
version = info.version,
|
||||
status = statusOf(info.version),
|
||||
isEnabled = info.isEnabled,
|
||||
)
|
||||
}
|
||||
.groupBy(TesterFeatureToggleUM::status)
|
||||
|
||||
return TesterFeatureToggleUM.Status.entries
|
||||
.mapNotNull { status ->
|
||||
togglesByStatus[status]?.let { toggles ->
|
||||
FeatureToggleGroupUM(status = status, toggles = toggles.toImmutableList())
|
||||
}
|
||||
}
|
||||
.toImmutableList()
|
||||
}
|
||||
|
||||
private fun statusOf(toggleVersion: String): TesterFeatureToggleUM.Status {
|
||||
val toggle = parseToggleVersion(toggleVersion) ?: return TesterFeatureToggleUM.Status.UNDEFINED
|
||||
val app = appVersion ?: return TesterFeatureToggleUM.Status.UNDEFINED
|
||||
return when {
|
||||
toggle > app -> TesterFeatureToggleUM.Status.PLANNED
|
||||
toggle < app -> TesterFeatureToggleUM.Status.RELEASED
|
||||
else -> TesterFeatureToggleUM.Status.PENDING
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Descending by release version; toggles with no planned release ("undefined") sink to the bottom.
|
||||
private val featureToggleComparator: Comparator<FeatureToggleInfo> =
|
||||
compareByDescending<FeatureToggleInfo> { parseToggleVersion(it.version) }
|
||||
.thenBy { it.name }
|
||||
|
||||
// Avoids Version.create() (which logs on parse failure) for the "no planned release" sentinel.
|
||||
private fun parseToggleVersion(version: String): Version? =
|
||||
if (version == DISABLED_FEATURE_TOGGLE_VERSION) null else Version.create(version)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue