Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-18 12:51:39 +03:00
commit a56bcff7dd
438 changed files with 9017 additions and 4452 deletions

View file

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

View file

@ -11,18 +11,6 @@
"name": "STAKING_TON_ENABLED",
"version": "undefined"
},
{
"name": "NFT_ENABLED",
"version": "5.25.0"
},
{
"name": "NFT_EVM_ENABLED",
"version": "5.25.0"
},
{
"name": "NFT_SOLANA_ENABLED",
"version": "5.25.0"
},
{
"name": "NFT_MEDIA_CONTENT_ENABLED",
"version": "undefined"
@ -31,26 +19,10 @@
"name": "STAKING_CARDANO_ENABLED",
"version": "undefined"
},
{
"name": "NEW_ARTWORK_LOADING",
"version": "5.25.0"
},
{
"name": "NEW_ATTESTATION_ENABLED",
"version": "5.24.0"
},
{
"name": "WALLET_CONNECT_REDESIGN_ENABLED",
"version": "undefined"
},
{
"name": "STAKING_LOADING_REFACTORING_ENABLED",
"version": "5.25.0"
},
{
"name": "DEEPLINK_NAVIGATION_ENABLED",
"version": "5.25.0"
},
{
"name": "PUSH_NOTIFICATIONS_ENABLED",
"version": "undefined"
@ -74,5 +46,9 @@
{
"name": "WALLET_BALANCE_FETCHER_ENABLED",
"version": "5.27.0"
},
{
"name": "HOT_WALLET_ENABLED",
"version": "undefined"
}
]

View file

@ -13,6 +13,9 @@ enum class ApiEnvironment {
@Json(name = "DEV")
DEV,
@Json(name = "DEV_2")
DEV_2,
@Json(name = "STAGE")
STAGE,

View file

@ -27,6 +27,7 @@ internal class Express(
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createDevEnvironment(),
createDev2Environment(),
createStageEnvironment(),
createMockedEnvironment(),
createProdEnvironment(),
@ -53,6 +54,12 @@ internal class Express(
headers = createHeaders(isProd = false),
)
private fun createDev2Environment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV_2,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(isProd = false),
)
private fun createStageEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.STAGE,
baseUrl = "[REDACTED_ENV_URL]",

View file

@ -55,7 +55,7 @@ internal class TangemTech(
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.tangem.org/v1/",
baseUrl = "https://api.tangem.org/",
headers = createHeaders(),
)

View file

@ -23,7 +23,7 @@ internal class DevApiConfigsManager(
private val apiConfigs: ApiConfigs,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : MutableApiConfigsManager {
) : MutableApiConfigsManager() {
override val configs: StateFlow<Map<ApiConfig, ApiEnvironment>>
field = MutableStateFlow(value = getInitialConfigs())
@ -34,22 +34,22 @@ internal class DevApiConfigsManager(
override fun initialize() {
isInitialized.value = false
// We can't use appPreferencesStore.getObjectMap as base flow,
// because we should keep possibility to work with configs synchronous.
// See [getBaseUrl]
appPreferencesStore.getObjectMap<ApiEnvironment>(PreferencesKeys.apiConfigsEnvironmentKey)
.distinctUntilChanged()
.onEach { savedEnvironments ->
configs.update { apiConfigs ->
apiConfigs.mapValues {
val (config, currentEnvironment) = it
val apiConfigs = configs.value
savedEnvironments[config.id.name] ?: currentEnvironment
}
configs.value = apiConfigs.mapValues {
val (config, currentEnvironment) = it
savedEnvironments[config.id.name] ?: currentEnvironment
}
if (!isInitialized.value) {
isInitialized.value = true
}
notifyListeners(apiConfigs = apiConfigs, savedEnvironments = savedEnvironments)
}
.launchIn(CoroutineScope(SupervisorJob() + dispatchers.default))
}
@ -96,6 +96,35 @@ internal class DevApiConfigsManager(
}
}
private fun notifyListeners(
apiConfigs: Map<ApiConfig, ApiEnvironment>,
savedEnvironments: Map<String, ApiEnvironment>,
) {
if (registerListeners.isNotEmpty()) {
val changedConfigs = apiConfigs.mapNotNull { (config, prevEnvironment) ->
val newEnvironment = savedEnvironments[config.id.name] ?: config.defaultEnvironment
if (prevEnvironment == newEnvironment) return@mapNotNull null
val environmentConfig = config.environmentConfigs
.firstOrNull { it.environment == newEnvironment }
?: return@mapNotNull null
config.id to environmentConfig
}
if (changedConfigs.isNotEmpty()) {
registerListeners.forEach { listener ->
val changedConfig = changedConfigs.firstOrNull { it.first == listener.id }?.second
if (changedConfig != null) {
listener.onChange(changedConfig)
}
}
}
}
}
private fun getInitialConfigs(): Map<ApiConfig, ApiEnvironment> {
return apiConfigs.associateWith(ApiConfig::defaultEnvironment)
}

View file

@ -4,9 +4,10 @@ import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfigs
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.ApiEnvironmentConfig
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.*
/**
* Implementation of [ApiConfigsManager] in MOCK environment
@ -17,13 +18,16 @@ import kotlinx.coroutines.flow.update
*/
internal class MockApiConfigsManager(
private val apiConfigs: ApiConfigs,
) : MutableApiConfigsManager {
dispatchers: CoroutineDispatcherProvider,
) : MutableApiConfigsManager() {
override val configs: StateFlow<Map<ApiConfig, ApiEnvironment>>
field = MutableStateFlow(value = getInitialConfigs())
override val isInitialized: StateFlow<Boolean> = MutableStateFlow(value = true)
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default)
override fun initialize() = Unit
override fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig {
@ -55,6 +59,20 @@ internal class MockApiConfigsManager(
}
}
override fun addListener(listener: ApiConfigEnvChangeListener) {
super.addListener(listener)
configs
.map { it.entries.firstOrNull { it.key.id == listener.id } }
.filterNotNull()
.onEach { (apiConfig, currentEnvironment) ->
listener.onChange(
environmentConfig = apiConfig.environmentConfigs.first { it.environment == currentEnvironment },
)
}
.launchIn(coroutineScope)
}
private fun getInitialConfigs(): Map<ApiConfig, ApiEnvironment> {
return apiConfigs.associateWith { it.defaultEnvironment }
}

View file

@ -2,6 +2,7 @@ package com.tangem.datasource.api.common.config.managers
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.ApiEnvironmentConfig
import kotlinx.coroutines.flow.Flow
/**
@ -9,14 +10,41 @@ import kotlinx.coroutines.flow.Flow
*
[REDACTED_AUTHOR]
*/
interface MutableApiConfigsManager : ApiConfigsManager {
abstract class MutableApiConfigsManager : ApiConfigsManager {
/** Api configs with current [ApiEnvironment] */
val configs: Flow<Map<ApiConfig, ApiEnvironment>>
abstract val configs: Flow<Map<ApiConfig, ApiEnvironment>>
/**
* A set of listeners registered to observe changes in API environment configurations.
* These listeners are notified whenever an environment change occurs.
*/
protected val registerListeners: Set<ApiConfigEnvChangeListener>
field = mutableSetOf<ApiConfigEnvChangeListener>()
/** Change api environment [environment] by [id] */
suspend fun changeEnvironment(id: String, environment: ApiEnvironment)
abstract suspend fun changeEnvironment(id: String, environment: ApiEnvironment)
/** Change api environment [environment] for all configs */
suspend fun changeEnvironment(environment: ApiEnvironment)
abstract suspend fun changeEnvironment(environment: ApiEnvironment)
/** Adds a [listener] to observe changes in API environment configurations */
open fun addListener(listener: ApiConfigEnvChangeListener) {
registerListeners += listener
}
/**
* Listener for observing changes in API environment configurations
*
* @property id the identifier of the API configuration this listener is associated with
*/
abstract class ApiConfigEnvChangeListener(val id: ApiConfig.ID) {
/**
* Called when the environment configuration changes
*
* @param environmentConfig the updated [ApiEnvironmentConfig] for the associated API configuration
*/
abstract fun onChange(environmentConfig: ApiEnvironmentConfig)
}
}

View file

@ -9,7 +9,7 @@ import retrofit2.http.Query
interface TangemTechMarketsApi {
@Suppress("LongParameterList")
@GET("coins/list")
@GET("v1/coins/list")
suspend fun getCoinsList(
@Query("currency") currency: String,
@Query("interval") interval: String,
@ -20,24 +20,24 @@ interface TangemTechMarketsApi {
@Query("timestamp") timestamp: Long?,
): ApiResponse<TokenMarketListResponse>
@GET("coins/{coin_id}")
@GET("v1/coins/{coin_id}")
suspend fun getCoinMarketData(
@Path("coin_id") coinId: String,
@Query("currency") currency: String,
@Query("language") language: String,
): ApiResponse<TokenMarketInfoResponse>
@GET("coins/{coin_id}/history")
@GET("v1/coins/{coin_id}/history")
suspend fun getCoinChart(
@Path("coin_id") coinId: String,
@Query("currency") currency: String,
@Query("interval") interval: String,
): ApiResponse<TokenMarketChartResponse>
@GET("coins/{coin_id}/exchanges")
@GET("v1/coins/{coin_id}/exchanges")
suspend fun getCoinExchanges(@Path("coin_id") coinId: String): ApiResponse<TokenMarketExchangesResponse>
@GET("coins/history_preview")
@GET("v1/coins/history_preview")
suspend fun getCoinsListCharts(
@Query("coin_ids") coinIds: String,
@Query("currency") currency: String,

View file

@ -33,10 +33,10 @@ data class OnrampQuoteResponse(
val providerId: String,
@Json(name = "minFromAmount")
val minFromAmount: String,
val minFromAmount: String?,
@Json(name = "maxFromAmount")
val maxFromAmount: String,
val maxFromAmount: String?,
@Json(name = "minToAmount")
val minToAmount: String?,

View file

@ -17,7 +17,7 @@ import java.util.concurrent.TimeUnit
@Suppress("TooManyFunctions")
interface TangemTechApi {
@GET("coins")
@GET("v1/coins")
suspend fun getCoins(
@Header("Cache-Control") cacheControl: String = "max-age=600",
@Query("contractAddress") contractAddress: String? = null,
@ -30,150 +30,150 @@ interface TangemTechApi {
@Query("limit") limit: Int? = null,
): ApiResponse<CoinsResponse>
@GET("rates")
@GET("v1/rates")
suspend fun getRates(@Query("currencyId") currencyId: String, @Query("coinIds") coinIds: String): RatesResponse
@GET("currencies")
@GET("v1/currencies")
suspend fun getCurrencyList(
@Header("Cache-Control") cacheControl: String = "max-age=600",
): ApiResponse<CurrenciesResponse>
@GET("geo")
@GET("v1/geo")
suspend fun getUserCountryCode(): GeoResponse
@GET("user-tokens/{user-id}")
@GET("v1/user-tokens/{user-id}")
suspend fun getUserTokens(@Path(value = "user-id") userId: String): ApiResponse<UserTokensResponse>
@PUT("user-tokens/{user-id}")
@PUT("v1/user-tokens/{user-id}")
suspend fun saveUserTokens(
@Path(value = "user-id") userId: String,
@Body userTokens: UserTokensResponse,
): ApiResponse<Unit>
@POST("user-tokens")
@POST("v1/user-tokens")
suspend fun markUserWallerWasCreated(@Body body: MarkUserWalletWasCreatedBody): ApiResponse<Unit>
/** Returns referral status by [walletId] */
@GET("referral/{walletId}")
@GET("v1/referral/{walletId}")
suspend fun getReferralStatus(@Path("walletId") walletId: String): ApiResponse<ReferralResponse>
/** Make user referral, requires [StartReferralBody] */
@POST("referral")
@POST("v1/referral")
suspend fun startReferral(@Body startReferralBody: StartReferralBody): ApiResponse<ReferralResponse>
@GET("quotes")
@GET("v1/quotes")
suspend fun getQuotes(
@Query("currencyId") currencyId: String,
@Query("coinIds") coinIds: String,
@Query("fields") fields: String,
): ApiResponse<QuotesResponse>
@GET("promotion")
@GET("v1/promotion")
suspend fun getPromotionInfo(
@Query("programName") name: String,
@Header("Cache-Control") cacheControl: String = "max-age=600",
): ApiResponse<PromotionInfoResponse>
@GET("settings/{wallet_id}")
@GET("v1/settings/{wallet_id}")
suspend fun getUserTokensSettings(@Path("wallet_id") walletId: String): ApiResponse<UserTokensSettingsResponse>
@PUT("settings/{wallet_id}")
@PUT("v1/settings/{wallet_id}")
suspend fun saveUserTokensSettings(
@Path("wallet_id") walletId: String,
@Body userTokensSettings: UserTokensSettingsResponse,
): ApiResponse<Unit>
@POST("user-network-account")
@POST("v1/user-network-account")
suspend fun createUserNetworkAccount(
@Body body: CreateUserNetworkAccountBody,
): ApiResponse<CreateUserNetworkAccountResponse>
@POST("account")
@POST("v1/account")
suspend fun createUserTokensAccount(
@Body body: CreateUserTokensAccountBody,
): ApiResponse<UserTokensAccountResponse>
@PUT("account/{account_id}")
@PUT("v1/account/{account_id}")
suspend fun updateUserTokensAccount(
@Path("account_id") accountId: Int,
@Body body: UpdateUserTokensAccountBody,
): ApiResponse<UserTokensAccountResponse>
@PUT("account/{account_id}/archive")
@PUT("v1/account/{account_id}/archive")
suspend fun archiveUserTokensAccount(@Path("account_id") accountId: Int): ApiResponse<UserTokensAccountResponse>
@PUT("account/{account_id}/unarchive")
@PUT("v1/account/{account_id}/unarchive")
suspend fun restoreUserTokensAccount(@Path("account_id") accountId: Int): ApiResponse<UserTokensAccountResponse>
@GET("features")
@GET("v1/features")
suspend fun getFeatures(): ApiResponse<FeaturesResponse>
@ReadTimeout(duration = 5, unit = TimeUnit.SECONDS)
@GET("networks/providers")
@GET("v1/networks/providers")
suspend fun getBlockchainProviders(): Map<String, List<ProviderModel>>
@GET("seedphrase-notification/{wallet_id}")
@GET("v1/seedphrase-notification/{wallet_id}")
suspend fun getSeedPhraseNotificationStatus(
@Path("wallet_id") walletId: String,
): ApiResponse<SeedPhraseNotificationDTO>
@PUT("seedphrase-notification/{wallet_id}")
@PUT("v1/seedphrase-notification/{wallet_id}")
suspend fun updateSeedPhraseNotificationStatus(
@Path("wallet_id") walletId: String,
@Body body: SeedPhraseNotificationDTO,
): ApiResponse<Unit>
@GET("seedphrase-notification/{wallet_id}/confirmed")
@GET("v1/seedphrase-notification/{wallet_id}/confirmed")
suspend fun getSeedPhraseSecondNotificationStatus(
@Path("wallet_id") walletId: String,
): ApiResponse<SeedPhraseNotificationDTO>
@PUT("seedphrase-notification/{wallet_id}/confirmed")
@PUT("v1/seedphrase-notification/{wallet_id}/confirmed")
suspend fun updateSeedPhraseSecondNotificationStatus(
@Path("wallet_id") walletId: String,
@Body body: SeedPhraseNotificationDTO,
): ApiResponse<Unit>
@GET("hot_crypto")
@GET("v1/hot_crypto")
suspend fun getHotCrypto(@Query("currency") currencyId: String): ApiResponse<HotCryptoResponse>
@GET("stories/{story_id}")
@GET("v1/stories/{story_id}")
suspend fun getStoryById(@Path("story_id") storyId: String): ApiResponse<StoryContentResponse>
// region push notifications
@GET("notification/push_notifications_eligible_networks")
@GET("v1/notification/push_notifications_eligible_networks")
suspend fun getEligibleNetworksForPushNotifications(): ApiResponse<List<CryptoNetworkResponse>>
@POST("user-wallets/applications/")
@POST("v1/user-wallets/applications/")
suspend fun createApplicationId(
@Body
body: NotificationApplicationCreateBody,
): ApiResponse<NotificationApplicationIdResponse>
@PATCH("user-wallets/applications/{application_id}")
@PATCH("v1/user-wallets/applications/{application_id}")
suspend fun updatePushTokenForApplicationId(
@Path("application_id") applicationId: String,
@Body body: NotificationApplicationCreateBody,
): ApiResponse<Unit>
@PATCH("user-wallets/wallets/{wallet_id}/notify")
@PATCH("v1/user-wallets/wallets/{wallet_id}/notify")
suspend fun setNotificationsEnabled(@Path("wallet_id") walletId: String, @Body body: WalletBody): ApiResponse<Unit>
// endregion
// region wallets
@PATCH("user-wallets/wallets/{wallet_id}")
@PATCH("v1/user-wallets/wallets/{wallet_id}")
suspend fun updateWallet(@Path("wallet_id") walletId: String, @Body body: WalletBody): ApiResponse<Unit>
@POST("user-wallets/wallets/create-and-connect-by-appuid/{application_id}")
@POST("v1/user-wallets/wallets/create-and-connect-by-appuid/{application_id}")
suspend fun associateApplicationIdWithWallets(
@Path("application_id") applicationId: String,
@Body body: List<WalletIdBody>,
): ApiResponse<Unit>
@GET("user-wallets/wallets/{wallet_id}")
@GET("v1/user-wallets/wallets/{wallet_id}")
suspend fun getWalletById(@Path("wallet_id") walletId: String): ApiResponse<WalletResponse>
@GET("user-wallets/wallets/by-app/{app_id}")
@GET("v1/user-wallets/wallets/by-app/{app_id}")
suspend fun getWallets(@Path("app_id") appId: String): ApiResponse<List<WalletResponse>>
// endregion
}

View file

@ -58,7 +58,7 @@ internal object NetworkModule {
dispatchers: CoroutineDispatcherProvider,
): ApiConfigsManager {
return when {
BuildConfig.BUILD_TYPE == MOCKED_BUILD_TYPE -> MockApiConfigsManager(apiConfigs)
BuildConfig.BUILD_TYPE == MOCKED_BUILD_TYPE -> MockApiConfigsManager(apiConfigs, dispatchers)
BuildConfig.TESTER_MENU_ENABLED -> DevApiConfigsManager(apiConfigs, appPreferencesStore, dispatchers)
else -> ProdApiConfigsManager(apiConfigs)
}

View file

@ -8,8 +8,10 @@ import com.squareup.moshi.Moshi
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.token.*
import com.tangem.datasource.local.token.DefaultStakingActionsStore
import com.tangem.datasource.local.token.DefaultStakingYieldsStore
import com.tangem.datasource.local.token.StakingActionsStore
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.listTypes
import com.tangem.datasource.utils.mapWithStringKeyTypes
@ -66,17 +68,6 @@ internal object StakingStoreModule {
)
}
@Provides
@Singleton
fun provideStakingBalanceStore(
persistenceStore: DataStore<Map<String, Set<YieldBalanceWrapperDTO>>>,
): StakingBalanceStore {
return DefaultStakingBalanceStore(
persistenceStore = persistenceStore,
runtimeStore = RuntimeSharedStore(),
)
}
@Provides
@Singleton
fun provideStakingActionsStore(): StakingActionsStore {

View file

@ -1,269 +0,0 @@
package com.tangem.datasource.local.token
import androidx.datastore.core.DataStore
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.token.StakingBalanceStore.StakingID
import com.tangem.datasource.local.token.converter.YieldBalanceConverter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
internal typealias YieldBalanceWrappersDTO = Map<String, Set<YieldBalanceWrapperDTO>>
internal typealias YieldBalanceListByWalletId = Map<UserWalletId, Set<YieldBalance>>
/**
* Default implementation of [StakingBalanceStore]
*
* @property persistenceStore persistence store
* @property runtimeStore runtime store
*/
internal class DefaultStakingBalanceStore(
private val persistenceStore: DataStore<YieldBalanceWrappersDTO>,
private val runtimeStore: RuntimeSharedStore<YieldBalanceListByWalletId>,
) : StakingBalanceStore {
override fun get(userWalletId: UserWalletId, stakingIds: List<StakingID>): Flow<Set<YieldBalance>> = channelFlow {
val cachedBalances = persistenceStore.data
.map {
val wrappers = it[userWalletId.stringValue].orEmpty()
.filter { wrapper ->
stakingIds.any { id ->
id.address == wrapper.addresses.address && id.integrationId == wrapper.integrationId
}
}
YieldBalanceConverter(isCached = true).convertSet(input = wrappers)
}
.firstOrNull()
.orEmpty()
if (cachedBalances.isNotEmpty()) {
send(cachedBalances)
}
runtimeStore.get()
.map {
it[userWalletId].orEmpty().filter { balance ->
stakingIds.any { id ->
id.address == balance.address && id.integrationId == balance.integrationId
}
}
.toSet()
}
.onEach {
val mergedBalances = mergeYieldBalances(
stakingIds = stakingIds,
cachedBalances = cachedBalances,
runtimeBalances = it,
)
send(mergedBalances)
}
.launchIn(scope = this)
}
override fun get(userWalletId: UserWalletId, stakingID: StakingID): Flow<YieldBalance?> {
return get(userWalletId = userWalletId, stakingIds = listOf(stakingID)).map { balances ->
balances.getBalance(stakingID = stakingID)
}
}
override suspend fun getSyncOrNull(userWalletId: UserWalletId): Set<YieldBalance>? {
val runtimeBalances = runtimeStore.getSyncOrNull()?.getValue(userWalletId).orEmpty()
val cachedBalances = persistenceStore.data.firstOrNull()?.get(userWalletId.stringValue).orEmpty()
if (runtimeBalances.isEmpty() && cachedBalances.isEmpty()) return null
return cachedBalances.mapTo(hashSetOf()) {
val cached = YieldBalanceConverter(source = StatusSource.ONLY_CACHE).convert(value = it)
val runtime = runtimeBalances.getBalance(address = cached.address, integrationId = cached.integrationId)
if (runtime == null || runtime is YieldBalance.Error) cached else runtime
}
}
override suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingIds: List<StakingID>): Set<YieldBalance>? {
val runtime = runtimeStore.getSyncOrNull()?.getValue(userWalletId)
val cached = persistenceStore.data.firstOrNull()?.get(userWalletId.stringValue)
if (runtime.isNullOrEmpty() && cached.isNullOrEmpty()) return null
return mergeYieldBalances(
cachedBalances = YieldBalanceConverter(source = StatusSource.ONLY_CACHE)
.convertSet(input = cached.orEmpty()),
runtimeBalances = runtime.orEmpty(),
stakingIds = stakingIds,
)
}
override suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingID: StakingID): YieldBalance? {
val balances = getSyncOrNull(userWalletId = userWalletId, stakingIds = listOf(stakingID)) ?: return null
return balances.getBalance(stakingID = stakingID)
}
override suspend fun store(userWalletId: UserWalletId, items: Set<YieldBalanceWrapperDTO>) {
coroutineScope {
launch {
val newBalances = YieldBalanceConverter(isCached = false).convertSet(input = items)
runtimeStore.update(default = emptyMap()) { saved ->
saved.toMutableMap().apply {
this[userWalletId] = saved[userWalletId]
?.addOrReplace(newBalances) { old, new ->
old.integrationId == new.integrationId && old.address == new.address
}
?: newBalances
}
}
}
launch { storeInPersistenceStore(userWalletId = userWalletId, items = items) }
}
}
override suspend fun refresh(userWalletId: UserWalletId, stakingIds: List<StakingID>) {
updateRuntimeStore(userWalletId = userWalletId) { saved ->
saved.mapTo(hashSetOf()) {
val yieldBalance = it.takeIf { balance ->
stakingIds.any { id -> balance.integrationId == id.integrationId && balance.address == id.address }
}
yieldBalance?.copySealed(source = StatusSource.CACHE) ?: it
}
}
}
override suspend fun store(userWalletId: UserWalletId, stakingID: StakingID, item: YieldBalanceWrapperDTO) {
coroutineScope {
launch {
storeInRuntimeStore(
userWalletId = userWalletId,
integrationId = stakingID.integrationId,
address = stakingID.address,
item = item,
)
storeInPersistenceStore(
userWalletId = userWalletId,
integrationId = stakingID.integrationId,
address = stakingID.address,
item = item,
)
}
}
}
override suspend fun storeSingleYieldBalance(userWalletId: UserWalletId, item: YieldBalance) {
runtimeStore.update(default = emptyMap()) { saved ->
saved.toMutableMap().apply {
this[userWalletId] = saved[userWalletId]
?.addOrReplace(item) { it.integrationId == item.integrationId && it.address == item.address }
?: setOf(item)
}
}
}
private suspend fun storeInRuntimeStore(
userWalletId: UserWalletId,
integrationId: String,
address: String,
item: YieldBalanceWrapperDTO,
) {
val newBalance = YieldBalanceConverter(isCached = false).convert(value = item)
runtimeStore.update(default = emptyMap()) { saved ->
saved.toMutableMap().apply {
this[userWalletId] = saved[userWalletId]
?.addOrReplace(newBalance) { it.integrationId == integrationId && it.address == address }
?: setOf(newBalance)
}
}
}
private suspend fun updateRuntimeStore(
userWalletId: UserWalletId,
function: (Set<YieldBalance>) -> Set<YieldBalance>,
) {
runtimeStore.update(default = emptyMap()) { saved ->
saved.toMutableMap().apply {
this[userWalletId] = function(this[userWalletId].orEmpty())
}
}
}
private suspend fun storeInPersistenceStore(userWalletId: UserWalletId, items: Set<YieldBalanceWrapperDTO>) {
persistenceStore.updateData { current ->
current.toMutableMap().apply {
this[userWalletId.stringValue] = current[userWalletId.stringValue]
?.addOrReplace(items = items) { old, new ->
old.integrationId == new.integrationId && old.addresses.address == new.addresses.address
}
?: items
}
}
}
private suspend fun storeInPersistenceStore(
userWalletId: UserWalletId,
integrationId: String,
address: String,
item: YieldBalanceWrapperDTO,
) {
persistenceStore.updateData { current ->
current.toMutableMap().apply {
this[userWalletId.stringValue] = current[userWalletId.stringValue]
?.addOrReplace(item) { it.integrationId == integrationId && it.addresses.address == address }
?: setOf(item)
}
}
}
private fun mergeYieldBalances(
cachedBalances: Set<YieldBalance>,
runtimeBalances: Set<YieldBalance>,
stakingIds: List<StakingID>,
): Set<YieldBalance> {
return stakingIds.mapTo(hashSetOf()) { id ->
val runtime = runtimeBalances.getBalance(stakingID = id)
if (runtime == null || runtime is YieldBalance.Error) {
getCachedBalanceIfPossible(cachedBalances = cachedBalances, stakingID = id)
} else {
runtime
}
}
}
private fun getCachedBalanceIfPossible(cachedBalances: Set<YieldBalance>, stakingID: StakingID): YieldBalance {
val cached = cachedBalances.getBalance(stakingID)
?: return YieldBalance.Error(integrationId = stakingID.address, address = stakingID.integrationId)
val updatedCached = when (cached) {
is YieldBalance.Data -> cached.copy(source = StatusSource.ONLY_CACHE)
is YieldBalance.Empty -> cached.copy(source = StatusSource.ONLY_CACHE)
is YieldBalance.Error,
is YieldBalance.Unsupported,
-> null
}
return updatedCached ?: YieldBalance.Error(integrationId = stakingID.address, address = stakingID.integrationId)
}
private fun Set<YieldBalance>.getBalance(stakingID: StakingID): YieldBalance? {
return getBalance(address = stakingID.address, integrationId = stakingID.integrationId)
}
private fun Set<YieldBalance>.getBalance(address: String?, integrationId: String?): YieldBalance? {
return firstOrNull { yieldBalance ->
val isCorrectAddress = address != null && address == yieldBalance.address
val isCorrectIntegration = integrationId != null && yieldBalance.integrationId == integrationId
isCorrectIntegration && isCorrectAddress
}
}
}

View file

@ -1,40 +0,0 @@
package com.tangem.datasource.local.token
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.staking.model.stakekit.YieldBalanceList
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow
/** Staking balance store */
interface StakingBalanceStore {
/** Get flow of [YieldBalanceList] by [userWalletId] and [stakingIds] */
fun get(userWalletId: UserWalletId, stakingIds: List<StakingID>): Flow<Set<YieldBalance>>
/** Get flow of [YieldBalance] by [userWalletId] and [stakingID] */
fun get(userWalletId: UserWalletId, stakingID: StakingID): Flow<YieldBalance?>
/** Get all [YieldBalance] synchronously or null by [userWalletId] */
suspend fun getSyncOrNull(userWalletId: UserWalletId): Set<YieldBalance>?
/** Get [YieldBalanceList] synchronously or null by [userWalletId] and [stakingIds] */
suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingIds: List<StakingID>): Set<YieldBalance>?
/** Get [YieldBalance] synchronously or null by [userWalletId] and [stakingID] */
suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingID: StakingID): YieldBalance?
/** Store [items] by [userWalletId] */
suspend fun store(userWalletId: UserWalletId, items: Set<YieldBalanceWrapperDTO>)
/** Store [item] by [userWalletId] and [stakingID] */
suspend fun store(userWalletId: UserWalletId, stakingID: StakingID, item: YieldBalanceWrapperDTO)
/** Store [item] by [userWalletId] */
suspend fun storeSingleYieldBalance(userWalletId: UserWalletId, item: YieldBalance)
/** Refresh balances of [stakingIds] by [userWalletId] */
suspend fun refresh(userWalletId: UserWalletId, stakingIds: List<StakingID>)
data class StakingID(val integrationId: String, val address: String)
}

View file

@ -0,0 +1,53 @@
package com.tangem.datasource.api.common.config
import com.google.common.truth.Truth
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import timber.log.Timber
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ApiConfigTest {
@Test
fun `all baseUrls ends with slash`() {
// Arrange
val allBaseUrls = createApiConfigs().flatMap { it.environmentConfigs.map { it.baseUrl } }
// Actual
val actual = allBaseUrls.all { it.endsWith("/") }
Timber.e(allBaseUrls.joinToString(separator = "\n"))
// Assert
Truth.assertThat(actual).isTrue()
}
private fun createApiConfigs(): ApiConfigs {
return ApiConfig.ID.entries.mapTo(destination = hashSetOf()) {
when (it) {
ApiConfig.ID.Express -> {
Express(
environmentConfigStorage = mockk(),
expressAuthProvider = mockk(),
appVersionProvider = mockk(),
appInfoProvider = mockk(),
)
}
ApiConfig.ID.TangemTech -> {
TangemTech(
appVersionProvider = mockk(),
authProvider = mockk(),
appInfoProvider = mockk(),
)
}
ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = mockk())
ApiConfig.ID.TangemPay -> TangemPay(appVersionProvider = mockk())
ApiConfig.ID.BlockAid -> BlockAid(configStorage = mockk())
}
}
}
}

View file

@ -162,7 +162,7 @@ internal class ProdApiConfigsManagerTest {
id = ApiConfig.ID.TangemTech,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.tangem.org/v1/",
baseUrl = "https://api.tangem.org/",
headers = mapOf(
"card_id" to ProviderSuspend { APP_CARD_ID },
"card_public_key" to ProviderSuspend { APP_CARD_PUBLIC_KEY },

View file

@ -0,0 +1,43 @@
package com.tangem.core.decompose.utils
import androidx.appcompat.app.AppCompatActivity
import com.arkivanov.essenty.instancekeeper.getOrCreateSimple
import com.arkivanov.essenty.lifecycle.subscribe
import com.tangem.core.decompose.context.AppComponentContext
import java.lang.ref.WeakReference
class ActivityInstanceHolder<T : Any> {
private var instance: T? = null
lateinit var instanceAccess: WeakReference<T>
private set
fun set(instance: T) {
this.instance = instance
instanceAccess = WeakReference(instance)
}
fun clear() {
instance = null
instanceAccess.clear()
}
}
inline fun <reified T : Any> AppComponentContext.getOrCreateActivityInstanceHolder(
noinline factory: (AppCompatActivity) -> T,
): ActivityInstanceHolder<T> {
val holder = instanceKeeper.getOrCreateSimple {
ActivityInstanceHolder<T>()
}
lifecycle.subscribe(
onCreate = {
holder.set(factory(activity))
},
onDestroy = {
holder.clear()
},
)
return holder
}

View file

@ -1 +0,0 @@
/build

View file

@ -1,34 +0,0 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.tangem.core.deeplink"
}
dependencies {
/* Common */
implementation(projects.common.routing)
/* Core */
implementation(projects.core.decompose)
/* Libs - AndroidX */
implementation(deps.lifecycle.runtime.ktx)
/* Libs - Other */
implementation(deps.timber)
/* DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/* Tests */
testImplementation(deps.test.junit)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)
}

View file

@ -1 +0,0 @@
/build

View file

@ -1,15 +0,0 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.core.deeplink.global"
}
dependencies {
/* Project */
implementation(projects.core.deepLinks)
}

View file

@ -1,14 +0,0 @@
package com.tangem.core.deeplink.global
import com.tangem.core.deeplink.DeepLink
@Deprecated("Use ReferralDeepLinkHandler")
class ReferralDeepLink(
val onReceive: () -> Unit,
) : DeepLink(shouldHandleDelayed = true) {
override val uri: String = "tangem://referral"
override fun onReceive(params: Map<String, String>) {
onReceive()
}
}

View file

@ -1,32 +0,0 @@
package com.tangem.core.deeplink.global
import com.tangem.core.deeplink.DeepLink
@Deprecated("Use SellDeepLinkHandler")
class SellCurrencyDeepLink(
val onReceive: (data: Data) -> Unit,
shouldHandleDelayed: Boolean,
) : DeepLink(shouldHandleDelayed) {
override val uri: String = "tangem://redirect_sell"
override fun onReceive(params: Map<String, String>) {
val data = Data(
transactionId = params["transactionId"] ?: return,
baseCurrencyAmount = params["baseCurrencyAmount"] ?: return,
depositWalletAddress = params["depositWalletAddress"] ?: return,
currencyId = params["currency_id"] ?: return,
depositWalletAddressTag = params["depositWalletAddressTag"],
)
onReceive(data)
}
data class Data(
val transactionId: String,
val baseCurrencyAmount: String,
val depositWalletAddress: String,
val currencyId: String,
val depositWalletAddressTag: String?,
)
}

View file

@ -1,36 +0,0 @@
package com.tangem.core.deeplink
/**
* Represents a deep link.
*/
abstract class DeepLink(val shouldHandleDelayed: Boolean = false) {
/**
* ID of the deep link.
*
* By default, it is the same as the [uri].
* */
val id: String get() = uri
/**
* URI of the deep link.
*
* **Note: Remember to add the URI in the AndroidManifest.xml file in the `app` module.**
*
* Query parameters will be received automatically.
*
* Path parameters can be added using the following syntax:
* ```kotlin
* "tangem://link" // Without parameters
* "tangem://link/{param1}/{param2}" // With path parameters
* ```
* */
abstract val uri: String
/**
* Method to be called when this deep link is received.
*
* @param params Map of parameters received from the deep link.
* */
abstract fun onReceive(params: Map<String, String>)
}

View file

@ -1,58 +0,0 @@
package com.tangem.core.deeplink
import android.content.Intent
/**
* Key to pass deeplink via intent
*/
const val DEEPLINK_KEY = "deeplink"
const val WEBLINK_KEY = "link"
// TODO: Add tests
/**
* Provides functionality to handle deep links.
*
* Allows deep links to be launched, registered, or unregistered.
*/
interface DeepLinksRegistry {
/**
* Finds matches registered deep links for the given [intent] and launches them.
*
* @return `true` if any deep link was received, `false` otherwise.
*/
fun launch(intent: Intent): Boolean
/**
* Registers the given [deepLink].
*/
fun register(deepLink: DeepLink)
/**
* Registers the given [deepLinks].
*/
fun register(deepLinks: Collection<DeepLink>)
/**
* Unregisters the given [deepLinks].
*/
fun unregister(deepLinks: Collection<DeepLink>)
/**
* Unregisters the given [deepLink].
*/
fun unregister(deepLink: DeepLink)
/**
* Unregisters deep links with the given [ids].
* */
fun unregisterByIds(ids: Collection<String>)
/**
* Triggers run last launched [Intent] with deeplink handlers that can handle delayed deeplink
* of specific [deepLinkClass] after handle [Intent] clear that and second time no intent will be handled
*/
fun triggerDelayedDeeplink(deepLinkClass: Class<out DeepLink>)
fun cancelDelayedDeeplink()
}

View file

@ -1,12 +0,0 @@
package com.tangem.core.deeplink
object DeeplinkConst {
const val TANGEM_SCHEME = "tangem"
const val WALLET_ID_KEY = "user_wallet_id"
const val NETWORK_ID_KEY = "network_id"
const val TYPE_KEY = "type"
const val TOKEN_ID_KEY = "token_id"
const val DERIVATION_PATH_KEY = "derivation_path"
const val TRANSACTION_ID_KEY = "transaction_id"
const val NAME_KEY = "name"
}

View file

@ -1,64 +0,0 @@
package com.tangem.core.deeplink.converter
import com.tangem.core.deeplink.DeeplinkConst.TANGEM_SCHEME
/**
* Builder class for constructing deep links with a fluent interface.
*/
internal class DeepLinkBuilder {
private var scheme: String = TANGEM_SCHEME
private var action: String = ""
private val pathParams: MutableList<String> = mutableListOf()
private val queryParams: MutableMap<String, String> = mutableMapOf()
/**
* Sets the scheme for the deep link (e.g., "tangem", "https")
*/
fun setScheme(scheme: String): DeepLinkBuilder {
this.scheme = scheme
return this
}
/**
* Sets the action for the deep link (e.g., "link", "wallet")
*/
fun setAction(action: String): DeepLinkBuilder {
this.action = action
return this
}
/**
* Adds a path parameter to the deep link
*/
fun addPathParam(param: String): DeepLinkBuilder {
pathParams.add(param)
return this
}
/**
* Adds a query parameter to the deep link
*/
fun addQueryParam(key: String, value: String): DeepLinkBuilder {
queryParams[key] = value
return this
}
/**
* Builds the deep link URI string
*/
fun build(): String {
val path = if (pathParams.isEmpty()) {
action
} else {
"$action/${pathParams.joinToString("/")}"
}
val queryString = if (queryParams.isEmpty()) {
""
} else {
"?" + queryParams.entries.joinToString("&") { "${it.key}=${it.value}" }
}
return "$scheme://$path$queryString"
}
}

View file

@ -1,55 +0,0 @@
package com.tangem.core.deeplink.converter
import com.tangem.common.routing.DeepLinkRoute
import com.tangem.common.routing.DeepLinkScheme
import com.tangem.core.deeplink.DEEPLINK_KEY
import com.tangem.core.deeplink.DeeplinkConst.DERIVATION_PATH_KEY
import com.tangem.core.deeplink.DeeplinkConst.NAME_KEY
import com.tangem.core.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.core.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.core.deeplink.DeeplinkConst.TRANSACTION_ID_KEY
import com.tangem.core.deeplink.DeeplinkConst.TYPE_KEY
import com.tangem.core.deeplink.DeeplinkConst.WALLET_ID_KEY
import com.tangem.utils.converter.Converter
object PayloadToDeeplinkConverter : Converter<Map<String, String>, String?> {
override fun convert(value: Map<String, String>): String? {
return when {
value[DEEPLINK_KEY] != null -> value[DEEPLINK_KEY]
isTangemPushNotificationPayload(value) -> buildNotificationDeeplink(value)
else -> null
}
}
@Suppress("ReturnCount")
private fun buildNotificationDeeplink(payload: Map<String, String>): String? {
val type = payload[TYPE_KEY] ?: return null
val networkId = payload[NETWORK_ID_KEY] ?: return null
val tokenId = payload[TOKEN_ID_KEY] ?: return null
val walletId = payload[WALLET_ID_KEY] ?: return null
val derivationPath = payload[DERIVATION_PATH_KEY] ?: return null
val transactionId = payload[TRANSACTION_ID_KEY]
val name = payload[NAME_KEY]
return DeepLinkBuilder().setScheme(DeepLinkScheme.Tangem.scheme).apply {
setAction(DeepLinkRoute.TokenDetails.host)
addQueryParam(NETWORK_ID_KEY, networkId)
addQueryParam(TOKEN_ID_KEY, tokenId)
addQueryParam(TYPE_KEY, type)
addQueryParam(WALLET_ID_KEY, walletId)
addQueryParam(DERIVATION_PATH_KEY, derivationPath)
transactionId?.let { addQueryParam(TRANSACTION_ID_KEY, it) }
name?.let { addQueryParam(NAME_KEY, it) }
}.build()
}
private fun isTangemPushNotificationPayload(payload: Map<String, String>): Boolean {
return payload.containsKey(TYPE_KEY) &&
payload.containsKey(NETWORK_ID_KEY) &&
payload.containsKey(TOKEN_ID_KEY) &&
payload.containsKey(WALLET_ID_KEY) &&
payload.containsKey(DERIVATION_PATH_KEY)
}
}

View file

@ -1,20 +0,0 @@
package com.tangem.core.deeplink.di
import com.tangem.core.deeplink.DeepLinksRegistry
import com.tangem.core.deeplink.impl.DefaultDeepLinksRegistry
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object DeepLinksModule {
@Provides
@Singleton
fun provideDeepLinksRegistry(): DeepLinksRegistry {
return DefaultDeepLinksRegistry()
}
}

View file

@ -1,201 +0,0 @@
package com.tangem.core.deeplink.impl
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import com.tangem.core.deeplink.DEEPLINK_KEY
import com.tangem.core.deeplink.DeepLink
import com.tangem.core.deeplink.DeepLinksRegistry
import timber.log.Timber
internal class DefaultDeepLinksRegistry : DeepLinksRegistry {
private var registries: List<DeepLink> = emptyList()
private var lastDeepLink: Uri? = null
override fun launch(intent: Intent): Boolean {
// Try to get deeplink from data (direct deeplink flow)
// Otherwise, try to get from extras (notification deeplink flow)
val deepLinkExtras = intent.getStringExtra(DEEPLINK_KEY)?.toUri()
val received = intent.data ?: deepLinkExtras ?: return false
lastDeepLink = received
var hasMatch = false
Timber.i(
"""
Received deep link intent
|- Received URI: $received
|- Registries: $registries
""".trimIndent(),
)
registries.forEach { deepLink ->
val expected = deepLink.uri.toUri()
if (!isMatches(expected, received)) return@forEach
hasMatch = true
val params = getParams(expected, received)
logMatch(hasMatch, expected, received, params)
deepLink.onReceive(params)
lastDeepLink = null // clear deeplink if it was handled
}
if (!hasMatch) {
logMatch(hasMatch, null, received, null)
}
return hasMatch
}
override fun register(deepLinks: Collection<DeepLink>) {
registries = (registries + deepLinks).distinctBy(DeepLink::id)
Timber.d(
"""
Registered deep links
|- Registries: $registries
""".trimIndent(),
)
}
override fun register(deepLink: DeepLink) {
registries = (registries + deepLink).distinctBy(DeepLink::id)
Timber.d(
"""
Registered deep link
|- Registries: $registries
""".trimIndent(),
)
}
override fun unregister(deepLinks: Collection<DeepLink>) {
registries = registries.filter { it !in deepLinks }
Timber.d(
"""
Unregistered deep links
|- Registries: $registries
""".trimIndent(),
)
}
override fun unregister(deepLink: DeepLink) {
registries = registries.filter { it.id != deepLink.id }
Timber.d(
"""
Unregistered deep link
|- Registries: $registries
""".trimIndent(),
)
}
override fun unregisterByIds(ids: Collection<String>) {
registries = registries.filter { it.id !in ids }
Timber.d(
"""
Unregistered deep links
|- Registries: $registries
""".trimIndent(),
)
}
override fun triggerDelayedDeeplink(deepLinkClass: Class<out DeepLink>) {
val received = lastDeepLink
if (received != null) {
var hasMatch = false
registries
.filterIsInstance(deepLinkClass)
.forEach { deepLink ->
if (!deepLink.shouldHandleDelayed) return@forEach
val expected = deepLink.uri.toUri()
if (!isMatches(expected, received)) return@forEach
hasMatch = true
val params = getParams(expected, received)
logMatch(hasMatch, expected, received, params)
deepLink.onReceive(params)
}
if (!hasMatch) {
logMatch(hasMatch, null, received, null)
}
lastDeepLink = null // clear deeplink in any case handle or not
}
}
override fun cancelDelayedDeeplink() {
lastDeepLink = null
}
private fun logMatch(hasMatch: Boolean, expected: Uri?, received: Uri?, params: Map<String, String>?) {
if (hasMatch) {
Timber.i(
"""
Matched deep link
|- Expected URI: $expected
|- Received URI: $received
|- Params: $params
""".trimIndent(),
)
} else {
Timber.i(
"""
No match found for deep link
|- Received URI: $received
|- Registries: $registries
""".trimIndent(),
)
}
}
private fun isMatches(received: Uri, expected: Uri): Boolean {
if (received == expected) return true
if (received.authority != expected.authority ||
received.pathSegments.size != expected.pathSegments.size
) {
return false
}
received.pathSegments.forEachIndexed { index, receivedSegment ->
val expectedSegment = expected.pathSegments[index]
if (receivedSegment != expectedSegment &&
!(receivedSegment.startsWith(prefix = "{") && receivedSegment.endsWith(suffix = "}"))
) {
return false
}
}
return true
}
private fun getParams(received: Uri, expected: Uri): Map<String, String> {
val params = mutableMapOf<String, String>()
received.pathSegments.forEachIndexed { index, receivedSegment ->
val expectedSegment = expected.pathSegments[index]
if (receivedSegment != expectedSegment &&
receivedSegment.startsWith(prefix = "{") &&
receivedSegment.endsWith(suffix = "}")
) {
val path = receivedSegment
.replace(oldValue = "{", newValue = "")
.replace(oldValue = "}", newValue = "")
params[path] = expectedSegment
}
}
expected.queryParameterNames.forEach { paramName ->
expected.getQueryParameter(paramName)?.let { param ->
params[paramName] = param
}
}
return params
}
}

View file

@ -1,21 +0,0 @@
package com.tangem.core.deeplink.utils
import com.arkivanov.essenty.lifecycle.subscribe
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.deeplink.DeepLink
import com.tangem.core.deeplink.DeepLinksRegistry
fun AppComponentContext.registerDeepLinks(registry: DeepLinksRegistry, vararg deepLinks: DeepLink) {
registerDeepLinks(registry, deepLinks.toList())
}
fun AppComponentContext.registerDeepLinks(registry: DeepLinksRegistry, deepLinks: Collection<DeepLink>) {
lifecycle.subscribe(
onCreate = {
registry.register(deepLinks)
},
onDestroy = {
registry.unregister(deepLinks)
},
)
}

View file

@ -1,112 +0,0 @@
package com.tangem.core.deeplink.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.core.deeplink.DeeplinkConst
import org.junit.Before
import org.junit.Test
internal class DeepLinkBuilderTest {
private lateinit var deepLinkBuilder: DeepLinkBuilder
@Before
fun setup() {
deepLinkBuilder = DeepLinkBuilder()
}
@Test
fun `GIVEN default builder WHEN build THEN should return default scheme`() {
// WHEN
val result = deepLinkBuilder.build()
// THEN
assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://")
}
@Test
fun `GIVEN custom scheme WHEN setScheme THEN should use custom scheme`() {
// GIVEN
val customScheme = "https"
// WHEN
val result = deepLinkBuilder
.setScheme(customScheme)
.build()
// THEN
assertThat(result).isEqualTo("$customScheme://")
}
@Test
fun `GIVEN action WHEN setAction THEN should include action in path`() {
// GIVEN
val action = "wallet"
// WHEN
val result = deepLinkBuilder
.setAction(action)
.build()
// THEN
assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://$action")
}
@Test
fun `GIVEN path params WHEN addPathParam THEN should include params in path`() {
// GIVEN
val action = "wallet"
val param1 = "123"
val param2 = "456"
// WHEN
val result = deepLinkBuilder
.setAction(action)
.addPathParam(param1)
.addPathParam(param2)
.build()
// THEN
assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://$action/$param1/$param2")
}
@Test
fun `GIVEN query params WHEN addQueryParam THEN should include params in query string`() {
// GIVEN
val action = "wallet"
val key1 = "param1"
val value1 = "value1"
val key2 = "param2"
val value2 = "value2"
// WHEN
val result = deepLinkBuilder
.setAction(action)
.addQueryParam(key1, value1)
.addQueryParam(key2, value2)
.build()
// THEN
assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://$action?$key1=$value1&$key2=$value2")
}
@Test
fun `GIVEN complex deep link WHEN build THEN should construct correct URI`() {
// GIVEN
val scheme = "https"
val action = "wallet"
val pathParam = "123"
val queryKey = "token"
val queryValue = "abc"
// WHEN
val result = deepLinkBuilder
.setScheme(scheme)
.setAction(action)
.addPathParam(pathParam)
.addQueryParam(queryKey, queryValue)
.build()
// THEN
assertThat(result).isEqualTo("$scheme://$action/$pathParam?$queryKey=$queryValue")
}
}

View file

@ -1,129 +0,0 @@
package com.tangem.core.deeplink.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.core.deeplink.DEEPLINK_KEY
import com.tangem.core.deeplink.DeeplinkConst.DERIVATION_PATH_KEY
import com.tangem.core.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.core.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.core.deeplink.DeeplinkConst.TYPE_KEY
import com.tangem.core.deeplink.DeeplinkConst.WALLET_ID_KEY
import org.junit.Test
internal class PayloadToDeeplinkConverterTest {
@Test
fun `GIVEN payload with deeplink key WHEN convert THEN should return deeplink value`() {
// GIVEN
val payload = mapOf(
DEEPLINK_KEY to "tangem://token-details?networkId=ethereum&tokenId=0x123&type=token&user_wallet_id=wallet123" +
"&derivation_path=m'0'0'0",
)
// WHEN
val result = PayloadToDeeplinkConverter.convert(payload)
// THEN
assertThat(result).isEqualTo(
"tangem://token-details?networkId=ethereum&tokenId=0x123&type=token&user_wallet_id=wallet123&derivation_path=m'0'0'0",
)
}
@Test
fun `GIVEN valid push notification payload with all vital values WHEN convert THEN should return correct deeplink`() {
// GIVEN
val payload = mapOf(
TYPE_KEY to "token",
NETWORK_ID_KEY to "ethereum",
TOKEN_ID_KEY to "0x123",
WALLET_ID_KEY to "wallet123",
DERIVATION_PATH_KEY to "m'0'0'0",
)
// WHEN
val result = PayloadToDeeplinkConverter.convert(payload)
// THEN
assertThat(result).isEqualTo(
"tangem://token?network_id=ethereum&token_id=0x123&type=token&user_wallet_id=wallet123&derivation_path=m'0'0'0",
)
}
@Test
fun `GIVEN push notification payload with missing type WHEN convert THEN should return null`() {
// GIVEN
val payload = mapOf(
NETWORK_ID_KEY to "ethereum",
TOKEN_ID_KEY to "0x123",
WALLET_ID_KEY to "wallet123",
DERIVATION_PATH_KEY to "m'0'0'0",
)
// WHEN
val result = PayloadToDeeplinkConverter.convert(payload)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN push notification payload with missing networkId WHEN convert THEN should return null`() {
// GIVEN
val payload = mapOf(
TYPE_KEY to "token",
TOKEN_ID_KEY to "0x123",
WALLET_ID_KEY to "wallet123",
DERIVATION_PATH_KEY to "m'0'0'0",
)
// WHEN
val result = PayloadToDeeplinkConverter.convert(payload)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN push notification payload with missing tokenId WHEN convert THEN should return null`() {
// GIVEN
val payload = mapOf(
TYPE_KEY to "token",
NETWORK_ID_KEY to "ethereum",
WALLET_ID_KEY to "wallet123",
DERIVATION_PATH_KEY to "m'0'0'0",
)
// WHEN
val result = PayloadToDeeplinkConverter.convert(payload)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN push notification payload with missing walletId WHEN convert THEN should return null`() {
// GIVEN
val payload = mapOf(
TYPE_KEY to "token",
NETWORK_ID_KEY to "ethereum",
TOKEN_ID_KEY to "0x123",
)
// WHEN
val result = PayloadToDeeplinkConverter.convert(payload)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN empty payload WHEN convert THEN should return null`() {
// GIVEN
val payload = emptyMap<String, String>()
// WHEN
val result = PayloadToDeeplinkConverter.convert(payload)
// THEN
assertThat(result).isNull()
}
}

View file

@ -1316,7 +1316,7 @@
<string name="warning_express_approval_in_progress_message">Die Genehmigung des Swaps ist im Gange und wird in Kürze abgeschlossen sein.</string>
<string name="warning_express_approval_in_progress_title">Genehmigung in Arbeit</string>
<string name="warning_express_dust_message">Der Mindestbetrag für den Tausch beträgt %1$s. Bitte stelle sicher, dass der Restsaldo nach dem Swap nicht unter %2$s liegt.</string>
<string name="warning_express_no_exchangeable_coins_description">Du hast keine %s austauschbaren Coins in Deiner Liste</string>
<string name="warning_express_no_exchangeable_coins_description">Sie haben keine Token in Ihrem Portfolio, gegen die %s getauscht werden kann. Bitte fügen Sie einen anderen Token hinzu, um den Tausch durchzuführen.</string>
<string name="warning_express_no_exchangeable_coins_title">Keine Token zum Tauschen verfügbar</string>
<string name="warning_express_not_enough_fee_for_token_tx_description">Um eine Transaktion durchzuführen, du etwas etwas einzahlen %1$s %2$s</string>
<string name="warning_express_not_enough_fee_for_token_tx_title">Die Gebühr %s kann nicht gedeckt werden</string>

View file

@ -1236,7 +1236,7 @@
<string name="warning_express_approval_in_progress_message">La aprobación del intercambio está en progreso y se completará en breve</string>
<string name="warning_express_approval_in_progress_title">Aprobación en proceso</string>
<string name="warning_express_dust_message">El monto mínimo de transacción es %1$s. Asegúrese de que el saldo restante después del canje no sea inferior a %2$s.</string>
<string name="warning_express_no_exchangeable_coins_description">No tiene %s monedas negociables en su lista</string>
<string name="warning_express_no_exchangeable_coins_description">No tienes tokens en tu portafolio a los que puedas intercambiar %s. Por favor, añade otro token para realizar el intercambio.</string>
<string name="warning_express_no_exchangeable_coins_title">No hay tokens disponibles para intercambiar</string>
<string name="warning_express_not_enough_fee_for_token_tx_description">Para realizar una transacción necesita depositar %1$s %2$s</string>
<string name="warning_express_not_enough_fee_for_token_tx_title">No se pueden cubrir %s tarifa</string>

View file

@ -49,6 +49,8 @@
<string name="app_settings_theme_mode_system">Par défaut du système</string>
<string name="app_settings_theme_selector_title">Thème</string>
<string name="app_settings_title">Paramètres de l\'application</string>
<string name="backup_complete_seed_description">Ces mots ne peuvent pas être récupérés en cas de perte. Assurez-vous de les conserver en lieu sûr.</string>
<string name="backup_seed_caution">Ne partagez jamais ces mots. Quiconque les apprend peut voler toutes vos cryptomonnaies. Tangem ne vous les demandera jamais. Les %s mots ci-dessous constituent la phrase de récupération de votre portefeuille. Cette phrase vous permet de récupérer votre portefeuille en cas de perte de votre appareil.</string>
<string name="balance_hidden_description">Pour masquer ou afficher vos soldes, il suffit de retourner l\'écran de votre appareil vers le bas ou de le désactiver dans les paramètres</string>
<string name="balance_hidden_do_not_show_button">Ne plus afficher</string>
<string name="balance_hidden_got_it_button">Compris</string>
@ -216,6 +218,7 @@
<string name="common_unreachable">Inaccessible</string>
<string name="common_unstake">Unstakez</string>
<string name="common_utxo_validate_withdrawal_message_warning">En raison d\'une limitations sur les %1$s, seuls les %2$d UTXO peuvent s\'intégrer dans une seule transaction. Ce qui signifie vous ne pouvez envoyer que %3$s ou moins. Réduisez le montant.</string>
<string name="common_value_copied">Valeur copiée</string>
<string name="common_week">semaine</string>
<string name="common_with">avec</string>
<string name="common_yes">Oui</string>
@ -699,6 +702,7 @@
<string name="organize_tokens_sort_by_balance">Par solde</string>
<string name="organize_tokens_title">Organiser les jetons</string>
<string name="organize_tokens_ungroup">Dégrouper</string>
<string name="push_transactions_notifications_description">Recevez des alertes pour les transactions entrantes sur les réseaux pris en charge.</string>
<string name="qr_scanner_camera_denied_gallery_button">Sélectionnez dans la galerie</string>
<string name="qr_scanner_camera_denied_settings_button">Paramètres</string>
<string name="qr_scanner_camera_denied_text">Vous n\'avez pas donné accès à votre caméra</string>
@ -855,6 +859,7 @@
<string name="send_validation_invalid_amount">Montant invalide</string>
<string name="send_validation_invalid_fee">Les frais de commissions dépassent le solde</string>
<string name="send_validation_invalid_total">Le total dépasse le solde</string>
<string name="send_with_swap_confirm_title">Échanger et envoyer</string>
<string name="sent_transaction_sent_title">Transaction envoyée</string>
<string name="settings_card_settings_footer">Scannez la carte/ bague que vous souhaitez configurer.</string>
<string name="settings_forget_wallet">Oublier le portefeuille</string>
@ -1085,6 +1090,7 @@
<string name="unlock_wallet_description_full">Utilisez %s ou scannez une carte/bague pour avoir accès à votre portefeuille</string>
<string name="unsupported_wc_version">Échec de la connexion : Cette dApp utilise Wallet Connect version1.0, qui n\'est pas prise en charge. Veuillez vous assurer que la dApp prend en charge Wallet Connect version2.0 pour réussir la connexion.</string>
<string name="user_push_notification_agreement_argument_one">Restez à jour avec les dernières fonctionnalités et actualités</string>
<string name="user_push_notification_agreement_argument_three">Recevez des notifications des transactions entrantes</string>
<string name="user_push_notification_agreement_argument_two">Soyez le premier informé des nouvelles promotions</string>
<string name="user_push_notification_agreement_header">Souhaitez-vous utiliser les\nnotifications push?</string>
<string name="user_wallet_list_add_button">Ajouter un nouveau portefeuille</string>
@ -1157,6 +1163,7 @@
<string name="wallet_promo_banner_button_title">Obtenez-le maintenant avec 10% de réduction</string>
<string name="wallet_promo_banner_description">Accédez à plus de 13 000 cryptomonnaies. Achetez, vendez, échangez et stakez en un seul clic.\nAssociez jusqu\'à trois cartes pour une sauvegarde.</string>
<string name="wallet_promo_banner_title">Découvrez le Portefeuille Tangem</string>
<string name="wallet_settings_push_notifications_description">Recevez des notifications sur les transactions entrantes du portefeuille et les mises à jour de Tangem.</string>
<string name="wallet_settings_title">Paramètres du portefeuille</string>
<string name="wallet_title">Tangem</string>
<string name="warning_access_denied_message">Utilisez %s ou scannez une carte/bague pour déverrouiller l\'accès à votre portefeuille</string>
@ -1184,7 +1191,7 @@
<string name="warning_express_approval_in_progress_message">L\'approbation de l\'échange est en cours et sera achevée sous peu</string>
<string name="warning_express_approval_in_progress_title">Approbation en cours</string>
<string name="warning_express_dust_message">Le montant minimum d\'échange est de %1$s. Veuillez vous assurer que le solde restant après l\'échange ne sera pas inférieur à %2$s.</string>
<string name="warning_express_no_exchangeable_coins_description">Vous n\'avez pas de pièces échangeables %s dans votre liste</string>
<string name="warning_express_no_exchangeable_coins_description">Vous navez aucun jeton dans votre portefeuille pouvant recevoir %s en échange. Veuillez ajouter un autre jeton pour effectuer léchange.</string>
<string name="warning_express_no_exchangeable_coins_title">Aucun jeton disponible à échanger</string>
<string name="warning_express_not_enough_fee_for_token_tx_description">Pour effectuer une transaction, vous devez déposer %1$s %2$s</string>
<string name="warning_express_not_enough_fee_for_token_tx_title">Impossible de couvrir %s frais</string>
@ -1243,9 +1250,13 @@
<string name="warning_some_networks_unreachable_message">Certains réseaux sont actuellement inaccessibles. Veuillez réessayer plus tard.</string>
<string name="warning_some_networks_unreachable_title">Certains réseaux sont inaccessibles</string>
<string name="warning_some_token_balances_not_updated">Certains soldes de jetons n\'ont pas pu être mis à jour</string>
<string name="warning_stellar_token_trustline_not_enough_xlm">Pas assez de %s. Rechargez votre compte XLM pour associer ce jeton</string>
<string name="warning_testnet_card_message">Il s\'agit d\'une carte Testnet. Elle ne peut pas traiter les transactions et ne doit être utilisée qu\'à des fins de test et de développement.</string>
<string name="warning_testnet_card_title">À des fins de test uniquement</string>
<string name="warning_token_balance_not_updated">Le solde peut être obsolète. Rafraîchissez la page.</string>
<string name="wc_alert_audit_malicious_domain">Domaine malveillant</string>
<string name="wc_alert_unsupported_dapps_description">Le portefeuille Tangem ne prend actuellement pas en charge %ss</string>
<string name="wc_alert_unsupported_dapps_title">dApp non prise en charge</string>
<string name="wc_connections">Connexions</string>
<string name="wc_disconnect_all">Déconnecter tout</string>
<string name="wc_disconnect_all_alert_desc">Texte sur la déconnexion de toutes les dApps</string>

View file

@ -4,6 +4,9 @@
<string name="access_code_alert_skip_ok">とにかくスキップ</string>
<string name="access_code_alert_skip_title">アクセスコードが設定されていません</string>
<string name="access_code_check_title">アクセスコードを入力</string>
<string name="access_code_check_warining_delete">アクセスコードが間違っています。あと%s回間違えるとホットウォレットが削除されます。</string>
<string name="access_code_check_warining_lock">アクセスコードが間違っています。あと%s回入力エラーが発生するとアプリがロックされます。</string>
<string name="access_code_check_warining_wait">アクセスコードが間違っています。 \n %s秒待ってから再試行してください。</string>
<string name="access_code_confirm_description">続行するには、以前に入力したコードを確認してください</string>
<string name="access_code_confirm_title">アクセスコードを再入力</string>
<string name="access_code_create_description">ウォレットのロックを解除するには、 %s桁のアクセスコードを設定します。</string>
@ -655,7 +658,7 @@
<string name="onboarding_button_continue_wallet">ウォレットへ進む</string>
<string name="onboarding_button_finalize_backup">バックアップを完了する</string>
<string name="onboarding_button_receive_crypto">暗号資産を受け取る</string>
<string name="onboarding_button_scan_origin_card">プライマリカードをスキャン</string>
<string name="onboarding_button_scan_origin_card">メインカードまたはリングをスキャン</string>
<string name="onboarding_button_skip_backup">スキップする</string>
<string name="onboarding_button_what_does_it_mean">どのように機能しますか?</string>
<string name="onboarding_create_wallet_body">すべての秘密鍵をカードまたはリング内で生成し、安全なウォレットを作成しましょう</string>
@ -706,7 +709,7 @@
<string name="onboarding_title_no_backup_cards">バックアップデバイスなし</string>
<string name="onboarding_title_notifications">通知</string>
<string name="onboarding_title_one_backup_card">バックアップデバイスが1つ追加されました</string>
<string name="onboarding_title_scan_origin_card">カードを準備してください</string>
<string name="onboarding_title_scan_origin_card">カードまたはリングを用意してください</string>
<string name="onboarding_title_two_backup_cards">バックアップデバイス2つが追加されました</string>
<string name="onboarding_top_up_body">始めるには、ウォレットに任意の金額を入金するだけです</string>
<string name="onboarding_top_up_body_no_account_error">始めるには、ウォレットに%1$s %2$s以上入金するだけです</string>
@ -924,6 +927,7 @@
<string name="send_validation_invalid_amount">無効な金額</string>
<string name="send_validation_invalid_fee">手数料が残高を超えています</string>
<string name="send_validation_invalid_total">合計金額が残高を超えています</string>
<string name="send_with_swap_confirm_title">スワップして送信</string>
<string name="send_with_swap_notification_text">トークンを送信すれば、送信中に変換されます。受信者は必要なものをシームレスに受け取ります。</string>
<string name="send_with_swap_recipient_amount_text">受信者に送信されます</string>
<string name="send_with_swap_recipient_amount_title">受取金額</string>
@ -1296,8 +1300,8 @@
<string name="warning_express_approval_in_progress_message">スワップ承認は現在進行中で、まもなく完了する予定です。</string>
<string name="warning_express_approval_in_progress_title">承認が進行中</string>
<string name="warning_express_dust_message">最低のスワップ金額は%1$s です。スワップ後の残金が%2$s を下回らないようにしてください。</string>
<string name="warning_express_no_exchangeable_coins_description">あなたのリストには、交換可能な %s トークンがありません</string>
<string name="warning_express_no_exchangeable_coins_title">スワップ可能なトークンがありません</string>
<string name="warning_express_no_exchangeable_coins_description">ポートフォリオ内に%sとスワップ可能なトークンがありません。交換を有効にするには、別のトークンを追加してください</string>
<string name="warning_express_no_exchangeable_coins_title">互換性のあるトークンが追加されていません</string>
<string name="warning_express_not_enough_fee_for_token_tx_description">取引を行うには、 %1$s %2$sを入金する必要があります。</string>
<string name="warning_express_not_enough_fee_for_token_tx_title">%s 手数料を支払えません</string>
<string name="warning_express_notification_invalid_reserve_amount_title">受け取る金額は、 %s 以上である必要があります。</string>
@ -1375,6 +1379,8 @@
<string name="wc_alert_unknown_error_description">エラーコード: %s 。問題が解決しない場合は、お気軽にサポートまでお問い合わせください。</string>
<string name="wc_alert_unknown_error_description_no_error_code">問題が解決しない場合は、お気軽にサポートまでお問い合わせください。</string>
<string name="wc_alert_unknown_error_title">不明なエラーが発生しました</string>
<string name="wc_alert_unsupported_dapps_description">Tangemウォレットは、現在%sをサポートしていません。</string>
<string name="wc_alert_unsupported_dapps_title">サポートされていないdApp</string>
<string name="wc_alert_unsupported_method_description">エラーコード: 8 005。問題が解決しない場合は、お気軽にサポートまでお問い合わせください。</string>
<string name="wc_alert_unsupported_method_title">不明なエラーが発生しました</string>
<string name="wc_alert_unsupported_networks_description">Tangemは現在%sで必要なネットワークをサポートしていません。</string>

View file

@ -620,7 +620,7 @@
<string name="onboarding_button_continue_wallet">Перейти к моему кошельку</string>
<string name="onboarding_button_finalize_backup">Завершение бэкапа</string>
<string name="onboarding_button_receive_crypto">Получить криптовалюту</string>
<string name="onboarding_button_scan_origin_card">Сканировать основную карту</string>
<string name="onboarding_button_scan_origin_card">Сканировать основную карту или кольцо</string>
<string name="onboarding_button_skip_backup">Пропустить</string>
<string name="onboarding_button_what_does_it_mean">Как это работает?</string>
<string name="onboarding_create_wallet_body">Давайте сгенерируем все ключи на вашей карте или кольце и создадим безопасный кошелек</string>
@ -676,7 +676,7 @@
<string name="onboarding_title_no_backup_cards">Нет резервных устройств</string>
<string name="onboarding_title_notifications">Уведомления</string>
<string name="onboarding_title_one_backup_card">Добавлено одно резервное устройство</string>
<string name="onboarding_title_scan_origin_card">Подготовьте свою карту</string>
<string name="onboarding_title_scan_origin_card">Подготовьте свою карту или кольцо</string>
<string name="onboarding_title_two_backup_cards">Добавлены два резервных девайса</string>
<string name="onboarding_top_up_body">Пополните кошелек на любую сумму, чтобы начать пользоваться картой</string>
<string name="onboarding_top_up_body_no_account_error">Пополните кошелек более чем на %1$s %2$s, чтобы начать пользоваться картой</string>
@ -723,7 +723,12 @@
<string name="organize_tokens_title">Упорядочить токены</string>
<string name="organize_tokens_ungroup">Список</string>
<string name="push_notifications_more_info">Подробнее</string>
<string name="push_notifications_permission_alert_description">Вы можете включить нотификации в настройках</string>
<string name="push_notifications_permission_alert_negative_button">Включить позже</string>
<string name="push_notifications_permission_alert_positive_button">Настройки</string>
<string name="push_notifications_permission_alert_title">Подключить нотификации</string>
<string name="push_transactions_notifications_description">Получайте уведомления о входящих транзакциях в поддерживаемых сетях.</string>
<string name="push_transactions_notifications_title">Уведомления о транзакциях</string>
<string name="qr_scanner_camera_denied_gallery_button">Выбрать из галереи</string>
<string name="qr_scanner_camera_denied_settings_button">Настройки</string>
<string name="qr_scanner_camera_denied_text">Вы не предоставили доступ к вашей камере</string>
@ -1119,6 +1124,7 @@
<string name="unlock_wallet_description_full">Используйте %s или отсканируйте карту/кольцо, чтобы получить доступ к своему кошельку</string>
<string name="unsupported_wc_version">Соединение не удалось: это dApp использует Wallet Connect версии 1.0, которая не поддерживается. Убедитесь, что dApp поддерживает Wallet Connect версии 2.0 для успешного подключения.</string>
<string name="user_push_notification_agreement_argument_one">Будьте в курсе новых функций и новостей</string>
<string name="user_push_notification_agreement_argument_three">Получайте уведомления о входящих транзакциях</string>
<string name="user_push_notification_agreement_argument_two">Узнавайте первым о новых акциях</string>
<string name="user_push_notification_agreement_header">Хотите использовать Push-уведомления?</string>
<string name="user_wallet_list_add_button">Добавить новый кошелек</string>
@ -1174,6 +1180,7 @@
<string name="wallet_promo_banner_button_title">Получить с 10% скидкой</string>
<string name="wallet_promo_banner_description">Получите доступ к более чем 13 000 криптовалют. Покупайте, продавайте, обменивайте и стейкайте в один клик. Свяжите до трех карт для резервного копирования. </string>
<string name="wallet_promo_banner_title">Откройте Tangem Wallet</string>
<string name="wallet_settings_push_notifications_description">Получайте уведомления о входящих транзакциях в кошельке и обновлениях Tangem.</string>
<string name="wallet_settings_push_notifications_title">Уведомления о транзакциях</string>
<string name="wallet_settings_title">Настройки кошелька</string>
<string name="wallet_title">Tangem</string>
@ -1202,7 +1209,7 @@
<string name="warning_express_approval_in_progress_message">Разрешение обмена в процессе и будет скоро завершено</string>
<string name="warning_express_approval_in_progress_title">Разрешение в процессе</string>
<string name="warning_express_dust_message">Минимальная сумма обмена - %1$s. Пожалуйста, убедитесь, что остаток после обмена также не будет меньше %2$s.</string>
<string name="warning_express_no_exchangeable_coins_description">У вас в списке нет монет доступных для обмена с %s</string>
<string name="warning_express_no_exchangeable_coins_description">У вас нет токенов в портфеле, на которые можно обменять %s. Пожалуйста, добавьте другой токен, чтобы выполнить обмен.</string>
<string name="warning_express_no_exchangeable_coins_title">Нет доступных для обмена токенов</string>
<string name="warning_express_not_enough_fee_for_token_tx_description">Чтобы совершить транзакцию, вам необходимо внести немного %1$s %2$s</string>
<string name="warning_express_not_enough_fee_for_token_tx_title">Невозможно покрыть комиссию %s</string>
@ -1268,6 +1275,7 @@
<string name="warning_token_trustline_subtitle">Согласно правилам сети, чтобы пополнить этот токен, необходимо сначала открыть Trustline — это позволит вашему кошельку принимать и хранить этот актив.</string>
<string name="warning_token_trustline_title">Откройте Trustline</string>
<string name="wc_alert_session_disconnected_description">Пожалуйста, вернитесь в браузер и выполните повторное подключение через WalletConnect.</string>
<string name="wc_alert_unsupported_dapps_title">Неподдерживаемый dApp</string>
<string name="wc_alert_wrong_card_description">Выбрана не верная карта или кольцо</string>
<string name="wc_common_address">Адрес</string>
<string name="wc_common_connect">Подключение</string>

View file

@ -316,6 +316,7 @@
<string name="express_exchange_status_receiving">Очікуємо на депозит</string>
<string name="express_exchange_status_receiving_active">Очікуємо на депозит...</string>
<string name="express_exchange_status_refunded">Повернено</string>
<string name="express_exchange_status_refunding">Повернення коштів</string>
<string name="express_exchange_status_sending">Надсилаємо вам</string>
<string name="express_exchange_status_sending_active">Надсилаємо вам...</string>
<string name="express_exchange_status_sent">Надіслано</string>
@ -370,6 +371,7 @@
<string name="give_permission_swap_subtitle" formatted="false">Щоб продовжити, вам потрібно дозволити смарт-контракту %1s використовувати ваш %2s</string>
<string name="give_permission_title">Надати дозвіл</string>
<string name="give_permission_unlimited">Необмежено</string>
<string name="home_button_create_new_wallet">Створити новий гаманець</string>
<string name="home_button_order">Купити</string>
<string name="home_button_scan">Сканувати</string>
<string name="hot_crypto_add_token_subtitle">в %s</string>
@ -572,10 +574,11 @@
<string name="nft_receive_unsupported_types_description">cNFT і pNFT наразі не підтримуються. Будь ласка, не надсилайте їх на свій гаманець.</string>
<string name="nft_send">Надіслати NFT</string>
<string name="nft_traits_title">Риси</string>
<string name="nft_untitled_collection">Безіменна колекція</string>
<string name="nft_wallet_count">NFTs в %2$d колекціях%1$d</string>
<plurals name="nft_wallet_count_android">
<item quantity="one">%1$d NFT в %2$d колекціi</item>
<item quantity="few"></item>
<item quantity="few">%1$d NFTs в %2$d колекціях</item>
<item quantity="many"></item>
<item quantity="other">%1$d NFTs в %2$d колекціях</item>
</plurals>
@ -720,6 +723,7 @@
<string name="organize_tokens_ungroup">Список</string>
<string name="push_notifications_more_info">Більше</string>
<string name="push_notifications_permission_alert_title">Увімкнути сповіщення</string>
<string name="push_transactions_notifications_description">Отримуйте сповіщення про вхідні транзакції в підтримуваних мережах.</string>
<string name="qr_scanner_camera_denied_gallery_button">Виберіть з галереї</string>
<string name="qr_scanner_camera_denied_settings_button">Налаштування</string>
<string name="qr_scanner_camera_denied_text">Ви не надали доступ до своєї камери</string>
@ -829,6 +833,9 @@
<string name="send_max_fee">Комісія може сягати до</string>
<string name="send_memo_destination_tag_error">Недопустимий Memo</string>
<string name="send_network_fee_warning_title">Покриття мережевої комісії</string>
<string name="send_nonce">Nonce</string>
<string name="send_nonce_footer">Унікальний номер транзакції. Змініть його, щоб пришвидшити відправку або скасувати завислу операцію.</string>
<string name="send_nonce_hint">Введіть nonce…</string>
<string name="send_notification_exceed_balance_text">Недостатньо коштів для здійснення переказу, оскільки загальна сума комісії та переказу перевищує наявний баланс</string>
<string name="send_notification_exceed_balance_title">Сума перевищує баланс</string>
<string name="send_notification_existential_deposit_text">Для збереження вашого акаунту у блокчейні та захисту від можливих ризиків необхідний баланс не менше %s. Ця сума залишиться на вашому рахунку та не може бути знята.</string>
@ -837,6 +844,7 @@
<string name="send_notification_fee_too_high_title">Встановлена комісія завелика</string>
<string name="send_notification_high_fee_text">Через особливості мережі %1$s комісія за переказ всього балансу вища. Щоб зменшити комісію, Ви можете залишити %2$s.</string>
<string name="send_notification_high_fee_title">Підвищена комісія</string>
<string name="send_notification_invalid_amount_rent_destination">Рахунок одержувача не активований. Мінімальна сума переказу повинна бути не менша балансу, необхідного для покриття арендної плати: %1$s.</string>
<string name="send_notification_invalid_amount_rent_fee">Баланс вашого рахунку не може бути меншим за орендну плату. Будь ласка, залиште на рахунку не менше %1$s або виведіть всі кошти.</string>
<string name="send_notification_invalid_amount_text">Включена комісія перевищує суму переказу, що призводить до від’ємного значення</string>
<string name="send_notification_invalid_amount_title">Недопустима сума</string>
@ -1095,6 +1103,8 @@
<string name="transaction_history_transaction_validator">валідатор: %s</string>
<string name="transfer_min_amount_error">Мінімум %s</string>
<string name="transfer_notification_invalid_minimum_transaction_amount_text">Мінімальна сума транзакції становить %1$s.</string>
<string name="tron_will_be_send_token_fee_description">Комісії мережі Tron для популярних токенів можуть бути вищими. Стейкінг TRX може допомогти знизити транзакційні витрати.</string>
<string name="tron_will_be_send_token_fee_title">Заощаджуйте на Tron комісіях</string>
<string name="try_to_load_data_again_button_title">Спробуйте знову</string>
<string name="twin_error_same_card">Ви відсканували одну й ту саму картку. Для створення twin-гаманця вам потрібно відсканувати картку з номером %d</string>
<string name="twin_error_wrong_twin">Ви відсканували не ту twin-картку. Будь ласка, спробуйте відсканувати іншу</string>
@ -1111,6 +1121,7 @@
<string name="unlock_wallet_description_full">Використовуйте %s або відскануйте картку/кільце, щоб отримати доступ до свого гаманця</string>
<string name="unsupported_wc_version">Не вдалося встановити з\'єднання: Цей dApp використовує Wallet Connect версії 1.0, яка не підтримується. Будь ласка, переконайтеся, що dApp підтримує Wallet Connect версії 2.0 для успішного підключення.</string>
<string name="user_push_notification_agreement_argument_one">Будьте в курсі останніх функцій та новин</string>
<string name="user_push_notification_agreement_argument_three">Отримуйте сповіщення про вхідні транзакції</string>
<string name="user_push_notification_agreement_argument_two">Дізнавайтеся першими про нові акції</string>
<string name="user_push_notification_agreement_header">Бажаєте використовувати Push-повідомлення?</string>
<string name="user_wallet_list_add_button">Додати новий гаманець</string>
@ -1122,6 +1133,7 @@
<string name="user_wallet_list_rename_popup_title">Перейменування гаманця</string>
<string name="user_wallet_list_unlock_all">Розблокувати все</string>
<string name="user_wallet_list_unlock_all_with">Розблокувати все з %s</string>
<string name="visa_onboarding_pin_not_accepted">PIN-код не прийнятий. Спробуйте ще раз або введіть інший код.</string>
<string name="wallet_balance_blockchain_unreachable_try_later">Блокчейн недоступний. Спробуйте пізніше</string>
<string name="wallet_balance_missing_derivation">Відскануйте картку або кільце</string>
<string name="wallet_been_activated_message">Цей гаманець вже був активований раніше.\nЯкщо це було зроблено не вами, зверніться до служби підтримки.\nTangem ніколи не продає гаманці разом з попередньо згенерованим кодом доступу.</string>
@ -1165,6 +1177,7 @@
<string name="wallet_promo_banner_button_title">Отримати з 10% знижкою</string>
<string name="wallet_promo_banner_description">Доступ до 13,000+ криптовалют. Купуйте, продавайте, обмінюйте та стейкайте одним дотиком.\nЗєднайте до трьох карток для бекапу.</string>
<string name="wallet_promo_banner_title">Відкрийте Tangem Wallet</string>
<string name="wallet_settings_push_notifications_description">Отримуйте сповіщення про вхідні транзакції в гаманці та оновлення Tangem.</string>
<string name="wallet_settings_push_notifications_title">Сповіщення про транзакції</string>
<string name="wallet_settings_title">Налаштування гаманця</string>
<string name="wallet_title">Tangem</string>
@ -1193,7 +1206,7 @@
<string name="warning_express_approval_in_progress_message">Дозвіл обміну триває і незабаром буде завершено</string>
<string name="warning_express_approval_in_progress_title">Затвердження в процесі</string>
<string name="warning_express_dust_message">Мінімальна сума обміну становить - %1$s. Будь ласка, переконайтеся, що залишок на рахунку після обміну буде не менше за %2$s.</string>
<string name="warning_express_no_exchangeable_coins_description">У вашому списку немає доступних монет для обміну %s</string>
<string name="warning_express_no_exchangeable_coins_description">У вашому портфелі немає токенів, на які можна обміняти %s. Будь ласка, додайте інший токен, щоб виконати обмін.</string>
<string name="warning_express_no_exchangeable_coins_title">Немає доступних токенів для обміну</string>
<string name="warning_express_not_enough_fee_for_token_tx_description">Щоб здійснити транзакцію, вам потрібно внести трохи %1$s %2$s</string>
<string name="warning_express_not_enough_fee_for_token_tx_title">Неможливо покрити комісію %s</string>
@ -1257,8 +1270,23 @@
<string name="warning_testnet_card_message">Це картка Testnet. Вона не може обробляти транзакції і повинна використовуватися лише для тестування та розробки.</string>
<string name="warning_testnet_card_title">Лише для цілей тестування</string>
<string name="warning_token_balance_not_updated">Баланс може бути застарілим. Оновіть сторінку.</string>
<string name="warning_token_trustline_button_title">Відкрити Trustline</string>
<string name="warning_token_trustline_subtitle">Відповідно до правил мережі, щоб поповнити цей токен, спочатку потрібно відкрити Trustline — це дозволить вашому гаманцю приймати та зберігати цей актив.</string>
<string name="warning_token_trustline_title">Відкрийте Trustline</string>
<string name="wc_alert_session_disconnected_description">Будь ласка, поверніться до браузеру і повторно підключіться через WalletConnect.</string>
<string name="wc_alert_wrong_card_description">Обрана не вірна картка або кільце</string>
<string name="wc_common_address">Адреса</string>
<string name="wc_common_connect">Підключення</string>
<string name="wc_connection_reqeust_can_view_balance">Переглянути баланс гаманця та активність</string>
<string name="wc_connection_request">Запит на підключення</string>
<string name="wc_contents">Вміст</string>
<string name="wc_copy_data_button_text">Копіювати дані</string>
<string name="wc_request_from">Запит від</string>
<string name="wc_signature_type">Тип підпису</string>
<string name="wc_transaction_info_to_title">До</string>
<string name="wc_transaction_request">Запит транзакції</string>
<string name="wc_transaction_request_title">Запит транзакції</string>
<string name="wc_wallet_connect">Підключення гаманця</string>
<string name="welcome_interrupted_backup_alert_discard">Відмовитися</string>
<string name="welcome_interrupted_backup_alert_message">Ви не завершили резервне копіювання. Бажаєте продовжити?</string>
<string name="welcome_interrupted_backup_alert_resume">Так, поновити</string>

View file

@ -4,6 +4,9 @@
<string name="access_code_alert_skip_ok">Skip anyway</string>
<string name="access_code_alert_skip_title">Access Code not set</string>
<string name="access_code_check_title">Enter Access Code</string>
<string name="access_code_check_warining_delete">Wrong access code. Your hot wallet will be deleted after %s more incorrect attempts.</string>
<string name="access_code_check_warining_lock">Wrong access code. App will be locked with %s more input errors</string>
<string name="access_code_check_warining_wait">Wrong access code.\nPlease wait %s seconds and try again.</string>
<string name="access_code_confirm_description">Confirm your previously entered code to continue</string>
<string name="access_code_confirm_title">Re-enter Access Code</string>
<string name="access_code_create_description">Set a %s-digit Access Code to unlock your wallet.</string>
@ -58,6 +61,9 @@
<string name="app_settings_theme_mode_system">System default</string>
<string name="app_settings_theme_selector_title">Theme</string>
<string name="app_settings_title">App settings</string>
<string name="auth_info_add_wallet_title">Add Wallet</string>
<string name="auth_info_subtitle">Select a wallet to log in</string>
<string name="auth_info_title">Welcome back!</string>
<string name="backup_complete_description">You successfully backed up your wallet.</string>
<string name="backup_complete_seed_description">These words cant be recovered if lost. Make sure to keep it somewhere secure.</string>
<string name="backup_complete_title">Backup Completed</string>
@ -432,6 +438,7 @@
<string name="hw_create_seed_description">Stay up to date with the latest features and news</string>
<string name="hw_create_seed_title">Seed phrase backup</string>
<string name="hw_create_title">Create Mobile Wallet</string>
<string name="hw_mobile_wallet">Mobile Wallet</string>
<string name="information_generated_with_ai">This information was generated with AI.\nTap here, if you find any errors.</string>
<string name="initial_message_change_access_code_body">To change the access code tap the card or ring as shown above and do not remove until the end of the operation</string>
<string name="initial_message_change_passcode_body">To change the passcode tap the card as shown above and do not remove until the end of the operation</string>
@ -666,7 +673,7 @@
<string name="onboarding_button_continue_wallet">Continue to my wallet</string>
<string name="onboarding_button_finalize_backup">Finalize backup</string>
<string name="onboarding_button_receive_crypto">Receive crypto</string>
<string name="onboarding_button_scan_origin_card">Scan primary card</string>
<string name="onboarding_button_scan_origin_card">Scan primary card or ring</string>
<string name="onboarding_button_skip_backup">Skip for later</string>
<string name="onboarding_button_what_does_it_mean">How does it work?</string>
<string name="onboarding_create_wallet_body">Let\'s generate all the keys on your card or ring and create a secure wallet</string>
@ -719,7 +726,7 @@
<string name="onboarding_title_no_backup_cards">No backup devices</string>
<string name="onboarding_title_notifications">Notifications</string>
<string name="onboarding_title_one_backup_card">One backup device added</string>
<string name="onboarding_title_scan_origin_card">Prepare your card</string>
<string name="onboarding_title_scan_origin_card">Prepare your card or ring</string>
<string name="onboarding_title_two_backup_cards">Two backup devices added</string>
<string name="onboarding_top_up_body">To get started, simply top up the wallet with any amount</string>
<string name="onboarding_top_up_body_no_account_error">To get started, simply top up the wallet with more than %1$s %2$s</string>
@ -1360,8 +1367,8 @@
<string name="warning_express_approval_in_progress_message">Swap approval is underway and will be completed shortly</string>
<string name="warning_express_approval_in_progress_title">Approval in progress</string>
<string name="warning_express_dust_message">The minimum swapping amount is %1$s. Please ensure that the remaining balance after the swap will not be less than %2$s.</string>
<string name="warning_express_no_exchangeable_coins_description">You do not have any %s exchangeable coins in your list</string>
<string name="warning_express_no_exchangeable_coins_title">No available tokens to swap</string>
<string name="warning_express_no_exchangeable_coins_description">You dont have any tokens in your portfolio that %s can be swapped to. Please add another token to enable the exchange.</string>
<string name="warning_express_no_exchangeable_coins_title">No compatible tokens added</string>
<string name="warning_express_not_enough_fee_for_token_tx_description">To make a transaction you need to deposit some %1$s %2$s</string>
<string name="warning_express_not_enough_fee_for_token_tx_title">Unable to cover %s fee</string>
<string name="warning_express_notification_invalid_reserve_amount_title">The amount to receive must be at least %s</string>

View file

@ -3,10 +3,7 @@ package com.tangem.core.ui.components.bottomsheets.modal
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -20,6 +17,7 @@ import com.tangem.core.ui.components.buttons.small.TangemIconButton
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@ -30,6 +28,7 @@ import com.tangem.core.ui.res.TangemThemePreview
fun TangemModalBottomSheetTitle(
modifier: Modifier = Modifier,
title: TextReference? = null,
subtitle: TextReference? = null,
@DrawableRes startIconRes: Int? = null,
onStartClick: (() -> Unit)? = null,
@DrawableRes endIconRes: Int? = null,
@ -49,13 +48,23 @@ fun TangemModalBottomSheetTitle(
.align(Alignment.CenterStart),
)
}
if (title != null) {
Text(
text = title.resolveReference(),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.align(Alignment.Center),
)
Column(modifier = Modifier.align(Alignment.Center)) {
if (title != null) {
Text(
text = title.resolveReference(),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
if (subtitle != null) {
Text(
text = subtitle.resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
}
if (endIconRes != null && onEndClick != null) {
TangemIconButton(
@ -79,6 +88,7 @@ private fun Preview_TangemModalBottomSheetTitle(
TangemThemePreview {
TangemModalBottomSheetTitle(
title = params.title,
subtitle = params.subtitle,
startIconRes = params.startIconRes,
onStartClick = params.onStartClick,
endIconRes = params.endIconRes,
@ -90,6 +100,7 @@ private fun Preview_TangemModalBottomSheetTitle(
private data class TangemModalBottomSheetTitleData(
val title: TextReference?,
val subtitle: TextReference?,
val startIconRes: Int?,
val onStartClick: (() -> Unit)?,
val endIconRes: Int?,
@ -104,6 +115,7 @@ private class TangemModalBottomSheetTitleProvider : PreviewParameterProvider<Tan
onStartClick = {},
endIconRes = null,
onEndClick = null,
subtitle = null,
),
TangemModalBottomSheetTitleData(
title = resourceReference(R.string.wallet_title),
@ -111,6 +123,7 @@ private class TangemModalBottomSheetTitleProvider : PreviewParameterProvider<Tan
onStartClick = null,
endIconRes = R.drawable.ic_close_24,
onEndClick = {},
subtitle = null,
),
TangemModalBottomSheetTitleData(
title = resourceReference(R.string.wallet_title),
@ -118,6 +131,23 @@ private class TangemModalBottomSheetTitleProvider : PreviewParameterProvider<Tan
onStartClick = {},
endIconRes = R.drawable.ic_close_24,
onEndClick = {},
subtitle = null,
),
TangemModalBottomSheetTitleData(
title = resourceReference(R.string.wallet_title),
subtitle = stringReference("Today • 2:37 PM"),
startIconRes = R.drawable.ic_back_24,
onStartClick = {},
endIconRes = R.drawable.ic_close_24,
onEndClick = {},
),
TangemModalBottomSheetTitleData(
title = null,
subtitle = stringReference("Today • 2:37 PM"),
startIconRes = R.drawable.ic_back_24,
onStartClick = {},
endIconRes = R.drawable.ic_close_24,
onEndClick = {},
),
TangemModalBottomSheetTitleData(
title = null,
@ -125,6 +155,7 @@ private class TangemModalBottomSheetTitleProvider : PreviewParameterProvider<Tan
onStartClick = {},
endIconRes = R.drawable.ic_close_24,
onEndClick = {},
subtitle = null,
),
)
}

View file

@ -107,7 +107,7 @@ private fun CellDecoration(
Box(
modifier = Modifier
.background(
color = TangemTheme.colors.background.tertiary,
color = TangemTheme.colors.field.primary,
shape = RoundedCornerShape(8.dp),
),
contentAlignment = Alignment.Center,

View file

@ -5,26 +5,32 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
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.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.VisualTransformation
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.components.inputrow.inner.DividerContainer
import com.tangem.core.ui.components.fields.SimpleTextField
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
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.TangemThemePreview
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
/**
* [InputRowEnter](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-799&mode=design&t=IQ5lBJEkFGU4WSvi-4)
@ -51,6 +57,7 @@ fun InputRowEnter(
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: TextReference? = null,
description: TextReference? = null,
titleColor: Color = TangemTheme.colors.text.secondary,
textColor: Color = TangemTheme.colors.text.primary1,
isSingleLine: Boolean = false,
@ -71,11 +78,32 @@ fun InputRowEnter(
.padding(TangemTheme.dimens.spacing12),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = titleColor,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = titleColor,
)
if (description != null) {
TangemTooltip(
modifier = Modifier
.size(16.dp)
.clip(CircleShape),
text = description.resolveReference(),
content = { contentModifier ->
Icon(
modifier = contentModifier.size(16.dp),
painter = painterResource(R.drawable.ic_token_info_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
},
)
}
}
SimpleTextField(
value = text,
onValueChange = onValueChange,
@ -121,6 +149,7 @@ private fun InputRowEnterPreview(
text = data.text,
iconRes = data.iconRes,
showDivider = data.showDivider,
description = stringReference(""),
onValueChange = {},
modifier = Modifier.background(TangemTheme.colors.background.action),
)

View file

@ -1,6 +1,7 @@
package com.tangem.core.ui.components.inputrow
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
@ -8,6 +9,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
@ -15,6 +17,7 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.components.fields.AmountTextField
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
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
@ -118,6 +121,7 @@ fun InputRowEnterInfoAmountV2(
modifier: Modifier = Modifier,
symbol: String? = null,
info: TextReference? = null,
description: TextReference? = null,
titleColor: Color = TangemTheme.colors.text.secondary,
textColor: Color = TangemTheme.colors.text.primary1,
infoColor: Color = TangemTheme.colors.text.tertiary,
@ -133,7 +137,9 @@ fun InputRowEnterInfoAmountV2(
paddingValues = PaddingValues(horizontal = 12.dp),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
@ -141,12 +147,22 @@ fun InputRowEnterInfoAmountV2(
style = TangemTheme.typography.subtitle2,
color = titleColor,
)
Icon(
modifier = Modifier.size(16.dp),
painter = painterResource(R.drawable.ic_token_info_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
if (description != null) {
TangemTooltip(
modifier = Modifier
.size(16.dp)
.clip(CircleShape),
text = description.resolveReference(),
content = { contentModifier ->
Icon(
modifier = contentModifier.size(16.dp),
painter = painterResource(R.drawable.ic_token_info_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
},
)
}
}
Row {
AmountTextField(

View file

@ -0,0 +1,86 @@
package com.tangem.core.ui.components.tooltip
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.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.clickableSingle
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TangemTooltip(text: String, content: @Composable (Modifier) -> Unit, modifier: Modifier = Modifier) {
val tooltipState = rememberTooltipState(isPersistent = true)
val coroutineScope = rememberCoroutineScope()
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(spacingBetweenTooltipAndAnchor = 8.dp),
state = tooltipState,
modifier = modifier,
tooltip = {
PlainTooltip(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
caretSize = DpSize(width = 14.dp, height = 8.dp),
contentColor = TangemTheme.colors.text.primary2,
containerColor = TangemTheme.colors.icon.secondary,
content = {
Text(
modifier = Modifier.background(TangemTheme.colors.icon.secondary),
text = text,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary2,
)
},
)
},
content = {
content(
Modifier.clickableSingle(
onClick = { coroutineScope.launch { tooltipState.show() } },
),
)
},
)
}
@Preview
@Composable
private fun TangemTooltip_Preview() {
TangemThemePreview {
Box(
modifier = Modifier
.size(500.dp)
.background(TangemTheme.colors.background.secondary),
contentAlignment = Alignment.Center,
) {
TangemTooltip(
modifier = Modifier
.background(TangemTheme.colors.background.secondary)
.size(64.dp),
text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed venenatis.",
content = { contentModifier ->
Icon(
modifier = contentModifier.size(64.dp),
painter = painterResource(R.drawable.ic_token_info_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
},
)
}
}
}