Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-27 19:01:12 +04:00
parent e3d2e7f2e1
commit 4fbb427a1d
16 changed files with 367 additions and 125 deletions

View file

@ -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,
)

View file

@ -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
*

View file

@ -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)

View file

@ -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 }
}

View file

@ -1,6 +0,0 @@
package com.tangem.core.configtoggle.feature.impl
internal object FeatureTogglesConstants {
const val LOCAL_CONFIG_PATH: String = "configs/feature_toggles_config"
}

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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,
)
}

View file

@ -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) {

View file

@ -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,
)

View file

@ -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>,
)

View file

@ -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,
)

View file

@ -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", ""),
}
}

View file

@ -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),
),
)
}

View file

@ -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)
}
}