Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-26 09:51:50 +03:00
commit 7e48e67354
942 changed files with 40912 additions and 7544 deletions

View file

@ -1,5 +1,7 @@
package com.tangem.core.analytics.models
const val IS_NOT_HTTP_ERROR = "Is not http error"
sealed class AnalyticsParam {
sealed class CardBalanceState(val value: String) {
@ -77,7 +79,9 @@ sealed class AnalyticsParam {
data object Backup : ScreensSources("Backup")
data object Onboarding : ScreensSources("Onboarding")
data object LongTap : ScreensSources("Long Tap")
data object Market : ScreensSources("Market")
data object Markets : ScreensSources("Markets")
data object MarketPulse : ScreensSources("Market Pulse")
data object TangemPay : ScreensSources("Tangem Pay")
data object WalletSettings : ScreensSources("Wallet Settings")
data object Upgrade : ScreensSources("Upgrade")
@ -86,6 +90,9 @@ sealed class AnalyticsParam {
data object CreateWalletIntro : ScreensSources("Create Wallet Intro")
data object AddNewWallet : ScreensSources("Add New Wallet")
data object CreateWallet : ScreensSources("Create Wallet")
data object NewsList : ScreensSources("News List")
data object NewsLink : ScreensSources("News Link")
data object NewsPage : ScreensSources("News Page")
}
sealed class TxSentFrom(val value: String) {

View file

@ -0,0 +1,22 @@
package com.tangem.core.analytics.models
/**
* Marker interface for analytics events that should be sent only once per session.
*
* Events implementing this interface will be tracked by their [oneTimeEventId] to ensure
* they are not sent multiple times during the same application session. Once an event
* with a specific [oneTimeEventId] has been sent, subsequent attempts to send an event
* with the same ID will be ignored.
*
* @see Analytics.send
*/
interface OneTimePerSessionEvent {
/**
* Unique identifier for the one-time event.
*
* This ID is used to track whether the event has already been sent in the current session.
* Events with the same [oneTimeEventId] will only be sent once, even if they are
* different instances of the same event class.
*/
val oneTimeEventId: String
}

View file

@ -6,6 +6,7 @@ import com.tangem.common.extensions.toHexString
import com.tangem.core.analytics.api.*
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.analytics.models.OneTimePerSessionEvent
import com.tangem.utils.coroutines.FeatureCoroutineExceptionHandler
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
@ -31,6 +32,7 @@ object Analytics : GlobalAnalyticsEventHandler {
private val handlers = mutableMapOf<String, AnalyticsHandler>()
private val paramsInterceptors = ConcurrentHashMap<String, ParamsInterceptor>()
private val oneEventsPerSession = ConcurrentHashMap<String, Boolean>()
private val analyticsFilters = mutableSetOf<AnalyticsEventFilter>()
private val analyticsMutex = Mutex()
@ -85,6 +87,11 @@ object Analytics : GlobalAnalyticsEventHandler {
override fun send(event: AnalyticsEvent) {
analyticsScope.launch {
if (event is OneTimePerSessionEvent &&
oneEventsPerSession.putIfAbsent(event.oneTimeEventId, true) != null
) {
return@launch
}
event.params = applyParamsInterceptors(event)
val eventFilter = analyticsFilters.firstOrNull { it.canBeAppliedTo(event) }

View file

@ -59,16 +59,32 @@
"name": "YIELD_SUPPLY_FEATURE_ENABLED",
"version": "5.30.0"
},
{
"name": "YIELD_SUPPLY_PENDING_TRANSACTIONS_ENABLED",
"version": "5.33.0"
},
{
"name": "NEW_ONRAMP_MAIN_ENABLED",
"version": "5.31.0"
},
{
"name": "ACCOUNTS_FEATURE_ENABLED",
"version": "undefined"
"version": "5.33.0"
},
{
"name": "FEED_ENABLED",
"version": "5.33.0"
},
{
"name": "APP_REDESIGN_ENABLED",
"version": "undefined"
},
{
"name": "GASLESS_TRANSACTIONS_ENABLED",
"version": "5.33.0"
},
{
"name": "SWAP_MARKET_LIST_ENABLED",
"version": "undefined"
}
]

View file

@ -12,6 +12,5 @@
<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

@ -18,6 +18,8 @@ interface AuthProvider {
fun getApiKey(apiEnvironment: Provider<ApiEnvironment>): ProviderSuspend<String>
fun getGaslessServiceApiKey(apiEnvironment: Provider<ApiEnvironment>): ProviderSuspend<String>
/**
* Returns map where keys(cardId) associated with cardPublicKey
*/

View file

@ -31,6 +31,7 @@ sealed class ApiConfig {
YieldSupply,
MoonPay,
News,
GaslessTxService,
}
private fun initializeId(): ID {
@ -39,12 +40,13 @@ sealed class ApiConfig {
is TangemTech -> ID.TangemTech
is StakeKit -> ID.StakeKit
is P2PEthPool -> ID.P2PEthPool
is TangemPay -> ID.TangemPay
is TangemPayAuth -> ID.TangemPayAuth
is TangemPay.Bff -> ID.TangemPay
is TangemPay.Auth -> ID.TangemPayAuth
is BlockAid -> ID.BlockAid
is YieldSupply -> ID.YieldSupply
is MoonPay -> ID.MoonPay
is News -> ID.News
is GaslessTxService -> ID.GaslessTxService
}
}

View file

@ -0,0 +1,66 @@
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
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.version.AppVersionProvider
/**
* Gasless transactions [ApiConfig]
*/
internal class GaslessTxService(
private val authProvider: AuthProvider,
private val appVersionProvider: AppVersionProvider,
private val appInfoProvider: AppInfoProvider,
) : 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.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values)
put(
key = "Authorization",
value = ProviderSuspend {
"Bearer ${authProvider.getGaslessServiceApiKey(Provider { environment }).invoke()}"
},
)
}
private companion object {
private const val PROD_BASE_URL = "https://gasless.tangem.org/"
private const val DEV_BASE_URL = "[REDACTED_ENV_URL]"
}
}

View file

@ -4,12 +4,16 @@ import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.utils.RequestHeader
import com.tangem.utils.Provider
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.version.AppVersionProvider
/**
* News [ApiConfig]
[REDACTED_AUTHOR]
*/
internal class News(
private val appVersionProvider: AppVersionProvider,
private val appInfoProvider: AppInfoProvider,
private val authProvider: AuthProvider,
) : ApiConfig() {
@ -52,6 +56,7 @@ internal class News(
apiEnvironment = Provider { environment },
).values,
)
putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values)
}
private companion object {

View file

@ -1,7 +1,7 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.domain.staking.model.ethpool.P2PStakingConfig
import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig
import com.tangem.lib.auth.P2PEthPoolAuthProvider
import com.tangem.utils.ProviderSuspend
@ -23,7 +23,7 @@ internal class P2PEthPool(
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
else -> if (P2PStakingConfig.USE_TESTNET) ApiEnvironment.DEV else ApiEnvironment.PROD
else -> if (P2PEthPoolStakingConfig.USE_TESTNET) ApiEnvironment.DEV else ApiEnvironment.PROD
}
}

View file

@ -1,11 +1,13 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.version.AppVersionProvider
internal class TangemPay(
internal sealed class TangemPay(
private val appVersionProvider: AppVersionProvider,
private val environmentConfigStorage: EnvironmentConfigStorage,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
@ -16,6 +18,8 @@ internal class TangemPay(
createProdEnvironment(),
)
protected abstract fun getBaseUrl(apiEnvironment: ApiEnvironment): String
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
@ -31,24 +35,75 @@ internal class TangemPay(
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "https://api.dev.us.paera.com/bff-v2/",
headers = createHeaders(),
baseUrl = getBaseUrl(ApiEnvironment.DEV),
headers = createHeaders(ApiEnvironment.DEV),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
baseUrl = getBaseUrl(ApiEnvironment.MOCK),
headers = createHeaders(ApiEnvironment.MOCK),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.us.paera.com/bff-v2/",
headers = createHeaders(),
baseUrl = getBaseUrl(ApiEnvironment.PROD),
headers = createHeaders(ApiEnvironment.PROD),
)
private fun createHeaders() = mapOf(
private fun createHeaders(apiEnvironment: ApiEnvironment) = mapOf(
"version" to ProviderSuspend { appVersionProvider.versionName },
"platform" to ProviderSuspend { "Android" },
"X-API-KEY" to ProviderSuspend { getBffStaticToken(apiEnvironment) },
)
private fun getBffStaticToken(apiEnvironment: ApiEnvironment): String {
return when (apiEnvironment) {
ApiEnvironment.MOCK,
ApiEnvironment.DEV,
-> environmentConfigStorage.getConfigSync().bffStaticTokenDev
ApiEnvironment.PROD -> environmentConfigStorage.getConfigSync().bffStaticToken
ApiEnvironment.STAGE,
ApiEnvironment.STAGE_2,
ApiEnvironment.DEV_2,
ApiEnvironment.DEV_3,
-> null
} ?: error("BffStaticToken is not provided for $apiEnvironment")
}
class Bff(
appVersionProvider: AppVersionProvider,
environmentConfigStorage: EnvironmentConfigStorage,
) : TangemPay(appVersionProvider, environmentConfigStorage) {
override fun getBaseUrl(apiEnvironment: ApiEnvironment): String {
return when (apiEnvironment) {
ApiEnvironment.DEV -> "https://api.dev.us.paera.com/bff-v2/"
ApiEnvironment.MOCK -> "[REDACTED_ENV_URL]"
ApiEnvironment.PROD -> "https://api.us.paera.com/bff-v2/"
ApiEnvironment.DEV_2,
ApiEnvironment.DEV_3,
ApiEnvironment.STAGE,
ApiEnvironment.STAGE_2,
-> error("Unknown environment: $apiEnvironment")
}
}
}
class Auth(
appVersionProvider: AppVersionProvider,
environmentConfigStorage: EnvironmentConfigStorage,
) : TangemPay(appVersionProvider, environmentConfigStorage) {
override fun getBaseUrl(apiEnvironment: ApiEnvironment): String {
return when (apiEnvironment) {
ApiEnvironment.DEV -> "https://api.dev.us.paera.com/"
ApiEnvironment.MOCK -> "[REDACTED_ENV_URL]"
ApiEnvironment.PROD -> "https://api.us.paera.com/"
ApiEnvironment.DEV_2,
ApiEnvironment.DEV_3,
ApiEnvironment.STAGE,
ApiEnvironment.STAGE_2,
-> error("Unknown environment: $apiEnvironment")
}
}
}
}

View file

@ -1,53 +0,0 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.version.AppVersionProvider
internal class TangemPayAuth(
private val appVersionProvider: AppVersionProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs = listOf(
createDevEnvironment(),
createMockedEnvironment(),
createProdEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
-> ApiEnvironment.DEV
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "https://api.dev.us.paera.com/",
headers = createHeaders(),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.us.paera.com/",
headers = createHeaders(),
)
private fun createHeaders() = mapOf(
"version" to ProviderSuspend { appVersionProvider.versionName },
"platform" to ProviderSuspend { "Android" },
)
}

View file

@ -2,9 +2,7 @@ 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.request.P2PEthPoolTransactionRequest
import com.tangem.datasource.api.ethpool.models.response.*
import retrofit2.http.*
@ -31,13 +29,13 @@ interface P2PEthPoolApi {
* 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)
* @param body Transaction 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>>
@Body body: P2PEthPoolTransactionRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolTransactionResponse>>
/**
* Prepare unstake transaction
@ -45,13 +43,13 @@ interface P2PEthPoolApi {
* 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)
* @param body Transaction parameters (delegator address, vault address, amount)
*/
@POST("api/v1/staking/pool/{network}/staking/unstake")
suspend fun createUnstakeTransaction(
@Path("network") network: String,
@Body body: P2PEthPoolUnstakeRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolUnstakeResponse>>
@Body body: P2PEthPoolTransactionRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolTransactionResponse>>
/**
* Prepare withdrawal transaction
@ -59,13 +57,13 @@ interface P2PEthPoolApi {
* Create unsigned transaction to withdraw available funds from exit queue.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param body Withdrawal parameters (staker address)
* @param body Transaction parameters (delegator address, vault address, amount)
*/
@POST("api/v1/staking/pool/{network}/staking/withdraw")
suspend fun createWithdrawTransaction(
@Path("network") network: String,
@Body body: P2PEthPoolWithdrawRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolWithdrawResponse>>
@Body body: P2PEthPoolTransactionRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolTransactionResponse>>
/**
* Broadcast signed transaction
@ -96,22 +94,4 @@ interface P2PEthPoolApi {
@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

@ -1,19 +0,0 @@
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,23 @@
package com.tangem.datasource.api.ethpool.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
/**
* Unified request body for creating staking transactions (deposit, unstake, withdraw)
*
* Used in:
* - POST /api/v1/staking/pool/{network}/staking/deposit
* - POST /api/v1/staking/pool/{network}/staking/unstake
* - POST /api/v1/staking/pool/{network}/staking/withdraw
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolTransactionRequest(
@Json(name = "delegatorAddress")
val delegatorAddress: String,
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "amount")
val amount: BigDecimal,
)

View file

@ -1,20 +0,0 @@
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

@ -1,15 +0,0 @@
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

@ -34,7 +34,7 @@ data class P2PEthPoolStakeDTO(
@JsonClass(generateAdapter = true)
data class P2PEthPoolExitQueueDTO(
@Json(name = "total")
val total: Double,
val total: BigDecimal,
@Json(name = "requests")
val requests: List<P2PEthPoolExitRequestDTO>,
)
@ -44,11 +44,11 @@ data class P2PEthPoolExitRequestDTO(
@Json(name = "ticket")
val ticket: String,
@Json(name = "totalAssets")
val totalAssets: Double,
val totalAssets: BigDecimal,
@Json(name = "timestamp")
val timestamp: Long,
@Json(name = "withdrawalTimestamp")
val withdrawalTimestamp: Long,
val withdrawalTimestamp: Long?,
@Json(name = "isClaimable")
val isClaimable: Boolean,
)

View file

@ -29,7 +29,7 @@ data class P2PEthPoolBroadcastResponse(
)
/**
* Transaction status from P2P API
* Transaction status from P2PEthPool API
*/
@JsonClass(generateAdapter = false)
enum class P2PEthPoolTxStatusDTO {

View file

@ -1,22 +0,0 @@
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

@ -4,9 +4,9 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Error response structure for P2P.org API
* Error response structure for P2P.org eth pooled API
*
* All P2P API endpoints return errors in this format
* All P2PEthPool API endpoints return errors in this format
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolErrorResponse(

View file

@ -6,7 +6,7 @@ import com.squareup.moshi.JsonClass
/**
* Unified response wrapper for all P2P.org API responses
*
* All P2P API endpoints return responses in this format:
* All P2PEthPool API endpoints return responses in this format:
* ```json
* {
* "error": null | { code, message, name, errors },

View file

@ -1,31 +0,0 @@
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

@ -3,14 +3,20 @@ 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 POST /api/v1/staking/pool/{network}/staking/withdraw
* Unified response for staking transactions (deposit, unstake, withdraw)
*
* Response for:
* - POST /api/v1/staking/pool/{network}/staking/deposit
* - POST /api/v1/staking/pool/{network}/staking/unstake
* - POST /api/v1/staking/pool/{network}/staking/withdraw
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolWithdrawResponse(
data class P2PEthPoolTransactionResponse(
@Json(name = "amount")
val amount: Double,
val amount: BigDecimal,
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "delegatorAddress")
@ -20,5 +26,5 @@ data class P2PEthPoolWithdrawResponse(
@Json(name = "createdAt")
val createdAt: DateTime,
@Json(name = "tickets")
val tickets: List<String>,
val tickets: List<String>? = null,
)

View file

@ -1,22 +0,0 @@
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

@ -2,6 +2,7 @@ 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}/vaults
@ -15,7 +16,7 @@ data class P2PEthPoolVaultsResponse(
)
/**
* Network identifier in P2P API
* Network identifier in P2PEthPool API
*/
@JsonClass(generateAdapter = false)
enum class P2PEthPoolNetworkDTO {
@ -33,15 +34,15 @@ data class P2PEthPoolVaultDTO(
@Json(name = "displayName")
val displayName: String,
@Json(name = "apy")
val apy: Double,
val apy: BigDecimal,
@Json(name = "baseApy")
val baseApy: Double,
val baseApy: BigDecimal,
@Json(name = "capacity")
val capacity: Double,
val capacity: BigDecimal,
@Json(name = "totalAssets")
val totalAssets: Double,
val totalAssets: BigDecimal,
@Json(name = "feePercent")
val feePercent: Double,
val feePercent: BigDecimal,
@Json(name = "isPrivate")
val isPrivate: Boolean,
@Json(name = "isGenesis")

View file

@ -32,6 +32,9 @@ data class ExchangeProvider(
@Json(name = "slippage")
val slippage: BigDecimal?,
@Json(name = "exchangeOnlyWithinSingleAddress")
val isExchangeOnlyWithinSingleAddress: Boolean = false,
)
@JsonClass(generateAdapter = false)

View file

@ -0,0 +1,21 @@
package com.tangem.datasource.api.gasless
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.gasless.models.*
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
interface GaslessTxServiceApi {
@GET("api/v1/tokens")
suspend fun getSupportedTokens(): ApiResponse<GaslessServiceResponse<GaslessSupportedTokens>>
@POST("api/v1/transaction/sign")
suspend fun signGaslessTransaction(
@Body transaction: GaslessTransactionRequest,
): ApiResponse<GaslessServiceResponse<GaslessSignedTransactionResultDTO>>
@GET("api/v1/config/fee-recipient")
suspend fun getFeeRecipient(): ApiResponse<GaslessServiceResponse<GaslessFeeRecipient>>
}

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.gasless.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class GaslessFeeRecipient(
@Json(name = "feeRecipientAddress") val address: String,
)

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.gasless.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class GaslessServiceResponse<T>(
@Json(name = "result") val result: T,
@Json(name = "success") val isSuccess: Boolean,
@Json(name = "timestamp") val timestamp: String,
)

View file

@ -0,0 +1,14 @@
package com.tangem.datasource.api.gasless.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Result of gasless transaction signing.
* Contains the signed transaction data and gas parameters.
*/
@JsonClass(generateAdapter = true)
data class GaslessSignedTransactionResultDTO(
@Json(name = "txHash")
val txHash: String,
)

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.gasless.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class GaslessSupportedTokens(
@Json(name = "tokens") val tokens: List<GaslessTokenDTO>,
)

View file

@ -0,0 +1,14 @@
package com.tangem.datasource.api.gasless.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class GaslessTokenDTO(
@Json(name = "tokenAddress") val tokenAddress: String,
@Json(name = "tokenSymbol") val tokenSymbol: String,
@Json(name = "tokenName") val tokenName: String,
@Json(name = "decimals") val decimals: Int,
@Json(name = "chainId") val chainId: Int,
@Json(name = "chain") val chain: String,
)

View file

@ -0,0 +1,93 @@
package com.tangem.datasource.api.gasless.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for gasless transaction submission.
* Represents complete transaction with fee delegation metadata.
*/
@JsonClass(generateAdapter = true)
data class GaslessTransactionRequest(
@Json(name = "gaslessTransaction")
val gaslessTransaction: GaslessTransactionData,
@Json(name = "signature")
val signature: String,
@Json(name = "userAddress")
val userAddress: String,
@Json(name = "chainId")
val chainId: Int,
@Json(name = "eip7702auth")
val eip7702Auth: Eip7702AuthorizationDTO? = null,
)
@JsonClass(generateAdapter = true)
data class GaslessTransactionData(
@Json(name = "transaction")
val transaction: TransactionData,
@Json(name = "fee")
val fee: FeeData,
@Json(name = "nonce")
val nonce: String,
)
@JsonClass(generateAdapter = true)
data class TransactionData(
@Json(name = "to")
val to: String,
@Json(name = "value")
val value: String,
@Json(name = "data")
val data: String,
)
@JsonClass(generateAdapter = true)
data class FeeData(
@Json(name = "feeToken")
val feeToken: String,
@Json(name = "maxTokenFee")
val maxTokenFee: String,
@Json(name = "coinPriceInToken")
val coinPriceInToken: String,
@Json(name = "feeTransferGasLimit")
val feeTransferGasLimit: String,
@Json(name = "baseGas")
val baseGas: String,
)
/**
* EIP-7702 authorization for account abstraction.
* Optional field, used only when EOA delegation is required.
*/
@JsonClass(generateAdapter = true)
data class Eip7702AuthorizationDTO(
@Json(name = "chainId")
val chainId: Int,
@Json(name = "address")
val address: String,
@Json(name = "nonce")
val nonce: String,
@Json(name = "yParity")
val yParity: Int,
@Json(name = "r")
val r: String,
@Json(name = "s")
val s: String,
)

View file

@ -27,15 +27,4 @@ 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

@ -17,4 +17,21 @@ data class NewsDetailsResponse(
@Json(name = "shortContent") val shortContent: String,
@Json(name = "content") val content: String,
@Json(name = "originalArticles") val originalArticles: List<NewsOriginalArticleDto>,
)
@JsonClass(generateAdapter = true)
data class NewsOriginalArticleDto(
@Json(name = "id") val id: Int,
@Json(name = "title") val title: String,
@Json(name = "source") val source: Source,
@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,
)
@JsonClass(generateAdapter = true)
data class Source(
@Json(name = "id") val id: Int,
@Json(name = "name") val name: String,
)

View file

@ -25,7 +25,6 @@ interface TangemPayApi {
@GET("v1/customer/wallets/{customer_wallet_id}")
suspend fun checkCustomerWalletId(
@Header("X-API-KEY") authHeader: String,
@Path("customer_wallet_id") customerWalletId: String,
): ApiResponse<CheckCustomerWalletResponse>

View file

@ -60,7 +60,15 @@ internal object ApiConfigsModule {
@Provides
@IntoSet
fun provideNewsConfig(authProvider: AuthProvider): ApiConfig = News(authProvider = authProvider)
fun provideNewsConfig(
appVersionProvider: AppVersionProvider,
authProvider: AuthProvider,
appInfoProvider: AppInfoProvider,
): ApiConfig = News(
appVersionProvider = appVersionProvider,
appInfoProvider = appInfoProvider,
authProvider = authProvider,
)
@Provides
@IntoSet
@ -78,13 +86,17 @@ internal object ApiConfigsModule {
@Provides
@IntoSet
fun provideTangemVisaConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemPay(appVersionProvider)
fun provideTangemPayBffConfig(
appVersionProvider: AppVersionProvider,
environmentConfigStorage: EnvironmentConfigStorage,
): ApiConfig = TangemPay.Bff(appVersionProvider, environmentConfigStorage)
@Provides
@IntoSet
fun provideTangemPayAuthConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemPayAuth(
appVersionProvider,
)
fun provideTangemPayAuthConfig(
appVersionProvider: AppVersionProvider,
environmentConfigStorage: EnvironmentConfigStorage,
): ApiConfig = TangemPay.Auth(appVersionProvider, environmentConfigStorage)
@Provides
@IntoSet
@ -97,4 +109,18 @@ internal object ApiConfigsModule {
fun provideMoonPayConfig(): ApiConfig {
return MoonPay()
}
@Provides
@IntoSet
fun provideGaslessServiceConfig(
appVersionProvider: AppVersionProvider,
authProvider: AuthProvider,
appInfoProvider: AppInfoProvider,
): ApiConfig {
return GaslessTxService(
authProvider = authProvider,
appVersionProvider = appVersionProvider,
appInfoProvider = appInfoProvider,
)
}
}

View file

@ -15,6 +15,7 @@ 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.gasless.GaslessTxServiceApi
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.TangemPayAuthApi
import com.tangem.datasource.api.stakekit.StakeKitApi
@ -36,8 +37,11 @@ import javax.inject.Singleton
internal object NetworkModule {
private const val TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS = 60L
private const val TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS = 60L
private const val STAKE_KIT_API_TIMEOUT_SECONDS = 60L
private const val P2P_ETH_POOL_API_TIMEOUT_SECONDS = 60L
@Provides
@Singleton
fun provideApiConfigManager(
@ -82,6 +86,12 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.P2PEthPool,
applyTimeoutAnnotations = false,
timeouts = Timeouts(
callTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS,
connectTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS,
readTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS,
writeTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS,
),
)
}
@ -180,4 +190,19 @@ internal object NetworkModule {
applyTimeoutAnnotations = false,
)
}
@Provides
@Singleton
fun provideGaslessTxServiceApi(retrofitApiBuilder: RetrofitApiBuilder): GaslessTxServiceApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.GaslessTxService,
applyTimeoutAnnotations = false,
timeouts = Timeouts(
callTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS,
connectTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS,
readTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS,
writeTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS,
),
)
}
}

View file

@ -3,8 +3,12 @@ package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.news.details.DefaultNewsDetailsStore
import com.tangem.datasource.local.news.details.NewsDetailsStore
import com.tangem.datasource.local.news.liked.DefaultNewsLikedStore
import com.tangem.datasource.local.news.liked.NewsLikedStore
import com.tangem.datasource.local.news.trending.DefaultTrendingNewsStore
import com.tangem.datasource.local.news.trending.TrendingNewsStore
import com.tangem.datasource.local.news.viewed.DefaultNewsViewedStore
import com.tangem.datasource.local.news.viewed.NewsViewedStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -26,4 +30,16 @@ internal object NewsStoreModule {
fun provideTrendingNewsStore(): TrendingNewsStore {
return DefaultTrendingNewsStore(store = RuntimeSharedStore())
}
@Provides
@Singleton
fun provideNewsViewedStore(): NewsViewedStore {
return DefaultNewsViewedStore(store = RuntimeSharedStore())
}
@Provides
@Singleton
fun provideNewsLikedStore(): NewsLikedStore {
return DefaultNewsLikedStore(store = RuntimeSharedStore())
}
}

View file

@ -80,7 +80,7 @@ internal object StakingStoreModule {
@Provides
@Singleton
fun provideP2PBalancesPersistenceStore(
fun provideP2PEthPoolBalancesPersistenceStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
@ -91,7 +91,7 @@ internal object StakingStoreModule {
types = mapWithStringKeyTypes(valueTypes = setTypes<P2PEthPoolAccountResponse>()),
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "p2p_balances") },
produceFile = { context.dataStoreFile(fileName = "p2p_eth_pool_balances") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
)
}

View file

@ -25,4 +25,7 @@ data class EnvironmentConfig(
val yieldModuleApiKey: String? = null,
val yieldModuleApiKeyDev: String? = null,
val bffStaticToken: String? = null,
val bffStaticTokenDev: String? = null,
val gaslessTxApiKeyDev: String? = null,
val gaslessTxApiKey: String? = null,
)

View file

@ -33,6 +33,9 @@ internal object EnvironmentConfigConverter : Converter<EnvironmentConfigModel, E
yieldModuleApiKey = value.yieldModuleApiKey,
yieldModuleApiKeyDev = value.yieldModuleApiKeyDev,
bffStaticToken = value.bffStaticToken,
bffStaticTokenDev = value.bffStaticTokenDev,
gaslessTxApiKeyDev = value.gaslessTxApiKeyDev,
gaslessTxApiKey = value.gaslessTxApiKey,
)
}
}

View file

@ -52,6 +52,9 @@ class EnvironmentConfigModel(
@Json(name = "blinkApiKey") val blinkApiKey: String?,
@Json(name = "tatumApiKey") val tatumApiKey: String?,
@Json(name = "bffStaticToken") val bffStaticToken: String?,
@Json(name = "bffStaticTokenDev") val bffStaticTokenDev: String?,
@Json(name = "gaslessTxApiKeyDev") val gaslessTxApiKeyDev: String?,
@Json(name = "gaslessTxApiKey") val gaslessTxApiKey: String?,
)
@JsonClass(generateAdapter = true)

View file

@ -0,0 +1,32 @@
package com.tangem.datasource.local.news.liked
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onStart
private typealias NewsLikedCache = Map<Int, Boolean>
internal class DefaultNewsLikedStore(
private val store: RuntimeSharedStore<NewsLikedCache>,
) : NewsLikedStore {
override fun getAll(): Flow<Map<Int, Boolean>> {
return store.get().onStart { emit(emptyMap()) }
}
override suspend fun getSync(): Map<Int, Boolean> {
return store.getSyncOrNull().orEmpty()
}
override suspend fun updateLiked(articleIds: Collection<Int>, liked: Boolean) {
if (articleIds.isEmpty()) return
store.update(emptyMap()) { current ->
val updated = current.toMutableMap()
articleIds.forEach { id ->
updated[id] = liked
}
updated
}
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.datasource.local.news.liked
import kotlinx.coroutines.flow.Flow
/**
* Store for news liked flags (runtime only).
*/
interface NewsLikedStore {
/**
* Observes all liked flags.
*/
fun getAll(): Flow<Map<Int, Boolean>>
/**
* Gets liked flags synchronously (returns empty map if no data).
*/
suspend fun getSync(): Map<Int, Boolean>
/**
* Updates liked flags for provided article ids.
*/
suspend fun updateLiked(articleIds: Collection<Int>, liked: Boolean)
}

View file

@ -0,0 +1,32 @@
package com.tangem.datasource.local.news.viewed
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onStart
private typealias NewsViewedCache = Map<Int, Boolean>
internal class DefaultNewsViewedStore(
private val store: RuntimeSharedStore<NewsViewedCache>,
) : NewsViewedStore {
override fun getAll(): Flow<Map<Int, Boolean>> {
return store.get().onStart { emit(emptyMap()) }
}
override suspend fun getSync(): Map<Int, Boolean> {
return store.getSyncOrNull().orEmpty()
}
override suspend fun updateViewed(articleIds: Collection<Int>, viewed: Boolean) {
if (articleIds.isEmpty()) return
store.update(emptyMap()) { current ->
val updated = current.toMutableMap()
articleIds.forEach { id ->
updated[id] = viewed
}
updated
}
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.datasource.local.news.viewed
import kotlinx.coroutines.flow.Flow
/**
* Store for news viewed flags (runtime only).
*/
interface NewsViewedStore {
/**
* Observes all viewed flags.
*/
fun getAll(): Flow<Map<Int, Boolean>>
/**
* Gets viewed flags synchronously (returns empty map if no data).
*/
suspend fun getSync(): Map<Int, Boolean>
/**
* Updates viewed flags for provided article ids.
*/
suspend fun updateViewed(articleIds: Collection<Int>, viewed: Boolean)
}

View file

@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.Flow
* (similar to StakingYieldsStore for StakeKit yields)
*
* Vault is ETH-specific concept for pooled staking.
* For other blockchains, P2P may use different structures.
* For other blockchains, P2PEthPool may use different structures.
*/
interface P2PEthPoolVaultsStore {
@ -23,7 +23,7 @@ interface P2PEthPoolVaultsStore {
suspend fun getSync(): List<P2PEthPoolVault>
/**
* Store vaults from P2P API
* Store vaults from P2PEthPool API
*/
suspend fun store(vaults: List<P2PEthPoolVault>)
}

View file

@ -69,12 +69,27 @@ class ApiConfigTest {
)
}
ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = mockk())
ApiConfig.ID.TangemPay -> TangemPay(appVersionProvider = mockk())
ApiConfig.ID.TangemPay -> TangemPay.Bff(
appVersionProvider = mockk(),
environmentConfigStorage = mockk()
)
ApiConfig.ID.TangemPayAuth -> TangemPay.Auth(
appVersionProvider = mockk(),
environmentConfigStorage = mockk()
)
ApiConfig.ID.BlockAid -> BlockAid(configStorage = mockk())
ApiConfig.ID.MoonPay -> MoonPay()
ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = mockk())
ApiConfig.ID.News -> News(authProvider = appAuthProvider)
ApiConfig.ID.TangemPayAuth -> TangemPayAuth(appVersionProvider = mockk())
ApiConfig.ID.News -> News(
appVersionProvider = mockk(),
authProvider = appAuthProvider,
appInfoProvider = mockk(),
)
ApiConfig.ID.GaslessTxService -> GaslessTxService(
authProvider = appAuthProvider,
appVersionProvider = mockk(),
appInfoProvider = mockk(),
)
}
}
}

View file

@ -18,6 +18,8 @@ internal class MockEnvironmentConfigStorage : EnvironmentConfigStorage {
blockAidApiKey = BLOCK_AID_API_KEY,
tangemApiKey = TANGEM_API_KEY,
tangemApiKeyDev = TANGEM_API_KEY_DEV,
bffStaticToken = TANGEM_PAY_BFF_KEY,
bffStaticTokenDev = TANGEM_PAY_BFF_KEY_DEV,
tangemApiKeyStage = TANGEM_API_KEY_STAGE,
yieldModuleApiKey = YIELD_MODULE_KEY,
yieldModuleApiKeyDev = YIELD_MODULE_KEY_DEV,
@ -33,6 +35,9 @@ internal class MockEnvironmentConfigStorage : EnvironmentConfigStorage {
const val BLOCK_AID_API_KEY = "block_aid_api_key"
const val TANGEM_API_KEY = "tangem_api_key"
const val TANGEM_API_KEY_DEV = "tangem_api_key_dev"
const val TANGEM_PAY_BFF_KEY = "tangem_pay_bff_key"
const val TANGEM_PAY_BFF_KEY_DEV = "tangem_pay_bff_key_dev"
const val TANGEM_GASLESS_API_KEY = "tangem_gasless_api_key"
const val TANGEM_API_KEY_STAGE = "tangem_api_key_stage"
const val YIELD_MODULE_KEY = "yield_module_api_key"
const val YIELD_MODULE_KEY_DEV = "yield_module_api_key_dev"

View file

@ -12,7 +12,9 @@ import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_
import com.tangem.datasource.api.common.config.ApiConfig.Companion.RELEASE_BUILD_TYPE
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.domain.staking.model.ethpool.P2PStakingConfig
import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_GASLESS_API_KEY
import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_PAY_BFF_KEY_DEV
import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig
import com.tangem.lib.auth.ExpressAuthProvider
import com.tangem.lib.auth.P2PEthPoolAuthProvider
import com.tangem.lib.auth.StakeKitAuthProvider
@ -45,6 +47,7 @@ internal class ProdApiConfigsManagerTest {
private val appAuthProvider = mockk<AuthProvider>()
private val appInfoProvider = mockk<AppInfoProvider>()
private val tangemApiKeyProvider = mockk<ProviderSuspend<String>>()
private val tangemGaslessApiKeyProvider = mockk<ProviderSuspend<String>>()
private lateinit var manager: ProdApiConfigsManager
@ -63,6 +66,8 @@ internal class ProdApiConfigsManagerTest {
every { stakeKitAuthProvider.getApiKey() } returns STAKE_KIT_API_KEY
every { p2pEthPoolAuthProvider.getApiKey() } returns P2P_API_KEY
every { appAuthProvider.getApiKey(any()) } returns tangemApiKeyProvider
every { appAuthProvider.getGaslessServiceApiKey(any()) } returns tangemGaslessApiKeyProvider
coEvery { tangemGaslessApiKeyProvider.invoke() } returns TANGEM_GASLESS_API_KEY
coEvery { tangemApiKeyProvider.invoke() } returns TANGEM_API_KEY
coEvery { appAuthProvider.getCardId() } returns APP_CARD_ID
coEvery { appAuthProvider.getCardPublicKey() } returns APP_CARD_PUBLIC_KEY
@ -111,12 +116,27 @@ internal class ProdApiConfigsManagerTest {
)
}
ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = stakeKitAuthProvider)
ApiConfig.ID.TangemPay -> TangemPay(appVersionProvider = appVersionProvider)
ApiConfig.ID.TangemPay -> TangemPay.Bff(
appVersionProvider = appVersionProvider,
environmentConfigStorage = environmentConfigStorage,
)
ApiConfig.ID.TangemPayAuth -> TangemPay.Auth(
appVersionProvider = appVersionProvider,
environmentConfigStorage = environmentConfigStorage,
)
ApiConfig.ID.BlockAid -> BlockAid(configStorage = environmentConfigStorage)
ApiConfig.ID.MoonPay -> MoonPay()
ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = p2pEthPoolAuthProvider)
ApiConfig.ID.News -> News(authProvider = appAuthProvider)
ApiConfig.ID.TangemPayAuth -> TangemPayAuth(appVersionProvider = appVersionProvider)
ApiConfig.ID.News -> News(
appVersionProvider = appVersionProvider,
authProvider = appAuthProvider,
appInfoProvider = appInfoProvider,
)
ApiConfig.ID.GaslessTxService -> GaslessTxService(
authProvider = appAuthProvider,
appVersionProvider = appVersionProvider,
appInfoProvider = appInfoProvider,
)
}
}
}
@ -128,11 +148,12 @@ internal class ProdApiConfigsManagerTest {
ApiConfig.ID.TangemTech -> createTangemTechModel()
ApiConfig.ID.StakeKit -> createStakeKitModel()
ApiConfig.ID.TangemPay -> createTangemPayModel()
ApiConfig.ID.TangemPayAuth -> createTangemPayAuthModel()
ApiConfig.ID.BlockAid -> createBlockAidSdkModel()
ApiConfig.ID.MoonPay -> createMoonPayModel()
ApiConfig.ID.P2PEthPool -> createP2PModel()
ApiConfig.ID.News -> createNewsModel()
ApiConfig.ID.TangemPayAuth -> createTangemPayAuthModel()
ApiConfig.ID.GaslessTxService -> createGaslessTxServiceModel()
}
}
@ -255,6 +276,7 @@ internal class ProdApiConfigsManagerTest {
headers = mapOf(
"version" to ProviderSuspend { VERSION_NAME },
"platform" to ProviderSuspend { "Android" },
"X-API-KEY" to ProviderSuspend { TANGEM_PAY_BFF_KEY_DEV },
),
),
)
@ -269,6 +291,38 @@ internal class ProdApiConfigsManagerTest {
headers = mapOf(
"version" to ProviderSuspend { VERSION_NAME },
"platform" to ProviderSuspend { "Android" },
"X-API-KEY" to ProviderSuspend { TANGEM_PAY_BFF_KEY_DEV },
),
),
)
}
private fun createGaslessTxServiceModel(): 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://gasless.tangem.org/"
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
return TestModel(
id = ApiConfig.ID.GaslessTxService,
expected = ApiEnvironmentConfig(
environment = environment,
baseUrl = baseUrl,
headers = mapOf(
"Authorization" to ProviderSuspend { "Bearer $TANGEM_GASLESS_API_KEY" },
"version" to ProviderSuspend { VERSION_NAME },
"platform" to ProviderSuspend { "android" },
"system_version" to ProviderSuspend { "Android 16" },
"language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() },
"timezone" to ProviderSuspend {
TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty()
},
"device" to ProviderSuspend { "${Build.MANUFACTURER} ${Build.MODEL}".checkHeaderValueOrEmpty() },
),
),
)
@ -300,7 +354,7 @@ internal class ProdApiConfigsManagerTest {
}
private fun createP2PModel(): TestModel {
val (environment, baseUrl) = if (P2PStakingConfig.USE_TESTNET) {
val (environment, baseUrl) = if (P2PEthPoolStakingConfig.USE_TESTNET) {
ApiEnvironment.DEV to "https://api-test.p2p.org/"
} else {
ApiEnvironment.PROD to "https://api.p2p.org/"
@ -338,6 +392,14 @@ internal class ProdApiConfigsManagerTest {
baseUrl = baseUrl,
headers = mapOf(
"api-key" to ProviderSuspend { TANGEM_API_KEY },
"version" to ProviderSuspend { VERSION_NAME },
"platform" to ProviderSuspend { "android" },
"system_version" to ProviderSuspend { "Android 16" },
"language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() },
"timezone" to ProviderSuspend {
TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty()
},
"device" to ProviderSuspend { "${Build.MANUFACTURER} ${Build.MODEL}".checkHeaderValueOrEmpty() },
),
),
)

View file

@ -0,0 +1,18 @@
package com.tangem.core.decompose.utils
import com.arkivanov.decompose.router.stack.StackNavigation
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
/**
* Retrieves the current stack of items from the [com.arkivanov.decompose.router.stack.ChildStack]
* @return A list representing the current stack of items
*/
suspend fun <T : Any> StackNavigation<T>.stack(): List<T> {
return suspendCancellableCoroutine { continuation ->
this.navigate(
transformer = { stack -> stack },
onComplete = { newStack, oldStack -> continuation.resume(newStack) },
)
}
}

View file

@ -14,11 +14,13 @@ sealed class BatchFetchResult<out TData> {
* @param data fetched data.
* @param empty indicates that data is empty and [BatchListSource] shouldn't create new batch for this result
* @param last indicates if this is the last batch for the request.
* @param total total number of items available on the server.
*/
data class Success<TData>(
val data: TData,
val empty: Boolean,
val last: Boolean,
val total: Int? = null,
) : BatchFetchResult<TData>()
/**

View file

@ -69,7 +69,7 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
private val updateFetcher: BatchUpdateFetcher<TKey, TData, TUpdate>? = null,
) : BatchListSource<TKey, TData, TUpdate> {
override val state = MutableStateFlow(BatchListState<TKey, TData>(emptyList(), PaginationStatus.None))
override val state = MutableStateFlow(BatchListState<TKey, TData>(emptyList(), PaginationStatus.None, null))
override val updateResults = MutableSharedFlow<Pair<TUpdate, BatchUpdateResult<TKey, TData>>>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
@ -254,6 +254,7 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
} else {
PaginationStatus.Paginating(res)
},
totalCount = res.total,
)
}
is BatchFetchResult.Error -> {
@ -262,6 +263,7 @@ private class DefaultBatchListSource<TKey, TData, TRequestParams : Any, TUpdate>
status = PaginationStatus.InitialLoadingError(
throwable = res.throwable,
),
totalCount = null,
)
}
}

View file

@ -8,10 +8,12 @@ package com.tangem.pagination
*
* @property data list of loaded batches.
* @property status current status of the pagination.
* @property totalCount total number of items available on the server.
*
* @see BatchListSource
*/
data class BatchListState<TKey, TData>(
val data: List<Batch<TKey, TData>>,
val status: PaginationStatus<TData>,
val totalCount: Int? = null,
)

View file

@ -61,6 +61,16 @@ dependencies {
api(deps.jodatime)
implementation(deps.timber)
implementation(deps.markdown)
implementation(deps.haze) {
exclude(module = "activity-compose")
exclude(module = "activity")
exclude(module = "activity-ktx")
}
implementation(deps.haze.materials) {
exclude(module = "activity-compose")
exclude(module = "activity")
exclude(module = "activity-ktx")
}
/** Tests */
testImplementation(deps.test.junit)

View file

@ -5,12 +5,6 @@
<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>
@ -27,6 +21,5 @@
<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

@ -0,0 +1,5 @@
package com.tangem.core.ui
interface DesignFeatureToggles {
val isRedesignEnabled: Boolean
}

View file

@ -16,4 +16,6 @@ interface UiDependencies {
val globalSnackbarHostState: SnackbarHostState
val eventMessageHandler: EventMessageHandler
val designFeatureToggles: DesignFeatureToggles
}

View file

@ -170,6 +170,8 @@ fun PrimaryButtonIconStart(
modifier: Modifier = Modifier,
showProgress: Boolean = false,
enabled: Boolean = true,
size: TangemButtonSize = TangemButtonSize.Default,
shape: Shape = size.toShape(),
) {
TangemButton(
modifier = modifier,
@ -180,6 +182,8 @@ fun PrimaryButtonIconStart(
enabled = enabled,
showProgress = showProgress,
textStyle = TangemTheme.typography.subtitle1,
size = size,
shape = shape,
)
}
// endregion PrimaryButton

View file

@ -1,51 +0,0 @@
package com.tangem.core.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemTheme
/**
* Currency placeholder icon
*
* @param id currency id
* @param modifier modifier
*/
@Composable
fun CurrencyPlaceholderIcon(id: String, modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier.background(color = TangemTheme.colors.icon.secondary, shape = CircleShape),
) {
Text(
text = id.firstOrNull()?.titlecase().orEmpty(),
modifier = Modifier.padding(TangemTheme.dimens.spacing4),
color = TangemTheme.colors.text.primary2,
textAlign = TextAlign.Center,
// FIXME("Incorrect typography. Replace with typography from design system")
style = TangemTheme.typography.h3.copy(fontWeight = FontWeight.Bold),
)
}
}
// region preview
@Preview(showBackground = true, heightDp = 40, widthDp = 40)
@Preview(showBackground = true, heightDp = 40, widthDp = 40, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_CurrencyPlaceholderIcon() {
TangemThemePreview {
CurrencyPlaceholderIcon(id = "DAI")
}
}
// endregion preview

View file

@ -70,17 +70,6 @@ fun BasicDialog(
)
}
@Composable
fun SimpleOkDialog(message: String, onDismissDialog: () -> Unit) {
TangemDialog(
type = DialogType.Message(message),
confirmButton = DialogButtonUM(onClick = onDismissDialog),
onDismissDialog = onDismissDialog,
title = null,
dismissButton = null,
)
}
/**
* Dialog with text field
*
@ -124,34 +113,6 @@ fun TextInputDialog(
)
}
@Composable
fun TextInputDialog(
fieldValue: String,
confirmButton: DialogButtonUM,
onDismissDialog: () -> Unit,
onValueChange: (String) -> Unit,
textFieldParams: AdditionalTextInputDialogUM = remember { AdditionalTextInputDialogUM() },
title: String? = null,
dismissButton: DialogButtonUM? = null,
isDismissable: Boolean = true,
) {
TangemDialog(
type = DialogType.SimpleTextInput(
value = fieldValue,
onValueChange = onValueChange,
params = textFieldParams,
),
confirmButton = confirmButton,
onDismissDialog = onDismissDialog,
title = title,
dismissButton = dismissButton,
properties = DialogProperties(
dismissOnBackPress = isDismissable,
dismissOnClickOutside = isDismissable,
),
)
}
@Composable
fun SelectorDialog(
selectedItemIndex: Int,
@ -441,14 +402,6 @@ private sealed class DialogType {
// endregion Defaults
// region Preview
@Composable
private fun SimpleOkDialogPreview() {
SimpleOkDialog(
message = "All protected passwords will be deleted from the " +
"secure storage, you must enter the wallet password to work with the app.",
) {}
}
@Composable
private fun BasicDialogPreview() {
BasicDialog(
@ -461,15 +414,6 @@ private fun BasicDialogPreview() {
)
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_SimpleOkDialog() {
TangemThemePreview {
SimpleOkDialogPreview()
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable

View file

@ -1,142 +0,0 @@
package com.tangem.core.ui.components
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.TextUnit
import com.tangem.core.ui.components.atoms.text.BoundCounter
/**
* https://stackoverflow.com/questions/69083061/how-to-make-middle-ellipsis-in-text-with-jetpack-compose
*/
@Deprecated("Use EllipsisText with TextEllipsis.Middle ellipsis instead")
@Suppress("LongMethod")
@Composable
fun MiddleEllipsisText(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
softWrap: Boolean = true,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
) {
// some letters, like "r", will have less width when placed right before "."
// adding a space to prevent such case
val layoutText = remember(text) { "$text $ellipsisText" }
val textLayoutResultState = remember(layoutText) {
mutableStateOf<TextLayoutResult?>(null)
}
SubcomposeLayout(modifier) { constraints ->
// result is ignored - we only need to fill our textLayoutResult
subcompose("measure") {
Text(
text = layoutText,
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
textAlign = textAlign,
lineHeight = lineHeight,
softWrap = softWrap,
maxLines = 1,
onTextLayout = { textLayoutResultState.value = it },
style = style,
)
}.first().measure(Constraints())
// to allow smart cast
val textLayoutResult = textLayoutResultState.value
?: // shouldn't happen - onTextLayout is called before subcompose finishes
return@SubcomposeLayout layout(0, 0) {}
val placeable = subcompose("visible") {
val finalText = remember(text, textLayoutResult, constraints.maxWidth) {
if (
text.isEmpty() ||
textLayoutResult.getBoundingBox(text.indices.last).right <= constraints.maxWidth
) {
// text not including ellipsis fits on the first line.
return@remember text
}
val ellipsisWidth = layoutText.indices.toList()
.takeLast(ELLIPSIS_CHARACTERS_COUNT)
.let widthLet@{ indices ->
// fix this bug: https://issuetracker.google.com/issues/197146630
// in this case width is invalid
for (i in indices) {
val width = textLayoutResult.getBoundingBox(i).width
if (width > 0) {
return@widthLet width * ELLIPSIS_CHARACTERS_COUNT
}
}
// this should not happen, because
// this error occurs only for the last character in the string
error("all ellipsis chars have invalid width")
}
val availableWidth = constraints.maxWidth - ellipsisWidth
val startCounter = BoundCounter(text, textLayoutResult) { it }
val endCounter = BoundCounter(text, textLayoutResult) { text.indices.last - it }
while (availableWidth - startCounter.width - endCounter.width > 0) {
val possibleEndWidth = endCounter.widthWithNextChar()
if (
startCounter.width >= possibleEndWidth &&
availableWidth - startCounter.width - possibleEndWidth >= 0
) {
endCounter.addNextChar()
} else if (availableWidth - startCounter.widthWithNextChar() - endCounter.width >= 0) {
startCounter.addNextChar()
} else {
break
}
}
startCounter.string.trimEnd() + ellipsisText + endCounter.string.reversed().trimStart()
}
Text(
text = finalText,
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
textAlign = textAlign,
lineHeight = lineHeight,
softWrap = softWrap,
onTextLayout = onTextLayout,
style = style,
)
}[0].measure(constraints)
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
}
private const val ELLIPSIS_CHARACTERS_COUNT = 3
private const val ELLIPSIS_CHARACTER = '.'
private val ellipsisText = List(ELLIPSIS_CHARACTERS_COUNT) { ELLIPSIS_CHARACTER }.joinToString(separator = "")

View file

@ -1,184 +0,0 @@
package com.tangem.core.ui.components
import androidx.annotation.FloatRange
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
private const val COEFFICIENT = 0.8f
@Suppress("MagicNumber")
@Composable
fun ResizableText(
text: String,
fontSizeRange: FontSizeRange,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
style: TextStyle = LocalTextStyle.current,
) {
val fontSizeValue = remember { mutableFloatStateOf(fontSizeRange.max.value) }
val readyToDraw = remember { mutableStateOf(false) }
val textState = remember { mutableStateOf(text) }
if (textState.value != text) {
readyToDraw.value = false
fontSizeValue.floatValue = fontSizeRange.max.value
textState.value = text
}
Text(
text = text,
modifier = modifier.drawWithContent { if (readyToDraw.value) drawContent() },
color = color,
fontSize = fontSizeValue.floatValue.sp,
overflow = overflow,
softWrap = false,
maxLines = maxLines,
onTextLayout = { textLayoutResult ->
if (textLayoutResult.hasVisualOverflow) {
val nextFontSizeValue = fontSizeValue.floatValue - fontSizeRange.step.value
if (nextFontSizeValue <= fontSizeRange.min.value) {
fontSizeValue.floatValue = fontSizeRange.min.value
readyToDraw.value = true
} else {
fontSizeValue.floatValue = nextFontSizeValue * COEFFICIENT
}
} else {
readyToDraw.value = true
}
},
style = style,
)
}
/**
* A Composable function that displays text which can be resized based on its content's overflow.
*
* This function draws text on the screen and checks if it overflows. If the text overflows,
* its font size is reduced recursively until it either fits the available space or reaches a
* specified minimum font size.
*/
@Composable
fun ResizableText(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
textAlign: TextAlign? = null,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
style: TextStyle = LocalTextStyle.current,
minFontSize: TextUnit = TextUnit.Unspecified,
@FloatRange(from = 0.0, to = 1.0, fromInclusive = false, toInclusive = false)
reduceFactor: Double = 0.9,
) {
var fontSize by remember { mutableStateOf(style.fontSize) }
var isReadyToDraw by remember { mutableStateOf(value = false) }
Text(
modifier = modifier
.drawWithContent {
if (isReadyToDraw) drawContent()
}
.wrapContentHeight(),
text = text,
color = color,
fontSize = fontSize,
textAlign = textAlign,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
style = style,
onTextLayout = { result ->
fun reduceFontSize() {
val reducedFontSize = fontSize * reduceFactor
if (minFontSize != TextUnit.Unspecified && reducedFontSize <= minFontSize) {
fontSize = minFontSize
isReadyToDraw = true
} else {
fontSize = reducedFontSize
}
}
if (result.hasVisualOverflow) {
reduceFontSize()
} else {
isReadyToDraw = true
}
},
)
}
@Composable
fun ResizableText(
text: String,
fontSizeValue: TextUnit,
fontSizeRange: FontSizeRange,
onFontSizeChange: (Float) -> Unit,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
style: TextStyle = LocalTextStyle.current,
) {
val readyToDraw = remember { mutableStateOf(false) }
val textState = remember { mutableStateOf(text) }
if (textState.value != text) {
readyToDraw.value = false
onFontSizeChange(fontSizeRange.max.value)
textState.value = text
}
Text(
text = text,
modifier = modifier.drawWithContent { if (readyToDraw.value) drawContent() },
color = color,
fontSize = fontSizeValue.value.sp,
overflow = overflow,
softWrap = false,
maxLines = maxLines,
onTextLayout = { result ->
if (result.hasVisualOverflow) {
val nextFontSizeValue = fontSizeValue.value - fontSizeRange.step.value
if (nextFontSizeValue <= fontSizeRange.min.value) {
onFontSizeChange(fontSizeRange.min.value)
readyToDraw.value = true
} else {
val newSizeValue = nextFontSizeValue * COEFFICIENT
onFontSizeChange(newSizeValue)
}
} else {
readyToDraw.value = true
}
},
style = style,
)
}
data class FontSizeRange(
val min: TextUnit,
val max: TextUnit,
val step: TextUnit = DEFAULT_TEXT_STEP,
) {
init {
require(min < max) { "min should be less than max, $this" }
require(step.value > 0) { "step should be greater than 0, $this" }
}
companion object {
private val DEFAULT_TEXT_STEP = 1.sp
}
}

View file

@ -15,6 +15,8 @@ import com.tangem.core.ui.res.TangemThemePreview
fun AppBarWithBackButtonAndIcon(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
backButtonEnabled: Boolean = true,
endButtonEnabled: Boolean = true,
text: String? = null,
subtitle: String? = null,
@DrawableRes backIconRes: Int? = null,
@ -30,11 +32,13 @@ fun AppBarWithBackButtonAndIcon(
startButton = TopAppBarButtonUM.Icon(
iconRes = backIconRes ?: R.drawable.ic_back_24,
onClicked = onBackClick,
isEnabled = backButtonEnabled,
),
endButton = if (iconRes != null && onIconClick != null) {
TopAppBarButtonUM.Icon(
iconRes = iconRes,
onClicked = onIconClick,
isEnabled = endButtonEnabled,
)
} else {
null

View file

@ -62,6 +62,7 @@ private fun getTextColorByType(type: AuditLabelUM.Type): Color {
AuditLabelUM.Type.Warning -> TangemTheme.colors.text.attention
AuditLabelUM.Type.Permit -> TangemTheme.colors.text.accent
AuditLabelUM.Type.Info -> TangemTheme.colors.text.primary2
AuditLabelUM.Type.General -> TangemTheme.colors.text.secondary
}
}
@ -73,6 +74,7 @@ private fun getBackgroundColorByType(type: AuditLabelUM.Type): Color {
AuditLabelUM.Type.Warning -> TangemTheme.colors.text.attention.copy(alpha = 0.1f)
AuditLabelUM.Type.Permit -> TangemTheme.colors.text.accent.copy(alpha = 0.1f)
AuditLabelUM.Type.Info -> TangemTheme.colors.text.accent
AuditLabelUM.Type.General -> TangemTheme.colors.control.default
}
}
@ -83,6 +85,7 @@ private fun getPaddingByType(type: AuditLabelUM.Type): PaddingValues {
AuditLabelUM.Type.Prohibition,
AuditLabelUM.Type.Warning,
AuditLabelUM.Type.Permit,
AuditLabelUM.Type.General,
-> PaddingValues(horizontal = 4.dp)
AuditLabelUM.Type.Info -> PaddingValues(horizontal = 6.dp, vertical = 1.dp)
}

View file

@ -25,5 +25,8 @@ data class AuditLabelUM(val text: TextReference, val type: Type) {
/** Blue with white text. Like, "best rate" */
Info,
/** Gray. Like, "info" */
General,
}
}

View file

@ -1,32 +1,25 @@
package com.tangem.core.ui.components.bottomsheets.internal
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.key.*
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModalBottomSheetWithBackHandling(
onDismissRequest: () -> Unit,
onBack: (() -> Unit),
modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null,
sheetState: SheetState = rememberModalBottomSheetState(),
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
shape: Shape = BottomSheetDefaults.ExpandedShape,
@ -38,28 +31,9 @@ fun ModalBottomSheetWithBackHandling(
contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets },
content: @Composable ColumnScope.() -> Unit,
) {
BackHandler(enabled = sheetState.targetValue != SheetValue.Hidden) {
onBack()
}
val requester = remember { FocusRequester() }
val backPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current
ModalBottomSheet(
onDismissRequest = onDismissRequest,
modifier = modifier
.focusRequester(requester)
.focusable()
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.key == Key.Back &&
keyEvent.type == KeyEventType.KeyUp &&
!keyEvent.nativeKeyEvent.isCanceled
) {
backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed()
return@onPreviewKeyEvent true
}
return@onPreviewKeyEvent false
},
modifier = modifier,
sheetState = sheetState,
sheetMaxWidth = sheetMaxWidth,
shape = shape,
@ -69,18 +43,17 @@ fun ModalBottomSheetWithBackHandling(
scrimColor = scrimColor,
dragHandle = dragHandle,
contentWindowInsets = contentWindowInsets,
properties = ModalBottomSheetDefaults.properties(
// Set false otherwise the onPreviewKeyEvent doesn't work at all.
// The functionality of shouldDismissOnBackPress is achieved by the BackHandler.
shouldDismissOnBackPress = false,
properties = ModalBottomSheetProperties(
shouldDismissOnBackPress = onBack == null,
),
content = content,
)
content = {
content()
LaunchedEffect(Unit) {
delay(timeMillis = 200)
requester.requestFocus()
}
BackHandler(enabled = onBack != null && sheetState.targetValue != SheetValue.Hidden) {
onBack?.invoke()
}
},
)
}
@OptIn(ExperimentalMaterial3Api::class)

View file

@ -27,6 +27,7 @@ import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.components.icons.HighlightedIcon
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@ -109,7 +110,7 @@ private fun ContentContainer(state: MessageBottomSheetUMV2.InfoBlock, modifier:
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens.spacing8),
text = body.resolveReference(),
text = body.resolveAnnotatedReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
@ -124,7 +125,6 @@ private fun ContentContainer(state: MessageBottomSheetUMV2.InfoBlock, modifier:
}
}
@Suppress("CanBeNonNullable")
@Composable
private fun BottomSheetIconContainer(
icon: MessageBottomSheetUMV2.Icon?,

View file

@ -16,7 +16,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@ -146,7 +145,8 @@ inline fun <reified T : TangemBottomSheetConfigContent> PreviewModalBottomSheet(
sheetState = SheetState(
skipPartiallyExpanded = skipPartiallyExpanded,
initialValue = Expanded,
density = LocalDensity.current,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
),
onBack = null,
bsContent = {
@ -207,32 +207,18 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheet(
noinline onBack: (() -> Unit)? = null,
noinline bsContent: @Composable ColumnScope.() -> Unit,
) {
if (onBack != null) {
ModalBottomSheetWithBackHandling(
modifier = modifier,
onDismissRequest = config.onDismissRequest,
sheetState = sheetState,
containerColor = Color.Transparent,
shape = TangemTheme.shapes.roundedCornersLarge,
contentWindowInsets = { WindowInsetsZero },
onBack = onBack,
dragHandle = null,
content = bsContent,
scrimColor = TangemTheme.colors.overlay.secondary,
)
} else {
ModalBottomSheet(
modifier = modifier,
onDismissRequest = config.onDismissRequest,
sheetState = sheetState,
containerColor = Color.Transparent,
shape = TangemTheme.shapes.roundedCornersLarge,
contentWindowInsets = { WindowInsetsZero },
dragHandle = null,
content = bsContent,
scrimColor = TangemTheme.colors.overlay.secondary,
)
}
ModalBottomSheetWithBackHandling(
modifier = modifier,
onDismissRequest = config.onDismissRequest,
sheetState = sheetState,
containerColor = Color.Transparent,
shape = TangemTheme.shapes.roundedCornersLarge,
contentWindowInsets = { WindowInsetsZero },
onBack = onBack,
dragHandle = null,
content = bsContent,
scrimColor = TangemTheme.colors.overlay.secondary,
)
}
// region Preview

View file

@ -18,7 +18,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@ -141,7 +140,8 @@ inline fun <reified T : TangemBottomSheetConfigContent> PreviewModalBottomSheetW
sheetState = SheetState(
skipPartiallyExpanded = skipPartiallyExpanded,
initialValue = Expanded,
density = LocalDensity.current,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
),
onBack = null,
containerColor = containerColor,
@ -247,32 +247,18 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheetWit
}
}
if (onBack != null) {
ModalBottomSheetWithBackHandling(
modifier = modifier,
onDismissRequest = config.onDismissRequest,
sheetState = sheetState,
containerColor = Color.Transparent,
shape = TangemTheme.shapes.roundedCornersLarge,
contentWindowInsets = { WindowInsetsZero },
onBack = onBack,
dragHandle = null,
content = bsContent,
scrimColor = TangemTheme.colors.overlay.secondary,
)
} else {
ModalBottomSheet(
modifier = modifier,
onDismissRequest = config.onDismissRequest,
sheetState = sheetState,
containerColor = Color.Transparent,
shape = TangemTheme.shapes.roundedCornersLarge,
contentWindowInsets = { WindowInsetsZero },
dragHandle = null,
content = bsContent,
scrimColor = TangemTheme.colors.overlay.secondary,
)
}
ModalBottomSheetWithBackHandling(
modifier = modifier,
onDismissRequest = config.onDismissRequest,
sheetState = sheetState,
containerColor = Color.Transparent,
shape = TangemTheme.shapes.roundedCornersLarge,
contentWindowInsets = { WindowInsetsZero },
onBack = onBack,
dragHandle = null,
content = bsContent,
scrimColor = TangemTheme.colors.overlay.secondary,
)
}
// region Preview

View file

@ -2,7 +2,6 @@ package com.tangem.core.ui.components.bottomsheets.sheet
import androidx.compose.foundation.layout.*
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue.Expanded
import androidx.compose.material3.rememberModalBottomSheetState
@ -134,7 +133,8 @@ inline fun <reified T : TangemBottomSheetConfigContent> PreviewBottomSheet(
sheetState = SheetState(
skipPartiallyExpanded = skipPartiallyExpanded,
initialValue = Expanded,
density = LocalDensity.current,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
),
onBack = null,
containerColor = containerColor,
@ -181,30 +181,16 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicBottomSheet(
}
}
if (onBack != null) {
ModalBottomSheetWithBackHandling(
modifier = modifier.statusBarsPadding(),
onDismissRequest = config.onDismissRequest,
sheetState = sheetState,
containerColor = containerColor,
shape = TangemTheme.shapes.bottomSheetLarge,
contentWindowInsets = { WindowInsetsZero },
dragHandle = { TangemBottomSheetDraggableHeader(color = containerColor) },
onBack = onBack,
content = bsContent,
scrimColor = TangemTheme.colors.overlay.secondary,
)
} else {
ModalBottomSheet(
modifier = modifier.statusBarsPadding(),
onDismissRequest = config.onDismissRequest,
sheetState = sheetState,
containerColor = containerColor,
shape = TangemTheme.shapes.bottomSheetLarge,
contentWindowInsets = { WindowInsetsZero },
dragHandle = { TangemBottomSheetDraggableHeader(color = containerColor) },
content = bsContent,
scrimColor = TangemTheme.colors.overlay.secondary,
)
}
ModalBottomSheetWithBackHandling(
modifier = modifier.statusBarsPadding(),
onDismissRequest = config.onDismissRequest,
sheetState = sheetState,
containerColor = containerColor,
shape = TangemTheme.shapes.bottomSheetLarge,
contentWindowInsets = { WindowInsetsZero },
dragHandle = { TangemBottomSheetDraggableHeader(color = containerColor) },
onBack = onBack,
content = bsContent,
scrimColor = TangemTheme.colors.overlay.secondary,
)
}

View file

@ -6,6 +6,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
@ -23,7 +24,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.components.ResizableText
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.utils.MultipleClickPreventer
@ -75,7 +75,7 @@ fun TangemButton(
)
},
text = {
ResizableText(
Text(
modifier = Modifier
.weight(1f, fill = false)
.heightIn(MinButtonContentSize, maxContentSize)
@ -86,7 +86,10 @@ fun TangemButton(
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
minFontSize = 12.sp,
autoSize = TextAutoSize.StepBased(
minFontSize = 12.sp,
maxFontSize = textStyle.fontSize,
),
)
},
icon = { iconResId ->

View file

@ -0,0 +1,122 @@
package com.tangem.core.ui.components.chip
import android.content.res.Configuration
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.chip.entity.ChipUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@Composable
fun Chip(state: ChipUM, modifier: Modifier = Modifier) {
val backgroundColor by animateColorAsState(
targetValue = if (state.isSelected) {
TangemTheme.colors.button.primary
} else {
TangemTheme.colors.button.secondary
},
)
val textColor by animateColorAsState(
targetValue = if (state.isSelected) {
TangemTheme.colors.text.primary2
} else {
TangemTheme.colors.text.primary1
},
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(color = backgroundColor)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = state.onClick,
)
.padding(PaddingValues(horizontal = 24.dp, vertical = 8.dp)),
) {
Text(
text = state.text.resolveReference(),
style = TangemTheme.typography.button,
color = textColor,
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ChipPreview() {
TangemThemePreview {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.background(TangemTheme.colors.background.primary)
.padding(16.dp),
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Chip(
state = ChipUM(
id = 0,
text = TextReference.Str("All News"),
isSelected = true,
onClick = {},
),
)
Chip(
state = ChipUM(
id = 1,
text = TextReference.Str("Regulation"),
isSelected = false,
onClick = {},
),
)
Chip(
state = ChipUM(
id = 2,
text = TextReference.Str("ETFs"),
isSelected = false,
onClick = {},
),
)
Chip(
state = ChipUM(
id = 3,
text = TextReference.Str("Institutions"),
isSelected = false,
onClick = {},
),
)
}
}
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.core.ui.components.chip.entity
import com.tangem.core.ui.extensions.TextReference
data class ChipUM(
val id: Int,
val text: TextReference,
val isSelected: Boolean = false,
val onClick: () -> Unit,
)

View file

@ -10,9 +10,8 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.focus.FocusRequester
@ -51,16 +50,22 @@ fun SearchBar(
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val interactionSource = remember { MutableInteractionSource() }
var isInitialComposition by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) {
isInitialComposition = false
}
BasicTextField(
modifier = modifier
.fillMaxWidth()
.heightIn(min = TangemTheme.dimens.size48)
.onFocusChanged { focusState ->
if (focusState.isFocused) {
state.onActiveChange(true)
} else {
state.onActiveChange(false)
if (!isInitialComposition) {
if (focusState.isFocused) {
state.onActiveChange(true)
} else {
state.onActiveChange(false)
}
}
}
.focusRequester(focusRequester)
@ -163,6 +168,7 @@ private fun ClearButton(
focusManager.clearFocus()
keyboardController?.hide()
state.onActiveChange(false)
state.onClearClick()
},
) {
Icon(

View file

@ -8,4 +8,5 @@ data class SearchBarUM(
val onQueryChange: (String) -> Unit,
val isActive: Boolean,
val onActiveChange: (Boolean) -> Unit,
val onClearClick: () -> Unit = {},
)

View file

@ -0,0 +1,57 @@
package com.tangem.core.ui.components.haze
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.tangem.core.ui.res.LocalHazeState
import com.tangem.core.ui.res.LocalPowerSavingState
import com.tangem.core.ui.res.LocalRootBackgroundColor
import dev.chrisbanes.haze.*
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
internal fun ProvideHaze(content: @Composable () -> Unit) {
val hazeState = rememberHazeState()
CompositionLocalProvider(
LocalHazeState provides hazeState,
LocalHazeStyle provides CupertinoMaterials.ultraThin(),
) {
content()
}
}
/**
* Applies a haze effect to the [Modifier] with consideration of global haze settings and power saving mode.
*
* @param configure A lambda to configure the [HazeEffectScope].
* @return A [Modifier] with the configured haze effect applied.
*/
@Composable
fun Modifier.hazeEffectTangem(
style: HazeStyle = HazeStyle.Unspecified,
configure: HazeEffectScope.() -> Unit = {},
): Modifier {
val powerSavingEnabled = LocalPowerSavingState.current.isPowerSavingModeEnabled.collectAsState()
val hazeState = LocalHazeState.current
val isGlobalBlurEnabled = hazeState.blurEnabled && !powerSavingEnabled.value
val rootBackground by LocalRootBackgroundColor.current
return hazeEffect(hazeState, style) {
fallbackTint = HazeTint(rootBackground)
if (isGlobalBlurEnabled) {
configure()
}
blurEnabled = isGlobalBlurEnabled
}
}
/**
* Applies a haze source to the [Modifier] using the current global haze state.
*/
@Composable
fun Modifier.hazeSourceTangem(zIndex: Float = 0f, key: Any? = null) =
this.hazeSource(LocalHazeState.current, zIndex, key)

View file

@ -7,4 +7,5 @@ enum class IconTint {
Accent,
Warning,
Inactive,
Informative,
}

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
@ -31,6 +32,7 @@ import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SendSelectNetworkFeeBottomSheetTestTags
/**
* [InputRowEnter](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-799&mode=design&t=IQ5lBJEkFGU4WSvi-4)
@ -92,7 +94,7 @@ fun InputRowEnter(
modifier = Modifier
.size(16.dp)
.clip(CircleShape),
text = description.resolveReference(),
text = description,
content = { contentModifier ->
Icon(
modifier = contentModifier.size(16.dp),
@ -113,7 +115,8 @@ fun InputRowEnter(
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing8),
.padding(top = TangemTheme.dimens.spacing8)
.testTag(SendSelectNetworkFeeBottomSheetTestTags.NONCE_INPUT_TEXT_FIELD),
)
}
iconRes?.let { iconRes ->

View file

@ -11,6 +11,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
@ -21,6 +22,7 @@ import com.tangem.core.ui.components.tooltip.TangemTooltip
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.SendSelectNetworkFeeBottomSheetTestTags
import com.tangem.core.ui.utils.rememberDecimalFormat
/**
@ -112,6 +114,7 @@ fun InputRowEnterInfoAmount(
}
}
@Suppress("LongMethod")
@Composable
fun InputRowEnterInfoAmountV2(
title: TextReference,
@ -139,20 +142,23 @@ fun InputRowEnterInfoAmountV2(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
.padding(16.dp)
.testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = titleColor,
modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_TITLE),
)
if (description != null) {
TangemTooltip(
modifier = Modifier
.size(16.dp)
.clip(CircleShape),
text = description.resolveReference(),
.clip(CircleShape)
.testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_TOOLTIP_ICON),
text = description,
content = { contentModifier ->
Icon(
modifier = contentModifier.size(16.dp),
@ -183,7 +189,8 @@ fun InputRowEnterInfoAmountV2(
backgroundColor = Color.Transparent,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing8)
.weight(1f),
.weight(1f)
.testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_INPUT_TEXT_FIELD),
)
info?.let { info ->
Text(
@ -192,7 +199,8 @@ fun InputRowEnterInfoAmountV2(
color = infoColor,
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing8)
.align(Alignment.Bottom),
.align(Alignment.Bottom)
.testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_FIAT_AMOUNT),
)
}
}

View file

@ -50,7 +50,7 @@ fun Label(state: LabelUM, modifier: Modifier = Modifier) {
val backgroundColor by animateColorAsState(
targetValue = when (state.style) {
LabelStyle.ACCENT -> TangemTheme.colors.text.accent.copy(alpha = 0.1f)
LabelStyle.REGULAR -> TangemTheme.colors.control.unchecked
LabelStyle.REGULAR -> TangemTheme.colors.control.default
LabelStyle.WARNING -> TangemTheme.colors.text.warning.copy(alpha = 0.1f)
},
)
@ -168,7 +168,9 @@ private fun LabelPreview() {
TangemThemePreview {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(16.dp),
modifier = Modifier
.background(TangemTheme.colors.background.primary)
.padding(16.dp),
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),

View file

@ -12,6 +12,7 @@ data class LabelUM(
@DrawableRes val icon: Int? = null,
val onIconClick: (() -> Unit)? = null,
val onClick: (() -> Unit)? = null,
val maxLines: Int = Int.MAX_VALUE,
)
@Immutable

View file

@ -1,5 +1,6 @@
package com.tangem.core.ui.components.pager
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
@ -8,18 +9,24 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlin.math.abs
import kotlin.math.min
// six - cause the central indicator has width multiplied twice
private const val TOTAL_MAX_INDICATORS = 6
private const val SPACER_COUNT_BETWEEN_INDICATORS = 4
/**
* Horizontal pager indicator
@ -29,82 +36,163 @@ import com.tangem.core.ui.res.TangemThemePreview
*/
@Composable
fun PagerIndicator(pagerState: PagerState, modifier: Modifier = Modifier, indicatorCount: Int = 5) {
if (pagerState.pageCount == 0) return
val listState = rememberLazyListState()
val indicatorColor = TangemTheme.colors.control.key
val overlayColor = TangemTheme.colors.overlay.secondary
val indicatorSize = 8.dp
val inactiveIndicatorColor = remember(indicatorColor) {
indicatorColor.copy(alpha = 0.5f)
}
val baseIndicatorSize = 8.dp
val spacing = 4.dp
val totalWidth: Dp = indicatorSize * indicatorCount + spacing * (indicatorCount - 1)
val widthInPx = LocalDensity.current.run { indicatorSize.toPx() }
val currentItem by remember {
val indicatorState by remember(pagerState, indicatorCount) {
derivedStateOf {
pagerState.currentPage
val count = pagerState.pageCount
val current = pagerState.currentPage
val winSize = min(indicatorCount, count)
val centerPosition = winSize / 2
val start = when {
count <= winSize -> 0
current <= centerPosition -> 0
current >= count - centerPosition - 1 -> count - winSize
else -> current - centerPosition
}
Triple(count, winSize, start)
}
}
val itemCount = pagerState.pageCount
val (itemCount, windowSize, windowStart) = indicatorState
val currentItem by remember { derivedStateOf { pagerState.currentPage } }
LaunchedEffect(key1 = currentItem) {
val viewportSize = listState.layoutInfo.viewportSize
listState.animateScrollToItem(
currentItem,
(widthInPx / 2 - viewportSize.width / 2).toInt(),
)
LaunchedEffect(currentItem, windowStart) {
if (itemCount > windowSize) {
listState.animateScrollToItem(windowStart.coerceIn(0, itemCount - 1))
}
}
val maxContainerWidth = remember(baseIndicatorSize, spacing) {
baseIndicatorSize * TOTAL_MAX_INDICATORS + spacing * SPACER_COUNT_BETWEEN_INDICATORS
}
Box(
modifier = modifier
.height(32.dp)
.width(maxContainerWidth + 32.dp)
.background(
color = overlayColor,
shape = CircleShape,
)
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(horizontal = 16.dp, vertical = 12.dp)
.clip(CircleShape),
contentAlignment = Alignment.Center,
) {
LazyRow(
modifier = Modifier
.width(totalWidth),
modifier = Modifier.wrapContentWidth(),
state = listState,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(spacing),
userScrollEnabled = false,
) {
indicatorItems(
itemCount = itemCount,
currentItem = currentItem,
indicatorShape = CircleShape,
activeColor = indicatorColor,
inActiveColor = indicatorColor.copy(alpha = 0.5f),
indicatorSize = indicatorSize,
inActiveColor = inactiveIndicatorColor,
baseSize = baseIndicatorSize,
windowSize = windowSize,
windowStart = windowStart,
)
}
}
}
@Suppress("MagicNumber", "CyclomaticComplexMethod")
private fun calculateIndicatorHeight(position: Int, currentPosition: Int, baseSize: Dp, windowSize: Int): Dp {
val distance = abs(position - currentPosition)
val mediumSize = 6.dp
val smallSize = 4.dp
if (windowSize < 5) {
return when {
distance <= 1 -> baseSize
distance == 2 -> mediumSize
else -> smallSize
}
}
val isEdgeFocus = currentPosition == 0 || currentPosition == windowSize - 1
val isNearEdgeFocus = currentPosition == 1 || currentPosition == windowSize - 2
return when {
isEdgeFocus -> when {
distance <= 2 -> baseSize
distance == 3 -> mediumSize
else -> smallSize
}
isNearEdgeFocus -> when {
distance <= 1 -> baseSize
distance == 2 -> mediumSize
else -> smallSize
}
else -> when {
distance <= 1 -> baseSize
else -> mediumSize
}
}
}
@Suppress("LongParameterList")
private fun LazyListScope.indicatorItems(
itemCount: Int,
currentItem: Int,
indicatorShape: Shape,
activeColor: Color,
inActiveColor: Color,
indicatorSize: Dp,
baseSize: Dp,
windowSize: Int,
windowStart: Int,
) {
items(itemCount) { index ->
val safeWindowSize = min(windowSize, itemCount)
if (safeWindowSize <= 0) return
val isSelected = index == currentItem
val windowEnd = windowStart + safeWindowSize
val currentPosInWindow = (currentItem - windowStart).coerceIn(0, safeWindowSize - 1)
items(itemCount) { pageIndex ->
val isInWindow = pageIndex in windowStart until windowEnd
val positionInWindow = (pageIndex - windowStart).coerceIn(0, safeWindowSize - 1)
val isSelected = pageIndex == currentItem
val refinedHeight = if (isInWindow) {
calculateIndicatorHeight(
position = positionInWindow,
currentPosition = currentPosInWindow,
baseSize = baseSize,
windowSize = safeWindowSize,
)
} else {
0.dp
}
val targetWidth = if (isSelected) refinedHeight * 2 else refinedHeight
val targetShape = if (isSelected) RoundedCornerShape(16.dp) else CircleShape
val animatedWidth by animateDpAsState(targetValue = targetWidth, label = "width")
val animatedHeight by animateDpAsState(targetValue = refinedHeight, label = "height")
Box(
modifier = Modifier
.clip(indicatorShape)
.size(indicatorSize)
.padding(vertical = (baseSize - animatedHeight) / 2)
.clip(targetShape)
.width(animatedWidth)
.height(animatedHeight)
.background(
if (isSelected) activeColor else inActiveColor,
indicatorShape,
targetShape,
),
)
}
@ -112,19 +200,31 @@ private fun LazyListScope.indicatorItems(
@Preview(showBackground = true)
@Composable
private fun PagerIndicatorPreviewFirstPage() {
private fun PagerIndicatorPreview() {
TangemThemePreview {
Box(
Column(
modifier = Modifier
.background(TangemTheme.colors.background.primary)
.padding(),
contentAlignment = Alignment.Center,
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
val pagerState = rememberPagerState(
initialPage = 0,
initialPage = 2,
pageCount = { 10 },
)
PagerIndicator(pagerState = pagerState)
val pagerState1 = rememberPagerState(
initialPage = 0,
pageCount = { 3 },
)
PagerIndicator(pagerState = pagerState1)
val pagerState2 = rememberPagerState(
initialPage = 0,
pageCount = { 1 },
)
PagerIndicator(pagerState = pagerState2)
}
}
}

View file

@ -0,0 +1,66 @@
package com.tangem.core.ui.components.powersaving
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.PowerManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class PowerSavingState(
appContext: Context,
lifecycle: androidx.lifecycle.Lifecycle,
) {
private val powerManager =
appContext.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: error("PowerManager not available")
val isPowerSavingModeEnabled: StateFlow<Boolean>
field = MutableStateFlow(powerManager.isPowerSaveMode)
init {
val intentFilter = IntentFilter().apply {
addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)
}
val receiver = PowerSavingModeReceiver()
ContextCompat.registerReceiver(
appContext,
receiver,
intentFilter,
ContextCompat.RECEIVER_NOT_EXPORTED,
)
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
appContext.unregisterReceiver(receiver)
}
})
}
inner class PowerSavingModeReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == PowerManager.ACTION_POWER_SAVE_MODE_CHANGED) {
isPowerSavingModeEnabled.value = powerManager.isPowerSaveMode
}
}
}
}
@Composable
internal fun rememberPowerSavingState(): PowerSavingState {
val appContext = LocalContext.current.applicationContext
val lifecycle = LocalLifecycleOwner.current.lifecycle
return remember(appContext, lifecycle) {
PowerSavingState(appContext = appContext, lifecycle = lifecycle)
}
}

View file

@ -1,86 +0,0 @@
package com.tangem.core.ui.components.rows
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH28
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
/**
* Simple clickable action row, without input and icon
*
* https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-807&mode=design&t=Ygv5sohTTHYAQcBS-4
*/
@Composable
fun SimpleActionRow(title: String, description: String, modifier: Modifier = Modifier, isClickable: Boolean = true) {
Box(
modifier = modifier
.background(color = TangemTheme.colors.background.action)
.height(TangemTheme.dimens.size44)
.fillMaxWidth(),
) {
Column(
modifier = Modifier
.padding(end = TangemTheme.dimens.spacing48)
.align(Alignment.CenterStart),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
AnimatedContent(targetState = title, label = "") { title ->
Text(
text = title,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
)
}
AnimatedContent(targetState = description, label = "") { description ->
Text(
text = description,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
}
}
if (isClickable) {
Icon(
painter = painterResource(id = R.drawable.ic_chevron_right_24),
contentDescription = null,
modifier = Modifier
.align(alignment = Alignment.CenterEnd)
.padding(end = TangemTheme.dimens.spacing12),
tint = TangemTheme.colors.icon.informative,
)
}
}
}
@Preview
@Composable
private fun SimpleActionRowPreview() {
Column {
TangemThemePreview(isDark = false) {
SimpleActionRow(
title = "Title",
description = "Description",
)
}
SpacerH28()
TangemThemePreview(isDark = false) {
SimpleActionRow(
title = "Title",
description = "Description",
)
}
}
}

View file

@ -25,7 +25,7 @@ import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SelectNetworkFeeBottomSheetTestTags
import com.tangem.core.ui.test.SwapSelectNetworkFeeBottomSheetTestTags
import com.tangem.utils.StringsSigns
@Composable
@ -70,7 +70,7 @@ fun SelectorRowItem(
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues)
.testTag(SelectNetworkFeeBottomSheetTestTags.SELECTOR_ITEM),
.testTag(SwapSelectNetworkFeeBottomSheetTestTags.SELECTOR_ITEM),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(

View file

@ -239,19 +239,19 @@ private fun StandardBottomSheet(
}
val newTarget =
when (val oldTarget = state.anchoredDraggableState.targetValue) {
Hidden -> if (newAnchors.hasAnchorFor(Hidden)) Hidden else oldTarget
Hidden -> if (newAnchors.hasPositionFor(Hidden)) Hidden else oldTarget
PartiallyExpanded ->
when {
newAnchors.hasAnchorFor(PartiallyExpanded) -> PartiallyExpanded
newAnchors.hasAnchorFor(Expanded) -> Expanded
newAnchors.hasAnchorFor(Hidden) -> Hidden
newAnchors.hasPositionFor(PartiallyExpanded) -> PartiallyExpanded
newAnchors.hasPositionFor(Expanded) -> Expanded
newAnchors.hasPositionFor(Hidden) -> Hidden
else -> oldTarget
}
Expanded ->
when {
newAnchors.hasAnchorFor(Expanded) -> Expanded
newAnchors.hasAnchorFor(PartiallyExpanded) -> PartiallyExpanded
newAnchors.hasAnchorFor(Hidden) -> Hidden
newAnchors.hasPositionFor(Expanded) -> Expanded
newAnchors.hasPositionFor(PartiallyExpanded) -> PartiallyExpanded
newAnchors.hasPositionFor(Hidden) -> Hidden
else -> oldTarget
}
}

View file

@ -102,11 +102,11 @@ class TangemSheetState(
/** Whether the sheet has an expanded state defined. */
val hasExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(Expanded)
get() = anchoredDraggableState.anchors.hasPositionFor(Expanded)
/** Whether the modal bottom sheet has a partially expanded state defined. */
val hasPartiallyExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(PartiallyExpanded)
get() = anchoredDraggableState.anchors.hasPositionFor(PartiallyExpanded)
/**
* Fully expand the bottom sheet with animation and suspend until it is fully expanded or
@ -274,8 +274,8 @@ internal fun consumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = available.toFloat()
val currentOffset = sheetState.requireOffset()
val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor()
return if (toFling < 0 && currentOffset > minAnchor) {
val minPosition = sheetState.anchoredDraggableState.anchors.minPosition()
return if (toFling < 0 && currentOffset > minPosition) {
onFling(toFling)
// since we go to the anchor with tween settling, consume all for the best UX
available

View file

@ -1,17 +0,0 @@
package com.tangem.core.ui.components.states
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
data class SelectableItemsState<T>(
val selectedItem: Item<T>,
val items: ImmutableList<Item<T>>,
)
data class Item<T>(
val id: Int,
val startText: TextReference,
val endText: TextReference,
val isSelected: Boolean,
val data: T,
)

View file

@ -2,6 +2,7 @@ package com.tangem.core.ui.components.token.internal
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -43,16 +44,33 @@ internal fun TokenFiatAmount(state: TokenFiatAmountState?, isBalanceHidden: Bool
isFlickering = state.isFlickering,
)
}
is TokenFiatAmountState.Icon -> {
IconAmount(modifier = modifier, state = state)
}
is TokenFiatAmountState.Loading -> {
RectangleShimmer(modifier = modifier.placeholderSize(), radius = 4.dp)
}
is TokenFiatAmountState.Locked -> {
LockedRectangle(modifier = modifier.placeholderSize())
}
is TokenFiatAmountState.Empty -> {
// Empty box for proper measurements
Box(modifier)
}
null -> Unit
}
}
@Composable
private fun IconAmount(state: TokenFiatAmountState.Icon, modifier: Modifier = Modifier) {
Icon(
modifier = modifier,
painter = rememberVectorPainter(image = ImageVector.vectorResource(state.iconRes)),
tint = state.tint.color(),
contentDescription = null,
)
}
@Composable
private fun ContentFiatAmount(
text: String,
@ -76,11 +94,7 @@ private fun ContentFiatAmount(
Icon(
modifier = Modifier.size(12.dp),
painter = rememberVectorPainter(image = ImageVector.vectorResource(icon.iconRes)),
tint = when (icon.tint) {
IconTint.Accent -> TangemTheme.colors.icon.accent
IconTint.Warning -> TangemTheme.colors.icon.attention
IconTint.Inactive -> TangemTheme.colors.icon.inactive
},
tint = icon.tint.color(),
contentDescription = null,
)
}
@ -91,6 +105,14 @@ private fun ContentFiatAmount(
}
}
@Composable
private fun IconTint.color() = when (this) {
IconTint.Accent -> TangemTheme.colors.icon.accent
IconTint.Warning -> TangemTheme.colors.icon.attention
IconTint.Inactive -> TangemTheme.colors.icon.inactive
IconTint.Informative -> TangemTheme.colors.icon.informative
}
@Composable
private fun FiatAmountText(
text: String,

View file

@ -90,7 +90,7 @@ private fun YieldSupplyApyLabel(apy: TextReference?, isActive: Boolean, modifier
color = if (isActive) {
TangemTheme.colors.text.accent.copy(alpha = 0.1f)
} else {
TangemTheme.colors.control.unchecked
TangemTheme.colors.control.default
},
shape = TangemTheme.shapes.roundedCornersSmall2,
),

View file

@ -43,7 +43,7 @@ internal fun YieldSupplyPromoBanner(state: PromoBannerState.Content, modifier: M
LaunchedEffect(state) {
state.onPromoShown()
}
val bgColor = TangemTheme.colors.control.unchecked
val bgColor = TangemTheme.colors.control.default
Column(modifier = modifier) {
Row(
modifier = Modifier

View file

@ -235,9 +235,16 @@ sealed class TokenItemState {
val isFlickering: Boolean = false,
) : FiatAmountState()
data class Icon(
val iconRes: Int,
val tint: IconTint = IconTint.Inactive,
) : FiatAmountState()
data object Loading : FiatAmountState()
data object Locked : FiatAmountState()
data object Empty : FiatAmountState()
}
@Immutable

View file

@ -1,29 +1,39 @@
package com.tangem.core.ui.components.tooltip
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.coroutines.launch
/**
* A Tangem-themed tooltip component that displays a tooltip with the provided text when the content is clicked.
*
* @param text The text to be displayed inside the tooltip.
* @param modifier The modifier to be applied to the tooltip component.
* @param enabled If false, the tooltip will not be shown when the content is clicked.
* @param content The content that triggers the tooltip when clicked.
*/
@Composable
fun TangemTooltip(
text: String,
text: TextReference,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable (Modifier) -> Unit,
@ -33,30 +43,10 @@ fun TangemTooltip(
enabled = enabled,
tooltipContent = {
Text(
modifier = Modifier.background(TangemTheme.colors.icon.secondary),
text = text,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary2,
)
},
content = content,
)
}
@Composable
fun TangemTooltip(
text: AnnotatedString,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable (Modifier) -> Unit,
) {
InternalTangemTooltip(
modifier = modifier,
enabled = enabled,
tooltipContent = {
Text(
modifier = Modifier.background(TangemTheme.colors.icon.secondary),
text = text,
modifier = Modifier
.background(TangemTheme.colors.icon.secondary)
.padding(horizontal = 6.dp, vertical = 8.dp),
text = text.resolveAnnotatedReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary2,
)
@ -75,16 +65,22 @@ private fun InternalTangemTooltip(
) {
val tooltipState = rememberTooltipState(isPersistent = true)
val coroutineScope = rememberCoroutineScope()
val windowSize = LocalWindowSize.current.width
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(spacingBetweenTooltipAndAnchor = 8.dp),
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
positioning = TooltipAnchorPosition.Above,
spacingBetweenTooltipAndAnchor = 8.dp,
),
state = tooltipState,
modifier = modifier,
tooltip = {
PlainTooltip(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
caretSize = DpSize(width = 14.dp, height = 8.dp),
modifier = Modifier.padding(end = 12.dp),
shape = RoundedCornerShape(14.dp),
caretShape = TooltipDefaults.caretShape(),
maxWidth = windowSize - 24.dp,
contentColor = TangemTheme.colors.text.primary2,
containerColor = TangemTheme.colors.icon.secondary,
content = { tooltipContent() },
@ -103,24 +99,23 @@ private fun InternalTangemTooltip(
)
}
@Preview
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360, heightDp = 720)
@Preview(showBackground = true, widthDp = 360, heightDp = 720, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TangemTooltip_Preview() {
TangemThemePreview {
Box(
modifier = Modifier
.size(500.dp)
.background(TangemTheme.colors.background.secondary),
contentAlignment = Alignment.Center,
.fillMaxSize()
.background(TangemTheme.colors.background.tertiary),
) {
TangemTooltip(
modifier = Modifier
.background(TangemTheme.colors.background.secondary)
.size(64.dp),
text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed venenatis.",
modifier = Modifier,
text = stringReference("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed venenatis."),
content = { contentModifier ->
Icon(
modifier = contentModifier.size(64.dp),
modifier = contentModifier,
painter = painterResource(R.drawable.ic_token_info_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,

View file

@ -29,6 +29,7 @@ import kotlinx.collections.immutable.ImmutableList
* @param txHistoryItems transactions
* @param modifier modifier
*/
@Suppress("CanBeNonNullable")
fun LazyListScope.txHistoryItems(
state: TxHistoryState,
txHistoryItems: LazyPagingItems<TxHistoryState.TxHistoryItemState>?,

View file

@ -0,0 +1,33 @@
package com.tangem.core.ui.decompose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
/**
* An interface describing the UI part of a component for a modular BottomSheet.
*
* Designed for use in Decompose components. It separates the UI into a title and content,
* providing access to the [BottomSheetState] to react to changes in the sheet's state (collapsed/expanded).
*/
@Stable
interface ComposableModularBottomSheetContentComponent {
/**
* Renders the title of the bottom sheet.
* @param bottomSheetState The current state of the bottom sheet. This can be used, for example,
* to change navigation buttons (e.g., hiding the "Back" button when collapsed).
*/
@Composable
fun Title(bottomSheetState: State<BottomSheetState>)
/**
* Renders the main content of the bottom sheet.
* @param bottomSheetState The current state of the bottom sheet. Useful for tracking visibility
* (e.g., for analytics or lifecycle effects when the sheet is [BottomSheetState.EXPANDED]).
*/
@Composable
fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier)
}

View file

@ -0,0 +1,344 @@
package com.tangem.core.ui.ds.badge
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.badge.TangemBadgeSize.*
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
/**
* Tangem badge component to display a small piece of information with optional icon.
* [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8441-83535&m=dev)
*
* @param badgeUM [TangemBadgeUM] containing all the badge parameters.
* @param modifier Modifier to be applied to the badge.
*
[REDACTED_AUTHOR]
*/
@Composable
fun TangemBadge(badgeUM: TangemBadgeUM, modifier: Modifier = Modifier) {
TangemBadge(
text = badgeUM.text,
iconRes = badgeUM.iconRes,
size = badgeUM.size,
shape = badgeUM.shape,
color = badgeUM.color,
type = badgeUM.type,
iconPosition = badgeUM.iconPosition,
modifier = modifier,
)
}
/**
* Tangem badge component to display a small piece of information with optional icon.
* [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8441-83535&m=dev)
*
* @param text TextReference for the badge label.
* @param modifier Modifier to be applied to the badge.
* @param iconRes Drawable resource ID for the icon to be displayed in the badge.
* @param size [TangemBadgeSize] defining the size of the badge.
* @param shape [TangemBadgeShape] defining the shape of the badge.
* @param color [TangemBadgeColor] defining the color scheme of the badge.
* @param type [TangemBadgeType] defining the style of the badge.
* @param iconPosition [TangemBadgeIconPosition] defining icon position of the badge.
*
[REDACTED_AUTHOR]
*/
@Composable
fun TangemBadge(
text: TextReference,
modifier: Modifier = Modifier,
@DrawableRes iconRes: Int? = null,
size: TangemBadgeSize = X9,
shape: TangemBadgeShape = TangemBadgeShape.Default,
color: TangemBadgeColor = TangemBadgeColor.Gray,
type: TangemBadgeType = TangemBadgeType.Solid,
iconPosition: TangemBadgeIconPosition = TangemBadgeIconPosition.Start,
) {
val iconColor = getIconColor(type = type, color = color)
Row(
horizontalArrangement = Arrangement.spacedBy(size.toContentPadding()),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.heightIn(min = size.toHeightDp())
.clip(shape.toShape(size))
.getBackgroundColor(type = type, color = color, shape = shape.toShape(size))
.padding(size.toPaddingDp(position = iconPosition)),
) {
AnimatedVisibility(
visible = iconRes != null && iconPosition == TangemBadgeIconPosition.Start,
modifier = Modifier.size(size = size.toContentSize()),
label = "Start Icon Visibility",
) {
val wrappedIconRes = remember(this) { requireNotNull(iconRes) }
Icon(
painter = painterResource(id = wrappedIconRes),
contentDescription = null,
tint = iconColor,
)
}
Text(
text = text.resolveReference(),
style = size.toTextStyle(),
maxLines = 1,
color = getTextColor(type = type, color = color),
)
AnimatedVisibility(
visible = iconRes != null && iconPosition == TangemBadgeIconPosition.End,
modifier = Modifier.size(size = size.toContentSize()),
label = "End Icon Visibility",
) {
val wrappedIconRes = remember(this) { requireNotNull(iconRes) }
Icon(
painter = painterResource(id = wrappedIconRes),
contentDescription = null,
tint = iconColor,
)
}
}
}
/**
* Tangem badge shape options.
*/
enum class TangemBadgeShape {
Default,
Rounded,
;
@ReadOnlyComposable
@Composable
internal fun toShape(size: TangemBadgeSize) = RoundedCornerShape(
when (this) {
Rounded -> when (size) {
X4,
X6,
-> TangemTheme.dimens2.x4
X9 -> TangemTheme.dimens2.x25
}
Default -> when (size) {
X4 -> TangemTheme.dimens2.x1
X6,
X9,
-> 6.dp
}
},
)
}
/**
* Tangem badge size options.
*/
enum class TangemBadgeSize {
X4,
X6,
X9,
;
@ReadOnlyComposable
@Composable
internal fun toHeightDp() = when (this) {
X4 -> TangemTheme.dimens2.x4
X6 -> TangemTheme.dimens2.x6
X9 -> TangemTheme.dimens2.x9
}
@ReadOnlyComposable
@Composable
internal fun toPaddingDp(position: TangemBadgeIconPosition) = when (this) {
X4 -> when (position) {
TangemBadgeIconPosition.Start -> PaddingValues(start = 4.dp, end = 6.dp)
TangemBadgeIconPosition.End -> PaddingValues(start = 6.dp, end = 4.dp)
}
X6 -> when (position) {
TangemBadgeIconPosition.Start -> PaddingValues(start = 8.dp, end = 12.dp)
TangemBadgeIconPosition.End -> PaddingValues(start = 12.dp, end = 8.dp)
}
X9 -> when (position) {
TangemBadgeIconPosition.Start -> PaddingValues(start = 12.dp, end = 16.dp)
TangemBadgeIconPosition.End -> PaddingValues(start = 16.dp, end = 12.dp)
}
}
@ReadOnlyComposable
@Composable
internal fun toContentSize() = when (this) {
X4 -> TangemTheme.dimens2.x3
X6,
X9,
-> TangemTheme.dimens2.x4
}
@ReadOnlyComposable
@Composable
internal fun toContentPadding() = when (this) {
X4 -> TangemTheme.dimens2.x0_5
X6,
X9,
-> TangemTheme.dimens2.x1
}
@ReadOnlyComposable
@Composable
internal fun toTextStyle() = when (this) {
X4 -> TangemTheme.typography2.captionSemibold11
X6 -> TangemTheme.typography2.captionSemibold12
X9 -> TangemTheme.typography2.bodySemibold16
}
}
/**
* Position of the icon in the Tangem badge.
*/
enum class TangemBadgeIconPosition {
Start,
End,
}
/**
* Tangem badge type options.
*/
enum class TangemBadgeType {
Solid,
Tinted,
Outline,
}
/**
* Tangem badge color options.
*/
enum class TangemBadgeColor {
Blue,
Red,
Gray,
}
@ReadOnlyComposable
@Composable
private fun getIconColor(type: TangemBadgeType, color: TangemBadgeColor) = when (color) {
TangemBadgeColor.Gray -> TangemTheme.colors2.markers.iconGray
TangemBadgeColor.Blue -> when (type) {
TangemBadgeType.Outline,
TangemBadgeType.Tinted,
-> TangemTheme.colors2.markers.iconBlue
TangemBadgeType.Solid -> TangemTheme.colors2.graphic.neutral.primaryInvertedConstant
}
TangemBadgeColor.Red -> when (type) {
TangemBadgeType.Outline,
TangemBadgeType.Tinted,
-> TangemTheme.colors2.markers.iconRed
TangemBadgeType.Solid -> TangemTheme.colors2.graphic.neutral.primaryInvertedConstant
}
}
@ReadOnlyComposable
@Composable
private fun getTextColor(type: TangemBadgeType, color: TangemBadgeColor) = when (color) {
TangemBadgeColor.Gray -> TangemTheme.colors2.markers.textGray
TangemBadgeColor.Blue -> when (type) {
TangemBadgeType.Outline,
TangemBadgeType.Tinted,
-> TangemTheme.colors2.markers.textBlue
TangemBadgeType.Solid -> TangemTheme.colors2.text.neutral.primaryInvertedConstant
}
TangemBadgeColor.Red -> when (type) {
TangemBadgeType.Outline,
TangemBadgeType.Tinted,
-> TangemTheme.colors2.markers.textRed
TangemBadgeType.Solid -> TangemTheme.colors2.text.neutral.primaryInvertedConstant
}
}
@ReadOnlyComposable
@Composable
private fun Modifier.getBackgroundColor(type: TangemBadgeType, color: TangemBadgeColor, shape: Shape) = when (type) {
TangemBadgeType.Solid -> background(
when (color) {
TangemBadgeColor.Gray -> TangemTheme.colors2.markers.backgroundSolidGray
TangemBadgeColor.Blue -> TangemTheme.colors2.markers.backgroundSolidBlue
TangemBadgeColor.Red -> TangemTheme.colors2.markers.backgroundSolidRed
},
)
TangemBadgeType.Tinted -> background(
when (color) {
TangemBadgeColor.Gray -> TangemTheme.colors2.markers.backgroundTintedGray
TangemBadgeColor.Blue -> TangemTheme.colors2.markers.backgroundTintedBlue
TangemBadgeColor.Red -> TangemTheme.colors2.markers.backgroundTintedRed
},
)
TangemBadgeType.Outline -> {
border(
color = when (color) {
TangemBadgeColor.Gray -> TangemTheme.colors2.markers.borderGray
TangemBadgeColor.Blue -> TangemTheme.colors2.markers.borderTintedBlue
TangemBadgeColor.Red -> TangemTheme.colors2.markers.borderTintedRed
},
shape = shape,
width = 1.dp,
)
}
}
// region Preview
@Composable
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TangemBadge_Preview(@PreviewParameter(TangemBadgePreviewProvider::class) params: TangemBadgeColor) {
TangemThemePreviewRedesign {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.background(TangemTheme.colors2.surface.level1)
.padding(8.dp),
) {
repeat(2) { yIndex ->
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
repeat(TangemBadgeType.entries.size) { index ->
TangemBadge(
text = stringReference("Title"),
iconRes = R.drawable.ic_information_24,
type = TangemBadgeType.entries[index],
color = params,
shape = TangemBadgeShape.entries[yIndex % 2],
iconPosition = TangemBadgeIconPosition.entries[yIndex % 2],
)
}
}
}
}
}
}
private class TangemBadgePreviewProvider : PreviewParameterProvider<TangemBadgeColor> {
override val values: Sequence<TangemBadgeColor>
get() = sequenceOf(
TangemBadgeColor.Gray,
TangemBadgeColor.Blue,
TangemBadgeColor.Red,
)
}
// endregion

View file

@ -0,0 +1,27 @@
package com.tangem.core.ui.ds.badge
import androidx.annotation.DrawableRes
import com.tangem.core.ui.ds.badge.TangemBadgeSize.X9
import com.tangem.core.ui.extensions.TextReference
/**
* UI model for [TangemBadge] component
*
* @param text TextReference for the badge label.
* @param modifier Modifier to be applied to the badge.
* @param iconRes Drawable resource ID for the icon to be displayed in the badge.
* @param size [TangemBadgeSize] defining the size of the badge.
* @param shape [TangemBadgeShape] defining the shape of the badge.
* @param color [TangemBadgeColor] defining the color scheme of the badge.
* @param type [TangemBadgeType] defining the style of the badge.
* @param iconPosition [TangemBadgeIconPosition] defining icon position of the badge.
*/
class TangemBadgeUM(
val text: TextReference,
@DrawableRes val iconRes: Int? = null,
val size: TangemBadgeSize = X9,
val shape: TangemBadgeShape = TangemBadgeShape.Default,
val color: TangemBadgeColor = TangemBadgeColor.Gray,
val type: TangemBadgeType = TangemBadgeType.Solid,
val iconPosition: TangemBadgeIconPosition = TangemBadgeIconPosition.Start,
)

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