Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-19 09:16:46 +03:00
commit 6437018fc8
1160 changed files with 35466 additions and 6540 deletions

1
core/ab-tests/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,31 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
alias(deps.plugins.ksp)
id("configuration")
}
android {
namespace = "com.tangem.core.abtests"
}
dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Other libraries */
implementation(deps.timber)
/** Core modules */
implementation(projects.core.analytics.models)
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.domain.models)
/** Amplitude experiment */
implementation(deps.amplitude.experiment)
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>NullableToStringCall:AmplitudeABTestsManager.kt$AmplitudeABTestsManager$${variant.key}</ID>
<ID>NullableToStringCall:AmplitudeABTestsManager.kt$AmplitudeABTestsManager$${variant.payload}</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -0,0 +1,40 @@
package com.tangem.core.abtests.di
import android.app.Application
import com.tangem.core.abtests.BuildConfig
import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.core.abtests.manager.impl.AmplitudeABTestsManager
import com.tangem.core.abtests.manager.impl.StubABTestsManager
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object ABTestsManagerModule {
@Provides
@Singleton
fun provideABTestsManager(
application: Application,
environmentConfigStorage: EnvironmentConfigStorage,
dispatchers: CoroutineDispatcherProvider,
): ABTestsManager {
return if (BuildConfig.AB_TESTS_ENABLED) {
StubABTestsManager()
} else {
AmplitudeABTestsManager(
application = application,
apiKeyProvider = Provider { environmentConfigStorage.getConfigSync().amplitudeApiKey },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
)
}
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.core.abtests.manager
interface ABTestsManager {
fun init()
fun setUserProperties(userId: String?, batch: String?, productType: String?, firmware: String?)
fun removeUserProperties()
fun getValue(key: String, defaultValue: String): String
}

View file

@ -0,0 +1,95 @@
package com.tangem.core.abtests.manager.impl
import android.app.Application
import com.amplitude.experiment.Experiment
import com.amplitude.experiment.ExperimentClient
import com.amplitude.experiment.ExperimentConfig
import com.amplitude.experiment.ExperimentUser
import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.utils.Provider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
internal class AmplitudeABTestsManager(
val application: Application,
val apiKeyProvider: Provider<String>,
val scope: CoroutineScope,
) : ABTestsManager {
private lateinit var client: ExperimentClient
override fun init() {
if (::client.isInitialized) {
Timber.w("AB Tests manager already initialized, skipping")
return
}
client = Experiment.initializeWithAmplitudeAnalytics(
application = application,
apiKey = apiKeyProvider(),
config = ExperimentConfig
.builder()
.automaticFetchOnAmplitudeIdentityChange(true)
.build(),
)
scope.launch {
try {
client.fetch().get()
val allVariants = client.all()
logAllVariants(allVariants)
} catch (exception: Exception) {
Timber.e(exception, "Failed to fetch AB test variants")
}
}
}
override fun setUserProperties(userId: String?, batch: String?, productType: String?, firmware: String?) {
val userProperties = mutableMapOf<String, Any>()
batch?.let { userProperties[AnalyticsParam.BATCH] = it }
productType?.let { userProperties[AnalyticsParam.PRODUCT_TYPE] = it }
firmware?.let { userProperties[AnalyticsParam.FIRMWARE] = it }
client.setUser(
ExperimentUser
.builder()
.userId(userId)
.userProperties(userProperties)
.build(),
)
}
override fun removeUserProperties() {
client.setUser(ExperimentUser())
}
override fun getValue(key: String, defaultValue: String): String {
return client.variant(key).value ?: defaultValue
}
private fun logAllVariants(allVariants: Map<String, com.amplitude.experiment.Variant>) {
Timber.d("=".repeat(SEPARATOR_LENGTH))
Timber.d("AB Tests: Fetched ${allVariants.size} variants")
Timber.d("=".repeat(SEPARATOR_LENGTH))
if (allVariants.isEmpty()) {
Timber.d("No variants available")
} else {
allVariants.entries.forEachIndexed { index, (key, variant) ->
Timber.d("[${index + 1}/${allVariants.size}] Key: $key")
Timber.d(" → Value: ${variant.value ?: "null"}")
Timber.d(" → Payload: ${variant.payload}")
Timber.d(" → Key: ${variant.key}")
Timber.d("-".repeat(SEPARATOR_LENGTH))
}
}
Timber.d("=".repeat(SEPARATOR_LENGTH))
}
private companion object {
const val SEPARATOR_LENGTH = 50
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.core.abtests.manager.impl
import com.tangem.core.abtests.manager.ABTestsManager
internal class StubABTestsManager : ABTestsManager {
override fun init() {
// intentionally do nothing
}
override fun setUserProperties(userId: String?, batch: String?, productType: String?, firmware: String?) {
// intentionally do nothing
}
override fun removeUserProperties() {
// intentionally do nothing
}
override fun getValue(key: String, defaultValue: String): String {
return defaultValue
}
}

View file

@ -84,6 +84,7 @@ sealed class AnalyticsParam {
data object LongTap : ScreensSources("Long Tap")
data object Markets : ScreensSources("Markets")
data object HotWallet : ScreensSources("Hot Wallet")
data object TangemPay : ScreensSources("Tangem Pay")
}
sealed class TxSentFrom(val value: String) {

View file

@ -61,9 +61,9 @@ object Analytics : GlobalAnalyticsEventHandler {
return paramsInterceptors.remove(interceptorId)
}
override fun setUserId(userWalletId: String) {
override fun setUserId(userId: String) {
analyticsScope.launch {
val userIdHash = userWalletId.hexToBytes()
val userIdHash = userId.hexToBytes()
.calculateSha256()
.toHexString()

View file

@ -1,17 +1,26 @@
package com.tangem.core.analytics.utils
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
/**
[REDACTED_AUTHOR]
*/
interface AnalyticsContextProxy {
interface TrackingContextProxy {
fun setContext(scanResponse: ScanResponse)
fun setContext(userWallet: UserWallet)
fun addContext(userWallet: UserWallet)
fun setHotWalletContext()
fun eraseContext()
fun addContext(scanResponse: ScanResponse)
fun addHotWalletContext()
fun removeContext()
}

View file

@ -49,12 +49,8 @@ dependencies {
implementation(projects.core.datasource)
implementation(projects.core.utils)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
}
val generateFeatureToggles by tasks.registering {
@ -93,9 +89,9 @@ fun Task.generateToggles(inputFilePath: String, generatedFileName: String) {
.add("mapOf(\n")
.indent()
.apply {
entries.forEachIndexed { index, entry ->
entries.forEach { entry ->
add(entry)
if (index != entries.lastIndex) add(",\n") else add("\n")
add(",\n")
}
}
.unindent()
@ -117,5 +113,15 @@ fun Task.generateToggles(inputFilePath: String, generatedFileName: String) {
val outputPackageDir = File(outputDir, "")
outputPackageDir.mkdirs()
fileSpec.writeTo(outputPackageDir)
// Remove redundant public visibility modifiers
val generatedFile = File(outputPackageDir, "com/tangem/core/configtoggle/$generatedFileName.kt")
if (generatedFile.exists()) {
val content = generatedFile.readText()
val fixedContent = content
.replace("public object ", "object ")
.replace("public val ", "val ")
generatedFile.writeText(fixedContent)
}
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>DoubleMutabilityForCollection:DevExcludedBlockchainsManager.kt$DevExcludedBlockchainsManager$private var blockchainTogglesMap: MutableMap&lt;String, Boolean&gt; by Delegates.notNull()</ID>
<ID>DoubleMutabilityForCollection:DevFeatureTogglesManager.kt$DevFeatureTogglesManager$private var featureTogglesMap: MutableMap&lt;String, Boolean&gt; by Delegates.notNull()</ID>
<ID>Indentation:ExcludedBlockchainToggles.kt$ExcludedBlockchainToggles$ </ID>
<ID>Indentation:FeatureToggles.kt$FeatureToggles$ </ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -30,9 +30,5 @@
{
"name": "zklink",
"version": "undefined"
},
{
"name": "scroll",
"version": "undefined"
}
]

View file

@ -19,6 +19,10 @@
"name": "STAKING_CARDANO_ENABLED",
"version": "undefined"
},
{
"name": "STAKING_ETH_ENABLED",
"version": "undefined"
},
{
"name": "USEDESK_ENABLED",
"version": "undefined"
@ -50,5 +54,9 @@
{
"name": "ACCOUNTS_FEATURE_ENABLED",
"version": "undefined"
},
{
"name": "FEED_ENABLED",
"version": "undefined"
}
]

View file

@ -1,12 +1,12 @@
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 com.tangem.test.core.ProvideTestModels
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*

View file

@ -1,10 +1,10 @@
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 com.tangem.test.core.ProvideTestModels
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*

View file

@ -90,10 +90,6 @@ dependencies {
implementation(deps.room.ktx)
ksp(deps.room.compiler)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
}

View file

@ -0,0 +1,17 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>CastNullableToNonNullableType:ApiResponseCallAdapterFactory.kt$ApiResponseCallAdapterFactory$as</ID>
<ID>MultilineLambdaItParameter:DefaultWalletManagersStore.kt$DefaultWalletManagersStore${ it.wallet.blockchain == walletManager.wallet.blockchain &amp;&amp; it.wallet.publicKey.derivationPath == walletManager.wallet.publicKey.derivationPath }</ID>
<ID>NestedScopeFunctions:PendingActionConverter.kt$PendingActionConverter$let { amount -&gt; PendingAction.PendingActionArgs.Amount( required = amount.required, minimum = amount.minimum, maximum = amount.maximum, ) }</ID>
<ID>NestedScopeFunctions:PendingActionConverter.kt$PendingActionConverter$let { duration -&gt; PendingAction.PendingActionArgs.Duration( required = duration.required, minimum = duration.minimum, maximum = duration.maximum, ) }</ID>
<ID>NestedScopeFunctions:PendingActionConverter.kt$PendingActionConverter$let { tronResource -&gt; PendingAction.PendingActionArgs.TronResource( required = tronResource.required, options = tronResource.options, ) }</ID>
<ID>NestedScopeFunctions:RetrofitApiBuilder.kt$RetrofitApiBuilder$let { withConnectTimeout(timeout = it.duration, unit = it.unit) }</ID>
<ID>NestedScopeFunctions:RetrofitApiBuilder.kt$RetrofitApiBuilder$let { withReadTimeout(timeout = it.duration, unit = it.unit) }</ID>
<ID>NestedScopeFunctions:RetrofitApiBuilder.kt$RetrofitApiBuilder$let { withWriteTimeout(timeout = it.duration, unit = it.unit) }</ID>
<ID>UnreachableCode:MockApiConfigsManager.kt$MockApiConfigsManager$apiConfigs + (apiConfig to environment)</ID>
<ID>UnreachableCode:MockApiConfigsManager.kt$MockApiConfigsManager$val apiConfig = apiConfigs.keys.firstOrNull { it.id.name == id } ?: error("Api config with id [$id] not found. Check that ApiConfig with id [$id] was provided into DI")</ID>
<ID>UnusedImports:NetworkModule.kt$import com.tangem.datasource.api.common.config.MoonPay</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -24,9 +24,12 @@ sealed class ApiConfig {
Express,
TangemTech,
StakeKit,
P2PEthPool,
TangemPay,
BlockAid,
YieldSupply,
MoonPay,
News,
}
private fun initializeId(): ID {
@ -34,9 +37,12 @@ sealed class ApiConfig {
is Express -> ID.Express
is TangemTech -> ID.TangemTech
is StakeKit -> ID.StakeKit
is P2PEthPool -> ID.P2PEthPool
is TangemPay -> ID.TangemPay
is BlockAid -> ID.BlockAid
is YieldSupply -> ID.YieldSupply
is MoonPay -> ID.MoonPay
is News -> ID.News
}
}

View file

@ -0,0 +1,43 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
/**
* MoonPay [ApiConfig]
*/
internal class MoonPay : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createProdEnvironment(),
createMockEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE,
-> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createProdEnvironment(): ApiEnvironmentConfig {
return ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.moonpay.com/",
)
}
private fun createMockEnvironment(): ApiEnvironmentConfig {
return ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
)
}
}

View file

@ -0,0 +1,62 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.utils.RequestHeader
import com.tangem.utils.Provider
/**
* News [ApiConfig]
[REDACTED_AUTHOR]
*/
internal class News(
private val authProvider: AuthProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createProdEnvironment(),
createDevEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE,
DEBUG_BUILD_TYPE,
-> ApiEnvironment.DEV
INTERNAL_BUILD_TYPE,
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = PROD_BASE_URL,
headers = createHeaders(ApiEnvironment.PROD),
)
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = DEV_BASE_URL,
headers = createHeaders(ApiEnvironment.DEV),
)
private fun createHeaders(environment: ApiEnvironment) = buildMap {
putAll(
RequestHeader.TangemApiKeyHeader(
authProvider = authProvider,
apiEnvironment = Provider { environment },
).values,
)
}
private companion object {
private const val PROD_BASE_URL = "https://api.tangem.org/"
private const val DEV_BASE_URL = "[REDACTED_ENV_URL]"
}
}

View file

@ -0,0 +1,63 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.lib.auth.P2PEthPoolAuthProvider
import com.tangem.utils.ProviderSuspend
/**
* P2P.org Ethereum Pooled Staking API configuration
*/
internal class P2PEthPool(
private val p2pAuthProvider: P2PEthPoolAuthProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createProdEnvironment(),
createTestEnvironment(),
createMockEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createProdEnvironment(): ApiEnvironmentConfig {
return ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.p2p.org/",
headers = createHeaders(),
)
}
private fun createTestEnvironment(): ApiEnvironmentConfig {
return ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "https://api-test.p2p.org/",
headers = createHeaders(),
)
}
private fun createMockEnvironment(): ApiEnvironmentConfig {
return ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)
}
private fun createHeaders() = buildMap {
put(key = "Authorization", value = ProviderSuspend { "Bearer ${p2pAuthProvider.getApiKey()}" })
put(key = "accept", value = ProviderSuspend { "application/json" })
put(key = "Content-Type", value = ProviderSuspend { "application/json" })
}
}

View file

@ -31,7 +31,7 @@ internal class TangemPay(
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "[REDACTED_ENV_URL]",
baseUrl = "https://api.dev.us.paera.com/bff/",
headers = createHeaders(),
)

View file

@ -14,10 +14,12 @@ sealed class ApiResponse<T : Any> {
* Represents a successful response from the API
*
* @property data the data returned by the API
* @property code the HTTP status code of the response
* @property headers the headers returned by the API
*/
data class Success<T : Any>(
val data: T,
val code: ApiResponseError.HttpException.Code = ApiResponseError.HttpException.Code.OK,
override val headers: Map<String, List<String>> = emptyMap(),
) : ApiResponse<T>()
@ -37,11 +39,20 @@ sealed class ApiResponse<T : Any> {
* Wraps data in a [ApiResponse.Success] instance
*
* @param data the data to wrap
* @param code the HTTP status code of the response
* @param headers the headers returned by the API
* @return a [ApiResponse.Success] instance containing the provided data
*/
internal fun <T : Any> apiSuccess(data: T, headers: Map<String, List<String>>): ApiResponse<T> {
return ApiResponse.Success(data, headers)
internal fun <T : Any> apiSuccess(
data: T,
code: ApiResponseError.HttpException.Code?,
headers: Map<String, List<String>>,
): ApiResponse<T> {
return ApiResponse.Success(
data = data,
code = code ?: ApiResponseError.HttpException.Code.OK,
headers = headers,
)
}
/**

View file

@ -18,8 +18,13 @@ sealed class ApiResponseError : Exception() {
val errorBody: String?,
) : ApiResponseError() {
// TODO: extract Code from HttpException
// region Error Codes
enum class Code(val numericCode: Int) {
// 2xx Success
OK(numericCode = 200),
CREATED(numericCode = 201),
ACCEPTED(numericCode = 202),
// 3xx Server Errors
NOT_MODIFIED(numericCode = 304),
// 4xx Server Errors

View file

@ -15,11 +15,12 @@ internal fun <T : Any> Response<T>.toSafeApiResponse(analyticsErrorHandler: Anal
val headers = headers().toMultimap()
val body = body()
val code = ApiResponseError.HttpException.Code.entries
.firstOrNull { it.numericCode == code() }
return if (isSuccessful && body != null) {
apiSuccess(data = body, headers = headers)
apiSuccess(data = body, code = code, headers = headers)
} else {
val code = ApiResponseError.HttpException.Code.entries
.firstOrNull { it.numericCode == code() }
val e = try {
if (code == null) {
ApiResponseError.UnknownException(IllegalArgumentException("Unknown error status code: ${code()}"))

View file

@ -0,0 +1,119 @@
package com.tangem.datasource.api.ethpool
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolDepositRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolUnstakeRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolWithdrawRequest
import com.tangem.datasource.api.ethpool.models.response.*
import retrofit2.http.*
/**
* P2P.org Ethereum Pooled Staking API client
*
* Documentation: https://docs.p2p.org/
*
* Base URL: https://api.p2p.org (prod) / https://api-test.p2p.org (testnet)
*/
interface P2PEthPoolApi {
/**
* Get list of available vaults
*
* @param network Ethereum pool network: "mainnet" or "hoodi" (testnet)
*/
@GET("api/v1/staking/pool/{network}/vaults")
suspend fun getVaults(
@Path("network") network: String = "mainnet",
): ApiResponse<P2PEthPoolResponse<P2PEthPoolVaultsResponse>>
/**
* Prepare deposit transaction
*
* Create unsigned transaction for depositing ETH into a vault.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param body Deposit parameters (delegator address, vault address, amount)
*/
@POST("api/v1/staking/pool/{network}/staking/deposit")
suspend fun createDepositTransaction(
@Path("network") network: String,
@Body body: P2PEthPoolDepositRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolDepositResponse>>
/**
* Prepare unstake transaction
*
* Create unsigned transaction to initiate unstaking process.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param body Unstake parameters (staker public key, stake transaction hash)
*/
@POST("api/v1/staking/pool/{network}/staking/unstake")
suspend fun createUnstakeTransaction(
@Path("network") network: String,
@Body body: P2PEthPoolUnstakeRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolUnstakeResponse>>
/**
* Prepare withdrawal transaction
*
* Create unsigned transaction to withdraw available funds from exit queue.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param body Withdrawal parameters (staker address)
*/
@POST("api/v1/staking/pool/{network}/staking/withdraw")
suspend fun createWithdrawTransaction(
@Path("network") network: String,
@Body body: P2PEthPoolWithdrawRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolWithdrawResponse>>
/**
* Broadcast signed transaction
*
* Submit a signed transaction to the blockchain network.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param body Signed transaction in hexadecimal format
*/
@POST("api/v1/staking/pool/{network}/transaction/send")
suspend fun broadcastTransaction(
@Path("network") network: String,
@Body body: P2PEthPoolBroadcastRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolBroadcastResponse>>
/**
* Get account summary
*
* Retrieve staking balance, rewards, and exit queue information for a specific account and vault.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param delegatorAddress Account address that initiated staking
* @param vaultAddress Ethereum address of the vault
*/
@GET("api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}")
suspend fun getAccountInfo(
@Path("network") network: String,
@Path("delegatorAddress") delegatorAddress: String,
@Path("vaultAddress") vaultAddress: String,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolAccountResponse>>
/**
* Get rewards history
*
* Retrieve historical rewards data for a specific account and vault.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param delegatorAddress Account address that initiated staking
* @param vaultAddress Ethereum address of the vault
* @param period Optional period filter (30, 60, or 90 days)
*/
@GET("api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}/rewards")
suspend fun getRewards(
@Path("network") network: String,
@Path("delegatorAddress") delegatorAddress: String,
@Path("vaultAddress") vaultAddress: String,
@Query("period") period: Int? = null,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolRewardsResponse>>
}

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.ethpool.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for broadcasting signed transaction
*
* Used in: POST /api/v1/staking/pool/{network}/transaction/send
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolBroadcastRequest(
@Json(name = "signedTransaction")
val signedTransaction: String,
)

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.api.ethpool.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for creating deposit transaction
*
* Used in: POST /api/v1/staking/pool/{network}/staking/deposit
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolDepositRequest(
@Json(name = "delegatorAddress")
val delegatorAddress: String,
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "amount")
val amount: Double,
)

View file

@ -0,0 +1,20 @@
package com.tangem.datasource.api.ethpool.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for creating unstake transaction
*
* Used in: POST /api/v1/staking/pool/{network}/staking/unstake
*
* Note: Documentation seems to contain Bitcoin-related fields (possibly copy-paste error).
* Using as-is per specification.
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolUnstakeRequest(
@Json(name = "stakerPublicKey")
val stakerPublicKey: String,
@Json(name = "stakeTransactionHash")
val stakeTransactionHash: String,
)

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.ethpool.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for creating withdrawal transaction
*
* Used in: POST /api/v1/staking/pool/{network}/staking/withdraw
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolWithdrawRequest(
@Json(name = "stakerAddress")
val stakerAddress: String,
)

View file

@ -0,0 +1,54 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
/**
* Response for GET /api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolAccountResponse(
@Json(name = "delegatorAddress")
val delegatorAddress: String,
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "stake")
val stake: P2PEthPoolStakeDTO,
@Json(name = "availableToUnstake")
val availableToUnstake: BigDecimal,
@Json(name = "availableToWithdraw")
val availableToWithdraw: BigDecimal,
@Json(name = "exitQueue")
val exitQueue: P2PEthPoolExitQueueDTO,
)
@JsonClass(generateAdapter = true)
data class P2PEthPoolStakeDTO(
@Json(name = "assets")
val assets: BigDecimal,
@Json(name = "totalEarnedAssets")
val totalEarnedAssets: BigDecimal,
)
@JsonClass(generateAdapter = true)
data class P2PEthPoolExitQueueDTO(
@Json(name = "total")
val total: Double,
@Json(name = "requests")
val requests: List<P2PEthPoolExitRequestDTO>,
)
@JsonClass(generateAdapter = true)
data class P2PEthPoolExitRequestDTO(
@Json(name = "ticket")
val ticket: String,
@Json(name = "totalAssets")
val totalAssets: Double,
@Json(name = "timestamp")
val timestamp: Long,
@Json(name = "withdrawalTimestamp")
val withdrawalTimestamp: Long,
@Json(name = "isClaimable")
val isClaimable: Boolean,
)

View file

@ -0,0 +1,41 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response for POST /api/v1/staking/pool/{network}/transaction/send
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolBroadcastResponse(
@Json(name = "hash")
val hash: String,
@Json(name = "status")
val status: P2PEthPoolTxStatusDTO,
@Json(name = "blockNumber")
val blockNumber: Int,
@Json(name = "transactionIndex")
val transactionIndex: Int,
@Json(name = "gasUsed")
val gasUsed: String,
@Json(name = "cumulativeGasUsed")
val cumulativeGasUsed: String,
@Json(name = "effectiveGasPrice")
val effectiveGasPrice: String?,
@Json(name = "from")
val from: String,
@Json(name = "to")
val to: String,
)
/**
* Transaction status from P2P API
*/
@JsonClass(generateAdapter = false)
enum class P2PEthPoolTxStatusDTO {
@Json(name = "success")
SUCCESS,
@Json(name = "failed")
FAILED,
}

View file

@ -0,0 +1,22 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.joda.time.DateTime
/**
* Response for POST /api/v1/staking/pool/{network}/staking/deposit
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolDepositResponse(
@Json(name = "amount")
val amount: Double,
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "delegatorAddress")
val delegatorAddress: String,
@Json(name = "unsignedTransaction")
val unsignedTransaction: P2PEthPoolUnsignedTxDTO,
@Json(name = "createdAt")
val createdAt: DateTime,
)

View file

@ -0,0 +1,29 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Error response structure for P2P.org API
*
* All P2P API endpoints return errors in this format
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolErrorResponse(
@Json(name = "error")
val error: P2PEthPoolErrorDetailsDTO,
@Json(name = "result")
val result: Any? = null, // null on error
)
@JsonClass(generateAdapter = true)
data class P2PEthPoolErrorDetailsDTO(
@Json(name = "code")
val code: Int, // Error code (e.g., 127106, 101111)
@Json(name = "message")
val message: String, // Human-readable error message
@Json(name = "name")
val name: String, // Error name/type
@Json(name = "errors")
val errors: List<String>? = null, // Optional validation errors array
)

View file

@ -0,0 +1,25 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Unified response wrapper for all P2P.org API responses
*
* All P2P API endpoints return responses in this format:
* ```json
* {
* "error": null | { code, message, name, errors },
* "result": { ... } | null
* }
* ```
*
* @param T The type of the result data
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolResponse<T>(
@Json(name = "error")
val error: P2PEthPoolErrorDetailsDTO?,
@Json(name = "result")
val result: T?,
)

View file

@ -0,0 +1,31 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.joda.time.DateTime
import java.math.BigDecimal
/**
* Response for GET /api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}/rewards
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolRewardsResponse(
@Json(name = "delegatorAddress")
val delegatorAddress: String,
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "rewards")
val rewards: List<P2PEthPoolRewardDTO>,
)
@JsonClass(generateAdapter = true)
data class P2PEthPoolRewardDTO(
@Json(name = "date")
val date: DateTime,
@Json(name = "apy")
val apy: Double,
@Json(name = "balance")
val balance: BigDecimal,
@Json(name = "rewards")
val rewards: BigDecimal,
)

View file

@ -0,0 +1,32 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
/**
* Unsigned transaction structure
*
* Used in deposit, withdraw responses
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolUnsignedTxDTO(
@Json(name = "serializeTx")
val serializeTx: String,
@Json(name = "to")
val to: String,
@Json(name = "data")
val data: String,
@Json(name = "value")
val value: String,
@Json(name = "nonce")
val nonce: Int,
@Json(name = "chainId")
val chainId: Int,
@Json(name = "gasLimit")
val gasLimit: BigDecimal,
@Json(name = "maxFeePerGas")
val maxFeePerGas: BigDecimal,
@Json(name = "maxPriorityFeePerGas")
val maxPriorityFeePerGas: BigDecimal,
)

View file

@ -0,0 +1,22 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response for POST /api/v1/staking/pool/{network}/staking/unstake
*
* Note: Contains Bitcoin-related fields (likely documentation error).
* Using as-is per specification.
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolUnstakeResponse(
@Json(name = "stakerPublicKey")
val stakerPublicKey: String,
@Json(name = "stakeTransactionHash")
val stakeTransactionHash: String,
@Json(name = "unstakeTransactionHex")
val unstakeTransactionHex: String, // unsigned
@Json(name = "unstakeFee")
val unstakeFee: Double,
)

View file

@ -0,0 +1,60 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.joda.time.DateTime
/**
* Response for GET /api/v1/staking/pool/{network}/vaults
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolVaultsResponse(
@Json(name = "network")
val network: P2PEthPoolNetworkDTO,
@Json(name = "vaults")
val vaults: List<P2PEthPoolVaultDTO>,
)
/**
* Network identifier in P2P API
*/
@JsonClass(generateAdapter = false)
enum class P2PEthPoolNetworkDTO {
@Json(name = "mainnet")
MAINNET,
@Json(name = "hoodi")
HOODI,
}
@JsonClass(generateAdapter = true)
data class P2PEthPoolVaultDTO(
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "displayName")
val displayName: String,
@Json(name = "apy")
val apy: Double,
@Json(name = "baseApy")
val baseApy: Double,
@Json(name = "capacity")
val capacity: Double,
@Json(name = "totalAssets")
val totalAssets: Double,
@Json(name = "feePercent")
val feePercent: Double,
@Json(name = "isPrivate")
val isPrivate: Boolean,
@Json(name = "isGenesis")
val isGenesis: Boolean,
@Json(name = "isSmoothingPool")
val isSmoothingPool: Boolean,
@Json(name = "isErc20")
val isErc20: Boolean,
@Json(name = "tokenName")
val tokenName: String?,
@Json(name = "tokenSymbol")
val tokenSymbol: String?,
@Json(name = "createdAt")
val createdAt: DateTime,
)

View file

@ -0,0 +1,24 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.joda.time.DateTime
/**
* Response for POST /api/v1/staking/pool/{network}/staking/withdraw
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolWithdrawResponse(
@Json(name = "amount")
val amount: Double,
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "delegatorAddress")
val delegatorAddress: String,
@Json(name = "unsignedTransaction")
val unsignedTransaction: P2PEthPoolUnsignedTxDTO,
@Json(name = "createdAt")
val createdAt: DateTime,
@Json(name = "tickets")
val tickets: List<String>,
)

View file

@ -1,5 +0,0 @@
package com.tangem.datasource.api.express.models
object TangemExpressValues {
const val EMPTY_CONTRACT_ADDRESS_VALUE = "0"
}

View file

@ -0,0 +1,48 @@
package com.tangem.datasource.api.moonpay
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import retrofit2.http.GET
import retrofit2.http.Query
interface MoonPayApi {
@GET("v4/ip_address/")
suspend fun getUserStatus(@Query("apiKey") moonPayApiKey: String): MoonPayUserStatus
@GET("v3/currencies/")
suspend fun getCurrencies(@Query("apiKey") moonPayApiKey: String): List<MoonPayCurrencies>
}
@JsonClass(generateAdapter = true)
data class MoonPayUserStatus(
@Json(name = "isBuyAllowed")
val isBuyAllowed: Boolean,
@Json(name = "isSellAllowed")
val isSellAllowed: Boolean,
@Json(name = "isAllowed")
val isMoonpayAllowed: Boolean,
@Json(name = "alpha3")
val countryCode: String,
@Json(name = "state")
val stateCode: String,
)
@Suppress("BooleanPropertyNaming")
@JsonClass(generateAdapter = true)
data class MoonPayCurrencies(
@Json(name = "type") val type: String,
@Json(name = "code") val code: String,
@Json(name = "supportsLiveMode") val supportsLiveMode: Boolean = false,
@Json(name = "isSuspended") val isSuspended: Boolean = true,
@Json(name = "isSupportedInUS") val isSupportedInUS: Boolean = false,
@Json(name = "isSellSupported") val isSellSupported: Boolean = false,
@Json(name = "notAllowedUSStates") val notAllowedUSStates: List<String> = emptyList(),
@Json(name = "metadata") val metadata: MoonPayCurrenciesMetadata? = null,
)
@JsonClass(generateAdapter = true)
data class MoonPayCurrenciesMetadata(
@Json(name = "contractAddress") val contractAddress: String?,
@Json(name = "networkCode") val networkCode: String?,
)

View file

@ -0,0 +1,43 @@
package com.tangem.datasource.api.news
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.news.models.response.NewsCategoriesResponse
import com.tangem.datasource.api.news.models.response.NewsDetailsResponse
import com.tangem.datasource.api.news.models.response.NewsListResponse
import com.tangem.datasource.api.news.models.response.NewsTrendingResponse
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
interface NewsApi {
@GET(NEWS_PATH)
suspend fun getNews(
@Query("page") page: Int? = null,
@Query("limit") limit: Int? = null,
@Query("lang") language: String? = null,
@Query("asOf") snapshot: String? = null,
@Query("tokenIds") tokenIds: List<String>? = null,
@Query("categoryIds") categoryIds: List<Int>? = null,
): ApiResponse<NewsListResponse>
@GET("$NEWS_PATH/{newsId}")
suspend fun getNewsDetails(
@Path("newsId") newsId: Int,
@Query("lang") language: String? = null,
): ApiResponse<NewsDetailsResponse>
@GET("$NEWS_PATH/trending")
suspend fun getTrendingNews(
@Query("limit") limit: Int? = null,
@Query("lang") language: String? = null,
): ApiResponse<NewsTrendingResponse>
@GET("$NEWS_PATH/categories")
suspend fun getCategories(): ApiResponse<NewsCategoriesResponse>
private companion object {
private const val NEWS_PATH = "api/v1/news"
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.datasource.api.news.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NewsArticleDto(
@Json(name = "id") val id: Int,
@Json(name = "createdAt") val createdAt: String,
@Json(name = "score") val score: Double,
@Json(name = "language") val language: String,
@Json(name = "isTrending") val isTrending: Boolean,
@Json(name = "categories") val categories: List<NewsCategoryDto>,
@Json(name = "relatedTokens") val relatedTokens: List<NewsRelatedTokenDto>,
@Json(name = "title") val title: String,
@Json(name = "newsUrl") val newsUrl: String,
)
@JsonClass(generateAdapter = true)
data class NewsCategoryDto(
@Json(name = "id") val id: Int,
@Json(name = "name") val name: String,
)
@JsonClass(generateAdapter = true)
data class NewsRelatedTokenDto(
@Json(name = "id") val id: String,
@Json(name = "symbol") val symbol: String,
@Json(name = "name") val name: String,
)
@JsonClass(generateAdapter = true)
data class NewsOriginalArticleDto(
@Json(name = "id") val id: Int,
@Json(name = "title") val title: String,
@Json(name = "sourceName") val sourceName: String,
@Json(name = "language") val language: String,
@Json(name = "publishedAt") val publishedAt: String,
@Json(name = "url") val url: String,
@Json(name = "imageUrl") val imageUrl: String? = null,
)

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.news.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NewsCategoriesResponse(
@Json(name = "items") val items: List<NewsCategoryDto>,
)

View file

@ -0,0 +1,20 @@
package com.tangem.datasource.api.news.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NewsDetailsResponse(
@Json(name = "id") val id: Int,
@Json(name = "createdAt") val createdAt: String,
@Json(name = "score") val score: Double,
@Json(name = "language") val language: String,
@Json(name = "isTrending") val isTrending: Boolean,
@Json(name = "categories") val categories: List<NewsCategoryDto>,
@Json(name = "relatedTokens") val relatedTokens: List<NewsRelatedTokenDto>,
@Json(name = "title") val title: String,
@Json(name = "newsUrl") val newsUrl: String,
@Json(name = "shortContent") val shortContent: String,
@Json(name = "content") val content: String,
@Json(name = "originalArticles") val originalArticles: List<NewsOriginalArticleDto>,
)

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.api.news.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NewsListResponse(
@Json(name = "meta") val meta: NewsListMetaDto,
@Json(name = "items") val items: List<NewsArticleDto>,
)
@JsonClass(generateAdapter = true)
data class NewsListMetaDto(
@Json(name = "page") val page: Int,
@Json(name = "limit") val limit: Int,
@Json(name = "total") val total: Long,
@Json(name = "hasNext") val hasNext: Boolean,
@Json(name = "asOf") val asOf: String,
)

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.news.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NewsTrendingResponse(
@Json(name = "meta") val meta: NewsTrendingMetaDto,
@Json(name = "items") val items: List<NewsArticleDto>,
)
@JsonClass(generateAdapter = true)
data class NewsTrendingMetaDto(
@Json(name = "limit") val limit: Int,
)

View file

@ -7,6 +7,7 @@ import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
@ -146,4 +147,22 @@ interface TangemPayApi {
@Header("Authorization") authHeader: String,
@Body body: CardDetailsRequest,
): ApiResponse<CardDetailsResponse>
@PUT("v1/customer/card/pin")
suspend fun setPin(
@Header("Authorization") authHeader: String,
@Body body: SetPinRequest,
): ApiResponse<SetPinResponse>
@POST("v1/customer/card/freeze")
suspend fun freezeCard(
@Header("Authorization") authHeader: String,
@Body body: FreezeUnfreezeCardRequest,
): ApiResponse<FreezeUnfreezeCardResponse>
@POST("v1/customer/card/unfreeze")
suspend fun unfreezeCard(
@Header("Authorization") authHeader: String,
@Body body: FreezeUnfreezeCardRequest,
): ApiResponse<FreezeUnfreezeCardResponse>
}

View file

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

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.pay.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class SetPinRequest(
@Json(name = "pin") val pin: String,
@Json(name = "session_id") val sessionId: String,
@Json(name = "iv") val iv: String,
)

View file

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

View file

@ -28,10 +28,49 @@ data class CustomerMeResponse(
@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 = "status") val status: Status,
@Json(name = "updated_at") val updatedAt: String,
@Json(name = "payment_account_id") val paymentAccountId: String,
)
) {
@JsonClass(generateAdapter = false)
enum class Status {
@Json(name = "new")
NEW,
@Json(name = "ready_for_manufacturing")
READY_FOR_MANUFACTURING,
@Json(name = "manufacturing")
MANUFACTURING,
@Json(name = "sent_to_delivery")
SENT_TO_DELIVERY,
@Json(name = "delivered")
DELIVERED,
@Json(name = "activating")
ACTIVATING,
@Json(name = "active")
ACTIVE,
@Json(name = "blocked")
BLOCKED,
@Json(name = "deactivating")
DEACTIVATING,
@Json(name = "deactivated")
DEACTIVATED,
@Json(name = "canceled")
CANCELED,
@Json(name = "unknown")
UNKNOWN,
}
}
@JsonClass(generateAdapter = true)
data class PaymentAccount(

View file

@ -0,0 +1,31 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
class FreezeUnfreezeCardResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "order_id") val orderId: String,
@Json(name = "status") val status: Status,
)
@JsonClass(generateAdapter = false)
enum class Status {
@Json(name = "NEW")
NEW,
@Json(name = "PROCESSING")
PROCESSING,
@Json(name = "COMPLETED")
COMPLETED,
@Json(name = "CANCELED")
CANCELED,
}
}

View file

@ -137,6 +137,9 @@ interface TangemTechApi {
@GET("v1/user-wallets/wallets/by-app/{app_id}")
suspend fun getWallets(@Path("app_id") appId: String): ApiResponse<List<WalletResponse>>
@POST("v1/user-wallets/wallets")
suspend fun createWallet(@Body body: WalletIdBody): ApiResponse<Unit>
// endregion
// promo

View file

@ -3,6 +3,9 @@ package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.common.extensions.calculateHashCode
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse.GroupType
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse.GroupType.NONE
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse.SortType
@JsonClass(generateAdapter = true)
@Suppress("BooleanPropertyNaming")
@ -68,4 +71,8 @@ data class UserTokensResponse(
@Json(name = "marketcap")
MARKETCAP,
}
}
}
fun GroupType?.orDefault(): GroupType = this ?: NONE
fun SortType?.orDefault(): SortType = this ?: SortType.MANUAL

View file

@ -7,5 +7,16 @@ import com.squareup.moshi.JsonClass
data class WalletIdBody(
@Json(name = "id") val walletId: String,
@Json(name = "name") val name: String,
@Json(name = "cards") val cards: List<CardInfoBody>,
)
@Json(name = "type") val walletType: WalletType? = null,
@Json(name = "cards") val cards: List<CardInfoBody>? = null,
) {
@JsonClass(generateAdapter = false)
enum class WalletType {
@Json(name = "card")
COLD,
@Json(name = "mobile")
HOT,
}
}

View file

@ -15,9 +15,23 @@ data class GetWalletAccountsResponse(
@JsonClass(generateAdapter = true)
data class Wallet(
@Json(name = "version") val version: Int = 0,
@Json(name = "group") val group: GroupType,
@Json(name = "sort") val sort: SortType,
@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,
)
}
/** Flattens the tokens from all wallet accounts into a single list */
fun GetWalletAccountsResponse.flattenTokens(): List<UserTokensResponse.Token> {
return accounts.flatMap { it.tokens.orEmpty() }
}
/** Converts the [GetWalletAccountsResponse] into a [UserTokensResponse] */
fun GetWalletAccountsResponse.toUserTokensResponse(): UserTokensResponse {
return UserTokensResponse(
group = wallet.group ?: GroupType.NONE,
sort = wallet.sort ?: SortType.MANUAL,
tokens = flattenTokens(),
)
}

View file

@ -0,0 +1,23 @@
package com.tangem.datasource.di
import com.tangem.datasource.local.accounts.AccountTokenMigrationStore
import com.tangem.datasource.local.accounts.DefaultAccountTokenMigrationStore
import com.tangem.datasource.local.datastore.RuntimeStateStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object AccountsMigrationStoreModule {
@Provides
@Singleton
fun provideAccountTokenMigrationStore(): AccountTokenMigrationStore {
return DefaultAccountTokenMigrationStore(
runtimeStateStore = RuntimeStateStore(emptyMap()),
)
}
}

View file

@ -4,6 +4,7 @@ import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.common.config.*
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.lib.auth.ExpressAuthProvider
import com.tangem.lib.auth.P2PEthPoolAuthProvider
import com.tangem.lib.auth.StakeKitAuthProvider
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.version.AppVersionProvider
@ -39,6 +40,12 @@ internal object ApiConfigsModule {
return StakeKit(stakeKitAuthProvider)
}
@Provides
@IntoSet
fun provideP2PEthPoolConfig(p2pAuthProvider: P2PEthPoolAuthProvider): ApiConfig {
return P2PEthPool(p2pAuthProvider)
}
@Provides
@IntoSet
fun provideTangemTechConfig(
@ -51,6 +58,10 @@ internal object ApiConfigsModule {
appInfoProvider = appInfoProvider,
)
@Provides
@IntoSet
fun provideNewsConfig(authProvider: AuthProvider): ApiConfig = News(authProvider = authProvider)
@Provides
@IntoSet
fun provideYieldSupplyConfig(
@ -74,4 +85,10 @@ internal object ApiConfigsModule {
fun provideBlockAidConfig(environmentConfigStorage: EnvironmentConfigStorage): ApiConfig {
return BlockAid(environmentConfigStorage)
}
@Provides
@IntoSet
fun provideMoonPayConfig(): ApiConfig {
return MoonPay()
}
}

View file

@ -5,13 +5,17 @@ import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfigs
import com.tangem.datasource.api.common.config.MoonPay
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.common.config.managers.DevApiConfigsManager
import com.tangem.datasource.api.common.config.managers.MockApiConfigsManager
import com.tangem.datasource.api.common.config.managers.ProdApiConfigsManager
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.markets.TangemTechMarketsApi
import com.tangem.datasource.api.moonpay.MoonPayApi
import com.tangem.datasource.api.news.NewsApi
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
@ -71,6 +75,15 @@ internal object NetworkModule {
)
}
@Provides
@Singleton
fun provideP2PEthPoolApi(retrofitApiBuilder: RetrofitApiBuilder): P2PEthPoolApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.P2PEthPool,
applyTimeoutAnnotations = false,
)
}
@Provides
@Singleton
fun provideOnrampApi(retrofitApiBuilder: RetrofitApiBuilder): OnrampApi {
@ -130,4 +143,22 @@ internal object NetworkModule {
applyTimeoutAnnotations = false,
)
}
@Provides
@Singleton
fun provideMoonPayApi(retrofitApiBuilder: RetrofitApiBuilder): MoonPayApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.MoonPay,
applyTimeoutAnnotations = false,
)
}
@Provides
@Singleton
fun provideNewsApi(retrofitApiBuilder: RetrofitApiBuilder): NewsApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.News,
applyTimeoutAnnotations = false,
)
}
}

View file

@ -1,18 +0,0 @@
package com.tangem.datasource.di.exchangeservice
import com.tangem.datasource.exchangeservice.swap.DefaultExpressServiceLoader
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface ExchangeServiceLoaderModule {
@Binds
@Singleton
fun bindExpressServiceLoader(defaultExpressServiceLoader: DefaultExpressServiceLoader): ExpressServiceLoader
}

View file

@ -197,6 +197,7 @@ internal class RetrofitApiBuilder @Inject constructor(
val excludedApiForLogging: Set<ApiConfig.ID> = setOf(
// ApiConfig.ID.StakeKit,
ApiConfig.ID.MoonPay,
)
}
}

View file

@ -1,91 +0,0 @@
package com.tangem.datasource.exchangeservice.swap
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.request.AssetsRequestBody
import com.tangem.datasource.api.express.models.request.LeastTokenInfo
import com.tangem.datasource.api.express.models.response.Asset
import com.tangem.datasource.exchangeservice.swap.ExpressUtils.getRefCode
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.ExpressAssetsStore
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.core.utils.lceContent
import com.tangem.domain.core.utils.lceError
import com.tangem.domain.core.utils.lceLoading
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
typealias InitializationStatusFlow = MutableStateFlow<Lce<Throwable, List<Asset>>>
/**
* Default implementation of [ExpressServiceLoader]
*
* @property tangemExpressApi express api
* @property expressAssetsStore local storage
*
[REDACTED_AUTHOR]
*/
internal class DefaultExpressServiceLoader @Inject constructor(
private val tangemExpressApi: TangemExpressApi,
private val expressAssetsStore: ExpressAssetsStore,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : ExpressServiceLoader {
private val initializationStatuses =
MutableStateFlow<Map<UserWalletId, InitializationStatusFlow>>(value = emptyMap())
override suspend fun update(userWallet: UserWallet, userTokens: List<LeastTokenInfo>) {
withContext(dispatchers.io) {
val initializationStatus = getInitializationStatusInternal(userWallet.walletId)
try {
if (userTokens.isNotEmpty()) {
val response = tangemExpressApi.getAssets(
userWalletId = userWallet.walletId.stringValue,
refCode = getRefCode(userWallet, appPreferencesStore),
body = AssetsRequestBody(tokensList = userTokens),
).getOrThrow()
expressAssetsStore.store(userWallet.walletId, response)
initializationStatus.update { response.lceContent() }
}
} catch (e: Throwable) {
if (expressAssetsStore.getSyncOrNull(userWallet.walletId) == null) {
initializationStatus.update { e.lceError() }
}
Timber.e(e, "Unable to fetch assets for: ${userWallet.walletId.stringValue}")
}
}
}
override fun getInitializationStatus(userWalletId: UserWalletId): Flow<Lce<Throwable, List<Asset>>> {
return flow { getInitializationStatusInternal(userWalletId).collect { emit(it) } }
}
@Suppress("SuspendFunWithFlowReturnType")
private suspend fun getInitializationStatusInternal(userWalletId: UserWalletId): InitializationStatusFlow {
val initializationStatus = initializationStatuses.value[userWalletId]
if (initializationStatus != null) return initializationStatus
val cached = expressAssetsStore.getSyncOrNull(userWalletId)
val default: InitializationStatusFlow = MutableStateFlow(value = cached?.lceContent() ?: lceLoading())
initializationStatuses.update { statuses ->
statuses.toMutableMap().apply {
put(key = userWalletId, value = default)
}
}
return default
}
}

View file

@ -1,22 +0,0 @@
package com.tangem.datasource.exchangeservice.swap
import com.tangem.datasource.api.express.models.request.LeastTokenInfo
import com.tangem.datasource.api.express.models.response.Asset
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
/**
* Express service loader
*
[REDACTED_AUTHOR]
*/
interface ExpressServiceLoader {
/** Update service using [userWallet] and [userTokens] */
suspend fun update(userWallet: UserWallet, userTokens: List<LeastTokenInfo>)
/** Get initialization status by [userWalletId] */
fun getInitializationStatus(userWalletId: UserWalletId): Flow<Lce<Throwable, List<Asset>>>
}

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.local.accounts
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
interface AccountTokenMigrationStore {
fun get(userWalletId: UserWalletId): Flow<Pair<String, String>?>
suspend fun store(userWalletId: UserWalletId, value: Pair<String, String>)
suspend fun remove(userWalletId: UserWalletId)
}

View file

@ -0,0 +1,23 @@
package com.tangem.datasource.local.accounts
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
internal class DefaultAccountTokenMigrationStore(
private val runtimeStateStore: RuntimeStateStore<Map<UserWalletId, Pair<String, String>>>,
) : AccountTokenMigrationStore {
override fun get(userWalletId: UserWalletId): Flow<Pair<String, String>?> {
return runtimeStateStore.get().map { it[userWalletId] }
}
override suspend fun store(userWalletId: UserWalletId, value: Pair<String, String>) {
runtimeStateStore.update { it + (userWalletId to value) }
}
override suspend fun remove(userWalletId: UserWalletId) {
runtimeStateStore.update { it - userWalletId }
}
}

View file

@ -16,6 +16,7 @@ data class EnvironmentConfig(
val express: ExpressModel? = null,
val devExpress: ExpressModel? = null,
val stakeKitApiKey: String? = null,
// val p2pApiKey: P2PKeys? = null, TODO p2p after release 5.30
val blockAidApiKey: String? = null,
val tangemApiKey: String? = null,
val tangemApiKeyDev: String? = null,

View file

@ -25,6 +25,7 @@ internal object EnvironmentConfigConverter : Converter<EnvironmentConfigModel, E
express = value.express,
devExpress = value.devExpress,
stakeKitApiKey = value.stakeKitApiKey,
// p2pApiKey = value.p2pApiKey, TODO p2p after release 5.30
blockAidApiKey = value.blockaidApiKey,
tangemApiKey = value.tangemApiKey,
tangemApiKeyDev = value.tangemApiKeyDev,

View file

@ -33,6 +33,7 @@ class EnvironmentConfigModel(
@Json(name = "hederaArkhiaKey") val hederaArkhiaKey: String?,
@Json(name = "polygonScanApiKey") val polygonScanApiKey: String?,
@Json(name = "stakeKitApiKey") val stakeKitApiKey: String?,
// @Json(name = "p2pApiKey") val p2pApiKey: P2PKeys?, TODO p2p after release 5.30
@Json(name = "bittensorDwellirKey") val bittensorDwellirApiKey: String?,
@Json(name = "bittensorOnfinalityKey") val bittensorOnfinalityKey: String?,
@Json(name = "koinosProApiKey") val koinosProApiKey: String?,
@ -97,6 +98,12 @@ data class TonCenterKeys(
@Json(name = "testnet") val testnet: String,
)
@JsonClass(generateAdapter = true)
data class P2PKeys(
@Json(name = "mainnet") val mainnet: String,
@Json(name = "hoodi") val hoodi: String,
)
@JsonClass(generateAdapter = true)
data class GetBlockToken(
@Json(name = "jsonRpc") val jsonRPC: String?,

View file

@ -121,6 +121,8 @@ object PreferencesKeys {
val YIELD_SUPPLY_WARNINGS_STATES_KEY by lazy { stringPreferencesKey(name = "yieldSupplyWarningsStates") }
val ACCESS_CODE_SKIPPED_STATES_KEY by lazy { stringPreferencesKey(name = "accessCodeSkippedStates") }
// region Notifications
val NOTIFICATIONS_APPLICATION_ID_KEY by lazy { stringPreferencesKey(name = "notificationsApplicationId") }
@ -171,6 +173,9 @@ object PreferencesKeys {
fun getHotWalletUnlockDeadlineKey(attemptId: String) =
longPreferencesKey(name = "hotWalletUnlockDeadline_$attemptId")
fun getTangemPayAddToWalletKey(customerWalletAddress: String) =
booleanPreferencesKey("tangem_pay_add_to_wallet_done_key_$customerWalletAddress")
// endregion
}

View file

@ -1,9 +1,13 @@
package com.tangem.datasource.local.visa
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.visa.model.VisaAuthTokens
interface TangemPayStorage {
suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String)
suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String?
suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens)
suspend fun getAuthTokens(customerWalletAddress: String): VisaAuthTokens?
@ -14,5 +18,9 @@ interface TangemPayStorage {
suspend fun clearOrderId(customerWalletAddress: String)
suspend fun clearAll(customerWalletAddress: String)
suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean
suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean)
suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String)
}

View file

@ -71,6 +71,9 @@ class ApiConfigTest {
ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = mockk())
ApiConfig.ID.TangemPay -> TangemPay(appVersionProvider = mockk())
ApiConfig.ID.BlockAid -> BlockAid(configStorage = mockk())
ApiConfig.ID.MoonPay -> MoonPay()
ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = mockk())
ApiConfig.ID.News -> News(authProvider = appAuthProvider)
}
}
}

View file

@ -2,7 +2,6 @@ package com.tangem.datasource.api.common.config.managers
import android.os.Build
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.common.config.*
@ -14,7 +13,9 @@ import com.tangem.datasource.api.common.config.ApiConfig.Companion.RELEASE_BUILD
import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.BLOCK_AID_API_KEY
import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_API_KEY
import com.tangem.lib.auth.ExpressAuthProvider
import com.tangem.lib.auth.P2PEthPoolAuthProvider
import com.tangem.lib.auth.StakeKitAuthProvider
import com.tangem.test.core.ProvideTestModels
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.version.AppVersionProvider
@ -39,6 +40,7 @@ internal class ProdApiConfigsManagerTest {
private val appVersionProvider = mockk<AppVersionProvider>()
private val expressAuthProvider = mockk<ExpressAuthProvider>()
private val stakeKitAuthProvider = mockk<StakeKitAuthProvider>()
private val p2pEthPoolAuthProvider = mockk<P2PEthPoolAuthProvider>()
private val appAuthProvider = mockk<AuthProvider>()
private val appInfoProvider = mockk<AppInfoProvider>()
private val tangemApiKeyProvider = mockk<ProviderSuspend<String>>()
@ -58,6 +60,7 @@ internal class ProdApiConfigsManagerTest {
every { appVersionProvider.versionName } returns VERSION_NAME
every { expressAuthProvider.getSessionId() } returns EXPRESS_SESSION_ID
every { stakeKitAuthProvider.getApiKey() } returns STAKE_KIT_API_KEY
every { p2pEthPoolAuthProvider.getApiKey() } returns P2P_API_KEY
every { appAuthProvider.getApiKey(any()) } returns tangemApiKeyProvider
coEvery { tangemApiKeyProvider.invoke() } returns TANGEM_API_KEY
coEvery { appAuthProvider.getCardId() } returns APP_CARD_ID
@ -109,6 +112,9 @@ internal class ProdApiConfigsManagerTest {
ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = stakeKitAuthProvider)
ApiConfig.ID.TangemPay -> TangemPay(appVersionProvider = appVersionProvider)
ApiConfig.ID.BlockAid -> BlockAid(configStorage = environmentConfigStorage)
ApiConfig.ID.MoonPay -> MoonPay()
ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = p2pEthPoolAuthProvider)
ApiConfig.ID.News -> News(authProvider = appAuthProvider)
}
}
}
@ -121,6 +127,9 @@ internal class ProdApiConfigsManagerTest {
ApiConfig.ID.StakeKit -> createStakeKitModel()
ApiConfig.ID.TangemPay -> createTangemPayModel()
ApiConfig.ID.BlockAid -> createBlockAidSdkModel()
ApiConfig.ID.MoonPay -> createMoonPayModel()
ApiConfig.ID.P2PEthPool -> createP2PModel()
ApiConfig.ID.News -> createNewsModel()
}
}
@ -239,7 +248,7 @@ internal class ProdApiConfigsManagerTest {
id = ApiConfig.ID.TangemPay,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "[REDACTED_ENV_URL]",
baseUrl = "https://api.dev.us.paera.com/bff/",
headers = mapOf(
"version" to ProviderSuspend { VERSION_NAME },
"platform" to ProviderSuspend { "Android" },
@ -263,6 +272,54 @@ internal class ProdApiConfigsManagerTest {
)
}
private fun createMoonPayModel(): TestModel {
return TestModel(
id = ApiConfig.ID.MoonPay,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.moonpay.com/",
),
)
}
private fun createP2PModel(): TestModel {
return TestModel(
id = ApiConfig.ID.P2PEthPool,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.p2p.org/",
headers = mapOf(
"Authorization" to ProviderSuspend { "Bearer $P2P_API_KEY" },
"accept" to ProviderSuspend { "application/json" },
"Content-Type" to ProviderSuspend { "application/json" },
),
),
)
}
private fun createNewsModel(): TestModel {
val (environment, baseUrl) = when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE,
DEBUG_BUILD_TYPE,
-> ApiEnvironment.DEV to "[REDACTED_ENV_URL]"
INTERNAL_BUILD_TYPE,
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD to "https://tangem.com/"
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
return TestModel(
id = ApiConfig.ID.News,
expected = ApiEnvironmentConfig(
environment = environment,
baseUrl = baseUrl,
headers = mapOf(
"api-key" to ProviderSuspend { TANGEM_API_KEY },
),
),
)
}
private fun String.checkHeaderValueOrEmpty(): String {
for (i in this.indices) {
val c = this[i]
@ -281,6 +338,7 @@ internal class ProdApiConfigsManagerTest {
const val VERSION_NAME = "debug"
const val EXPRESS_SESSION_ID = "express_session_id"
const val STAKE_KIT_API_KEY = "stake_kit_api_key"
const val P2P_API_KEY = "p2p_api_key"
const val APP_CARD_ID = "app_card_id"
const val APP_CARD_PUBLIC_KEY = "Bearer app_public_key"
}

View file

@ -1,9 +1,9 @@
package com.tangem.datasource.local.logs
import com.google.common.truth.Truth
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
/**
[REDACTED_AUTHOR]
@ -12,7 +12,7 @@ import org.junit.jupiter.params.provider.MethodSource
internal class LogsSanitizerTest {
@ParameterizedTest
@MethodSource("provideTestModels")
@ProvideTestModels
fun sanitize(model: TestModel) {
// Act
val actual = LogsSanitizer.sanitize(model.input)

View file

@ -1,9 +1,9 @@
package com.tangem.core.decompose.navigation
import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.pushNew
import com.arkivanov.decompose.router.stack.replaceCurrent
import com.arkivanov.essenty.instancekeeper.InstanceKeeper
import kotlin.reflect.KClass
@ -14,11 +14,14 @@ internal class DefaultRouter(
private val navigation: StackNavigation<Route>
get() = navigationProvider.getOrCreate()
@OptIn(ExperimentalDecomposeApi::class)
override fun push(route: Route, onComplete: (isSuccess: Boolean) -> Unit) {
navigation.pushNew(route, onComplete)
}
override fun replaceCurrent(route: Route, onComplete: (Boolean) -> Unit) {
navigation.replaceCurrent(route, { onComplete(true) })
}
override fun replaceAll(vararg routes: Route, onComplete: (isSuccess: Boolean) -> Unit) {
val newRoutes = routes.toList()

View file

@ -8,6 +8,10 @@ class DummyRouter : Router {
onComplete(true)
}
override fun replaceCurrent(route: Route, onComplete: (Boolean) -> Unit) {
onComplete(true)
}
override fun replaceAll(vararg routes: Route, onComplete: (isSuccess: Boolean) -> Unit) {
onComplete(true)
}

View file

@ -16,6 +16,14 @@ interface Router {
*/
fun push(route: Route, onComplete: (isSuccess: Boolean) -> Unit = {})
/**
* Replaces a current route with a new one.
*
* @param route The route to replace a current one.
* @param onComplete The callback to be invoked when the operation is complete.
*/
fun replaceCurrent(route: Route, onComplete: (isSuccess: Boolean) -> Unit = {})
/**
* Replaces ***all*** routes in the navigation stack with the specified [routes].
*

View file

@ -18,6 +18,10 @@ inline fun AppComponentContext.InnerPopRouter(
router.push(route, onComplete)
}
override fun replaceCurrent(route: Route, onComplete: (Boolean) -> Unit) {
router.replaceCurrent(route, onComplete)
}
override fun replaceAll(vararg routes: Route, onComplete: (Boolean) -> Unit) {
router.replaceAll(*routes, onComplete = onComplete)
}

View file

@ -3,6 +3,7 @@ package com.tangem.core.decompose.navigation.inner
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.pushNew
import com.arkivanov.decompose.router.stack.replaceCurrent
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.navigation.Route
import com.tangem.core.decompose.navigation.Router
@ -32,6 +33,14 @@ inline fun <reified T : Route> AppComponentContext.InnerRouter(
}
}
override fun replaceCurrent(route: Route, onComplete: (Boolean) -> Unit) {
if (route is T) {
stackNavigation.replaceCurrent(route, { onComplete(true) })
} else {
fallBackRouter.replaceCurrent(route, onComplete)
}
}
override fun replaceAll(vararg routes: Route, onComplete: (Boolean) -> Unit) {
if (routes.any { it is T }) {
val newRoutes = routes.toList().filterIsInstance<T>()

View file

@ -0,0 +1,33 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>BooleanPropertyNaming:BigDecimalPercentFormat.kt$BigDecimalPercentFormat$val withPercentSign: Boolean = true</ID>
<ID>BooleanPropertyNaming:FullScreen.kt$FullScreenLayout$private val focusable: Boolean</ID>
<ID>BooleanPropertyNaming:TokenItemState.kt$TokenItemState.TitleState.Content$val earnApyIsActive: Boolean = false</ID>
<ID>CanBeNonNullable:FooterContainer.kt$footer: TextReference? = null</ID>
<ID>CanBeNonNullable:InputRowImageBase.kt$iconRes: Int?</ID>
<ID>CanBeNonNullable:InputRowImageInfo.kt$subtitleEndIconRes: Int?</ID>
<ID>CanBeNonNullable:SearchBar.kt$keyboardController: SoftwareKeyboardController?</ID>
<ID>CanBeNonNullable:TextFields.kt$caption: String? = null</ID>
<ID>CanBeNonNullable:TransactionList.kt$txHistoryItems: LazyPagingItems&lt;TxHistoryState.TxHistoryItemState&gt;?</ID>
<ID>CastNullableToNonNullableType:DialogFullScreen.kt$as</ID>
<ID>CastNullableToNonNullableType:FullScreen.kt$FullScreenLayout$as</ID>
<ID>MultilineLambdaItParameter:Actions.kt${ ActionButtonContent( config = config, text = { textColor -&gt; Text(text = config.text, textColor = textColor) }, modifier = it.padding( start = TangemTheme.dimens.spacing16, end = TangemTheme.dimens.spacing24, ), ) }</ID>
<ID>MultilineLambdaItParameter:TangemDropdownMenu.kt${ if (it) { // Menu is expanded. 1f } else { // Menu is dismissed. 0.8f } }</ID>
<ID>MultilineLambdaItParameter:TangemDropdownMenu.kt${ if (it) { // Menu is expanded. 1f } else { // Menu is dismissed. 0f } }</ID>
<ID>NestedScopeFunctions:MessageBottomSheetUMV2.kt$apply(init)</ID>
<ID>NestedScopeFunctions:Shadow.kt$apply { isDither = true isAntiAlias = true setShadowLayer( radiusPx, offset.x.toPx(), offset.y.toPx(), color.toArgb(), ) }</ID>
<ID>NoNameShadowing:PinTextField.kt$value</ID>
<ID>NoNameShadowing:SimpleTextField.kt$textStyle</ID>
<ID>NoNameShadowing:TangemTheme.kt$systemUiController</ID>
<ID>NoNameShadowing:TextAnimatedCounter.kt$char</ID>
<ID>PropertyUsedBeforeDeclaration:InputManager.kt$InputManager$_query</ID>
<ID>ReusedModifierInstance:EllipsisText.kt$Text( text = layoutText, color = color, style = style, fontStyle = fontStyle, textDecoration = textDecoration, textAlign = textAlign, softWrap = softWrap, maxLines = 1, onTextLayout = { textLayoutResultState.value = it }, modifier = modifier, )</ID>
<ID>ReusedModifierInstance:Label.kt$Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier .padding(horizontal = 4.dp) .clip(TangemTheme.shapes.roundedCorners8) .background(color = backgroundColor) .then( if (state.onClick != null) { Modifier.clickable( interactionSource = remember { MutableInteractionSource() }, indication = ripple(), onClick = state.onClick, ) } else { Modifier }, ) .padding(horizontal = 8.dp, vertical = 4.dp), ) { Text( modifier = Modifier.weight(1.0f, fill = false), text = text.resolveReference(), 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) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = ripple(bounded = false), onClick = { state.onIconClick?.invoke() }, ), ) } }</ID>
<ID>ReusedModifierInstance:TangemRadioButton.kt$AnimatedVisibility( visible = isSelected, label = "Radio button animation", modifier = modifier .size(TangemTheme.dimens.size24), ) { Icon( painter = painterResource(id = R.drawable.ic_check_circle_24), contentDescription = null, tint = TangemTheme.colors.control.checked, ) }</ID>
<ID>ReusedModifierInstance:TokenPrice.kt$Icon( modifier = modifier, painter = painterResource( id = when (animatedType) { PriceChangeType.UP -&gt; R.drawable.ic_arrow_up_8 PriceChangeType.DOWN -&gt; R.drawable.ic_arrow_down_8 PriceChangeType.NEUTRAL -&gt; R.drawable.ic_elipse_8 }, ), tint = when (animatedType) { PriceChangeType.UP -&gt; TangemTheme.colors.icon.accent PriceChangeType.DOWN -&gt; TangemTheme.colors.icon.warning PriceChangeType.NEUTRAL -&gt; TangemTheme.colors.icon.inactive }, contentDescription = null, )</ID>
<ID>UnnecessaryEventHandlerParameter:PinTextField.kt$onValueChange: (String) -&gt; Unit</ID>
<ID>UnnecessaryEventHandlerParameter:ResizableText.kt$onFontSizeChange: (Float) -&gt; Unit</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -6,18 +6,21 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@ -34,6 +37,7 @@ data class SmallButtonConfig(
val onClick: () -> Unit,
val icon: TangemButtonIconPosition = TangemButtonIconPosition.None,
val isEnabled: Boolean = true,
val isLoading: Boolean = false,
)
/**
@ -68,7 +72,7 @@ private fun SmallButton(config: SmallButtonConfig, isPrimary: Boolean, modifier:
label = "Update background color",
)
Row(
Box(
modifier = modifier
.defaultMinSize(
minWidth = TangemTheme.dimens.size46,
@ -79,7 +83,7 @@ private fun SmallButton(config: SmallButtonConfig, isPrimary: Boolean, modifier:
color = backgroundColor,
shape = shape,
)
.clickable(enabled = config.isEnabled, onClick = config.onClick)
.clickable(enabled = !config.isLoading && config.isEnabled, onClick = config.onClick)
.padding(
paddingValues = when (config.icon) {
is TangemButtonIconPosition.None -> PaddingValues(
@ -95,42 +99,54 @@ private fun SmallButton(config: SmallButtonConfig, isPrimary: Boolean, modifier:
)
},
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
contentAlignment = Alignment.Center,
) {
ContentContainer(
iconPosition = config.icon,
text = {
val textColor by animateColorAsState(
targetValue = when {
!config.isEnabled -> TangemTheme.colors.text.disabled
isPrimary -> TangemTheme.colors.text.primary2
else -> TangemTheme.colors.text.primary1
},
label = "Update text color",
)
if (config.isLoading) {
CircularProgressIndicator(
strokeWidth = TangemTheme.dimens.size2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.size(TangemTheme.dimens.size16),
)
}
Row(
modifier = Modifier.conditional(config.isLoading) { alpha(0f) },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
ContentContainer(
iconPosition = config.icon,
text = {
val textColor by animateColorAsState(
targetValue = when {
!config.isEnabled -> TangemTheme.colors.text.disabled
isPrimary -> TangemTheme.colors.text.primary2
else -> TangemTheme.colors.text.primary1
},
label = "Update text color",
)
Text(
modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing4),
text = config.text.resolveReference(),
color = textColor,
maxLines = 1,
style = TangemTheme.typography.button,
)
},
icon = { iconResId ->
Icon(
modifier = Modifier.size(TangemTheme.dimens.size16),
painter = painterResource(id = iconResId),
tint = if (config.isEnabled) {
TangemTheme.colors.icon.secondary
} else {
TangemTheme.colors.icon.inactive
},
contentDescription = null,
)
},
)
Text(
modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing4),
text = config.text.resolveReference(),
color = textColor,
maxLines = 1,
style = TangemTheme.typography.button,
)
},
icon = { iconResId ->
Icon(
modifier = Modifier.size(TangemTheme.dimens.size16),
painter = painterResource(id = iconResId),
tint = if (config.isEnabled) {
TangemTheme.colors.icon.secondary
} else {
TangemTheme.colors.icon.inactive
},
contentDescription = null,
)
},
)
}
}
}
@ -172,6 +188,7 @@ private fun ButtonsSample() {
)
PrimarySmallButton(config = config)
SecondarySmallButton(config = config.copy(text = TextReference.Str(value = "Add")))
SecondarySmallButton(config = config.copy(text = TextReference.Str(value = "Add"), isLoading = true))
SecondarySmallButton(
config = config.copy(
text = TextReference.Str(value = "Rating"),
@ -191,5 +208,12 @@ private fun ButtonsSample() {
isEnabled = false,
),
)
SecondarySmallButton(
config = config.copy(
text = TextReference.Str(value = "Add token"),
icon = TangemButtonIconPosition.Start(iconResId = R.drawable.ic_plus_24),
isLoading = true,
),
)
}
}

View file

@ -22,6 +22,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -133,6 +135,13 @@ fun ActionBaseButton(
}
}
.clip(shape)
.semantics {
contentDescription = if (config.shouldDimContent) {
"Action button is dimmed"
} else {
"Action button is not dimmed"
}
}
.combinedClickable(
enabled = config.isEnabled,
onClick = config.onClick,

View file

@ -12,6 +12,7 @@ import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.PopUpMenuTestTags
@ -28,7 +29,7 @@ fun TangemDropdownItem(item: TangemDropdownMenuItem, dismissParent: () -> Unit,
.padding(vertical = TangemTheme.dimens.spacing8, horizontal = TangemTheme.dimens.spacing16)
.testTag(PopUpMenuTestTags.BUTTON),
text = item.title.resolveReference(),
style = TangemTheme.typography.button.copy(color = item.textColorProvider()),
style = TangemTheme.typography.button.copy(color = item.textColor.resolveReference()),
)
}
@ -42,7 +43,7 @@ private fun Preview_TokenDetailsAppBarDropdownItem() {
dismissParent = {},
item = TangemDropdownMenuItem(
title = TextReference.Res(id = R.string.token_details_hide_token),
textColorProvider = { TangemTheme.colors.text.warning },
textColor = themedColor { TangemTheme.colors.text.warning },
onClick = { },
),
)

View file

@ -1,11 +1,10 @@
package com.tangem.core.ui.components.dropdownmenu
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.extensions.ColorReference
import com.tangem.core.ui.extensions.TextReference
data class TangemDropdownMenuItem(
val title: TextReference,
val textColorProvider: @Composable () -> Color,
val textColor: ColorReference,
val onClick: () -> Unit,
)

View file

@ -0,0 +1,79 @@
package com.tangem.core.ui.components.feature
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
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
@Composable
fun FeatureBlock(title: String, description: String, iconRes: Int, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
) {
Icon(
modifier = Modifier
.padding(horizontal = 12.dp),
painter = painterResource(iconRes),
contentDescription = null,
tint = TangemTheme.colors.icon.primary1,
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
) {
Text(
text = title,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier
.padding(top = 4.dp),
text = description,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewFeatureBlock() {
TangemThemePreview {
Column(
modifier = Modifier
.background(TangemTheme.colors.background.primary)
.padding(16.dp),
) {
FeatureBlock(
title = stringResourceSafe(R.string.backup_info_save_title),
description = stringResourceSafe(R.string.backup_info_save_description, "12"),
iconRes = R.drawable.ic_lock_24,
)
Spacer(modifier = Modifier.height(24.dp))
FeatureBlock(
title = stringResourceSafe(R.string.backup_info_keep_title),
description = stringResourceSafe(R.string.backup_info_keep_description),
iconRes = R.drawable.ic_settings_24,
)
}
}
}

View file

@ -134,13 +134,16 @@ fun InputRowRecipient(
QrButton(
visible = value.isBlank(),
onQrCodeClick = onQrCodeClick,
modifier = Modifier.testTag(SendAddressScreenTestTags.QR_BUTTON),
)
PasteButton(
isPasteButtonVisible = value.isBlank(),
onClick = onPasteClick,
backgroundColorEnabled = TangemTheme.colors.button.secondary,
textColor = TangemTheme.colors.text.primary1,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing8),
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing8)
.testTag(SendAddressScreenTestTags.ADDRESS_PASTE_BUTTON),
)
}
}
@ -224,6 +227,7 @@ private fun ResolvedAddressRow(isLoading: Boolean, resolvedAddress: String?) {
text = state.address,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.testTag(SendAddressScreenTestTags.RESOLVED_ADDRESS),
)
}
}

View file

@ -21,7 +21,7 @@ import com.tangem.core.ui.utils.getGreyScaleColorFilter
* @param onImageError composable to show if image loading failed
*/
@Composable
internal fun InputRowAsyncImage(
fun InputRowAsyncImage(
imageUrl: String,
modifier: Modifier = Modifier,
isGrayscale: Boolean = false,

View file

@ -71,7 +71,6 @@ fun NetworkTitle(
Spacer(modifier = Modifier.size(TangemTheme.dimens.spacing8))
Box(
modifier = Modifier
.weight(weight = 1f)
.heightIn(min = TangemTheme.dimens.size24),
contentAlignment = Alignment.CenterEnd,
content = action,

View file

@ -53,7 +53,7 @@ fun SelectorRowItem(
val textStyle = if (isSelected && showSelectedAppearance) {
TangemTheme.typography.subtitle2
} else {
TangemTheme.typography.body2
TangemTheme.typography.body1
}
Box(
modifier = modifier

View file

@ -82,7 +82,7 @@ private fun Preview_TooltipText() {
) {
TooltipText(
text = stringReference("Text"),
onInfoClick = { /* [REDACTED_TODO_COMMENT]*/ },
onInfoClick = { },
)
}
}

View file

@ -27,6 +27,7 @@ import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.token.internal.*
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState
import com.tangem.core.ui.components.token.state.TokenItemState.Subtitle2State
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.rememberHapticFeedback
import com.tangem.core.ui.extensions.stringReference
@ -691,7 +692,10 @@ object AccountItemPreviewData {
value = stringReference("24 tokens"),
isAvailable = false,
),
subtitle2State = null,
subtitle2State = Subtitle2State.PriceChangeContent(
priceChangePercent = "0,43 %",
type = PriceChangeType.UP,
),
onItemClick = {},
onItemLongClick = {},
)

View file

@ -11,7 +11,6 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.audits.AuditLabel
import com.tangem.core.ui.components.text.applyBladeBrush
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@ -31,7 +30,7 @@ internal fun TokenCryptoAmount(
isFlickering = state.isFlickering,
)
}
is TokenItemState.Subtitle2State.LabelContent -> {
is TokenCryptoAmountState.LabelContent -> {
AuditLabel(state = state.auditLabelUM, modifier = modifier)
}
is TokenCryptoAmountState.Unreachable -> {
@ -46,6 +45,15 @@ internal fun TokenCryptoAmount(
is TokenCryptoAmountState.Locked -> {
LockedRectangle(modifier = modifier.placeholderSize())
}
is TokenCryptoAmountState.PriceChangeContent -> {
PriceBlock(
modifier = modifier,
price = null,
type = state.type,
priceChangePercent = state.priceChangePercent,
isFlickering = state.isFlickering,
)
}
null -> Unit
}
}

View file

@ -64,8 +64,8 @@ internal fun TokenPrice(state: TokenPriceState?, modifier: Modifier = Modifier)
}
@Composable
private fun PriceBlock(
price: String,
internal fun PriceBlock(
price: String?,
isFlickering: Boolean,
modifier: Modifier = Modifier,
type: PriceChangeType? = null,
@ -75,13 +75,15 @@ private fun PriceBlock(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
PriceText(
modifier = Modifier.weight(weight = 1f, fill = false),
text = price,
isFlickering = isFlickering,
)
if (price != null) {
PriceText(
modifier = Modifier.weight(weight = 1f, fill = false),
text = price,
isFlickering = isFlickering,
)
SpacerW6()
SpacerW6()
}
if (type != null) {
PriceChangeIcon(

View file

@ -240,6 +240,12 @@ sealed class TokenItemState {
val isFlickering: Boolean = false,
) : Subtitle2State()
data class PriceChangeContent(
val priceChangePercent: String,
val type: PriceChangeType,
val isFlickering: Boolean = false,
) : Subtitle2State()
data class LabelContent(val auditLabelUM: AuditLabelUM) : Subtitle2State()
data object Unreachable : Subtitle2State()

View file

@ -83,7 +83,7 @@ fun PortfolioTokensListItem(state: PortfolioTokensListItemUM, isBalanceHidden: B
}
@Composable
private fun ExpandedPortfolioHeader(state: TokenItemState, isCollapsable: Boolean, modifier: Modifier = Modifier) {
fun ExpandedPortfolioHeader(state: TokenItemState, isCollapsable: Boolean, modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier

View file

@ -0,0 +1,33 @@
package com.tangem.core.ui.extensions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
/**
* Utility class for keeping themed color reference from app theme.
*
* It necessary to use [Immutable] annotation for runtime stability.
*
* @property value color provider from theme
*/
@Immutable
data class ColorReference(val value: @Composable () -> Color)
/**
* Creates a [ColorReference] using a themed color from the app theme with a lambda.
*
* @param value The color provider from theme.
* @return A [ColorReference] representing the themed color.
*/
fun themedColor(value: @Composable () -> Color): ColorReference {
return ColorReference(value)
}
/**
* Resolves [ColorReference] to [Color]
*/
@Composable
fun ColorReference.resolveReference(): Color {
return value()
}

View file

@ -20,6 +20,7 @@ object TangemColorPalette {
// region Light
val Light1 = Color(0xFFF5F5F5)
val Light1V2 = Color(0xFFF4F4F4)
val Light2 = Color(0xFFEBEBEB)
val Light3 = Color(0xFFD3D3D3)
val Light4 = Color(0xFFC9C9C9)
@ -27,6 +28,7 @@ object TangemColorPalette {
// endregion Light
// region Green
val Green = Color(0xFF0C9F3D)
val Meadow = Color(0xFF1ACE80)
val MagicMint = Color(0xFFA3EBCC)
val DarkGreen = Color(0xFF06311F)
@ -45,4 +47,9 @@ object TangemColorPalette {
val Tangerine = Color(0xFFFFB71B)
val Mustard = Color(0xFFFDDE55)
// endregion Yellow
// region Overlay
val Overlay1 = Color(0x66000000)
val Overlay2 = Color(0xB2000000)
// endregion Overlay
}

View file

@ -0,0 +1,536 @@
@file:Suppress("LongParameterList")
package com.tangem.core.ui.res
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
@Stable
class TangemColors2 internal constructor(
val text: Text,
val graphic: Graphic,
val button: Button,
val surface: Surface,
val controls: Controls,
val field: Field,
val overlay: Overlay,
val border: Border,
val fill: Fill,
val skeleton: Skeleton,
val markers: Markers,
) {
@Stable
class Text internal constructor(
val neutral: Neutral,
val status: Status,
) {
@Stable
class Neutral internal constructor(
primary: Color,
primaryInverted: Color,
secondary: Color,
tertiary: Color,
primaryInvertedConstant: Color,
) {
var primary by mutableStateOf(primary)
private set
var primaryInverted by mutableStateOf(primaryInverted)
private set
var secondary by mutableStateOf(secondary)
private set
var tertiary by mutableStateOf(tertiary)
private set
var primaryInvertedConstant by mutableStateOf(primaryInvertedConstant)
private set
fun update(other: Neutral) {
primary = other.primary
primaryInverted = other.primaryInverted
secondary = other.secondary
tertiary = other.tertiary
primaryInvertedConstant = other.primaryInvertedConstant
}
}
@Stable
class Status internal constructor(
disabled: Color,
accent: Color,
warning: Color,
attention: Color,
positive: Color,
) {
var disabled by mutableStateOf(disabled)
private set
var accent by mutableStateOf(accent)
private set
var warning by mutableStateOf(warning)
private set
var attention by mutableStateOf(attention)
private set
var positive by mutableStateOf(positive)
private set
fun update(other: Status) {
disabled = other.disabled
accent = other.accent
warning = other.warning
attention = other.attention
positive = other.positive
}
}
fun update(other: Text) {
neutral.update(other.neutral)
status.update(other.status)
}
}
@Stable
class Graphic internal constructor(
val neutral: Neutral,
val status: Status,
) {
@Stable
class Neutral internal constructor(
primary: Color,
primaryInverted: Color,
secondary: Color,
tertiary: Color,
quaternary: Color,
primaryInvertedConstant: Color,
tertiaryConstant: Color,
) {
var primary by mutableStateOf(primary)
private set
var primaryInverted by mutableStateOf(primaryInverted)
private set
var secondary by mutableStateOf(secondary)
private set
var tertiary by mutableStateOf(tertiary)
private set
var quaternary by mutableStateOf(quaternary)
private set
var primaryInvertedConstant by mutableStateOf(primaryInvertedConstant)
private set
var tertiaryConstant by mutableStateOf(tertiaryConstant)
private set
fun update(other: Neutral) {
primary = other.primary
primaryInverted = other.primaryInverted
secondary = other.secondary
tertiary = other.tertiary
quaternary = other.quaternary
primaryInvertedConstant = other.primaryInvertedConstant
tertiaryConstant = other.tertiaryConstant
}
}
@Stable
class Status internal constructor(
accent: Color,
warning: Color,
attention: Color,
) {
var accent by mutableStateOf(accent)
private set
var warning by mutableStateOf(warning)
private set
var attention by mutableStateOf(attention)
private set
fun update(other: Status) {
accent = other.accent
warning = other.warning
attention = other.attention
}
}
fun update(other: Graphic) {
neutral.update(other.neutral)
status.update(other.status)
}
}
@Stable
class Button internal constructor(
backgroundPrimary: Color,
backgroundSecondary: Color,
backgroundDisabled: Color,
backgroundPositive: Color,
textPrimary: Color,
textSecondary: Color,
textDisabled: Color,
iconPrimary: Color,
iconSecondary: Color,
iconDisabled: Color,
borderPrimary: Color,
) {
var backgroundPrimary by mutableStateOf(backgroundPrimary)
private set
var backgroundSecondary by mutableStateOf(backgroundSecondary)
private set
var backgroundDisabled by mutableStateOf(backgroundDisabled)
private set
var backgroundPositive by mutableStateOf(backgroundPositive)
private set
var textPrimary by mutableStateOf(textPrimary)
private set
var textSecondary by mutableStateOf(textSecondary)
private set
var textDisabled by mutableStateOf(textDisabled)
private set
var iconPrimary by mutableStateOf(iconPrimary)
private set
var iconSecondary by mutableStateOf(iconSecondary)
private set
var iconDisabled by mutableStateOf(iconDisabled)
private set
var borderPrimary by mutableStateOf(borderPrimary)
private set
fun update(other: Button) {
backgroundPrimary = other.backgroundPrimary
backgroundSecondary = other.backgroundSecondary
backgroundDisabled = other.backgroundDisabled
backgroundPositive = other.backgroundPositive
textPrimary = other.textPrimary
textSecondary = other.textSecondary
textDisabled = other.textDisabled
iconPrimary = other.iconPrimary
iconSecondary = other.iconSecondary
iconDisabled = other.iconDisabled
borderPrimary = other.borderPrimary
}
}
@Stable
class Surface internal constructor(
level1: Color,
level2: Color,
level3: Color,
level4: Color,
) {
var level1 by mutableStateOf(level1)
private set
var level2 by mutableStateOf(level2)
private set
var level3 by mutableStateOf(level3)
private set
var level4 by mutableStateOf(level4)
private set
fun update(other: Surface) {
level1 = other.level1
level2 = other.level2
level3 = other.level3
level4 = other.level4
}
}
@Stable
class Controls internal constructor(
backgroundDefault: Color,
backgroundChecked: Color,
iconDefault: Color,
iconDisabled: Color,
) {
var backgroundDefault by mutableStateOf(backgroundDefault)
private set
var backgroundChecked by mutableStateOf(backgroundChecked)
private set
var iconDefault by mutableStateOf(iconDefault)
private set
var iconDisabled by mutableStateOf(iconDisabled)
private set
fun update(other: Controls) {
backgroundDefault = other.backgroundDefault
backgroundChecked = other.backgroundChecked
iconDefault = other.iconDefault
iconDisabled = other.iconDisabled
}
}
@Stable
class Field internal constructor(
backgroundDefault: Color,
backgroundFocused: Color,
textPlaceholder: Color,
textDefault: Color,
textDisabled: Color,
iconDefault: Color,
iconDisabled: Color,
textInvalid: Color,
borderInvalid: Color,
) {
var backgroundDefault by mutableStateOf(backgroundDefault)
private set
var backgroundFocused by mutableStateOf(backgroundFocused)
private set
var textPlaceholder by mutableStateOf(textPlaceholder)
private set
var textDefault by mutableStateOf(textDefault)
private set
var textDisabled by mutableStateOf(textDisabled)
private set
var iconDefault by mutableStateOf(iconDefault)
private set
var iconDisabled by mutableStateOf(iconDisabled)
private set
var textInvalid by mutableStateOf(textInvalid)
private set
var borderInvalid by mutableStateOf(borderInvalid)
private set
fun update(other: Field) {
backgroundDefault = other.backgroundDefault
backgroundFocused = other.backgroundFocused
textPlaceholder = other.textPlaceholder
textDefault = other.textDefault
textDisabled = other.textDisabled
iconDefault = other.iconDefault
iconDisabled = other.iconDisabled
textInvalid = other.textInvalid
borderInvalid = other.borderInvalid
}
}
@Stable
class Overlay internal constructor(
overlayPrimary: Color,
overlaySecondary: Color,
) {
var overlayPrimary by mutableStateOf(overlayPrimary)
private set
var overlaySecondary by mutableStateOf(overlaySecondary)
private set
fun update(other: Overlay) {
overlayPrimary = other.overlayPrimary
overlaySecondary = other.overlaySecondary
}
}
@Stable
class Border internal constructor(
val neutral: Neutral,
val status: Status,
) {
@Stable
class Neutral internal constructor(
primary: Color,
secondary: Color,
) {
var primary by mutableStateOf(primary)
private set
var secondary by mutableStateOf(secondary)
private set
fun update(other: Neutral) {
primary = other.primary
secondary = other.secondary
}
}
@Stable
class Status internal constructor(
accent: Color,
warning: Color,
attention: Color,
) {
var accent by mutableStateOf(accent)
private set
var warning by mutableStateOf(warning)
private set
var attention by mutableStateOf(attention)
private set
fun update(other: Status) {
accent = other.accent
warning = other.warning
attention = other.attention
}
}
fun update(other: Border) {
neutral.update(other.neutral)
status.update(other.status)
}
}
@Stable
class Fill internal constructor(
val neutral: Neutral,
val status: Status,
) {
@Stable
class Neutral internal constructor(
primary: Color,
primaryInverted: Color,
primaryInvertedConstant: Color,
secondary: Color,
tertiaryConstant: Color,
quaternary: Color,
) {
var primary by mutableStateOf(primary)
private set
var primaryInverted by mutableStateOf(primaryInverted)
private set
var primaryInvertedConstant by mutableStateOf(primaryInvertedConstant)
private set
var secondary by mutableStateOf(secondary)
private set
var tertiaryConstant by mutableStateOf(tertiaryConstant)
private set
var quaternary by mutableStateOf(quaternary)
private set
fun update(other: Neutral) {
primary = other.primary
primaryInverted = other.primaryInverted
primaryInvertedConstant = other.primaryInvertedConstant
secondary = other.secondary
tertiaryConstant = other.tertiaryConstant
quaternary = other.quaternary
}
}
@Stable
class Status internal constructor(
accent: Color,
warning: Color,
attention: Color,
) {
var accent by mutableStateOf(accent)
private set
var warning by mutableStateOf(warning)
private set
var attention by mutableStateOf(attention)
private set
fun update(other: Status) {
accent = other.accent
warning = other.warning
attention = other.attention
}
}
fun update(other: Fill) {
neutral.update(other.neutral)
status.update(other.status)
}
}
@Stable
class Skeleton internal constructor(
backgroundPrimary: Color,
) {
var backgroundPrimary by mutableStateOf(backgroundPrimary)
private set
fun update(other: Skeleton) {
backgroundPrimary = other.backgroundPrimary
}
}
@Stable
class Markers internal constructor(
backgroundSolidGray: Color,
backgroundDisabled: Color,
backgroundSolidBlue: Color,
textGray: Color,
textDisabled: Color,
iconGray: Color,
iconDisabled: Color,
borderGray: Color,
backgroundTintedBlue: Color,
textBlue: Color,
backgroundSolidRed: Color,
backgroundTintedRed: Color,
iconBlue: Color,
iconRed: Color,
textRed: Color,
backgroundTintedGray: Color,
borderTintedBlue: Color,
borderTintedRed: Color,
) {
var backgroundSolidGray by mutableStateOf(backgroundSolidGray)
private set
var backgroundDisabled by mutableStateOf(backgroundDisabled)
private set
var backgroundSolidBlue by mutableStateOf(backgroundSolidBlue)
private set
var textGray by mutableStateOf(textGray)
private set
var textDisabled by mutableStateOf(textDisabled)
private set
var iconGray by mutableStateOf(iconGray)
private set
var iconDisabled by mutableStateOf(iconDisabled)
private set
var borderGray by mutableStateOf(borderGray)
private set
var backgroundTintedBlue by mutableStateOf(backgroundTintedBlue)
private set
var textBlue by mutableStateOf(textBlue)
private set
var backgroundSolidRed by mutableStateOf(backgroundSolidRed)
private set
var backgroundTintedRed by mutableStateOf(backgroundTintedRed)
private set
var iconBlue by mutableStateOf(iconBlue)
private set
var iconRed by mutableStateOf(iconRed)
private set
var textRed by mutableStateOf(textRed)
private set
var backgroundTintedGray by mutableStateOf(backgroundTintedGray)
private set
var borderTintedBlue by mutableStateOf(borderTintedBlue)
private set
var borderTintedRed by mutableStateOf(borderTintedRed)
private set
fun update(other: Markers) {
backgroundSolidGray = other.backgroundSolidGray
backgroundDisabled = other.backgroundDisabled
backgroundSolidBlue = other.backgroundSolidBlue
textGray = other.textGray
textDisabled = other.textDisabled
iconGray = other.iconGray
iconDisabled = other.iconDisabled
borderGray = other.borderGray
backgroundTintedBlue = other.backgroundTintedBlue
textBlue = other.textBlue
backgroundSolidRed = other.backgroundSolidRed
backgroundTintedRed = other.backgroundTintedRed
iconBlue = other.iconBlue
iconRed = other.iconRed
textRed = other.textRed
backgroundTintedGray = other.backgroundTintedGray
borderTintedBlue = other.borderTintedBlue
borderTintedRed = other.borderTintedRed
}
}
fun update(other: TangemColors2) {
text.update(other.text)
graphic.update(other.graphic)
button.update(other.button)
surface.update(other.surface)
controls.update(other.controls)
field.update(other.field)
overlay.update(other.overlay)
border.update(other.border)
fill.update(other.fill)
skeleton.update(other.skeleton)
markers.update(other.markers)
}
}

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