From 4fbb427a1dc2394df66e0ff26903aa78ed59969c Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 27 May 2026 19:01:12 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../configtoggle/feature/FeatureToggleInfo.kt | 14 ++ .../feature/FeatureTogglesManager.kt | 3 + .../feature/MutableFeatureTogglesManager.kt | 4 +- .../feature/impl/DevFeatureTogglesManager.kt | 74 +++++----- .../feature/impl/FeatureTogglesConstants.kt | 6 - .../core/configtoggle/version/Version.kt | 2 +- .../version/VersionAvailabilityContract.kt | 4 +- .../manager/DevFeatureTogglesManagerTest.kt | 78 +++++++++-- .../components/divider/DividerWithPadding.kt | 10 ++ .../tester/presentation/TesterActivity.kt | 3 +- .../models/TesterFeatureToggle.kt | 14 -- .../state/FeatureToggleGroupUM.kt | 14 ++ ...tentState.kt => FeatureTogglesScreenUM.kt} | 9 +- .../state/TesterFeatureToggleUM.kt | 30 +++++ .../featuretoggles/ui/FeatureTogglesScreen.kt | 127 ++++++++++++++---- .../viewmodels/FeatureTogglesViewModel.kt | 100 ++++++++++---- 16 files changed, 367 insertions(+), 125 deletions(-) create mode 100644 core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureToggleInfo.kt delete mode 100644 core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/FeatureTogglesConstants.kt delete mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/models/TesterFeatureToggle.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/FeatureToggleGroupUM.kt rename features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/{FeatureTogglesContentState.kt => FeatureTogglesScreenUM.kt} (68%) create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/TesterFeatureToggleUM.kt diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureToggleInfo.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureToggleInfo.kt new file mode 100644 index 0000000000..7f10dcd5ca --- /dev/null +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureToggleInfo.kt @@ -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, +) \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesManager.kt index 91fd8bc028..06a4e8b675 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesManager.kt @@ -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 * diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/MutableFeatureTogglesManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/MutableFeatureTogglesManager.kt index d1d09ced03..fef3b17bf0 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/MutableFeatureTogglesManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/MutableFeatureTogglesManager.kt @@ -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 + /** Get feature toggles with version info */ + fun getFeatureToggles(): List /** Change availability [isEnabled] of toggle with name [name] */ suspend fun changeToggle(name: String, isEnabled: Boolean) diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt index 07ab34a5f9..c9c93797a4 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt @@ -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 = getFileFeatureToggles() + private val fileFeatureToggles: List = buildFileFeatureToggles() - @Suppress("DoubleMutabilityForCollection") - private var featureTogglesMap: MutableMap by Delegates.notNull() + private val currentToggles: MutableStateFlow> = 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 = featureTogglesMap + override fun getFeatureToggles(): List = 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 { - 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) { - 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 { + val savedFeatureToggles = runBlocking { featureTogglesLocalStorage.getSyncOrEmpty() } + return fileFeatureToggles.map { it.copy(isEnabled = savedFeatureToggles[it.name] ?: it.isEnabled) } + } + + private fun buildFileFeatureToggles(): List { + 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.toAvailabilityMap(): Map = + associate { it.name to it.isEnabled } } \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/FeatureTogglesConstants.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/FeatureTogglesConstants.kt deleted file mode 100644 index 63087a2e90..0000000000 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/FeatureTogglesConstants.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.tangem.core.configtoggle.feature.impl - -internal object FeatureTogglesConstants { - - const val LOCAL_CONFIG_PATH: String = "configs/feature_toggles_config" -} \ No newline at end of file diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/version/Version.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/version/Version.kt index f5d01447c9..403f8e62a8 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/version/Version.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/version/Version.kt @@ -10,7 +10,7 @@ import com.tangem.utils.logging.TangemLogger * [REDACTED_AUTHOR] */ -internal class Version private constructor(value: String) : Comparable { +class Version private constructor(value: String) : Comparable { private val major: Int private val minor: Int diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/version/VersionAvailabilityContract.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/version/VersionAvailabilityContract.kt index 2360052af4..7d22255b97 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/version/VersionAvailabilityContract.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/version/VersionAvailabilityContract.kt @@ -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 diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt index c548eed01b..4b612dc7f8 100644 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevFeatureTogglesManagerTest.kt @@ -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.toToggleInfoList(): List = 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 { 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() diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/divider/DividerWithPadding.kt b/core/ui/src/main/java/com/tangem/core/ui/components/divider/DividerWithPadding.kt index 8b1181ef25..53f310105a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/divider/DividerWithPadding.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/divider/DividerWithPadding.kt @@ -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, + ) } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt index 7ed452749d..3829e306b7 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt @@ -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) { diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/models/TesterFeatureToggle.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/models/TesterFeatureToggle.kt deleted file mode 100644 index 782c2a7714..0000000000 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/models/TesterFeatureToggle.kt +++ /dev/null @@ -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, -) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/FeatureToggleGroupUM.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/FeatureToggleGroupUM.kt new file mode 100644 index 0000000000..67ab69f9f1 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/FeatureToggleGroupUM.kt @@ -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, +) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/FeatureTogglesContentState.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/FeatureTogglesScreenUM.kt similarity index 68% rename from features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/FeatureTogglesContentState.kt rename to features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/FeatureTogglesScreenUM.kt index 8ead0deb5e..fe9d1551ae 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/FeatureTogglesContentState.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/FeatureTogglesScreenUM.kt @@ -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, + val featureToggleGroups: ImmutableList, val onToggleValueChange: (String, Boolean) -> Unit, val onRestartAppClick: () -> Unit, ) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/TesterFeatureToggleUM.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/TesterFeatureToggleUM.kt new file mode 100644 index 0000000000..5184260e3a --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/TesterFeatureToggleUM.kt @@ -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", "✅"), + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/ui/FeatureTogglesScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/ui/FeatureTogglesScreen.kt index 6d6a2b8536..1d7d298c47 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/ui/FeatureTogglesScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/ui/FeatureTogglesScreen.kt @@ -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 { + fun group(status: TesterFeatureToggleUM.Status, vararg toggles: Triple) = + 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), + ), + ) } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt index 130a27e2ff..c97999b6f1 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt @@ -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 + 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 { - return this - .getFeatureToggles() - .map { TesterFeatureToggle(it.key, it.value) } + private fun MutableFeatureTogglesManager.getTesterFeatureToggleGroups(): ImmutableList { + 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 = + compareByDescending { 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) + } } \ No newline at end of file