Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-17 15:49:48 +03:00
commit 20ac9bfad9
993 changed files with 31364 additions and 10416 deletions

View file

@ -1,4 +1,6 @@
import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants
import com.squareup.kotlinpoet.*
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import org.json.JSONArray
plugins {
alias(deps.plugins.android.library)
@ -9,8 +11,24 @@ plugins {
id("configuration")
}
buildscript {
dependencies {
classpath("com.squareup:kotlinpoet:1.15.0")
classpath("org.json:json:20231013")
}
}
android {
namespace = "com.tangem.core.configtoggle"
sourceSets["main"].java.srcDir("build/generated/source/toggles")
}
tasks.named("preBuild") {
dependsOn(generateFeatureToggles, generateExcludedBlockchainToggles)
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
@ -32,7 +50,72 @@ dependencies {
implementation(projects.core.utils)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
}
val generateFeatureToggles by tasks.registering {
generateToggles(
inputFilePath = "src/main/assets/configs/feature_toggles_config.json",
generatedFileName = "FeatureToggles",
)
}
val generateExcludedBlockchainToggles by tasks.registering {
generateToggles(
inputFilePath = "src/main/assets/configs/excluded_blockchains_config.json",
generatedFileName = "ExcludedBlockchainToggles",
)
}
fun Task.generateToggles(inputFilePath: String, generatedFileName: String) {
val inputFile = file(inputFilePath)
val outputDir = file("build/generated/source/toggles")
inputs.file(inputFile)
outputs.dir(outputDir)
doLast {
val jsonText = inputFile.readText()
val jsonArray = JSONArray(jsonText)
val entries = (0 until jsonArray.length()).map { i ->
val obj = jsonArray.getJSONObject(i)
val name = obj.getString("name")
val version = obj.getString("version")
CodeBlock.of("%S to %S", name, version)
}
val mapInitializer = CodeBlock.builder()
.add("mapOf(\n")
.indent()
.apply {
entries.forEachIndexed { index, entry ->
add(entry)
if (index != entries.lastIndex) add(",\n") else add("\n")
}
}
.unindent()
.add(")")
.build()
val objectBuilder = TypeSpec.objectBuilder(name = generatedFileName)
.addKdoc("Generated from $inputFilePath")
.addProperty(
PropertySpec.builder("values", MAP.parameterizedBy(STRING, STRING))
.initializer(mapInitializer)
.build()
)
val fileSpec = FileSpec.builder(packageName = "com.tangem.core.configtoggle", fileName = generatedFileName)
.addType(objectBuilder.build())
.build()
val outputPackageDir = File(outputDir, "")
outputPackageDir.mkdirs()
fileSpec.writeTo(outputPackageDir)
}
}

View file

@ -19,14 +19,6 @@
"name": "STAKING_CARDANO_ENABLED",
"version": "undefined"
},
{
"name": "WALLET_CONNECT_REDESIGN_ENABLED",
"version": "5.27.0"
},
{
"name": "PUSH_NOTIFICATIONS_ENABLED",
"version": "5.26.2"
},
{
"name": "USEDESK_ENABLED",
"version": "undefined"
@ -43,10 +35,6 @@
"name": "SEND_REDESIGN_ENABLED",
"version": "5.28.0"
},
{
"name": "WALLET_BALANCE_FETCHER_ENABLED",
"version": "5.27.0"
},
{
"name": "HOT_WALLET_ENABLED",
"version": "undefined"
@ -62,5 +50,17 @@
{
"name": "NEW_TOKEN_RECEIVE_ENABLED",
"version": "5.28.0"
},
{
"name": "YIELD_SUPPLY_FEATURE_ENABLED",
"version": "undefined"
},
{
"name": "NEW_ONRAMP_MAIN_ENABLED",
"version": "undefined"
},
{
"name": "ACCOUNTS_FEATURE_ENABLED",
"version": "undefined"
}
]

View file

@ -3,6 +3,4 @@ package com.tangem.core.configtoggle.blockchain
interface ExcludedBlockchainsManager {
val excludedBlockchainsIds: Set<String>
suspend fun init()
}

View file

@ -1,80 +0,0 @@
package com.tangem.core.configtoggle.blockchain.impl
import com.tangem.core.configtoggle.blockchain.MutableExcludedBlockchainsManager
import com.tangem.core.configtoggle.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
import com.tangem.core.configtoggle.version.VersionProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.datasource.local.preferences.utils.storeObjectMap
internal class DefaultExcludedBlockchainsManager(
private val localTogglesStorage: TogglesStorage,
private val appPreferencesStore: AppPreferencesStore,
private val versionProvider: VersionProvider,
) : MutableExcludedBlockchainsManager {
private var isInitialized: Boolean = false
private lateinit var currentExcludedBlockchains: MutableMap<String, Boolean>
private lateinit var localExcludedBlockchains: Map<String, Boolean>
override val excludedBlockchainsIds: Set<String>
get() {
if (!isInitialized) error("ExcludedBlockchainsManager is not initialized")
return currentExcludedBlockchains
.filterValues { it }
.keys
}
override suspend fun init() {
localTogglesStorage.populate(path = "configs/excluded_blockchains_config")
val storedExcludedBlockchainsIds = appPreferencesStore.getObjectMapSync<Boolean>(
key = PreferencesKeys.EXCLUDED_BLOCKCHAINS_KEY,
)
localExcludedBlockchains = localTogglesStorage.toggles
.associateToggles(currentVersion = versionProvider.get().orEmpty())
.mapValues { (_, isIncluded) -> !isIncluded }
currentExcludedBlockchains = (localExcludedBlockchains.keys + storedExcludedBlockchainsIds.keys)
.fold(mutableMapOf()) { acc, blockchainId ->
val isExcluded = storedExcludedBlockchainsIds[blockchainId] ?: localExcludedBlockchains[blockchainId]
requireNotNull(isExcluded) {
"Unable to find $blockchainId in local or stored excluded blockchains"
}
acc[blockchainId] = isExcluded
acc
}
isInitialized = true
}
override suspend fun excludeBlockchain(mainnetId: String, isExcluded: Boolean) {
currentExcludedBlockchains[mainnetId] = isExcluded
storeCurrent()
}
override fun isMatchLocalConfig(): Boolean {
return currentExcludedBlockchains == localExcludedBlockchains
}
override suspend fun recoverLocalConfig() {
currentExcludedBlockchains = localExcludedBlockchains.toMutableMap()
storeCurrent()
}
private suspend fun storeCurrent() {
appPreferencesStore.storeObjectMap(
key = PreferencesKeys.EXCLUDED_BLOCKCHAINS_KEY,
value = currentExcludedBlockchains,
)
}
}

View file

@ -0,0 +1,66 @@
package com.tangem.core.configtoggle.blockchain.impl
import com.tangem.core.configtoggle.ExcludedBlockchainToggles
import com.tangem.core.configtoggle.blockchain.MutableExcludedBlockchainsManager
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.runBlocking
import kotlin.properties.Delegates
/**
* [MutableExcludedBlockchainsManager] implementation in dev or mocked build
*
* @property versionProvider application version provider
* @property localTogglesStorage local storage for blockchain toggles
*/
internal class DevExcludedBlockchainsManager(
private val versionProvider: VersionProvider,
private val localTogglesStorage: LocalTogglesStorage,
) : MutableExcludedBlockchainsManager {
private val fileBlockchainToggles: Map<String, Boolean> = getFileBlockchainToggles()
private var blockchainTogglesMap: MutableMap<String, Boolean> by Delegates.notNull()
override val excludedBlockchainsIds: Set<String>
get() = blockchainTogglesMap.filterValues { !it }.keys
init {
val savedExcludedBlockchains = runBlocking { localTogglesStorage.getSyncOrEmpty() }
blockchainTogglesMap = fileBlockchainToggles
.mapValues { (blockchainId, isEnabled) ->
savedExcludedBlockchains[blockchainId] ?: isEnabled
}
.toMutableMap()
}
override suspend fun excludeBlockchain(mainnetId: String, isExcluded: Boolean) {
blockchainTogglesMap[mainnetId] = isExcluded
localTogglesStorage.store(blockchainTogglesMap)
}
override fun isMatchLocalConfig(): Boolean {
return blockchainTogglesMap == fileBlockchainToggles
}
override suspend fun recoverLocalConfig() {
blockchainTogglesMap = fileBlockchainToggles.toMutableMap()
localTogglesStorage.store(blockchainTogglesMap)
}
override fun toString(): String {
return blockchainTogglesMap
.filterKeys { it.isNotEmpty() }
.toTableString(tableName = this@DevExcludedBlockchainsManager::class.java.simpleName)
}
private fun getFileBlockchainToggles(): Map<String, Boolean> {
val appVersion = versionProvider.get()
return ExcludedBlockchainToggles.values.defineTogglesAvailability(appVersion = appVersion)
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.core.configtoggle.blockchain.impl
import com.tangem.core.configtoggle.ExcludedBlockchainToggles
import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager
import com.tangem.core.configtoggle.utils.defineTogglesAvailability
import com.tangem.core.configtoggle.version.VersionProvider
/**
* [ExcludedBlockchainsManager] implementation in PROD build
*
* @property versionProvider application version provider
*/
internal class ProdExcludedBlockchainsManager(
private val versionProvider: VersionProvider,
) : ExcludedBlockchainsManager {
override val excludedBlockchainsIds: Set<String> = getBlockchainToggles()
private fun getBlockchainToggles(): Set<String> {
val appVersion = versionProvider.get()
return ExcludedBlockchainToggles.values
.defineTogglesAvailability(appVersion = appVersion)
.filterValues { !it }
.keys
}
}

View file

@ -3,11 +3,10 @@ package com.tangem.core.configtoggle.di
import android.content.Context
import com.tangem.core.configtoggle.BuildConfig
import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.MutableExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.impl.DefaultExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.impl.DevExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.impl.ProdExcludedBlockchainsManager
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.DefaultVersionProvider
import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.local.preferences.AppPreferencesStore
import dagger.Module
import dagger.Provides
@ -24,26 +23,20 @@ internal object ExcludedBlockchainsManagerModule {
@Singleton
fun provideExcludedBlockchainsManager(
@ApplicationContext context: Context,
assetLoader: AssetLoader,
appPreferencesStore: AppPreferencesStore,
): ExcludedBlockchainsManager {
val localTogglesStorage = LocalTogglesStorage(assetLoader)
val versionProvider = DefaultVersionProvider(context)
return DefaultExcludedBlockchainsManager(
localTogglesStorage,
appPreferencesStore,
versionProvider,
)
}
@Provides
@Singleton
fun provideMutableExcludedBlockchainsManager(
manager: ExcludedBlockchainsManager,
): MutableExcludedBlockchainsManager? {
if (!BuildConfig.TESTER_MENU_ENABLED) return null
return manager as MutableExcludedBlockchainsManager
return if (BuildConfig.TESTER_MENU_ENABLED) {
DevExcludedBlockchainsManager(
versionProvider = versionProvider,
localTogglesStorage = LocalTogglesStorage(
appPreferencesStore = appPreferencesStore,
preferencesKey = LocalTogglesStorage.EXCLUDED_BLOCKCHAINS_KEY,
),
)
} else {
ProdExcludedBlockchainsManager(versionProvider = versionProvider)
}
}
}

View file

@ -7,14 +7,12 @@ import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.DefaultVersionProvider
import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.local.preferences.AppPreferencesStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.runBlocking
import javax.inject.Singleton
@Module
@ -25,29 +23,20 @@ internal object FeatureTogglesManagerModule {
@Singleton
fun provideFeatureTogglesManager(
@ApplicationContext context: Context,
assetLoader: AssetLoader,
appPreferencesStore: AppPreferencesStore,
): FeatureTogglesManager {
val localTogglesStorage = LocalTogglesStorage(assetLoader)
val versionProvider = DefaultVersionProvider(context)
return if (BuildConfig.TESTER_MENU_ENABLED) {
DevFeatureTogglesManager(
localTogglesStorage = localTogglesStorage,
appPreferencesStore = appPreferencesStore,
versionProvider = versionProvider,
featureTogglesLocalStorage = LocalTogglesStorage(
appPreferencesStore = appPreferencesStore,
preferencesKey = LocalTogglesStorage.FEATURE_TOGGLES_KEY,
),
)
} else {
ProdFeatureTogglesManager(
localTogglesStorage = localTogglesStorage,
versionProvider = versionProvider,
)
}.also {
// We need to initialize during the hilt graph creation
// in order to provide the feature toggles correctly to other dependencies.
runBlocking {
it.init()
}
ProdFeatureTogglesManager(versionProvider = versionProvider)
}
}
}

View file

@ -7,9 +7,6 @@ package com.tangem.core.configtoggle.feature
*/
interface FeatureTogglesManager {
/** Initialize manager */
suspend fun init()
/** Check feature toggle availability by name [name] */
fun isFeatureEnabled(name: String): Boolean
}

View file

@ -1,77 +1,68 @@
package com.tangem.core.configtoggle.feature.impl
import androidx.annotation.VisibleForTesting
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager
import com.tangem.core.configtoggle.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
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 com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.storeObject
import kotlinx.coroutines.runBlocking
import kotlin.properties.Delegates
/**
* Feature toggles manager implementation in DEV build
* Feature toggles manager implementation in dev or mocked build
*
* @property localTogglesStorage local feature toggles storage
* @property appPreferencesStore application local store
* @property versionProvider application version provider
* @property featureTogglesLocalStorage local storage for feature toggles
*/
internal class DevFeatureTogglesManager(
private val localTogglesStorage: TogglesStorage,
private val appPreferencesStore: AppPreferencesStore,
private val versionProvider: VersionProvider,
private val featureTogglesLocalStorage: LocalTogglesStorage,
) : MutableFeatureTogglesManager {
private var featureTogglesMap: MutableMap<String, Boolean>? = null
private var localFeatureTogglesMap: Map<String, Boolean>? = null
private var fileFeatureTogglesMap: Map<String, Boolean> = getFileFeatureToggles()
private var featureTogglesMap: MutableMap<String, Boolean> by Delegates.notNull()
override suspend fun init() {
if (featureTogglesMap != null && localFeatureTogglesMap != null) {
return // Already initialized
}
init {
val savedFeatureToggles = runBlocking { featureTogglesLocalStorage.getSyncOrEmpty() }
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
val savedFeatureToggles = appPreferencesStore.getObjectSyncOrNull<Map<String, Boolean>>(
key = PreferencesKeys.FEATURE_TOGGLES_KEY,
) ?: emptyMap<String, Boolean>()
val localFeatureToggles = localTogglesStorage.toggles
.associateToggles(currentVersion = versionProvider.get().orEmpty())
localFeatureTogglesMap = localFeatureToggles
featureTogglesMap = localFeatureToggles
featureTogglesMap = fileFeatureTogglesMap
.mapValues { resultToggle ->
savedFeatureToggles[resultToggle.key] ?: resultToggle.value
}
.toMutableMap()
}
override fun isFeatureEnabled(name: String): Boolean = featureTogglesMap!![name] ?: false
override fun isFeatureEnabled(name: String): Boolean = featureTogglesMap[name] == true
override fun isMatchLocalConfig(): Boolean = featureTogglesMap == localFeatureTogglesMap
override fun getFeatureToggles(): Map<String, Boolean> = featureTogglesMap
override fun getFeatureToggles(): Map<String, Boolean> = featureTogglesMap!!
override fun isMatchLocalConfig(): Boolean = featureTogglesMap == fileFeatureTogglesMap
override suspend fun changeToggle(name: String, isEnabled: Boolean) {
featureTogglesMap!![name] ?: return
featureTogglesMap!![name] = isEnabled
appPreferencesStore.storeFeatureToggles(value = featureTogglesMap!!)
featureTogglesMap[name] ?: return
featureTogglesMap[name] = isEnabled
featureTogglesLocalStorage.store(value = featureTogglesMap)
}
override suspend fun recoverLocalConfig() {
featureTogglesMap = localFeatureTogglesMap!!.toMutableMap()
appPreferencesStore.storeFeatureToggles(value = localFeatureTogglesMap!!)
featureTogglesMap = fileFeatureTogglesMap.toMutableMap()
featureTogglesLocalStorage.store(value = fileFeatureTogglesMap)
}
override fun toString(): String {
return featureTogglesMap.toTableString(tableName = this@DevFeatureTogglesManager::class.java.simpleName)
}
private fun getFileFeatureToggles(): Map<String, Boolean> {
val appVersion = versionProvider.get()
return FeatureToggles.values.defineTogglesAvailability(appVersion = appVersion)
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun setFeatureToggles(map: MutableMap<String, Boolean>) {
featureTogglesMap = map
}
private suspend fun AppPreferencesStore.storeFeatureToggles(value: Map<String, Boolean>) {
storeObject(PreferencesKeys.FEATURE_TOGGLES_KEY, value)
}
}

View file

@ -1,41 +1,30 @@
package com.tangem.core.configtoggle.feature.impl
import androidx.annotation.VisibleForTesting
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.configtoggle.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
import com.tangem.core.configtoggle.utils.defineTogglesAvailability
import com.tangem.core.configtoggle.version.VersionProvider
/**
* Feature toggles manager implementation in PROD build
*
* @property localTogglesStorage local feature toggles storage
* @property versionProvider application version provider
* @property versionProvider application version provider
*/
internal class ProdFeatureTogglesManager(
private val localTogglesStorage: TogglesStorage,
private val versionProvider: VersionProvider,
) : FeatureTogglesManager {
private var featureToggles: Map<String, Boolean>? = null
private val featureToggles: Map<String, Boolean> = getFileFeatureToggles()
override suspend fun init() {
if (featureToggles != null) {
return // Already initialized
}
override fun isFeatureEnabled(name: String): Boolean = featureToggles[name] == true
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
featureToggles = localTogglesStorage.toggles
.associateToggles(currentVersion = versionProvider.get() ?: "")
private fun getFileFeatureToggles(): Map<String, Boolean> {
val appVersion = versionProvider.get()
return FeatureToggles.values.defineTogglesAvailability(appVersion = appVersion)
}
override fun isFeatureEnabled(name: String): Boolean = featureToggles!![name] ?: false
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun getProdFeatureToggles() = featureToggles!!
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun setProdFeatureToggles(map: Map<String, Boolean>) {
featureToggles = map
}
fun getProdFeatureToggles() = featureToggles
}

View file

@ -1,24 +1,34 @@
package com.tangem.core.configtoggle.storage
import com.tangem.datasource.asset.loader.AssetLoader
import kotlin.properties.Delegates
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.datasource.local.preferences.utils.storeObjectMap
/**
* Storage implementation for storing local feature toggles.
* Feature toggles are declared in file [LOCAL_CONFIG_PATH].
* Local storage for toggles
*
* @property assetLoader asset loader
* @property appPreferencesStore app preferences store
* @property preferencesKey preferences key
*
[REDACTED_AUTHOR]
*/
internal class LocalTogglesStorage(
private val assetLoader: AssetLoader,
) : TogglesStorage {
private val appPreferencesStore: AppPreferencesStore,
private val preferencesKey: Preferences.Key<String>,
) {
override var toggles: List<ConfigToggle> by Delegates.notNull()
private set
suspend fun getSyncOrEmpty(): Map<String, Boolean> {
return appPreferencesStore.getObjectMapSync<Boolean>(key = preferencesKey)
}
override suspend fun populate(path: String) {
toggles = assetLoader.loadList<ConfigToggle>(path)
suspend fun store(value: Map<String, Boolean>) {
appPreferencesStore.storeObjectMap(key = preferencesKey, value = value)
}
companion object {
val FEATURE_TOGGLES_KEY by lazy { stringPreferencesKey(name = "featureToggles") }
val EXCLUDED_BLOCKCHAINS_KEY by lazy { stringPreferencesKey(name = "excludedBlockchainsV2") }
}
}

View file

@ -1,13 +1,13 @@
package com.tangem.core.configtoggle.utils
import com.tangem.core.configtoggle.storage.ConfigToggle
import com.tangem.core.configtoggle.version.VersionAvailabilityContract
internal fun List<ConfigToggle>.associateToggles(currentVersion: String): Map<String, Boolean> {
return associate { localToggle ->
Pair(
first = localToggle.name,
second = VersionAvailabilityContract(currentVersion, localToggle.version),
)
internal fun Map<String, String>.defineTogglesAvailability(appVersion: String?): Map<String, Boolean> {
return if (appVersion == null) {
mapValues { false }
} else {
mapValues { (_, version) ->
VersionAvailabilityContract(currentVersion = appVersion, localVersion = version)
}
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.core.configtoggle.utils
import java.util.Locale
internal fun Map<String, Boolean>.toTableString(tableName: String): String {
return buildString {
append("$tableName:\n")
append("|------------------------------------------|-----------|\n")
append(String.format(Locale.getDefault(), "| %-40s | %-9s |\n", "name", "isEnabled"))
append("|------------------------------------------|-----------|\n")
entries.forEachIndexed { index, (name, isEnabled) ->
append(String.format(Locale.getDefault(), "| %-40s | %-9s |\n", name, isEnabled))
}
append("|------------------------------------------|-----------|")
}
}

View file

@ -0,0 +1,203 @@
package com.tangem.core.configtoggle.blockchain.impl
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.ExcludedBlockchainToggles
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.VersionProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DevExcludedBlockchainsManagerTest {
private val versionProvider = mockk<VersionProvider>()
private val localTogglesStorage = mockk<LocalTogglesStorage>(relaxUnitFun = true)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Initialization {
@BeforeAll
fun setupAll() {
val toggles = mapOf("CHAIN_1" to "1.0.0", "CHAIN_2" to "2.0.0")
mockkObject(ExcludedBlockchainToggles)
every { ExcludedBlockchainToggles.values } returns toggles
}
@AfterAll
fun tearDownAll() {
unmockkObject(ExcludedBlockchainToggles)
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, localTogglesStorage)
}
@Test
fun `successfully initialize manager`() = runTest {
// Arrange
val appVersion = "1.0.0"
val savedToggles = mapOf("CHAIN_1" to false, "CHAIN_2" to true)
every { versionProvider.get() } returns appVersion
coEvery { localTogglesStorage.getSyncOrEmpty() } returns savedToggles
// Act
val actual = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage).excludedBlockchainsIds
// Assert
val expected = setOf("CHAIN_1")
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder {
versionProvider.get()
localTogglesStorage.getSyncOrEmpty()
}
}
@Test
fun `successfully initialize manager if versionProvider returns null`() = runTest {
// Arrange
every { versionProvider.get() } returns null
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
// Act
val actual = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage).excludedBlockchainsIds
// Assert
Truth.assertThat(actual).containsExactly("CHAIN_1", "CHAIN_2")
coVerifyOrder {
versionProvider.get()
localTogglesStorage.getSyncOrEmpty()
}
}
@Test
fun `successfully initialize manager if storage returns empty map`() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
// Act
val actual = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage).excludedBlockchainsIds
// Assert
Truth.assertThat(actual).containsExactly("CHAIN_2")
coVerifyOrder {
versionProvider.get()
localTogglesStorage.getSyncOrEmpty()
}
}
@Test
fun `failure initialize manager if storage throws exception`() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
val exception = Exception("Test exception")
coEvery { localTogglesStorage.getSyncOrEmpty() } throws exception
// Act
val actual = runCatching { DevExcludedBlockchainsManager(versionProvider, localTogglesStorage) }
.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder {
versionProvider.get()
localTogglesStorage.getSyncOrEmpty()
}
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ExcludeBlockchain {
@Test
fun excludeBlockchain_changesStatusAndSaves() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage)
manager.excludeBlockchain("CHAIN_1", false)
// Act
val actual = manager.excludedBlockchainsIds
// Assert
Truth.assertThat(actual).contains("CHAIN_1")
coVerify { localTogglesStorage.store(match { it["CHAIN_1"] == false }) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsMatchLocalConfig {
@Test
fun isMatchLocalConfig_returnsTrueIfMatchesFile() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage)
// Act
val actual = manager.isMatchLocalConfig()
// Assert
Truth.assertThat(actual).isTrue()
}
@Test
fun isMatchLocalConfig_returnsFalseIfDiffersFromFile() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage)
manager.excludeBlockchain("CHAIN_1", false)
// Act
val actual = manager.isMatchLocalConfig()
// Assert
Truth.assertThat(actual).isFalse()
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class RecoverLocalConfig {
@Test
fun recoverLocalConfig_resetsToFileAndSaves() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
val toggles = mapOf("CHAIN_1" to "2.0.0", "CHAIN_2" to "2.0.0")
mockkObject(ExcludedBlockchainToggles)
every { ExcludedBlockchainToggles.values } returns toggles
coEvery { localTogglesStorage.getSyncOrEmpty() } returns mapOf("CHAIN_1" to true)
val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage)
manager.recoverLocalConfig()
// Act
val actual = manager.excludedBlockchainsIds
// Assert
Truth.assertThat(actual).containsExactly("CHAIN_1", "CHAIN_2")
unmockkObject(ExcludedBlockchainToggles)
clearMocks(versionProvider, localTogglesStorage)
}
}
}

View file

@ -0,0 +1,98 @@
package com.tangem.core.configtoggle.blockchain.impl
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.ExcludedBlockchainToggles
import com.tangem.core.configtoggle.version.VersionProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ProdExcludedBlockchainsManagerTest {
private val versionProvider = mockk<VersionProvider>()
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Initialization {
@BeforeAll
fun setupAll() {
val toggles = mapOf("CHAIN_1" to "1.0.0", "CHAIN_2" to "2.0.0")
mockkObject(ExcludedBlockchainToggles)
every { ExcludedBlockchainToggles.values } returns toggles
}
@AfterAll
fun tearDownAll() {
unmockkObject(ExcludedBlockchainToggles)
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider)
}
@Test
fun `successfully initialize excluded blockchains`() = runTest {
// Arrange
val appVersion = "1.0.0"
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdExcludedBlockchainsManager(versionProvider).excludedBlockchainsIds
// Assert
val expected = setOf("CHAIN_2")
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder { versionProvider.get() }
}
@Test
fun `all blockchains excluded if versionProvider returns null`() = runTest {
// Arrange
every { versionProvider.get() } returns null
// Act
val actual = ProdExcludedBlockchainsManager(versionProvider).excludedBlockchainsIds
// Assert
val expected = setOf("CHAIN_1", "CHAIN_2")
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder { versionProvider.get() }
}
@Test
fun `all blockchains excluded if versionProvider returns empty string`() = runTest {
// Arrange
every { versionProvider.get() } returns ""
// Act
val actual = ProdExcludedBlockchainsManager(versionProvider).excludedBlockchainsIds
// Assert
val expected = setOf("CHAIN_1", "CHAIN_2")
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder { versionProvider.get() }
}
@Test
fun `failure initialize if versionProvider throws exception`() = runTest {
// Arrange
val exception = Exception("Test exception")
every { versionProvider.get() } throws exception
// Act
val actual = runCatching { ProdExcludedBlockchainsManager(versionProvider) }.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder { versionProvider.get() }
}
}
}

View file

@ -0,0 +1,489 @@
package com.tangem.core.configtoggle.manager
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.manager.ProdFeatureTogglesManagerTest.IsFeatureEnabledModel
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.VersionProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DevFeatureTogglesManagerTest {
private val versionProvider = mockk<VersionProvider>()
private val featureTogglesLocalStorage = mockk<LocalTogglesStorage>(relaxUnitFun = true)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Initialization {
@BeforeAll
fun setupAll() {
val featureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0")
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns featureToggles
}
@AfterAll
fun tearDownAll() {
unmockkObject(FeatureToggles)
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, featureTogglesLocalStorage)
}
@Test
fun `successfully initialize manager`() = runTest {
// Arrange
val appVersion = "1.0.0"
val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true)
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
// Assert
val expected = savedFeatureToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
}
@Test
fun `successfully initialize manager if versionProvider returns null`() = runTest {
// Arrange
val appVersion = null
val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true)
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
// Assert
val expected = savedFeatureToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
}
@Test
fun `successfully initialize manager if versionProvider returns empty string`() = runTest {
// Arrange
val appVersion = ""
val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true)
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
// Assert
val expected = savedFeatureToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
}
@Test
fun `failure initialize manager if versionProvider throws exception`() = runTest {
// Arrange
val exception = Exception("Test exception")
every { versionProvider.get() } throws exception
// Act
val actual = runCatching { DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) }
.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder { versionProvider.get() }
coVerify(inverse = true) { featureTogglesLocalStorage.getSyncOrEmpty() }
}
@Test
fun `successfully initialize manager if storage returns empty map`() = runTest {
// Arrange
val appVersion = "1.0.0"
val savedFeatureToggles = emptyMap<String, Boolean>()
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
}
@Test
fun `successfully initialize manager if storage returns unknown toggles`() = runTest {
// Arrange
val appVersion = "1.0.0"
val savedFeatureToggles = mapOf("TOGGLE_3" to true)
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
}
@Test
fun `failure initialize manager if storage throws exception`() = runTest {
// Arrange
val appVersion = "1.0.0"
val exception = Exception("Test exception")
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } throws exception
// Act
val actual = runCatching { DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) }
.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsFeatureEnabled {
private lateinit var manager: DevFeatureTogglesManager
@BeforeAll
fun setupAll() {
every { versionProvider.get() } returns "1.0.0"
val featureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to "undefined",
"ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0",
)
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns featureToggles
val savedFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage)
}
@AfterAll
fun tearDownAll() {
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
@ParameterizedTest
@ProvideTestModels
fun isFeatureEnabled(model: IsFeatureEnabledModel) {
// Act
val actual = manager.isFeatureEnabled(name = model.name)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
private fun provideTestModels() = listOf(
IsFeatureEnabledModel(name = "ACTIVE2_TEST_FEATURE_ENABLED", expected = false),
IsFeatureEnabledModel(name = "INACTIVE_TEST_FEATURE_ENABLED", expected = true),
IsFeatureEnabledModel(name = "UNKNOWN_FEATURE_ENABLED", expected = false),
IsFeatureEnabledModel(name = "", expected = false),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsMatchLocalConfig {
@ParameterizedTest
@ProvideTestModels
fun isMatchLocalConfig(model: IsMatchLocalConfigModel) = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns model.fileFeatureToggles
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).apply {
setFeatureToggles(model.storedFeatureToggles.toMutableMap())
}
// Act
val actual = manager.isMatchLocalConfig()
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
private fun provideTestModels() = listOf(
IsMatchLocalConfigModel(
fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"),
storedFeatureToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false),
expected = true,
),
IsMatchLocalConfigModel(
fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"),
storedFeatureToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to true),
expected = false,
),
IsMatchLocalConfigModel(
fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"),
storedFeatureToggles = mapOf("TOGGLE_1" to true),
expected = false,
),
IsMatchLocalConfigModel(
fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"),
storedFeatureToggles = mapOf("TOGGLE_3" to true, "TOGGLE_4" to false),
expected = false,
),
IsMatchLocalConfigModel(
fileFeatureToggles = emptyMap(),
storedFeatureToggles = emptyMap(),
expected = true,
),
IsMatchLocalConfigModel(
fileFeatureToggles = emptyMap(),
storedFeatureToggles = mapOf("TOGGLE_1" to true),
expected = false,
),
)
}
data class IsMatchLocalConfigModel(
val fileFeatureToggles: Map<String, String>,
val storedFeatureToggles: Map<String, Boolean>,
val expected: Boolean,
)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetFeatureToggles {
@Test
fun getFeatureToggles() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
val fileFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to "undefined",
"ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0",
)
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns fileFeatureToggles
val savedFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage)
// Act
val actual = manager.getFeatureToggles()
// Assert
val expected = savedFeatureToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ChangeToggle {
@ParameterizedTest
@ProvideTestModels
fun changeToggle(model: ChangeToggleModel) = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns model.initialToggles
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage)
// Act
manager.changeToggle(name = model.name, isEnabled = model.isEnabled)
val actual = manager.getFeatureToggles()
// Assert
val expected = model.expectedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
if (model.expectedStoreSaving) {
featureTogglesLocalStorage.store(model.expectedToggles)
}
}
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
private fun provideTestModels() = listOf(
ChangeToggleModel(
initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"),
name = "TOGGLE_1",
isEnabled = false,
expectedToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false),
expectedStoreSaving = true,
),
ChangeToggleModel(
initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"),
name = "TOGGLE_2",
isEnabled = true,
expectedToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to true),
expectedStoreSaving = true,
),
ChangeToggleModel(
initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"),
name = "TOGGLE_3",
isEnabled = true,
expectedToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false),
expectedStoreSaving = false,
),
ChangeToggleModel(
initialToggles = emptyMap(),
name = "TOGGLE_1",
isEnabled = true,
expectedToggles = emptyMap(),
expectedStoreSaving = false,
),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class RecoverLocalConfig {
@Test
fun recoverLocalConfig() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
val fileFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to "undefined",
"ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0",
)
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns fileFeatureToggles
val savedFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage)
// Act
manager.recoverLocalConfig()
val actual = manager.getFeatureToggles()
// Assert
val expected = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
featureTogglesLocalStorage.store(expected)
}
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
}
data class ChangeToggleModel(
val initialToggles: Map<String, String>,
val name: String,
val isEnabled: Boolean,
val expectedToggles: Map<String, Boolean>,
val expectedStoreSaving: Boolean,
)
}

View file

@ -1,237 +0,0 @@
package com.tangem.core.configtoggle.manager
import android.annotation.SuppressLint
import com.google.common.truth.Truth
import com.squareup.moshi.Moshi
import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants
import com.tangem.core.configtoggle.storage.ConfigToggle
import com.tangem.core.configtoggle.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
import com.tangem.core.configtoggle.version.VersionProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.collections.set
/**
[REDACTED_AUTHOR]
*/
@SuppressLint("CheckResult")
internal class DevTogglesManagerTest {
private val localTogglesStorage = mockk<TogglesStorage>()
private val appPreferenceStore = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = TestingCoroutineDispatcherProvider(),
preferencesDataStore = mockk(relaxed = true),
)
private val versionProvider = mockk<VersionProvider>()
private val manager = DevFeatureTogglesManager(
localTogglesStorage = localTogglesStorage,
appPreferencesStore = appPreferenceStore,
versionProvider = versionProvider,
)
@Test
fun `successfully initialize storage if shared prefs kept feature toggles`() = runTest {
val currentVersion = "0.1.0"
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
coEvery {
appPreferenceStore.getObjectSyncOrNull<Map<String, Boolean>>(PreferencesKeys.FEATURE_TOGGLES_KEY)
} returns savedFeatureTogglesMap
coEvery { localTogglesStorage.toggles } returns localFeatureToggles
coEvery { versionProvider.get() } returns currentVersion
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles
.associateToggles(currentVersion)
.mapValues { resultToggle ->
savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value
}
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `successfully initialize storage if shared prefs kept empty list`() = runTest {
val currentVersion = "0.1.0"
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
coEvery {
appPreferenceStore.getObjectSyncOrNull<Map<String, Boolean>>(PreferencesKeys.FEATURE_TOGGLES_KEY)
} returns emptyMap()
coEvery { localTogglesStorage.toggles } returns localFeatureToggles
coEvery { versionProvider.get() } returns currentVersion
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles
.associateToggles(currentVersion)
.mapValues { resultToggle ->
savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value
}
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `successfully initialize storage if shared prefs didn't keep feature toggles`() = runTest {
val currentVersion = "0.1.0"
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
coEvery {
appPreferenceStore.getObjectSyncOrNull<Map<String, Boolean>>(PreferencesKeys.FEATURE_TOGGLES_KEY)
} returns null
coEvery { localTogglesStorage.toggles } returns localFeatureToggles
coEvery { versionProvider.get() } returns currentVersion
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles
.associateToggles(currentVersion)
.mapValues(Map.Entry<String, Boolean>::value)
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `successfully initialize storage if versionProvider returns null`() = runTest {
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
coEvery { appPreferenceStore.getSyncOrNull(PreferencesKeys.FEATURE_TOGGLES_KEY) } returns savedFeatureToggles
coEvery { localTogglesStorage.toggles } returns localFeatureToggles
coEvery { versionProvider.get() } returns null
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles
.associateToggles(currentVersion = "")
.mapValues { resultToggle ->
savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value
}
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `get feature availability if feature toggle exists`() {
val featureToggles = mutableMapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
manager.setFeatureToggles(featureToggles)
val actual = manager.isFeatureEnabled(name = "INACTIVE_TEST_FEATURE_ENABLED")
Truth.assertThat(actual).isTrue()
}
@Test
fun `get feature availability if feature toggle doesn't exists`() {
val featureToggles = mutableMapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
manager.setFeatureToggles(featureToggles)
val actual = manager.isFeatureEnabled(name = "")
Truth.assertThat(actual).isFalse()
}
@Test
fun getFeatureToggles() {
val expected = mutableMapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
manager.setFeatureToggles(expected)
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `change toggle that contains in map`() = runTest {
val changeableToggleName = "INACTIVE_TEST_FEATURE_ENABLED"
val resultMap = mutableMapOf(
changeableToggleName to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
manager.setFeatureToggles(resultMap)
manager.changeToggle(changeableToggleName, true)
resultMap[changeableToggleName] = true
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(resultMap)
}
@Test
fun `change toggle that doesn't contains in map`() = runTest {
val resultMap = mutableMapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
manager.setFeatureToggles(resultMap)
manager.changeToggle("FEATURE_TOGGLE", true)
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(resultMap)
}
private companion object {
val savedFeatureToggles = """
[
{
"name": "INACTIVE_TEST_FEATURE_ENABLED",
"version": "undefined"
},
{
"name": "ACTIVE2_TEST_FEATURE_ENABLED",
"version": "1.0.0"
}
]
""".trimIndent()
val savedFeatureTogglesMap = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
val localFeatureToggles = listOf(
ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"),
ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"),
)
}
}

View file

@ -0,0 +1,155 @@
package com.tangem.core.configtoggle.manager
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager
import com.tangem.core.configtoggle.version.VersionProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ProdFeatureTogglesManagerTest {
private val versionProvider = mockk<VersionProvider>()
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Initialization {
@BeforeAll
fun setupAll() {
val featureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0")
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns featureToggles
}
@AfterAll
fun tearDownAll() {
unmockkObject(FeatureToggles)
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider)
}
@Test
fun `successfully initialize storage`() = runTest {
// Arrange
val appVersion = "1.0.0"
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder { versionProvider.get() }
}
@Test
fun `successfully initialize storage if versionProvider returns null`() = runTest {
// Arrange
val appVersion = null
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder { versionProvider.get() }
}
@Test
fun `successfully initialize storage if versionProvider returns empty string`() = runTest {
// Arrange
val appVersion = ""
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder { versionProvider.get() }
}
@Test
fun `failure initialize storage if versionProvider throws exception`() = runTest {
// Arrange
val exception = Exception("Test exception")
every { versionProvider.get() } throws exception
// Act
val actual = runCatching { ProdFeatureTogglesManager(versionProvider) }.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder { versionProvider.get() }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsFeatureEnabled {
private lateinit var manager: ProdFeatureTogglesManager
@BeforeAll
fun setupAll() {
every { versionProvider.get() } returns "1.0.0"
val featureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to "undefined",
"ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0",
)
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns featureToggles
manager = ProdFeatureTogglesManager(versionProvider)
}
@AfterAll
fun tearDownAll() {
clearMocks(versionProvider)
unmockkObject(FeatureToggles)
}
@ParameterizedTest
@ProvideTestModels
fun isFeatureEnabled(model: IsFeatureEnabledModel) {
// Act
val actual = manager.isFeatureEnabled(name = model.name)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
private fun provideTestModels() = listOf(
IsFeatureEnabledModel(name = "ACTIVE2_TEST_FEATURE_ENABLED", expected = true),
IsFeatureEnabledModel(name = "INACTIVE_TEST_FEATURE_ENABLED", expected = false),
IsFeatureEnabledModel(name = "UNKNOWN_FEATURE_ENABLED", expected = false),
IsFeatureEnabledModel(name = "", expected = false),
)
}
data class IsFeatureEnabledModel(val name: String, val expected: Boolean)
}

View file

@ -1,136 +0,0 @@
package com.tangem.core.configtoggle.manager
import android.content.pm.PackageManager
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants
import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager
import com.tangem.core.configtoggle.storage.ConfigToggle
import com.tangem.core.configtoggle.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
import com.tangem.core.configtoggle.version.VersionProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class ProdTogglesManagerTest {
private val localTogglesStorage = mockk<TogglesStorage>()
private val versionProvider = mockk<VersionProvider>()
private val manager = ProdFeatureTogglesManager(localTogglesStorage, versionProvider)
@Test
fun `successfully initialize storage`() = runTest {
val currentVersion = "1.0.0"
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
every { localTogglesStorage.toggles } returns localFeatureToggles
every { versionProvider.get() } returns currentVersion
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles.associateToggles(currentVersion)
Truth.assertThat(manager.getProdFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `successfully initialize storage if versionProvider returns null`() = runTest {
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
every { localTogglesStorage.toggles } returns localFeatureToggles
every { versionProvider.get() } returns null
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
Truth.assertThat(manager.getProdFeatureToggles()).containsExactlyEntriesIn(disabledFeatureToggles)
}
@Test
fun `failure initialize storage if localFeatureTogglesStorage throws exception`() = runTest {
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
every { localTogglesStorage.toggles } throws IllegalStateException(
"Property featureToggles should be initialized before get.",
)
runCatching { manager.init() }
.onSuccess { throw IllegalStateException("localFeatureToggles shouldn't be initialized") }
.onFailure {
Truth
.assertThat(it)
.hasMessageThat()
.contains("Property featureToggles should be initialized before get.")
Truth.assertThat(it).isInstanceOf(IllegalStateException::class.java)
}
coVerifyOrder { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) }
verifyAll(inverse = true) { versionProvider.get() }
}
@Test
fun `failure initialize storage if versionProvider throws exception`() = runTest {
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
every { localTogglesStorage.toggles } returns localFeatureToggles
every { versionProvider.get() } throws PackageManager.NameNotFoundException()
runCatching { manager.init() }
.onSuccess { throw IllegalStateException("versionProvider should throws exception") }
.onFailure {
Truth.assertThat(it).isInstanceOf(PackageManager.NameNotFoundException::class.java)
}
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
}
@Test
fun `get feature availability if feature toggle exists`() {
val featureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
manager.setProdFeatureToggles(featureToggles)
val actual = manager.isFeatureEnabled(name = "INACTIVE_TEST_FEATURE_ENABLED")
Truth.assertThat(actual).isTrue()
}
@Test
fun `get feature availability if feature toggle doesn't exists`() {
val featureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
manager.setProdFeatureToggles(featureToggles)
val actual = manager.isFeatureEnabled(name = "")
Truth.assertThat(actual).isFalse()
}
private companion object {
val localFeatureToggles = listOf(
ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"),
ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"),
)
val disabledFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
}
}

View file

@ -1,110 +0,0 @@
package com.tangem.core.configtoggle.storage
import android.annotation.SuppressLint
import com.google.common.truth.Truth
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants
import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.IOException
/**
[REDACTED_AUTHOR]
*/
@SuppressLint("CheckResult")
internal class LocalTogglesStorageTest {
private val assetReader = mockk<AssetReader>()
private val moshi = mockk<Moshi>()
private val jsonAdapter = mockk<JsonAdapter<List<ConfigToggle>>>()
// Impossible to mockk AssetLoader because it implement inline functions
private val assetLoader = AssetLoader(
assetReader = assetReader,
moshi = moshi,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val storage = LocalTogglesStorage(assetLoader)
@Test
fun `successfully initialize storage`() = runTest {
everyReadingJson() returns json
everyCreatingMoshiAdapter() returns jsonAdapter
everyMappingJson() returns featureToggles
storage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
coVerifyOrder {
assetReader.read(CONFIG_FILE_NAME)
jsonAdapter.fromJson(json)
}
Truth.assertThat(storage.toggles).containsExactlyElementsIn(featureToggles)
}
@Test
fun `failure initialize storage if assetReader throws exception`() = runTest {
everyReadingJson() returns json
everyCreatingMoshiAdapter() returns jsonAdapter
everyMappingJson() throws IOException()
storage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
coVerifyOrder {
assetReader.read(CONFIG_FILE_NAME)
jsonAdapter.fromJson(json)
}
Truth.assertThat(storage.toggles).containsExactlyElementsIn(emptyList<ConfigToggle>())
}
@Test
fun `failure initialize storage if jsonAdapter throws exception`() = runTest {
everyReadingJson() throws IOException()
storage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
coVerifyOrder { assetReader.read(CONFIG_FILE_NAME) }
verifyAll(inverse = true) { jsonAdapter.fromJson(any<String>()) }
Truth.assertThat(storage.toggles).containsExactlyElementsIn(emptyList<ConfigToggle>())
}
private fun everyReadingJson() = coEvery { assetReader.read(CONFIG_FILE_NAME) }
private fun everyCreatingMoshiAdapter() = every {
val types = Types.newParameterizedType(List::class.java, ConfigToggle::class.java)
moshi.adapter<List<ConfigToggle>>(types)
}
private fun everyMappingJson() = every { jsonAdapter.fromJson(json) }
private companion object {
const val CONFIG_FILE_NAME = "configs/feature_toggles_config.json"
val json = """
[
{
"name": "INACTIVE_TEST_FEATURE_ENABLED",
"version": "undefined"
},
{
"name": "ACTIVE2_TEST_FEATURE_ENABLED",
"version": "1.0.0"
}
]
""".trimIndent()
val featureToggles = listOf(
ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"),
ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"),
)
}
}

View file

@ -7,34 +7,51 @@ package com.tangem.datasource.api.common.response
*/
sealed class ApiResponse<T : Any> {
/**
* Represents a successful response from the API.
*
* @property data The data returned by the API.
*/
data class Success<T : Any>(val data: T) : ApiResponse<T>()
/** Map of headers (header name with list of values) */
abstract val headers: Map<String, List<String>>
/**
* Represents an error response or failure from the API.
* Represents a successful response from the API
*
* @property cause The cause of the error.
* @property data the data returned by the API
* @property headers the headers returned by the API
*/
data class Error(val cause: ApiResponseError) : ApiResponse<Nothing>()
data class Success<T : Any>(
val data: T,
override val headers: Map<String, List<String>> = emptyMap(),
) : ApiResponse<T>()
/**
* Represents an error response or failure from the API
*
* @property cause the cause of the error
* @property headers the headers returned by the API
*/
data class Error(
val cause: ApiResponseError,
override val headers: Map<String, List<String>> = emptyMap(),
) : ApiResponse<Nothing>()
}
/**
* Wraps data in a [ApiResponse.Success] instance.
* Wraps data in a [ApiResponse.Success] instance
*
* @param data The data to wrap.
* @return A [ApiResponse.Success] instance containing the provided data.
* @param data the data to wrap
* @param headers the headers returned by the API
* @return a [ApiResponse.Success] instance containing the provided data
*/
internal fun <T : Any> apiSuccess(data: T): ApiResponse<T> = ApiResponse.Success(data)
internal fun <T : Any> apiSuccess(data: T, headers: Map<String, List<String>>): ApiResponse<T> {
return ApiResponse.Success(data, headers)
}
/**
* Wraps an [ApiResponseError] in a [ApiResponse.Error] instance.
* Wraps an [ApiResponseError] in a [ApiResponse.Error] instance
*
* @param cause The error to wrap.
* @return A [ApiResponse.Error] instance containing the provided error.
* @param cause the error to wrap
* @param headers the headers returned by the API
* @return a [ApiResponse.Error] instance containing the provided error
*/
@Suppress("UNCHECKED_CAST")
internal fun <T : Any> apiError(cause: ApiResponseError): ApiResponse<T> = ApiResponse.Error(cause) as ApiResponse<T>
internal fun <T : Any> apiError(cause: ApiResponseError, headers: Map<String, List<String>>): ApiResponse<T> {
return ApiResponse.Error(cause, headers) as ApiResponse<T>
}

View file

@ -47,7 +47,8 @@ internal class ApiResponseCallDelegate<T : Any>(
Timber.e(e, "onFailure UnknownException")
ApiResponseError.UnknownException(e)
}
val safeResponse = apiError<T>(error)
val safeResponse = apiError<T>(cause = error, headers = emptyMap())
responseCallback.onResponse(this@ApiResponseCallDelegate, Response.success(safeResponse))
}

View file

@ -20,6 +20,8 @@ sealed class ApiResponseError : Exception() {
// region Error Codes
enum class Code(val numericCode: Int) {
// 3xx Server Errors
NOT_MODIFIED(numericCode = 304),
// 4xx Server Errors
BAD_REQUEST(numericCode = 400),
UNAUTHORIZED(numericCode = 401),
@ -64,10 +66,6 @@ sealed class ApiResponseError : Exception() {
;
override fun toString(): String = "$numericCode - $name"
companion object {
val values = values()
}
}
// endregion Error Codes
}

View file

@ -19,4 +19,9 @@ inline fun <T> catchApiResponseError(onError: (ApiResponseError) -> Unit, block:
onError(e)
throw e
}
}
/** Checks if the ApiResponseError is a network error with the specified HTTP status [code] */
fun ApiResponseError.isNetworkError(code: ApiResponseError.HttpException.Code): Boolean {
return this is ApiResponseError.HttpException && this.code == code
}

View file

@ -0,0 +1,3 @@
package com.tangem.datasource.api.common.response
const val IF_NONE_MATCH_HEADER = "IfNoneMatch"

View file

@ -12,12 +12,13 @@ import java.util.concurrent.TimeoutException
import javax.net.ssl.SSLHandshakeException
internal fun <T : Any> Response<T>.toSafeApiResponse(analyticsErrorHandler: AnalyticsErrorHandler): ApiResponse<T> {
val headers = headers().toMultimap()
val body = body()
return if (isSuccessful && body != null) {
apiSuccess(body)
apiSuccess(data = body, headers = headers)
} else {
val code = ApiResponseError.HttpException.Code.values
val code = ApiResponseError.HttpException.Code.entries
.firstOrNull { it.numericCode == code() }
val e = try {
if (code == null) {
@ -33,7 +34,7 @@ internal fun <T : Any> Response<T>.toSafeApiResponse(analyticsErrorHandler: Anal
ApiResponseError.UnknownException(e)
}
apiError(e)
apiError(e, headers)
}
}

View file

@ -56,7 +56,7 @@ data class TxDetails(
val txData: String?, // transaction data if DEX, null if CEX
@Json(name = "txValue")
val txValue: String, // amount (same as fromAmount for Coin, but for bridge equal to otherNativeFee)
val txValue: String?, // amount (same as fromAmount for Coin, but for bridge equal to otherNativeFee)
@Json(name = "otherNativeFee")
val otherNativeFee: String?,

View file

@ -9,6 +9,7 @@ import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Query
@Suppress("TooManyFunctions")
interface TangemPayApi {
// region: auth
@ -32,6 +33,11 @@ interface TangemPayApi {
@POST("v1/auth/token")
suspend fun getTokenByCustomerWallet(@Body request: GetTokenByCustomerWalletRequest): ApiResponse<JWTResponse>
@POST("v1/auth/token/refresh")
suspend fun refreshCustomerWalletAccessToken(
@Body request: RefreshCustomerWalletAccessTokenRequest,
): ApiResponse<JWTResponse>
@POST("v1/auth/token")
suspend fun getAccessTokenByCardWallet(@Body request: GetAccessTokenByCardWalletRequest): ApiResponse<JWTResponse>
@ -103,4 +109,10 @@ interface TangemPayApi {
@GET("v1/customer/kyc")
suspend fun getKycAccess(@Header("Authorization") authHeader: String): ApiResponse<KycAccessInfoResponse>
@GET("v1/customer/me")
suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse<CustomerMeResponse>
@POST("v1/deeplink/validate")
suspend fun validateDeeplink(@Body body: DeeplinkValidityRequest): ApiResponse<DeeplinkValidityResponse>
}

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.pay.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class DeeplinkValidityRequest(
@Json(name = "link") val link: String,
)

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.api.pay.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class RefreshCustomerWalletAccessTokenRequest(
@Json(name = "auth_type") val authType: String = "customer_wallet",
@Json(name = "refresh_token") val refreshToken: String,
)

View file

@ -0,0 +1,48 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CustomerMeResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "id") val id: String,
@Json(name = "state") val state: String,
@Json(name = "createdAt") val createdAt: String,
@Json(name = "product_instance") val productInstance: ProductInstance?,
@Json(name = "payment_account") val paymentAccount: PaymentAccount?,
@Json(name = "kyc") val kyc: Kyc?,
)
@JsonClass(generateAdapter = true)
data class ProductInstance(
@Json(name = "id") val id: String,
@Json(name = "cid") val cid: String,
@Json(name = "card_id") val cardId: String,
@Json(name = "card_wallet_address") val cardWalletAddress: String,
@Json(name = "status") val status: String,
@Json(name = "updated_at") val updatedAt: String,
@Json(name = "payment_account_id") val paymentAccountId: String,
)
@JsonClass(generateAdapter = true)
data class PaymentAccount(
@Json(name = "id") val id: String,
@Json(name = "address") val address: String,
@Json(name = "customer_wallet_address") val customerWalletAddress: String,
)
@JsonClass(generateAdapter = true)
data class Kyc(
@Json(name = "id") val id: String,
@Json(name = "provider") val provider: String,
@Json(name = "status") val status: String,
@Json(name = "risk") val risk: String,
@Json(name = "review_answer") val reviewAnswer: String,
@Json(name = "created_at") val createdAt: String,
)
}

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class DeeplinkValidityResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "status") val status: String,
)
}

View file

@ -119,9 +119,6 @@ interface TangemTechApi {
@Path("application_id") applicationId: String,
@Body body: NotificationApplicationCreateBody,
): ApiResponse<Unit>
@PATCH("v1/user-wallets/wallets/{wallet_id}/notify")
suspend fun setNotificationsEnabled(@Path("wallet_id") walletId: String, @Body body: WalletBody): ApiResponse<Unit>
// endregion
// region user-wallets
@ -148,17 +145,22 @@ interface TangemTechApi {
// region account
@GET("/v1/wallets/{walletId}/accounts")
suspend fun getWalletAccounts(@Path("walletId") walletId: String): ApiResponse<GetWalletAccountsResponse>
suspend fun getWalletAccounts(
@Path("walletId") walletId: String,
@Header("If-None-Match") eTag: String? = null,
): ApiResponse<GetWalletAccountsResponse>
@PUT("/v1/wallets/{walletId}/accounts")
suspend fun saveWalletAccounts(
@Path("walletId") walletId: String,
@Header("If-Match") ifMatch: String,
): ApiResponse<SaveWalletAccountsResponse>
@Header("If-Match") eTag: String,
@Body body: SaveWalletAccountsResponse,
): ApiResponse<Unit>
@GET("/v1/wallets/{walletId}/accounts/archived")
suspend fun getWalletArchivedAccounts(
@Path("walletId") walletId: String,
@Header("If-None-Match") eTag: String? = null,
): ApiResponse<GetWalletArchivedAccountsResponse>
// endregion
}

View file

@ -15,7 +15,7 @@ data class GetWalletAccountsResponse(
@JsonClass(generateAdapter = true)
data class Wallet(
@Json(name = "version") val version: Int,
@Json(name = "version") val version: Int = 0,
@Json(name = "group") val group: GroupType,
@Json(name = "sort") val sort: SortType,
@Json(name = "totalAccounts") val totalAccounts: Int,

View file

@ -7,7 +7,7 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
@JsonClass(generateAdapter = true)
data class WalletAccountDTO(
@Json(name = "id") val id: String,
@Json(name = "name") val name: String,
@Json(name = "name") val name: String?,
@Json(name = "derivation") val derivationIndex: Int,
@Json(name = "icon") val icon: String,
@Json(name = "iconColor") val iconColor: String,

View file

@ -8,6 +8,7 @@ import com.tangem.datasource.local.walletconnect.DefaultWalletConnectStore
import com.tangem.datasource.local.walletconnect.WalletConnectStore
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.setTypes
import com.tangem.domain.walletconnect.model.WcPendingApprovalSessionDTO
import com.tangem.domain.walletconnect.model.WcSessionDTO
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -30,6 +31,7 @@ object WalletConnectModule {
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
): WalletConnectStore {
val scope = CoroutineScope(context = dispatchers.io + SupervisorJob())
return DefaultWalletConnectStore(
persistenceStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
@ -38,7 +40,16 @@ object WalletConnectModule {
defaultValue = emptySet(),
),
produceFile = { context.dataStoreFile(fileName = "wallet_connect_sessions") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
scope = scope,
),
pendingApprovalSessionsStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = setTypes<WcPendingApprovalSessionDTO>(),
defaultValue = emptySet(),
),
produceFile = { context.dataStoreFile(fileName = "wallet_connect_pending_approval_sessions") },
scope = scope,
),
)
}

View file

@ -9,6 +9,8 @@ data class EnvironmentConfig(
val mercuryoWidgetId: String = "",
val mercuryoSecret: String = "",
val amplitudeApiKey: String = "",
val appsFlyerApiKey: String = "",
val appsAppId: String = "",
val blockchainSdkConfig: BlockchainSdkConfig = BlockchainSdkConfig(),
val walletConnectProjectId: String = "",
val express: ExpressModel? = null,

View file

@ -19,6 +19,8 @@ internal object EnvironmentConfigConverter : Converter<EnvironmentConfigModel, E
mercuryoSecret = value.mercuryoSecret,
blockchainSdkConfig = BlockchainSDKConfigConverter.convert(value = value),
amplitudeApiKey = value.amplitudeApiKey,
appsFlyerApiKey = value.appsFlyer.appsFlyerDevKey,
appsAppId = value.appsFlyer.appsFlyerAppID,
walletConnectProjectId = value.walletConnectProjectId,
express = value.express,
devExpress = value.devExpress,

View file

@ -23,6 +23,7 @@ class EnvironmentConfigModel(
@Json(name = "infuraProjectId") val infuraProjectId: String?,
@Json(name = "tronGridApiKey") val tronGridApiKey: String,
@Json(name = "amplitudeApiKey") val amplitudeApiKey: String,
@Json(name = "appsFlyer") val appsFlyer: AppsFlyerModel,
@Json(name = "kaspaSecondaryApiUrl") val kaspaSecondaryApiUrl: String,
@Json(name = "walletConnectProjectId") val walletConnectProjectId: String,
@Json(name = "chiaFireAcademyApiKey") val chiaFireAcademyApiKey: String?,
@ -102,4 +103,12 @@ data class ExpressModel(
val apiKey: String,
@Json(name = "signVerifierPublicKey")
val signVerifierPublicKey: String,
)
@JsonClass(generateAdapter = true)
data class AppsFlyerModel(
@Json(name = "appsFlyerDevKey")
val appsFlyerDevKey: String,
@Json(name = "appsFlyerAppID")
val appsFlyerAppID: String,
)

View file

@ -14,11 +14,18 @@ interface RuntimeStateStore<T> {
/** Get flow of elements [T] */
fun get(): StateFlow<T>
/** Get element [T] synchronously or null */
suspend fun getSyncOrNull(): T?
/** Store [value] */
suspend fun store(value: T)
/** Update current value by [function] */
suspend fun update(function: (T) -> T)
/** Clear stored value */
fun clear()
companion object {
/**
@ -32,6 +39,8 @@ interface RuntimeStateStore<T> {
override fun get(): StateFlow<T> = flow
override suspend fun getSyncOrNull(): T? = flow.value
override suspend fun store(value: T) {
flow.value = value
}
@ -39,6 +48,10 @@ interface RuntimeStateStore<T> {
override suspend fun update(function: (T) -> T) {
flow.update(function)
}
override fun clear() {
flow.value = defaultValue
}
}
}
}

View file

@ -2,8 +2,6 @@ package com.tangem.datasource.local.network.entity
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.datasource.local.network.entity.NetworkStatusDM.NoAccount
import com.tangem.datasource.local.network.entity.NetworkStatusDM.Verified
import dev.onenowy.moshipolymorphicadapter.PolymorphicAdapterType
import dev.onenowy.moshipolymorphicadapter.annotations.NameLabel
import java.math.BigDecimal
@ -44,6 +42,7 @@ sealed interface NetworkStatusDM {
@Json(name = "selected_address") override val selectedAddress: String,
@Json(name = "available_addresses") override val availableAddresses: Set<Address>,
@Json(name = "amounts") val amounts: Map<String, BigDecimal>,
@Json(name = "yield_supply_statuses") val yieldSupplyStatuses: Map<String, YieldSupplyStatus?> = emptyMap(),
) : NetworkStatusDM
/**
@ -107,4 +106,11 @@ sealed interface NetworkStatusDM {
Secondary,
}
}
@JsonClass(generateAdapter = true)
data class YieldSupplyStatus(
@Json(name = "is_active") val isActive: Boolean,
@Json(name = "is_initialized") val isInitialized: Boolean,
@Json(name = "is_allowed_to_spend") val isAllowedToSpend: Boolean,
)
}

View file

@ -7,7 +7,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys.IS_TANGEM_TOS_ACC
import com.tangem.datasource.local.preferences.PreferencesKeys.SAVE_USER_WALLETS_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_OPEN_WELCOME_ON_RESUME_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SAVE_ACCESS_CODES_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SHOW_ASK_BIOMETRY_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.SHOW_RATING_DIALOG_AT_LAUNCH_COUNT_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.USED_CARDS_INFO_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.USER_WAS_INTERACT_WITH_RATING_KEY
@ -26,7 +26,7 @@ object PreferencesKeys {
val SAVE_USER_WALLETS_KEY by lazy { booleanPreferencesKey(name = "saveUserWallets") }
val SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY by lazy { booleanPreferencesKey("saveUserWalletShown") }
val SHOULD_SHOW_ASK_BIOMETRY_KEY by lazy { booleanPreferencesKey("saveUserWalletShown") }
val APP_LAUNCH_COUNT_KEY by lazy { intPreferencesKey(name = "launchCount") }
@ -56,10 +56,6 @@ object PreferencesKeys {
val LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY by lazy { stringPreferencesKey(name = "lastSwappedCryptoCurrency") }
val FEATURE_TOGGLES_KEY by lazy { stringPreferencesKey(name = "featureToggles") }
val EXCLUDED_BLOCKCHAINS_KEY by lazy { stringPreferencesKey(name = "excludedBlockchainsV2") }
val WAS_TWINS_ONBOARDING_SHOWN by lazy { booleanPreferencesKey(name = "twinsOnboardingShown") }
val IS_TANGEM_TOS_ACCEPTED_KEY by lazy { booleanPreferencesKey(name = "tangem_tos_accepted") }
@ -187,7 +183,7 @@ object PreferencesKeys {
internal fun getTapPrefKeysToMigrate(): Set<String> {
return setOf(
SAVE_USER_WALLETS_KEY,
SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY,
SHOULD_SHOW_ASK_BIOMETRY_KEY,
APP_LAUNCH_COUNT_KEY,
SHOW_RATING_DIALOG_AT_LAUNCH_COUNT_KEY,
FUNDS_FOUND_DATE_KEY,

View file

@ -39,5 +39,13 @@ internal class DefaultUserTokensResponseStore(
)
}
override suspend fun clear(userWalletId: UserWalletId) {
appPreferencesStore.updateData { preferences ->
val key = createPreferencesKey(userWalletId = userWalletId.stringValue)
preferences.toMutablePreferences().apply { remove(key) }
}
}
private fun createPreferencesKey(userWalletId: String) = stringPreferencesKey(name = "user_tokens_$userWalletId")
}

View file

@ -17,4 +17,6 @@ interface UserTokensResponseStore {
suspend fun getSyncOrNull(userWalletId: UserWalletId): UserTokensResponse?
suspend fun store(userWalletId: UserWalletId, response: UserTokensResponse)
suspend fun clear(userWalletId: UserWalletId)
}

View file

@ -15,6 +15,8 @@ interface UserWalletsStore {
val userWallets: Flow<List<UserWallet>>
val userWalletsSync: List<UserWallet>
fun getSyncOrNull(key: UserWalletId): UserWallet?
fun getSyncStrict(key: UserWalletId): UserWallet

View file

@ -0,0 +1,16 @@
package com.tangem.datasource.local.visa
import com.tangem.domain.visa.model.VisaAuthTokens
interface TangemPayStorage {
suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens)
suspend fun getAuthTokens(customerWalletAddress: String): VisaAuthTokens?
suspend fun storeCustomerWalletAddress(customerWalletAddress: String)
suspend fun getCustomerWalletAddress(): String?
suspend fun clear(customerWalletAddress: String)
}

View file

@ -1,18 +1,39 @@
package com.tangem.datasource.local.walletconnect
import androidx.datastore.core.DataStore
import com.tangem.domain.walletconnect.model.WcPendingApprovalSessionDTO
import com.tangem.domain.walletconnect.model.WcSessionDTO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.transform
import org.joda.time.DateTime
internal typealias WcSessionCollection = Set<WcSessionDTO>
internal class DefaultWalletConnectStore(
private val persistenceStore: DataStore<WcSessionCollection>,
private val pendingApprovalSessionsStore: DataStore<Set<WcPendingApprovalSessionDTO>>,
) : WalletConnectStore {
override val sessions: Flow<WcSessionCollection>
get() = persistenceStore.data
override val pendingApproval: Flow<Set<WcPendingApprovalSessionDTO>>
get() = pendingApprovalSessionsStore.data
.transform { pendingApprovalSet ->
val now = DateTime.now()
val expired = pendingApprovalSet
.filterTo(mutableSetOf()) { it.expiredTime < now.millis }
val shouldSomeClear = expired.isNotEmpty()
val actualData = if (shouldSomeClear) {
removePendingApproval(expired)
} else {
pendingApprovalSet
}
emit(actualData)
}
.distinctUntilChanged()
override suspend fun saveSessions(sessions: WcSessionCollection) {
persistenceStore.updateData { data -> data.plus(sessions) }
}
@ -20,4 +41,16 @@ internal class DefaultWalletConnectStore(
override suspend fun removeSessions(sessions: WcSessionCollection) {
persistenceStore.updateData { data -> data.minus(sessions) }
}
override suspend fun savePendingApproval(
sessions: Set<WcPendingApprovalSessionDTO>,
): Set<WcPendingApprovalSessionDTO> {
return pendingApprovalSessionsStore.updateData { data -> data.plus(sessions) }
}
override suspend fun removePendingApproval(
sessions: Set<WcPendingApprovalSessionDTO>,
): Set<WcPendingApprovalSessionDTO> {
return pendingApprovalSessionsStore.updateData { data -> data.minus(sessions) }
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.datasource.local.walletconnect
import com.tangem.domain.walletconnect.model.WcPendingApprovalSessionDTO
import com.tangem.domain.walletconnect.model.WcSessionDTO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
@ -7,6 +8,8 @@ import kotlinx.coroutines.flow.first
interface WalletConnectStore {
val sessions: Flow<WcSessionCollection>
val pendingApproval: Flow<Set<WcPendingApprovalSessionDTO>>
suspend fun findSessionByTopic(topic: String) = sessions.first().find { it.topic == topic }
suspend fun saveSessions(sessions: WcSessionCollection)
@ -14,4 +17,7 @@ interface WalletConnectStore {
suspend fun removeSessions(sessions: WcSessionCollection)
suspend fun removeSession(session: WcSessionDTO) = removeSessions(setOf(session))
suspend fun savePendingApproval(sessions: Set<WcPendingApprovalSessionDTO>): Set<WcPendingApprovalSessionDTO>
suspend fun removePendingApproval(sessions: Set<WcPendingApprovalSessionDTO>): Set<WcPendingApprovalSessionDTO>
}

View file

@ -0,0 +1,6 @@
package com.tangem.datasource.utils
import androidx.datastore.core.DataStore
import kotlinx.coroutines.flow.firstOrNull
suspend fun <T> DataStore<T>.getSyncOrNull(): T? = data.firstOrNull()

View file

@ -44,7 +44,11 @@ class NetworkLogsSaveInterceptor(
throw e
}
logResponseMessage(response, startNs)
if (restrictedForLogURLs.contains(request.url.host + request.url.encodedPath)) {
logResponseWithEmptyMessage(response, startNs)
} else {
logResponseMessage(response, startNs)
}
return response
}
@ -87,6 +91,14 @@ class NetworkLogsSaveInterceptor(
}
}
private fun logResponseWithEmptyMessage(response: Response, startNs: Long) {
val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs)
saveLogMessage(
"<-- ${response.code}",
" ${response.request.url} (${tookMs}ms)\n",
)
}
private fun logResponseMessage(response: Response, startNs: Long) {
val responseHeaders = response.headers
val responseBody = response.body!!
@ -202,4 +214,13 @@ class NetworkLogsSaveInterceptor(
private fun saveLogMessage(vararg messages: String) {
appLogsStore.saveLogMessage(tag = "NetworkLogs", *messages)
}
private companion object {
/**
* List of URLs (host + path) for which logging is restricted
*/
val restrictedForLogURLs = listOf(
"api.stakek.it/v1/yields/enabled",
)
}
}

View file

@ -42,7 +42,10 @@ class NetworkStatusDMSerializationTest {
{ "value": "0x123456", "type": "primary" },
{ "value": "0xabcdef", "type": "secondary" }
],
"amounts": { "ETH": "1.2345" }
"amounts": { "ETH": "1.2345" },
"yield_supply_statuses": {
"ETH": { "is_active": false, "is_initialized": false, "is_allowed_to_spend": false }
}
}
""".trimIndent()
@ -62,6 +65,13 @@ class NetworkStatusDMSerializationTest {
NetworkStatusDM.Address("0xabcdef", NetworkStatusDM.Address.Type.Secondary),
),
amounts = mapOf("ETH" to BigDecimal("1.2345")),
yieldSupplyStatuses = mapOf(
"ETH" to NetworkStatusDM.YieldSupplyStatus(
isActive = false,
isInitialized = false,
isAllowedToSpend = false,
),
),
)
Truth.assertThat(result).isEqualTo(expected)
@ -82,6 +92,13 @@ class NetworkStatusDMSerializationTest {
NetworkStatusDM.Address("0xabcdef", NetworkStatusDM.Address.Type.Secondary),
),
amounts = mapOf("ETH" to BigDecimal("1.2345")),
yieldSupplyStatuses = mapOf(
"ETH" to NetworkStatusDM.YieldSupplyStatus(
isActive = false,
isInitialized = false,
isAllowedToSpend = false,
),
),
)
// Act
@ -100,7 +117,10 @@ class NetworkStatusDMSerializationTest {
{ "value": "0x123456", "type": "primary" },
{ "value": "0xabcdef", "type": "secondary" }
],
"amounts": { "ETH": "1.2345" }
"amounts": { "ETH": "1.2345" },
"yield_supply_statuses": {
"ETH": { "is_active": false, "is_initialized": false, "is_allowed_to_spend": false }
}
}
""".stripJsonWhitespace()

View file

@ -280,7 +280,7 @@
<string name="common_origin_ring">Primärring</string>
<string name="common_passphrase">Passphrase</string>
<string name="common_paste">Einfügen</string>
<string name="common_privacy_policy">Datenschutzbestimmungen</string>
<string name="common_privacy_policy">Datenschutzrichtlinie</string>
<string name="common_range">%1$s-%2$s</string>
<string name="common_range_with_space">%1$s — %2$s</string>
<string name="common_read_more">Weiterlesen</string>
@ -317,6 +317,7 @@
<string name="common_supported_networks">Unterstützte Netzwerke</string>
<string name="common_swap">Tauschen</string>
<string name="common_tangem">Tangem</string>
<string name="common_tangem_wallet">Tangem Wallet</string>
<string name="common_terms_and_conditions">Allgemeine Geschäftsbedingungen</string>
<string name="common_terms_of_use">Nutzungsbedingungen</string>
<string name="common_today">Heute</string>
@ -418,7 +419,7 @@
<string name="express_exchange_by">Getauscht von %s</string>
<string name="express_exchange_notification_failed_text">Besuche die Website des Anbieters, um dein Geld zurückzuerhalten</string>
<string name="express_exchange_notification_failed_title">Fehler beim Vorgang durch Anbieter</string>
<string name="express_exchange_notification_long_transaction_time_text">Dein Wechsel dauert länger als erwartet. Wende Dich für Hilfe bitte an den Support Deines Anbieters.</string>
<string name="express_exchange_notification_long_transaction_time_text">Dein Swap dauert länger als üblich, aber Dein Geld ist absolut sicher und wird geliefert. Bei Fragen kannst Du Dich an das Support-Team des Anbieters wenden.</string>
<string name="express_exchange_notification_long_transaction_time_title">Lange Transaktionszeit</string>
<string name="express_exchange_notification_refund_text">Der Transaktionsbetrag wurde aufgrund von OKX- oder Bridge-Regeln in %1$s auf deine Wallet zurückerstattet. %2$s</string>
<string name="express_exchange_notification_refund_title">Der Betrag wurde in %1$s (%2$s Netzwerk) zurückerstattet.</string>

View file

@ -197,6 +197,7 @@
<string name="common_camera_denied_alert_message">カメラへのアクセスを許可していません。プライバシー設定を調整してください。</string>
<string name="common_cancel">キャンセル</string>
<string name="common_change">変更</string>
<string name="common_choose_account">アカウントを選択</string>
<string name="common_choose_action">アクションを選択</string>
<string name="common_choose_network">ネットワークを選択</string>
<string name="common_choose_token">トークンを選択</string>
@ -1247,6 +1248,14 @@
<string name="swapping_to_title">受け取る</string>
<string name="swapping_token_list_title">トークンを選択</string>
<string name="swapping_token_not_available">利用不可</string>
<string name="tangempay_onboarding_get_card_button_text">カードをGET</string>
<string name="tangempay_onboarding_pay_description">ウォレットに追加して、どこでもスマートフォンで支払いができます。</string>
<string name="tangempay_onboarding_pay_title">Apple Pay &amp; Google Pay</string>
<string name="tangempay_onboarding_purchases_description">USDC残高を使用して、毎日のお買い物をシームレスに支払います。</string>
<string name="tangempay_onboarding_purchases_title">日常の買い物</string>
<string name="tangempay_onboarding_security_description">カードの詳細は保護されており、アプリ内で完全に制御できます。</string>
<string name="tangempay_onboarding_security_title">内蔵セキュリティ</string>
<string name="tangempay_onboarding_title">無料のCryptoカード\nを数分で入手しましょう</string>
<string name="this_is_my_wallet_title">これは私のウォレットです</string>
<string name="toast_balances_hidden">残高非表示</string>
<string name="toast_balances_shown">残高表示</string>
@ -1634,7 +1643,6 @@
<string name="yield_module_earn_sheet_current_apy_title">現在のAPY</string>
<string name="yield_module_earn_sheet_my_funds_title">私の資金</string>
<string name="yield_module_earn_sheet_provider_description">あなたの%1$sはAaveに預けられ、利息が付きます。あなたは%2$sトークンを保有しており、これは残高を表し、時間の経過とともに増加します。入金すると、資金はAaveに預け入れられ、取引手数料を差し引いた利息が付きます。</string>
<string name="yield_module_earn_sheet_status_active">アクティブ</string>
<string name="yield_module_earn_sheet_title">利回りを得る</string>
<string name="yield_module_earn_sheet_total_earnings_title">総収益</string>
<string name="yield_module_earn_sheet_transfers_title">Aaveへの送金</string>
@ -1656,8 +1664,8 @@
<string name="yield_module_promo_screen_how_it_works_button_title">使い方</string>
<string name="yield_module_promo_screen_self_custodial_subtitle">Aaveは世界中で何百万人もの人々に信頼されています。総貸付額は104億ドルです。</string>
<string name="yield_module_promo_screen_self_custodial_title">分散型・自己管理型</string>
<string name="yield_module_promo_screen_terms_disclaimer">サービスを使用することにより、プロバイダーに同意したものとみなされます</string>
<string name="yield_module_promo_screen_title">年間%s%の収益</string>
<string name="yield_module_promo_screen_terms_disclaimer">サービスを利用することにより、プロバイダー\n %1$sおよび%2$sに同意したことになります</string>
<string name="yield_module_promo_screen_title">年間%s%%の収益</string>
<string name="yield_module_promo_screen_variable_rate_info">Aave • 変動金利</string>
<string name="yield_module_provider">Aave</string>
<string name="yield_module_rate_info_sheet_chart_average">平均%s</string>
@ -1670,6 +1678,7 @@
<string name="yield_module_start_earning_sheet_description">%sはAaveに供給され、すぐに利用可能になります</string>
<string name="yield_module_start_earning_sheet_fee_policy">手数料ポリシーを見る</string>
<string name="yield_module_start_earning_sheet_next_deposits">次回の入金は自動的にAaveに供給されます。</string>
<string name="yield_module_status_active">アクティブ</string>
<string name="yield_module_stop_earning">収益を停止する</string>
<string name="yield_module_stop_earning_sheet_description">オフにすると、Aaveから資金が引き出され、ウォレットの%sに戻され、報酬の獲得が停止されます。</string>
<string name="yield_module_stop_earning_sheet_fee_note">出金金額からネットワーク手数料が差し引かれます。</string>

View file

@ -644,6 +644,8 @@
<string name="notification_referral_promo_button">Присоединиться</string>
<string name="notification_referral_promo_text">Поделись промокодом — заработай 5 USDT с каждой покупки. Твои друзья получат скидку 10% на карту Tangem!</string>
<string name="notification_referral_promo_title">Получай бонусы за каждого друга!</string>
<string name="notification_sepa_text">Tangem снижает комиссии SEPA почти до нуля, предлагая вам лучшие условия.</string>
<string name="notification_sepa_title">Покупайте криптовалюту без комиссии!</string>
<string name="onboarding_access_code_feature_1_description">Установите единый код доступа для защиты всех ваших карт или колец</string>
<string name="onboarding_access_code_feature_1_title">Защита</string>
<string name="onboarding_access_code_feature_2_description">Установите индивидуальный код доступа для каждой карты или кольца позже.</string>

View file

@ -200,6 +200,7 @@
<string name="common_camera_denied_alert_message">You have not given access to your camera, please adjust your privacy settings</string>
<string name="common_cancel">Cancel</string>
<string name="common_change">Change</string>
<string name="common_choose_account">Choose account</string>
<string name="common_choose_action">Choose action</string>
<string name="common_choose_network">Choose network</string>
<string name="common_choose_token">Choose token</string>
@ -511,18 +512,18 @@
<string name="hot_access_code_set_biometric_ask">Do you want to allow “Tangem” to use biometric authentication? To confirm your identity and open the app</string>
<string name="hot_crypto_add_token_subtitle">to %s</string>
<string name="hot_crypto_token_network">On %s network</string>
<string name="hw_access_code_create_alert_title">Are you sure you want to exit the access code creation process?</string>
<string name="hw_access_code_create_alert_title">Are you sure you want to cancel access code setup?</string>
<string name="hw_activation_need_backup">Backup Now</string>
<string name="hw_activation_need_description">To complete setup, back up your wallet and secure app access with a Access Code.</string>
<string name="hw_activation_need_finish">Finish Now</string>
<string name="hw_activation_need_title">Finish Wallet Activation</string>
<string name="hw_activation_need_warning_description">To complete setup, secure app access with Access Code.</string>
<string name="hw_activation_need_warning_description">Complete setup by securing the app with an access code.</string>
<string name="hw_backup_alert_description">If you do, you\'ll need to start over.</string>
<string name="hw_backup_alert_title">Are you sure you want to exit the activation process?</string>
<string name="hw_backup_alert_title">Are you sure you want to quit the activation process?</string>
<string name="hw_backup_close_description">If you do, you\'ll need to start over.</string>
<string name="hw_backup_google_drive_description">Recover an existing wallet stored in your Google Drive backup</string>
<string name="hw_backup_google_drive_title">Google Drive Backup</string>
<string name="hw_backup_hardware_description">Upgrade your security right away with a best in class hardware wallet from Tangem.</string>
<string name="hw_backup_google_drive_description">Recover existing wallet via Google Drive backup</string>
<string name="hw_backup_google_drive_title">Google Drive backup</string>
<string name="hw_backup_hardware_description">Level up your security with a superior Tangem hardware wallet.</string>
<string name="hw_backup_hardware_title">Hardware Wallet</string>
<string name="hw_backup_need_action">Go to backup</string>
<string name="hw_backup_need_description">To secure your wallet with a Access Code, complete the backup first.</string>
@ -537,22 +538,22 @@
<string name="hw_create_title">Create Mobile Wallet</string>
<string name="hw_import_seed_phrase_already_imported">This recovery phrase has already been imported</string>
<string name="hw_mobile_wallet">Mobile Wallet</string>
<string name="hw_upgrade_error_card_already_has_wallet">This device cant be used for upgrade, it already contains another wallet.</string>
<string name="hw_upgrade_error_card_key_import">Choose another device, this one cant be used for upgrade.</string>
<string name="hw_upgrade_error_card_already_has_wallet">Cant upgrade. A wallet already exists on this device</string>
<string name="hw_upgrade_error_card_key_import">Pick another device. This one cant be used for the upgrade.</string>
<string name="hw_upgrade_error_wallet2_card_required">An error occurred during the operation.</string>
<string name="hw_upgrade_funds_access_description">Your funds stay safe and fully accessible during the process</string>
<string name="hw_upgrade_funds_access_title">Funds access</string>
<string name="hw_upgrade_general_security_description">All private wallet data will be removed from the mobile app and stored securely on your Tangem device only</string>
<string name="hw_upgrade_general_security_description">Your wallet data will be erased from the app and stored on your Tangem device</string>
<string name="hw_upgrade_general_security_title">General Security</string>
<string name="hw_upgrade_key_migration_description">Private keys will be moved from the app to your Tangem card or ring</string>
<string name="hw_upgrade_key_migration_description">Private keys will be moved from the app to your Tangem device</string>
<string name="hw_upgrade_key_migration_title">Key Migration</string>
<string name="hw_upgrade_scan_device">Scan device</string>
<string name="hw_upgrade_start_action">Start upgrade</string>
<string name="hw_upgrade_start_description">Youre about to upgrade your wallet to Tangem Wallet. This will keep your assets safe with cold storage.</string>
<string name="hw_upgrade_start_description">Youre about to upgrade to a Tangem device, where your assets stay safe in cold storage.</string>
<string name="hw_upgrade_start_title">Tangem Wallet</string>
<string name="hw_upgrade_title">Upgrade to Hardware Wallet</string>
<string name="hw_upgrade_to_cold_banner_description">Keep your crypto safe with Tangems best-in-class hardware wallet.</string>
<string name="hw_upgrade_to_cold_banner_title">Upgrade wallet with a hardware backup</string>
<string name="hw_upgrade_to_cold_banner_description">Keep your crypto safe with Tangem\'s top-tier hardware wallet.</string>
<string name="hw_upgrade_to_cold_banner_title">Upgrade your wallet to hardware storage</string>
<string name="information_generated_with_ai">This information was generated with AI.\nTap here, if you find any errors.</string>
<string name="initial_message_change_access_code_body">To change the access code tap the card or ring as shown above and do not remove until the end of the operation</string>
<string name="initial_message_change_passcode_body">To change the passcode tap the card as shown above and do not remove until the end of the operation</string>
@ -758,8 +759,8 @@
<string name="notification_referral_promo_text">Share your code - earn 5 USDT per sale. Your friend gets 10% OFF.</string>
<string name="notification_referral_promo_title">Get REWARDS for every friend!</string>
<string name="notification_sepa_button">Buy crypto</string>
<string name="notification_sepa_text">Enjoy zero fees when purchasing crypto via SEPA transfer.</string>
<string name="notification_sepa_title">Buy Crypto with SEPA</string>
<string name="notification_sepa_text">Tangem slashes SEPA fees to almost nothing, giving you the best deal yet.</string>
<string name="notification_sepa_title">Buy crypto and don\'t pay fees!</string>
<string name="onboarding_access_code_feature_1_description">Set up a single access code to protect all your devices.</string>
<string name="onboarding_access_code_feature_1_title">Protect</string>
<string name="onboarding_access_code_feature_2_description">Set an individual access code for each card or ring later.</string>
@ -1266,6 +1267,14 @@
<string name="swapping_to_title">You receive</string>
<string name="swapping_token_list_title">Choose token</string>
<string name="swapping_token_not_available">not available</string>
<string name="tangempay_onboarding_get_card_button_text">Get card</string>
<string name="tangempay_onboarding_pay_description">Add to your wallet and pay with your phone anywhere.</string>
<string name="tangempay_onboarding_pay_title">Apple Pay &amp; Google Pay</string>
<string name="tangempay_onboarding_purchases_description">Use your USDC balance to pay for everyday purchases seamlessly.</string>
<string name="tangempay_onboarding_purchases_title">Everyday purchases</string>
<string name="tangempay_onboarding_security_description">Your card details are protected — full control in the app.</string>
<string name="tangempay_onboarding_security_title">Built-in security</string>
<string name="tangempay_onboarding_title">Get your free Crypto Card\nin minutes</string>
<string name="this_is_my_wallet_title">This is my wallet</string>
<string name="toast_balances_hidden">Balances hidden</string>
<string name="toast_balances_shown">Balances shown</string>
@ -1614,6 +1623,7 @@
<string name="wc_alert_wrong_card_title">We\'ve got some kind of problem</string>
<string name="wc_all_dapps_disconnected">All dApps disconnected</string>
<string name="wc_allow_to_spend">Allow to spend</string>
<string name="wc_approve_description">By approving, you allow dApp or Smart contract to use tokens in future transactions.</string>
<string name="wc_common_address">Address</string>
<string name="wc_common_connect">Connect</string>
<string name="wc_common_loading">Loading</string>
@ -1702,7 +1712,6 @@
<string name="yield_module_earn_sheet_current_apy_title">Current APY</string>
<string name="yield_module_earn_sheet_my_funds_title">My Funds</string>
<string name="yield_module_earn_sheet_provider_description">Your %1$s is now deposited in Aave and earning interest. You hold a%2$s token, which represents your balance and grows over time. When you top up, funds go to Aave to earn interest, minus a transaction fee.</string>
<string name="yield_module_earn_sheet_status_active">Active</string>
<string name="yield_module_earn_sheet_title">Earn</string>
<string name="yield_module_earn_sheet_total_earnings_title">Total earnings</string>
<string name="yield_module_earn_sheet_transfers_title">Transfers to Aave</string>
@ -1724,8 +1733,8 @@
<string name="yield_module_promo_screen_how_it_works_button_title">How it works?</string>
<string name="yield_module_promo_screen_self_custodial_subtitle">Aave is trusted by millions worldwide. Total lended value is $10.4B.</string>
<string name="yield_module_promo_screen_self_custodial_title">Decentralized and self-custodial</string>
<string name="yield_module_promo_screen_terms_disclaimer">By using service, you agree with provider</string>
<string name="yield_module_promo_screen_title">Earn %s% yearly</string>
<string name="yield_module_promo_screen_terms_disclaimer">By using service, you agree with provider\n%1$s and %2$s</string>
<string name="yield_module_promo_screen_title">Earn %s%% yearly</string>
<string name="yield_module_promo_screen_variable_rate_info">Aave • Variable Interest Rate</string>
<string name="yield_module_provider">Aave</string>
<string name="yield_module_rate_info_sheet_chart_average">Avg %s</string>
@ -1738,6 +1747,8 @@
<string name="yield_module_start_earning_sheet_description">Your %s will be supplied to Aave and will stay instantly available</string>
<string name="yield_module_start_earning_sheet_fee_policy">See fee policy</string>
<string name="yield_module_start_earning_sheet_next_deposits">Your next deposits will be automatically supplied to Aave.</string>
<string name="yield_module_status_active">Active</string>
<string name="yield_module_status_paused">Paused</string>
<string name="yield_module_stop_earning">Stop earning</string>
<string name="yield_module_stop_earning_sheet_description">Turning off will withdraw your funds from Aave, return them to %s in your wallet, and stop earning rewards.</string>
<string name="yield_module_stop_earning_sheet_fee_note">The network fee will be deducted from the amount you withdraw.</string>

View file

@ -112,7 +112,7 @@ fun PrimaryButton(
@Composable
fun PrimaryButtonIconEnd(
text: String,
@DrawableRes iconResId: Int,
@DrawableRes iconResId: Int?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
size: TangemButtonSize = TangemButtonSize.Default,
@ -122,7 +122,7 @@ fun PrimaryButtonIconEnd(
TangemButton(
modifier = modifier,
text = text,
icon = TangemButtonIconPosition.End(iconResId),
icon = if (iconResId != null) TangemButtonIconPosition.End(iconResId) else TangemButtonIconPosition.None,
onClick = onClick,
colors = TangemButtonsDefaults.primaryButtonColors,
enabled = enabled,

View file

@ -30,7 +30,7 @@ import com.tangem.core.ui.components.fields.SimpleDialogTextField
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.DialogTestTags
import com.tangem.core.ui.test.BaseDialogTestTags
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -219,13 +219,14 @@ private fun TangemDialog(
color = TangemTheme.colors.background.primary,
)
.padding(vertical = TangemTheme.dimens.spacing24)
.testTag(DialogTestTags.DIALOG_CONTAINER),
.testTag(BaseDialogTestTags.CONTAINER),
) {
if (title != null) {
Text(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing24)
.fillMaxWidth(),
.fillMaxWidth()
.testTag(BaseDialogTestTags.TITLE),
text = title,
style = when (type) {
is DialogType.Message -> TangemTheme.typography.h2
@ -262,7 +263,8 @@ private fun DialogContent(type: DialogType, modifier: Modifier = Modifier) {
Text(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing24)
.fillMaxWidth(),
.fillMaxWidth()
.testTag(BaseDialogTestTags.TEXT),
text = type.message,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,

View file

@ -3,6 +3,7 @@ package com.tangem.core.ui.components
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.PixelFormat
import android.os.Build
import android.view.KeyEvent
import android.view.View
import android.view.WindowManager
@ -100,8 +101,12 @@ private class FullScreenLayout(
width = WindowManager.LayoutParams.MATCH_PARENT
height = WindowManager.LayoutParams.MATCH_PARENT
format = PixelFormat.TRANSLUCENT
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
if (focusable && Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
} else {
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
}
}
fun show() {
@ -142,7 +147,7 @@ private class FullScreenLayout(
fun dispose() {
dismiss()
setViewTreeSavedStateRegistryOwner(null)
setViewTreeLifecycleOwner(null)
setViewTreeSavedStateRegistryOwner(null)
setViewTreeViewModelStoreOwner(null)
}

View file

@ -11,8 +11,10 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.BaseBottomSheetTestTags
/**
* [Show in Figma](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=281-248&mode=design&t=bXqehWPHyATKcZEW-4)
@ -38,7 +40,7 @@ fun SimpleSettingsRow(
onItemsClick()
}
},
),
).testTag(BaseBottomSheetTestTags.ACTION_TITLE),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
) {

View file

@ -15,10 +15,12 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.MarketsTestTags
@Suppress("MagicNumber")
@Composable
@ -56,7 +58,7 @@ fun TangemSwitch(
onClick = {
onCheckedChange(!checked)
},
),
).testTag(MarketsTestTags.ADD_TO_PORTFOLIO_SWITCH),
) {
BoxWithConstraints(
modifier = Modifier

View file

@ -0,0 +1,132 @@
package com.tangem.core.ui.components.account
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
enum class AccountIconSize {
Default, Large, Medium, Small, ExtraSmall
}
/**
* Displays a portfolio icon using a predefined vector resource.
*
* The background color is defined by [color], and the icon tint is constant white.
* The icon size and container shape are adapted based on the provided [size].
*
* @param resId The vector drawable resource ID to display.
* @param color The background color of the icon container.
* @param size The size of the icon, defined by [AccountIconSize].
*/
@Composable
fun AccountResIcon(@DrawableRes resId: Int, color: Color, size: AccountIconSize, modifier: Modifier = Modifier) {
val boxModifier = modifier.selectBoxModifier(size)
val iconSize = Modifier.selectIconSize(size)
Box(
contentAlignment = Alignment.Center,
modifier = boxModifier.background(color),
) {
Icon(
modifier = iconSize,
tint = TangemTheme.colors.text.constantWhite,
imageVector = ImageVector.vectorResource(id = resId),
contentDescription = null,
)
}
}
/**
* Displays a portfolio icon using a single character.
*
* The background color is defined by [color], the text is uppercased,
* and its style is chosen based on the provided [size].
* The container shape and size also adapt to [size].
*
* @param char The character to display inside the icon.
* @param color The background color of the icon container.
* @param size The size of the icon, defined by [AccountIconSize].
*/
@Composable
fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: Modifier = Modifier) {
val boxModifier = modifier.selectBoxModifier(size)
val textStyle = when (size) {
AccountIconSize.Default -> TangemTheme.typography.h3
AccountIconSize.Large -> TangemTheme.typography.h1
AccountIconSize.Medium -> TangemTheme.typography.subtitle1
AccountIconSize.Small -> TangemTheme.typography.subtitle2
AccountIconSize.ExtraSmall -> TangemTheme.typography.caption1
}
Box(
contentAlignment = Alignment.Center,
modifier = boxModifier.background(color),
) {
Text(
text = char.uppercase(),
style = textStyle,
color = TangemTheme.colors.text.constantWhite,
)
}
}
private fun Modifier.selectIconSize(size: AccountIconSize): Modifier = when (size) {
AccountIconSize.Default -> this.size(20.dp)
AccountIconSize.Large -> this.size(40.dp)
AccountIconSize.Medium -> this.size(16.dp)
AccountIconSize.Small -> this.size(12.dp)
AccountIconSize.ExtraSmall -> this.size(8.dp)
}
private fun Modifier.selectBoxModifier(size: AccountIconSize): Modifier = when (size) {
AccountIconSize.Default -> size(36.dp).clip(RoundedCornerShape(10.dp))
AccountIconSize.Large -> size(88.dp).clip(RoundedCornerShape(24.dp))
AccountIconSize.Medium -> size(28.dp).clip(RoundedCornerShape(8.dp))
AccountIconSize.Small -> size(20.dp).clip(RoundedCornerShape(6.dp))
AccountIconSize.ExtraSmall -> size(14.dp).clip(RoundedCornerShape(4.dp))
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
Row(
modifier = Modifier.background(TangemTheme.colors.background.primary),
) {
Sample()
}
}
}
@Composable
private fun Sample() {
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) {
AccountResIcon(resId = R.drawable.ic_shirt_24, color = Color.Red, size = AccountIconSize.Default)
AccountResIcon(resId = R.drawable.ic_rounded_star_24, color = Color.Blue, size = AccountIconSize.Large)
AccountResIcon(resId = R.drawable.ic_user_24, color = Color.Magenta, size = AccountIconSize.Medium)
AccountResIcon(resId = R.drawable.ic_family_24, color = Color.DarkGray, size = AccountIconSize.Small)
AccountResIcon(resId = R.drawable.ic_wallet_24, color = Color.Green, size = AccountIconSize.ExtraSmall)
}
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) {
AccountCharIcon(char = 'D', color = Color.Red, size = AccountIconSize.Default)
AccountCharIcon(char = 'L', color = Color.Blue, size = AccountIconSize.Large)
AccountCharIcon(char = 'M', color = Color.Magenta, size = AccountIconSize.Medium)
AccountCharIcon(char = 'S', color = Color.DarkGray, size = AccountIconSize.Small)
AccountCharIcon(char = 'E', color = Color.Green, size = AccountIconSize.ExtraSmall)
}
}

View file

@ -32,6 +32,12 @@ sealed class TopAppBarButtonUM(
enabled = enabled,
)
fun Close(enabled: Boolean = true, onCloseClick: () -> Unit) = Icon(
iconRes = R.drawable.ic_close_24,
onClicked = onCloseClick,
enabled = enabled,
)
fun Text(text: TextReference, onTextClicked: () -> Unit, enabled: Boolean = true) = Text(
text = text,
onClicked = onTextClicked,

View file

@ -49,6 +49,8 @@ inline fun <reified T : TangemBottomSheetConfigContent> TangemModalBottomSheet(
config: TangemBottomSheetConfig,
containerColor: Color = TangemTheme.colors.background.primary,
skipPartiallyExpanded: Boolean = true,
dismissOnClickOutside: Boolean = true,
scrollableContent: Boolean = true,
noinline onBack: (() -> Unit)? = null,
crossinline title: @Composable BoxScope.(T) -> Unit = {},
crossinline content: @Composable ColumnScope.(T) -> Unit,
@ -61,6 +63,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> TangemModalBottomSheet(
containerColor = containerColor,
title = title,
content = content,
scrollableContent = scrollableContent,
skipPartiallyExpanded = skipPartiallyExpanded,
)
} else {
@ -69,6 +72,8 @@ inline fun <reified T : TangemBottomSheetConfigContent> TangemModalBottomSheet(
containerColor = containerColor,
title = title,
content = content,
dismissOnClickOutside = dismissOnClickOutside,
scrollableContent = scrollableContent,
onBack = onBack,
skipPartiallyExpanded = skipPartiallyExpanded,
)
@ -81,21 +86,38 @@ inline fun <reified T : TangemBottomSheetConfigContent> DefaultModalBottomSheet(
config: TangemBottomSheetConfig,
containerColor: Color,
skipPartiallyExpanded: Boolean = true,
dismissOnClickOutside: Boolean = true,
scrollableContent: Boolean = true,
noinline onBack: (() -> Unit)? = null,
crossinline title: @Composable (BoxScope.(T) -> Unit),
crossinline content: @Composable (ColumnScope.(T) -> Unit),
) {
var isVisible by remember { mutableStateOf(value = config.isShown) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = skipPartiallyExpanded)
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = skipPartiallyExpanded,
confirmValueChange = {
if (!dismissOnClickOutside) {
it != SheetValue.Hidden // Ignore transitions to hidden (prevents dismiss on outside click/back press)
} else {
true
}
},
)
if (isVisible && config.content is T) {
BasicModalBottomSheet<T>(
config = config,
sheetState = sheetState,
containerColor = containerColor,
title = title,
onBack = onBack,
content = content,
bsContent = {
BsContent(
config = config,
containerColor = containerColor,
scrollableContent = scrollableContent,
title = title,
content = content,
)
},
)
}
@ -114,6 +136,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> PreviewModalBottomSheet(
config: TangemBottomSheetConfig,
containerColor: Color,
skipPartiallyExpanded: Boolean = true,
scrollableContent: Boolean = true,
crossinline title: @Composable (BoxScope.(T) -> Unit),
crossinline content: @Composable (ColumnScope.(T) -> Unit),
) {
@ -125,49 +148,64 @@ inline fun <reified T : TangemBottomSheetConfigContent> PreviewModalBottomSheet(
density = LocalDensity.current,
),
onBack = null,
containerColor = containerColor,
title = title,
content = content,
bsContent = {
BsContent(
config = config,
containerColor = containerColor,
scrollableContent = scrollableContent,
title = title,
content = content,
)
},
)
}
@Composable
inline fun <reified T : TangemBottomSheetConfigContent> BsContent(
config: TangemBottomSheetConfig,
containerColor: Color,
scrollableContent: Boolean = true,
crossinline title: @Composable (BoxScope.(T) -> Unit),
crossinline content: @Composable (ColumnScope.(T) -> Unit),
) {
val model = config.content as? T ?: return
val maxHeight = LocalConfiguration.current.screenHeightDp * MODAL_SHEET_MAX_HEIGHT
Column(
modifier = Modifier
.systemBarsPadding()
.padding(horizontal = 8.dp, vertical = 8.dp)
.clip(TangemTheme.shapes.roundedCornersLarge)
.background(containerColor)
.heightIn(max = maxHeight.dp)
.fillMaxWidth(),
) {
Box(modifier = Modifier.fillMaxWidth()) {
title(model)
}
if (scrollableContent) {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
content(model)
}
} else {
content(model)
}
}
}
@Suppress("LongParameterList")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheet(
config: TangemBottomSheetConfig,
sheetState: SheetState,
containerColor: Color,
noinline onBack: (() -> Unit)? = null,
crossinline title: @Composable (BoxScope.(T) -> Unit),
crossinline content: @Composable (ColumnScope.(T) -> Unit),
noinline bsContent: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
) {
val model = config.content as? T ?: return
val bsContent: @Composable ColumnScope.() -> Unit = {
val maxHeight = LocalConfiguration.current.screenHeightDp * MODAL_SHEET_MAX_HEIGHT
Column(
modifier = Modifier
.systemBarsPadding()
.padding(horizontal = 8.dp, vertical = 8.dp)
.clip(TangemTheme.shapes.roundedCornersLarge)
.background(containerColor)
.heightIn(max = maxHeight.dp)
.fillMaxWidth(),
) {
Box(modifier = Modifier.fillMaxWidth()) {
title(model)
}
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
content(model)
}
}
}
if (onBack != null) {
ModalBottomSheetWithBackHandling(
modifier = modifier,

View file

@ -8,6 +8,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -20,6 +21,7 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.WalletConnectDetailsBottomSheetTestTags
/**
* Title component for [TangemModalBottomSheet] with [TangemIconButton] for buttons.
@ -54,7 +56,9 @@ fun TangemModalBottomSheetTitle(
text = title.resolveReference(),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.align(Alignment.CenterHorizontally),
modifier = Modifier
.align(Alignment.CenterHorizontally)
.testTag(WalletConnectDetailsBottomSheetTestTags.TITLE),
)
}
if (subtitle != null) {
@ -62,7 +66,9 @@ fun TangemModalBottomSheetTitle(
text = subtitle.resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.align(Alignment.CenterHorizontally),
modifier = Modifier
.align(Alignment.CenterHorizontally)
.testTag(WalletConnectDetailsBottomSheetTestTags.DATE),
)
}
}
@ -72,7 +78,8 @@ fun TangemModalBottomSheetTitle(
onClick = onEndClick,
modifier = Modifier
.padding(16.dp)
.align(Alignment.CenterEnd),
.align(Alignment.CenterEnd)
.testTag(WalletConnectDetailsBottomSheetTestTags.CLOSE_BUTTON),
)
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.core.ui.components.bottomsheets.modal
import android.content.res.Configuration
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
@ -49,7 +50,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> TangemModalBottomSheetWi
noinline onBack: (() -> Unit)? = null,
crossinline title: @Composable BoxScope.(T) -> Unit = {},
crossinline content: @Composable (T) -> Unit,
crossinline footer: @Composable (BoxScope.(T) -> Unit),
noinline footer: @Composable (BoxScope.(T) -> Unit)?,
) {
val isAlwaysVisible = LocalBottomSheetAlwaysVisible.current
@ -84,7 +85,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> DefaultModalBottomSheetW
noinline onBack: (() -> Unit)? = null,
crossinline title: @Composable BoxScope.(T) -> Unit,
crossinline content: @Composable (T) -> Unit,
crossinline footer: @Composable (BoxScope.(T) -> Unit),
noinline footer: @Composable (BoxScope.(T) -> Unit)?,
) {
var isVisible by remember { mutableStateOf(value = config.isShown) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = skipPartiallyExpanded)
@ -118,7 +119,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> PreviewModalBottomSheetW
skipPartiallyExpanded: Boolean = true,
crossinline title: @Composable BoxScope.(T) -> Unit,
crossinline content: @Composable (T) -> Unit,
crossinline footer: @Composable BoxScope.(T) -> Unit,
noinline footer: @Composable (BoxScope.(T) -> Unit)?,
) {
BasicModalBottomSheetWithFooter<T>(
config = config,
@ -145,7 +146,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheetWit
noinline onBack: (() -> Unit)? = null,
crossinline title: @Composable BoxScope.(T) -> Unit,
crossinline content: @Composable (T) -> Unit,
crossinline footer: @Composable (BoxScope.(T) -> Unit),
noinline footer: @Composable (BoxScope.(T) -> Unit)?,
modifier: Modifier = Modifier,
) {
val model = config.content as? T ?: return
@ -156,8 +157,13 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheetWit
val scrollState = rememberScrollState(initial = initial)
val isKeyboardOpen by rememberIsKeyboardVisible()
val buttonHeight = TangemTheme.dimens.spacing80
val contentBottomPadding = TangemTheme.dimens.spacing80
val buttonHeight by animateDpAsState(
if (footer != null) {
80.dp
} else {
0.dp
},
)
// Offset calculation for keyboard scroll adjustment:
// 1) Button height (footer)
// 2) Column content bottom padding
@ -202,7 +208,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheetWit
Column(
modifier = Modifier
.verticalScroll(state = scrollState)
.padding(bottom = contentBottomPadding),
.padding(bottom = buttonHeight),
) {
content(model)
}
@ -218,7 +224,9 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheetWit
.height(buttonHeight)
.align(Alignment.BottomCenter),
) {
footer(model)
if (footer != null) {
footer(model)
}
}
}
}

View file

@ -2,12 +2,13 @@ package com.tangem.core.ui.components.containers
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.res.TangemTheme
@ -17,14 +18,14 @@ import com.tangem.core.ui.res.TangemTheme
*
* @param modifier of component
* @param footer text
* @param footerTopPadding padding between footer and field
* @param paddingValues padding between footer and field
* @param content field content
*/
@Composable
fun FooterContainer(
modifier: Modifier = Modifier,
footer: TextReference? = null,
footerTopPadding: Dp = TangemTheme.dimens.spacing8,
paddingValues: PaddingValues = PaddingValues(top = 8.dp),
content: @Composable () -> Unit,
) {
Column(modifier = modifier) {
@ -36,7 +37,7 @@ fun FooterContainer(
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.padding(top = footerTopPadding),
.padding(paddingValues),
)
}
}

View file

@ -13,7 +13,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import com.tangem.core.ui.R
import com.tangem.core.ui.components.account.AccountCharIcon
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.account.AccountResIcon
import com.tangem.core.ui.components.currency.DefaultCurrencyIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
@Composable
@ -58,6 +62,18 @@ internal fun ContentIcon(
background = icon.background,
alpha = alpha,
)
is CurrencyIconState.CryptoPortfolio.Icon -> AccountResIcon(
modifier = modifier,
resId = icon.resId,
color = icon.color,
size = AccountIconSize.Default,
)
is CurrencyIconState.CryptoPortfolio.Letter -> AccountCharIcon(
modifier = modifier,
char = icon.char.resolveReference().first(),
color = icon.color,
size = AccountIconSize.Default,
)
CurrencyIconState.Loading,
CurrencyIconState.Locked,
is CurrencyIconState.Empty,

View file

@ -48,6 +48,8 @@ fun CurrencyIcon(
is CurrencyIconState.FiatIcon,
is CurrencyIconState.CustomTokenIcon,
is CurrencyIconState.TokenIcon,
is CurrencyIconState.CryptoPortfolio.Icon,
is CurrencyIconState.CryptoPortfolio.Letter,
-> {
ContentIconContainer(
icon = state,

View file

@ -4,6 +4,7 @@ import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
/**
* Represents the various states an icon can be in.
@ -86,6 +87,25 @@ sealed class CurrencyIconState {
override val topBadgeIconResId: Int? = null
}
@Immutable
sealed class CryptoPortfolio : CurrencyIconState() {
override val showCustomBadge: Boolean = false
override val topBadgeIconResId: Int? = null
abstract val color: Color
data class Icon(
@DrawableRes val resId: Int,
override val color: Color,
override val isGrayscale: Boolean,
) : CryptoPortfolio()
data class Letter(
val char: TextReference,
override val color: Color,
override val isGrayscale: Boolean,
) : CryptoPortfolio()
}
data object Loading : CurrencyIconState() {
override val isGrayscale: Boolean = false
override val showCustomBadge: Boolean = false
@ -125,6 +145,12 @@ sealed class CurrencyIconState {
showCustomBadge = showCustomBadge,
topBadgeIconResId = topBadgeIconResId,
)
is CryptoPortfolio.Icon -> copy(
isGrayscale = isGrayscale,
)
is CryptoPortfolio.Letter -> copy(
isGrayscale = isGrayscale,
)
is Loading,
is Locked,
is Empty,

View file

@ -24,7 +24,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.StakingSendScreenTestTags
import com.tangem.core.ui.test.SendScreenTestTags
import com.tangem.core.ui.utils.*
import java.math.BigDecimal
import java.text.DecimalFormat
@ -106,7 +106,7 @@ fun AmountTextField(
visualTransformation = visualTransformation,
modifier = Modifier
.background(backgroundColor)
.testTag(StakingSendScreenTestTags.INPUT_TEXT_FIELD),
.testTag(SendScreenTestTags.INPUT_TEXT_FIELD),
)
}
}

View file

@ -25,7 +25,6 @@ import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.utils.StringsSigns.PASSWORD_VISUAL_CHAR
@ -159,14 +158,17 @@ private fun CellDecoration(
)
}
} else {
Text(
Box(
modifier = Modifier.sizeIn(minWidth = minWidth, minHeight = minHeight),
text = text,
style = TangemTheme.typography.h3,
color = color,
textAlign = TextAlign.Center,
lineHeight = 48.sp,
)
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = text,
style = TangemTheme.typography.h3,
color = color,
textAlign = TextAlign.Center,
)
}
}
}
}

View file

@ -11,7 +11,6 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@ -39,7 +38,7 @@ import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SelectCountryBottomSheetTestTags
import com.tangem.core.ui.test.BaseSearchBarTestTags
@Composable
fun SearchBar(
@ -47,10 +46,10 @@ fun SearchBar(
modifier: Modifier = Modifier,
colors: TextFieldColors = TangemSearchBarDefaults.defaultTextFieldColors,
enabled: Boolean = true,
focusRequester: FocusRequester = remember { FocusRequester() },
) {
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
BasicTextField(
@ -65,7 +64,7 @@ fun SearchBar(
}
}
.focusRequester(focusRequester)
.testTag(SelectCountryBottomSheetTestTags.SEARCH_BAR),
.testTag(BaseSearchBarTestTags.SEARCH_BAR),
enabled = enabled,
value = state.query,
onValueChange = state.onQueryChange,
@ -97,10 +96,6 @@ fun SearchBar(
)
},
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
@Suppress("LongParameterList")

View file

@ -0,0 +1,104 @@
package com.tangem.core.ui.components.grid
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* EnumeratedTwoColumnGrid component
*
* @param items component items
* @param modifier composable modifier
*
*/
@Composable
fun EnumeratedTwoColumnGrid(items: ImmutableList<EnumeratedTwoColumnGridItem>, modifier: Modifier = Modifier) {
VerticalGrid(
modifier = modifier,
items = items,
) { item ->
Row(
modifier = Modifier.padding(all = TangemTheme.dimens.size8),
verticalAlignment = Alignment.CenterVertically,
) {
if (LocalLayoutDirection.current == LayoutDirection.Ltr) {
Text(
modifier = Modifier.width(TangemTheme.dimens.size40),
text = "${item.index}.",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
Text(
text = item.mnemonic,
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
)
} else {
Text(
text = item.mnemonic,
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier.width(TangemTheme.dimens.size40),
text = "${item.index}.",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
}
}
}
}
@Composable
private inline fun <T> VerticalGrid(
items: ImmutableList<T>,
modifier: Modifier = Modifier,
crossinline content: @Composable (T) -> Unit,
) {
val columnLength = items.size / 2
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
repeat(2) { index ->
Column {
for (i in 0 until columnLength) {
val item = items[index * columnLength + i]
content(item)
}
}
}
}
}
@Preview(widthDp = 360, heightDp = 640, showBackground = true)
@Preview(widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
EnumeratedTwoColumnGrid(
items = List(24) {
EnumeratedTwoColumnGridItem(
index = it + 1,
mnemonic = "word${it + 1}",
)
}.toImmutableList(),
)
}
}

View file

@ -0,0 +1,6 @@
package com.tangem.core.ui.components.grid.entity
data class EnumeratedTwoColumnGridItem(
val index: Int,
val mnemonic: String,
)

View file

@ -17,6 +17,7 @@ import androidx.compose.ui.Alignment.Companion.CenterEnd
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -32,6 +33,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SendAddressScreenTestTags
import com.tangem.core.ui.utils.DEFAULT_ANIMATION_DURATION
import kotlinx.coroutines.delay
@ -92,6 +94,7 @@ fun InputRowRecipient(
text = it.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = color,
modifier = Modifier.testTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD_TITLE),
)
}
Box(
@ -115,7 +118,8 @@ fun InputRowRecipient(
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing12)
.weight(1f)
.align(CenterVertically),
.align(CenterVertically)
.testTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD),
)
CrossIcon(
onClick = onPasteClick,

View file

@ -2,19 +2,22 @@ package com.tangem.core.ui.components.label
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.label.entity.LabelStyle
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
@ -48,8 +51,18 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) {
},
)
val iconColor by animateColorAsState(
targetValue = when (state.style) {
LabelStyle.ACCENT -> TangemTheme.colors.icon.accent
LabelStyle.REGULAR -> TangemTheme.colors.icon.informative
LabelStyle.WARNING -> TangemTheme.colors.icon.warning
},
)
AnimatedContent(targetState = state.text) { text ->
Box(
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier
.padding(horizontal = 4.dp)
.background(
@ -63,6 +76,15 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) {
style = TangemTheme.typography.caption1,
color = textColor,
)
AnimatedVisibility(state.icon != null) {
val wrappedIcon = remember(this) { requireNotNull(state.icon) }
Icon(
imageVector = ImageVector.vectorResource(wrappedIcon),
tint = iconColor,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
}
}
}
}
@ -73,6 +95,7 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) {
private fun LabelPreview() {
TangemThemePreview {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(16.dp),
) {
Label(
@ -81,24 +104,39 @@ private fun LabelPreview() {
style = LabelStyle.REGULAR,
),
)
Spacer(modifier = Modifier.height(8.dp))
Label(
state = LabelUM(
text = TextReference.Str("Accent Label"),
style = LabelStyle.ACCENT,
),
)
Spacer(modifier = Modifier.height(8.dp))
Label(
state = LabelUM(
text = TextReference.Str("Warning Label"),
style = LabelStyle.WARNING,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Regular Label"),
style = LabelStyle.REGULAR,
icon = R.drawable.ic_information_24,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Accent Label"),
style = LabelStyle.ACCENT,
icon = R.drawable.ic_information_24,
),
)
Label(
state = LabelUM(
text = TextReference.Str("Warning Label"),
style = LabelStyle.WARNING,
icon = R.drawable.ic_information_24,
),
)
}
}
}

View file

@ -1,10 +1,13 @@
package com.tangem.core.ui.components.label.entity
import androidx.annotation.DrawableRes
import com.tangem.core.ui.extensions.TextReference
data class LabelUM(
val text: TextReference,
val style: LabelStyle,
@DrawableRes val icon: Int? = null,
val onIconClick: (() -> Unit)? = null,
)
enum class LabelStyle {

View file

@ -10,6 +10,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -20,6 +21,7 @@ import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.MarketPriceBlockTestTags
import com.tangem.utils.StringsSigns.DASH_SIGN
/**
@ -45,7 +47,8 @@ fun MarketPriceBlock(state: MarketPriceBlockState, modifier: Modifier = Modifier
.fillMaxWidth()
.heightIn(min = TangemTheme.dimens.size72)
.padding(all = TangemTheme.dimens.spacing12)
.onSizeChanged { rootWidth = it.width },
.onSizeChanged { rootWidth = it.width }
.testTag(MarketPriceBlockTestTags.BLOCK),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
horizontalAlignment = Alignment.Start,
) {
@ -64,6 +67,7 @@ private fun Title(currencyName: String, modifier: Modifier = Modifier) {
text = stringResourceSafe(id = R.string.wallet_marketplace_block_title, currencyName),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.subtitle2,
modifier = Modifier.testTag(MarketPriceBlockTestTags.TEXT),
)
}
}

View file

@ -27,7 +27,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
@ -63,8 +62,8 @@ fun Notification(
NotificationConfig.IconTint.Unspecified -> null
NotificationConfig.IconTint.Accent -> TangemTheme.colors.icon.accent
NotificationConfig.IconTint.Attention -> TangemTheme.colors.icon.attention
NotificationConfig.IconTint.Warning -> TangemTheme.colors.icon.warning
},
iconSize: Dp = 20.dp,
isEnabled: Boolean = true,
) {
NotificationBaseContainer(
@ -78,7 +77,7 @@ fun Notification(
MainContent(
iconResId = config.iconResId,
iconTint = iconTint,
iconSize = iconSize,
iconSize = config.iconSize,
title = config.title,
titleColor = titleColor,
subtitle = config.subtitle,
@ -117,7 +116,9 @@ internal fun NotificationBaseContainer(
) {
Box {
Column(
modifier = Modifier.padding(all = TangemTheme.dimens.spacing12),
modifier = Modifier
.padding(all = TangemTheme.dimens.spacing12)
.testTag(NotificationTestTags.CONTAINER),
verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing12),
) {
content()
@ -152,7 +153,8 @@ private fun MainContent(
tint = iconTint,
modifier = Modifier
.size(size = iconSize)
.align(alignment = Alignment.Top),
.align(alignment = Alignment.Top)
.testTag(NotificationTestTags.ICON),
)
SpacerW(width = TangemTheme.dimens.spacing10)
@ -220,7 +222,7 @@ internal fun TextsBlock(
text = subtitleText,
color = subtitleColor,
style = TangemTheme.typography.caption2,
modifier = Modifier.testTag(NotificationTestTags.TEXT),
modifier = Modifier.testTag(NotificationTestTags.MESSAGE),
)
}
}

View file

@ -1,6 +1,8 @@
package com.tangem.core.ui.components.notifications
import androidx.annotation.DrawableRes
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.extensions.TextReference
/**
@ -26,6 +28,7 @@ data class NotificationConfig(
val onCloseClick: (() -> Unit)? = null,
val showArrowIcon: Boolean = onClick != null,
val iconTint: IconTint = IconTint.Unspecified,
val iconSize: Dp = 20.dp,
) {
sealed class ButtonsState {
@ -62,5 +65,6 @@ data class NotificationConfig(
Unspecified,
Accent,
Attention,
Warning,
}
}

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.Placeable
@ -309,7 +310,13 @@ private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier
},
)
fiatAmount?.placeRelative(x = layoutWidth - fiatAmount.width, y = verticalPadding)
fiatAmount?.placeRelative(
x = layoutWidth - fiatAmount.width,
y = when (state.subtitle2State) {
null -> (layoutHeight - fiatAmount.height).div(other = 2)
else -> verticalPadding
},
)
priceChange?.placeRelative(
x = icon.width,
@ -462,7 +469,7 @@ private fun calculateLayoutHeight(
return max(firstColumnHeight, secondColumnHeight).coerceAtLeast(minLayoutHeight)
}
@Preview(widthDp = 360)
@Preview(widthDp = 360, showBackground = true)
@Composable
private fun Preview_TokenItem_InLight(@PreviewParameter(TokenItemStateProvider::class) state: TokenItemState) {
TangemThemePreview(isDark = false) {
@ -589,6 +596,8 @@ private class TokenItemStateProvider : CollectionPreviewParameterProvider<TokenI
titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Polygon")),
iconState = customTokenIconState.copy(isGrayscale = true),
),
AccountItemPreviewData.accountItem,
AccountItemPreviewData.accountItem.copy(iconState = AccountItemPreviewData.accountLetterIcon),
),
) {
@ -641,4 +650,38 @@ private class TokenItemStateProvider : CollectionPreviewParameterProvider<TokenI
)
}
}
}
object AccountItemPreviewData {
val accountResIcon: CurrencyIconState.CryptoPortfolio.Icon
get() = CurrencyIconState.CryptoPortfolio.Icon(
resId = R.drawable.ic_rounded_star_24,
color = Color.Blue,
isGrayscale = false,
)
val accountLetterIcon: CurrencyIconState.CryptoPortfolio.Letter
get() = CurrencyIconState.CryptoPortfolio.Letter(
char = stringReference("A"),
color = Color.Blue,
isGrayscale = false,
)
val accountItem: TokenItemState.Content
get() = TokenItemState.Content(
id = UUID.randomUUID().toString(),
iconState = accountResIcon,
titleState = TokenItemState.TitleState.Content(
text = stringReference(value = "Portfolio"),
),
fiatAmountState = FiatAmountState.Content(
text = "$22,129.65",
),
subtitleState = TokenItemState.SubtitleState.TextContent(
value = stringReference("24 tokens"),
isAvailable = false,
),
subtitle2State = null,
onItemClick = {},
onItemLongClick = {},
)
}

View file

@ -23,7 +23,7 @@ import java.util.UUID
* @param modifier modifier
*/
@Composable
internal fun TxHistoryGroupTitle(config: TxHistoryItemState.GroupTitle, modifier: Modifier = Modifier) {
fun TxHistoryGroupTitle(config: TxHistoryItemState.GroupTitle, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.background(TangemTheme.colors.background.primary)

View file

@ -9,12 +9,14 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.TransactionHistoryBlockTestTags
/**
* Transactions block title
@ -38,10 +40,13 @@ fun TxHistoryTitle(onExploreClick: () -> Unit, modifier: Modifier = Modifier) {
text = stringResourceSafe(id = R.string.common_transactions),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.subtitle2,
modifier = Modifier.testTag(TransactionHistoryBlockTestTags.TITLE_TEXT),
)
Row(
modifier = Modifier.clickable(onClick = onExploreClick),
modifier = Modifier
.clickable(onClick = onExploreClick)
.testTag(TransactionHistoryBlockTestTags.EXPLORER_ICON),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing2),
) {
@ -55,6 +60,7 @@ fun TxHistoryTitle(onExploreClick: () -> Unit, modifier: Modifier = Modifier) {
text = stringResourceSafe(id = R.string.common_explorer),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.subtitle2,
modifier = Modifier.testTag(TransactionHistoryBlockTestTags.EXPLORER_TEXT),
)
}
}

View file

@ -10,4 +10,13 @@ interface ComposableBottomSheetComponent {
@Composable
fun BottomSheet()
}
fun getEmptyComposableBottomSheetComponent() = EmptyComposableBottomSheetComponent
object EmptyComposableBottomSheetComponent : ComposableBottomSheetComponent {
override fun dismiss() {}
@Composable
override fun BottomSheet() {}
}

View file

@ -0,0 +1,37 @@
package com.tangem.core.ui.decompose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
@Stable
interface ComposableModularContentComponent {
@Composable
fun Title()
@Composable
fun Content(modifier: Modifier)
@Composable
fun Footer()
}
fun getEmptyComposableModularContentComponent() = EmptyComposableModularContentComponent
object EmptyComposableModularContentComponent : ComposableModularContentComponent {
@Composable
override fun Title() {
/* no-op */
}
@Composable
override fun Content(modifier: Modifier) {
/* no-op */
}
@Composable
override fun Footer() {
/* no-op */
}
}

View file

@ -0,0 +1,6 @@
package com.tangem.core.ui.test
object BaseAmountBlockTestTags {
const val PRIMARY_AMOUNT = "STAKING_SEND_DETAILS_SCREEN_PRIMARY_AMOUNT"
const val SECONDARY_AMOUNT = "TAKING_SEND_DETAILS_SCREEN_SECONDARY_AMOUNT"
}

View file

@ -0,0 +1,5 @@
package com.tangem.core.ui.test
object BaseBottomSheetTestTags {
const val ACTION_TITLE = "BASE_BOTTOM_SHEET_ACTION_TITLE"
}

View file

@ -0,0 +1,7 @@
package com.tangem.core.ui.test
object BaseDialogTestTags {
const val CONTAINER = "BASE_DIALOG_CONTAINER"
const val TITLE = "BASE_DIALOG_TITLE"
const val TEXT = "BASE_DIALOG_TEXT"
}

View file

@ -0,0 +1,5 @@
package com.tangem.core.ui.test
object BaseSearchBarTestTags {
const val SEARCH_BAR = "BASE_SEARCH_BAR"
}

View file

@ -0,0 +1,8 @@
package com.tangem.core.ui.test
object DeviceSettingsScreenTestTags {
const val LAZY_LIST = "DEVICE_SETTINGS_SCREEN_LAZY_LIST"
const val IMAGE_BLOCK = "DEVICE_SETTINGS_SCREEN_IMAGE_BLOCK"
const val ITEM_TITLE = "DEVICE_SETTINGS_SCREEN_ITEM_TITLE"
const val ITEM_SUBTITLE = "DEVICE_SETTINGS_SCREEN_ITEM_SUBTITLE"
}

View file

@ -1,5 +0,0 @@
package com.tangem.core.ui.test
object DialogTestTags {
const val DIALOG_CONTAINER = "DIALOG_CONTAINER"
}

View file

@ -5,8 +5,13 @@ object MainScreenTestTags {
const val MORE_BUTTON = "MAIN_SCREEN_MORE_BUTTON"
const val TOP_BAR = "MAIN_SCREEN_TOP_BAR"
const val TOKEN_LIST_ITEM = "MAIN_SCREEN_TOKEN_LIST_ITEM"
const val WALLET_BALANCE = "MAIN_SCREEN_WALLET_BALANCE"
const val WALLET_LIST_ITEM = "MAIN_SCREEN_WALLET_LIST_ITEM"
const val ORGANIZE_TOKENS_BUTTON = "MAIN_SCREEN_ORGANIZE_TOKENS_BUTTON"
const val MULTI_CURRENCY_ACTION_BUTTON = "MAIN_SCREEN_MULTI_CURRENCY_ACTION_BUTTON"
const val CARD_TITLE = "MAIN_SCREEN_CARD_TITLE"
const val CARD_IMAGE = "MAIN_SCREEN_CARD_IMAGE"
const val WALLET_BALANCE = "MAIN_SCREEN_WALLET_BALANCE"
const val TOTAL_BALANCE_MENU_ITEM = "MAIN_SCREEN_TOTAL_BALANCE_MENU_ITEM"
const val TOTAL_BALANCE_CONTAINER = "MAIN_SCREEN_TOTAL_BALANCE_CONTAINER"
}

View file

@ -0,0 +1,6 @@
package com.tangem.core.ui.test
object MarketPriceBlockTestTags {
const val BLOCK = "MAIN_SCREEN_MARKET_PRICE_BLOCK"
const val TEXT = "MAIN_SCREEN_MARKET_PRICE_TEXT"
}

View file

@ -0,0 +1,7 @@
package com.tangem.core.ui.test
object MarketsTestTags {
const val TOKENS_LIST = "MARKETS_TOKENS_LIST"
const val TOKENS_LIST_ITEM = "MARKETS_TOKENS_LIST_ITEM"
const val ADD_TO_PORTFOLIO_SWITCH = "MARKETS_ADD_TO_PORTFOLIO_SWITCH"
}

View file

@ -1,6 +1,8 @@
package com.tangem.core.ui.test
object NotificationTestTags {
const val CONTAINER = "NOTIFICATION_CONTAINER"
const val TITLE = "NOTIFICATION_TITLE"
const val TEXT = "NOTIFICATION_TEXT"
const val MESSAGE = "NOTIFICATION_MESSAGE"
const val ICON = "NOTIFICATION_ICON"
}

View file

@ -0,0 +1,10 @@
package com.tangem.core.ui.test
object ResetCardScreenTestTags {
const val TITLE = "RESET_CARD_SCREEN_TITLE"
const val ATTENTION_IMAGE = "RESET_CARD_SCREEN_ATTENTION_IMAGE"
const val SUBTITLE = "RESET_CARD_SCREEN_SUBTITLE"
const val DESCRIPTION = "RESET_CARD_SCREEN_DESCRIPTION"
const val CHECKBOX = "RESET_CARD_SCREEN_CHECKBOX"
const val CHECKBOX_TEXT = "RESET_CARD_SCREEN_CHECKBOX_TEXT"
}

View file

@ -5,7 +5,6 @@ object SelectCountryBottomSheetTestTags {
const val LAZY_LIST = "SELECT_COUNTRY_BOTTOM_SHEET_LAZY_LIST"
const val COUNTRY_ITEM = "SELECT_COUNTRY_BOTTOM_SHEET_COUNTRY_ITEM"
const val UNAVAILABLE_COUNTRY_ITEM = "SELECT_COUNTRY_BOTTOM_SHEET_UNAVAILABLE_COUNTRY_ITEM"
const val SEARCH_BAR = "SELECT_COUNTRY_BOTTOM_SHEET_SEARCH_BAR"
const val COUNTRY_ICON = "SELECT_COUNTRY_BOTTOM_SHEET_COUNTRY_ICON"
const val UNAVAILABLE_COUNTRY_ICON = "SELECT_COUNTRY_BOTTOM_SHEET_UNAVAILABLE_COUNTRY_ICON"
const val COUNTRY_NAME = "SELECT_COUNTRY_BOTTOM_SHEET_COUNTRY_NAME"

View file

@ -0,0 +1,6 @@
package com.tangem.core.ui.test
object SendAddressScreenTestTags {
const val ADDRESS_TEXT_FIELD_TITLE = "SEND_ADDRESS_TEXT_FIELD_TITLE"
const val ADDRESS_TEXT_FIELD = "SEND_ADDRESS_TEXT_FIELD"
}

View file

@ -0,0 +1,16 @@
package com.tangem.core.ui.test
object SendScreenTestTags {
const val SCREEN_CONTAINER = "SEND_SCREEN_CONTAINER"
const val AMOUNT_CONTAINER_TITLE = "SEND_SCREEN_AMOUNT_CONTAINER_TITLE"
const val AMOUNT_CONTAINER_TEXT = "SEND_SCREEN_AMOUNT_CONTAINER_TEXT"
const val INPUT_TEXT_FIELD = "SEND_SCREEN_INPUT_TEXT_FIELD"
const val SECONDARY_AMOUNT = "SEND_SCREEN_SECONDARY_AMOUNT"
const val CURRENCY_BUTTON = "SEND_SCREEN_CURRENCY_BUTTON"
const val FIAT_ICON = "SEND_SCREEN_FIAT_ICON"
const val CURRENCY_ICON = "SEND_SCREEN_CURRENCY_ICON"
const val MAX_BUTTON = "END_SCREEN_MAX_BUTTON"
const val PREVIOUS_BUTTON = "SEND_SCREEN_PREVIOUS_BUTTON"
}

Some files were not shown because too many files have changed in this diff Show more