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

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