Updated on 2026-08-14

This commit is contained in:
Tangem 2024-10-29 14:31:53 +03:00
commit 652159dbce
316 changed files with 6242 additions and 3084 deletions

View file

@ -22,6 +22,9 @@ android {
resources.excludes.add("META-INF/LICENSE.md")
resources.excludes.add("META-INF/NOTICE.md")
}
androidResources {
generateLocaleConfig = true
}
}
configurations.all {
@ -143,6 +146,10 @@ dependencies {
implementation(projects.features.walletSettings.impl)
implementation(projects.features.markets.api)
implementation(projects.features.markets.impl)
implementation(projects.features.onramp.api)
implementation(projects.features.onramp.impl)
implementation(projects.features.onboardingV2.api)
implementation(projects.features.onboardingV2.impl)
/** AndroidX libraries */
implementation(deps.androidx.core.ktx)

View file

@ -1,16 +1,22 @@
package com.tangem.common
import android.Manifest
import android.content.Context
import android.util.Log
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.espresso.intent.Intents
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.kaspersky.components.composesupport.config.withComposeSupport
import com.kaspersky.kaspresso.kaspresso.Kaspresso
import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.tap.MainActivity
import com.tangem.tap.domain.sdk.TangemSdkManager
import dagger.hilt.android.testing.HiltAndroidRule
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.rules.RuleChain
import org.junit.runner.RunWith
@ -24,6 +30,9 @@ abstract class BaseTestCase : TestCase(
@Inject
lateinit var tangemSdkManager: TangemSdkManager
@Inject
lateinit var appPreferencesStore: AppPreferencesStore
@get:Rule
open val composeTestRule = createAndroidComposeRule<MainActivity>()
@ -48,9 +57,22 @@ abstract class BaseTestCase : TestCase(
hiltRule.inject()
Intents.init()
additionalBeforeSection()
runBlocking {
delay(INIT_DELAY)
}
}.after {
runBlocking {
appPreferencesStore.editData { prefs -> prefs.clear() }
}
additionalAfterSection()
Intents.release()
}
companion object {
private const val INIT_DELAY = 1000L
}
}

View file

@ -143,9 +143,6 @@ class DetailsScreenTest : BaseTestCase() {
}
}
ComposeScreen.onComposeScreen<WalletSettingsTestScreen>(composeTestRule) {
step("Assert Link more cards button does not exist") {
linkMoreCardsButton.assertIsNotDisplayed()
}
step("Assert Card Settings button is visible") {
cardSettingsButton.assertIsDisplayed()
}

View file

@ -1,6 +1,7 @@
package com.tangem.tests
import android.content.Intent.ACTION_VIEW
import androidx.test.espresso.intent.matcher.UriMatchers
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.screens.DisclaimerTestScreen
@ -9,6 +10,7 @@ import com.tangem.tap.features.home.redux.HomeMiddleware.NEW_BUY_WALLET_URL
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.kakao.intent.KIntent
import org.hamcrest.Matchers
import org.junit.Test
@HiltAndroidTest
@ -29,7 +31,7 @@ class StoriesTest : BaseTestCase() {
step("Assert: browser opened") {
val expectedIntent = KIntent {
hasAction(ACTION_VIEW)
hasData(NEW_BUY_WALLET_URL)
hasData { toString().startsWith(NEW_BUY_WALLET_URL) }
}
expectedIntent.intended()
device.uiDevice.pressBack()

View file

@ -748,6 +748,16 @@
"networkId": "core/test"
}
]
},
{
"id": "casper-network",
"name": "Casper",
"symbol": "CSPR",
"networks": [
{
"networkId": "casper-network/test"
}
]
}
]
}

View file

@ -8,9 +8,9 @@ import com.tangem.core.analytics.filter.OneTimeEventFilter
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.connection.NetworkConnectionManager
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.apptheme.GetAppThemeModeUseCase
@ -19,21 +19,18 @@ import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository
import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.feedback.GetCardInfoUseCase
import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase
import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.features.onramp.OnrampFeatureToggles
import com.tangem.tap.common.log.TangemAppLoggerInitializer
import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles
import com.tangem.tap.domain.walletconnect2.domain.WalletConnectSessionsRepository
import com.tangem.tap.features.home.featuretoggles.HomeFeatureToggles
import com.tangem.tap.proxy.AppStateHolder
import dagger.hilt.EntryPoint
@ -50,7 +47,7 @@ interface ApplicationEntryPoint {
fun getAppStateHolder(): AppStateHolder
fun getAssetLoader(): AssetLoader
fun getIssuersConfigStorage(): IssuersConfigStorage
fun getFeatureTogglesManager(): FeatureTogglesManager
@ -60,18 +57,12 @@ interface ApplicationEntryPoint {
fun getWalletConnect2Repository(): WalletConnect2Repository
fun getWalletConnectSessionsRepository(): WalletConnectSessionsRepository
fun getScanCardProcessor(): ScanCardProcessor
fun getAppCurrencyRepository(): AppCurrencyRepository
fun getWalletManagersFacade(): WalletManagersFacade
fun getNetworksRepository(): NetworksRepository
fun getCurrenciesRepository(): CurrenciesRepository
fun getAppThemeModeRepository(): AppThemeModeRepository
fun getBalanceHidingRepository(): BalanceHidingRepository
@ -102,8 +93,6 @@ interface ApplicationEntryPoint {
fun getSendFeedbackEmailUseCase(): SendFeedbackEmailUseCase
fun getSaveBlockchainErrorUseCase(): SaveBlockchainErrorUseCase
fun getGetCardInfoUseCase(): GetCardInfoUseCase
fun getUrlOpener(): UrlOpener
@ -119,4 +108,6 @@ interface ApplicationEntryPoint {
fun getHomeFeatureToggles(): HomeFeatureToggles
fun getGetUserCountryCodeUseCase(): GetUserCountryUseCase
fun getOnrampFeatureToggles(): OnrampFeatureToggles
}

View file

@ -56,7 +56,6 @@ import com.tangem.features.pushnotifications.api.navigation.PushNotificationsRou
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.staking.api.navigation.StakingRouter
import com.tangem.features.tester.api.TesterRouter
import com.tangem.features.tokendetails.navigation.TokenDetailsRouter
import com.tangem.features.wallet.navigation.WalletRouter
import com.tangem.operations.backup.BackupService
@ -113,9 +112,6 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
lateinit var appStateHolder: AppStateHolder
/** Router for opening tester menu */
@Inject
lateinit var testerRouter: TesterRouter
@Inject
lateinit var cardSdkOwner: CardSdkOwner
@ -309,17 +305,9 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
store.dispatch(
DaggerGraphAction.SetActivityDependencies(
testerRouter = testerRouter,
scanCardUseCase = scanCardUseCase,
walletRouter = walletRouter,
walletConnectInteractor = walletConnectInteractor,
tokenDetailsRouter = tokenDetailsRouter,
cardSdkConfigRepository = cardSdkConfigRepository,
sendRouter = sendRouter,
qrScanningRouter = qrScanningRouter,
emailSender = emailSender,
stakingRouter = stakingRouter,
pushNotificationsRouter = pushNotificationsRouter,
),
)
}

View file

@ -20,10 +20,10 @@ import com.tangem.core.analytics.models.Basic.TransactionSent.WalletForm
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.datasource.api.common.MoshiConverter
import com.tangem.datasource.api.common.createNetworkLoggingInterceptor
import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.connection.NetworkConnectionManager
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.apptheme.GetAppThemeModeUseCase
@ -33,18 +33,16 @@ import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.common.LogConfig
import com.tangem.domain.feedback.GetCardInfoUseCase
import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase
import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.features.onramp.OnrampFeatureToggles
import com.tangem.tap.common.analytics.AnalyticsFactory
import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder
import com.tangem.tap.common.analytics.handlers.amplitude.AmplitudeAnalyticsHandler
@ -53,10 +51,8 @@ import com.tangem.tap.common.images.createCoilImageLoader
import com.tangem.tap.common.log.TangemAppLoggerInitializer
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.appReducer
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles
import com.tangem.tap.domain.tasks.product.DerivationsFinder
import com.tangem.tap.domain.walletconnect2.domain.WalletConnectSessionsRepository
import com.tangem.tap.features.home.featuretoggles.HomeFeatureToggles
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.tap.proxy.redux.DaggerGraphState
@ -85,8 +81,8 @@ abstract class TangemApplication : Application(), ImageLoaderFactory {
private val environmentConfigStorage: EnvironmentConfigStorage
get() = entryPoint.getEnvironmentConfigStorage()
private val assetLoader: AssetLoader
get() = entryPoint.getAssetLoader()
private val issuersConfigStorage: IssuersConfigStorage
get() = entryPoint.getIssuersConfigStorage()
private val featureTogglesManager: FeatureTogglesManager
get() = entryPoint.getFeatureTogglesManager()
@ -100,9 +96,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory {
private val walletConnect2Repository: WalletConnect2Repository
get() = entryPoint.getWalletConnect2Repository()
private val walletConnectSessionsRepository: WalletConnectSessionsRepository
get() = entryPoint.getWalletConnectSessionsRepository()
private val scanCardProcessor: ScanCardProcessor
get() = entryPoint.getScanCardProcessor()
@ -112,12 +105,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory {
private val walletManagersFacade: WalletManagersFacade
get() = entryPoint.getWalletManagersFacade()
private val networksRepository: NetworksRepository
get() = entryPoint.getNetworksRepository()
private val currenciesRepository: CurrenciesRepository
get() = entryPoint.getCurrenciesRepository()
private val appThemeModeRepository: AppThemeModeRepository
get() = entryPoint.getAppThemeModeRepository()
@ -163,9 +150,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory {
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase
get() = entryPoint.getSendFeedbackEmailUseCase()
private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase
get() = entryPoint.getSaveBlockchainErrorUseCase()
private val getCardInfoUseCase: GetCardInfoUseCase
get() = entryPoint.getGetCardInfoUseCase()
@ -189,6 +173,9 @@ abstract class TangemApplication : Application(), ImageLoaderFactory {
private val getUserCountryUseCase: GetUserCountryUseCase
get() = entryPoint.getGetUserCountryCodeUseCase()
private val onrampFeatureToggles: OnrampFeatureToggles
get() = entryPoint.getOnrampFeatureToggles()
// endregion
override fun onCreate() {
@ -211,10 +198,7 @@ abstract class TangemApplication : Application(), ImageLoaderFactory {
runBlocking {
featureTogglesManager.init()
val config = environmentConfigStorage.initialize()
store.dispatch(GlobalAction.SetConfigManager(environmentConfigStorage))
initWithConfigDependency(environmentConfig = config)
initWithConfigDependency(environmentConfig = environmentConfigStorage.initialize())
}
loadNativeLibraries()
@ -244,13 +228,10 @@ abstract class TangemApplication : Application(), ImageLoaderFactory {
networkConnectionManager = networkConnectionManager,
cardScanningFeatureToggles = cardScanningFeatureToggles,
walletConnectRepository = walletConnect2Repository,
walletConnectSessionsRepository = walletConnectSessionsRepository,
scanCardProcessor = scanCardProcessor,
appCurrencyRepository = appCurrencyRepository,
walletManagersFacade = walletManagersFacade,
appStateHolder = appStateHolder,
networksRepository = networksRepository,
currenciesRepository = currenciesRepository,
appThemeModeRepository = appThemeModeRepository,
balanceHidingRepository = balanceHidingRepository,
walletsRepository = walletsRepository,
@ -259,19 +240,19 @@ abstract class TangemApplication : Application(), ImageLoaderFactory {
saveTwinsOnboardingShownUseCase = saveTwinsOnboardingShownUseCase,
generateWalletNameUseCase = generateWalletNameUseCase,
cardRepository = cardRepository,
tangemSdkLogger = tangemSdkLogger,
settingsRepository = settingsRepository,
blockchainSDKFactory = blockchainSDKFactory,
saveBlockchainErrorUseCase = saveBlockchainErrorUseCase,
sendFeedbackEmailUseCase = sendFeedbackEmailUseCase,
getCardInfoUseCase = getCardInfoUseCase,
assetLoader = assetLoader,
issuersConfigStorage = issuersConfigStorage,
urlOpener = urlOpener,
shareManager = shareManager,
appRouter = appRouter,
transactionSignerFactory = transactionSignerFactory,
homeFeatureToggles = homeFeatureToggles,
getUserCountryUseCase = getUserCountryUseCase,
onrampFeatureToggles = onrampFeatureToggles,
environmentConfigStorage = environmentConfigStorage,
),
),
)

View file

@ -2,7 +2,6 @@ package com.tangem.tap.common.redux.global
import com.tangem.common.CompletionResult
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.redux.StateDialog
@ -74,8 +73,6 @@ sealed class GlobalAction : Action {
data class IsSignWithRing(val isSignWithRing: Boolean) : GlobalAction()
data class SetConfigManager(val environmentConfigStorage: EnvironmentConfigStorage) : GlobalAction()
object ExchangeManager : GlobalAction() {
object Init : GlobalAction() {
data class Success(

View file

@ -56,8 +56,7 @@ private fun handleAction(action: Action, appState: () -> AppState?) {
restoreAppCurrency()
}
is GlobalAction.ExchangeManager.Init -> {
val appStateSafe = appState() ?: return
val config = appStateSafe.globalState.environmentConfigStorage?.getConfigSync() ?: return
val config = store.inject(DaggerGraphState::environmentConfigStorage).getConfigSync()
scope.launch {
val scanResponseProvider: () -> ScanResponse? = {

View file

@ -47,9 +47,6 @@ fun globalReducer(action: Action, state: AppState, appStateHolder: AppStateHolde
is GlobalAction.RestoreAppCurrency.Success -> {
globalState.copy(appCurrency = action.appCurrency)
}
is GlobalAction.SetConfigManager -> {
globalState.copy(environmentConfigStorage = action.environmentConfigStorage)
}
is GlobalAction.UpdateWalletSignedHashes -> {
val card = globalState.scanResponse?.card ?: return globalState
val wallet = card.wallets

View file

@ -1,6 +1,5 @@
package com.tangem.tap.common.redux.global
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.models.scan.ScanResponse
@ -16,7 +15,6 @@ data class GlobalState(
val onboardingState: OnboardingState = OnboardingState(),
val cardVerifiedOnline: Boolean = false,
val tapWalletManager: TapWalletManager = TapWalletManager(),
val environmentConfigStorage: EnvironmentConfigStorage? = null,
val appCurrency: AppCurrency = AppCurrency.Default,
val scanCardFailsCounter: Int = 0,
val dialog: StateDialog? = null,

View file

@ -1,10 +1,7 @@
package com.tangem.tap.di.domain
import com.tangem.domain.staking.*
import com.tangem.domain.staking.repositories.StakingErrorResolver
import com.tangem.domain.staking.repositories.StakingPendingTransactionRepository
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.staking.repositories.StakingTransactionHashRepository
import com.tangem.domain.staking.repositories.*
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -51,6 +48,32 @@ internal object StakingDomainModule {
)
}
@Provides
@Singleton
fun provideFetchActionsUseCase(
stakingRepository: StakingRepository,
stakingActionRepository: StakingActionRepository,
stakingErrorResolver: StakingErrorResolver,
): FetchActionsUseCase {
return FetchActionsUseCase(
stakingRepository = stakingRepository,
stakingActionRepository = stakingActionRepository,
stakingErrorResolver = stakingErrorResolver,
)
}
@Provides
@Singleton
fun provideGetActionsUseCase(
stakingActionRepository: StakingActionRepository,
stakingErrorResolver: StakingErrorResolver,
): GetActionsUseCase {
return GetActionsUseCase(
stakingActionRepository = stakingActionRepository,
stakingErrorResolver = stakingErrorResolver,
)
}
@Provides
@Singleton
fun provideGetStakingTokensUseCase(
@ -123,38 +146,12 @@ internal object StakingDomainModule {
)
}
@Provides
@Singleton
fun provideSavePendingTransactionUseCase(
stakingPendingTransactionRepository: StakingPendingTransactionRepository,
stakingErrorResolver: StakingErrorResolver,
): SavePendingTransactionUseCase {
return SavePendingTransactionUseCase(
stakingPendingTransactionRepository = stakingPendingTransactionRepository,
stakingErrorResolver = stakingErrorResolver,
)
}
@Provides
@Singleton
fun provideInvalidatePendingTransactionsUseCase(
stakingPendingTransactionRepository: StakingPendingTransactionRepository,
stakingErrorResolver: StakingErrorResolver,
): InvalidatePendingTransactionsUseCase {
return InvalidatePendingTransactionsUseCase(
stakingPendingTransactionRepository = stakingPendingTransactionRepository,
stakingErrorResolver = stakingErrorResolver,
)
}
@Provides
@Singleton
fun provideGetPendingTransactionsUseCase(
stakingPendingTransactionRepository: StakingPendingTransactionRepository,
stakingErrorResolver: StakingErrorResolver,
): GetStakingPendingTransactionsUseCase {
return GetStakingPendingTransactionsUseCase(
stakingPendingTransactionRepository = stakingPendingTransactionRepository,
stakingErrorResolver = stakingErrorResolver,
)
}

View file

@ -7,7 +7,6 @@ import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.repository.*
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.features.markets.MarketsFeatureToggles
import com.tangem.features.staking.api.featuretoggles.StakingFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -68,17 +67,6 @@ internal object TokensDomainModule {
)
}
@Provides
@Singleton
fun provideGetCardTokensListUseCase(
currenciesRepository: CurrenciesRepository,
quotesRepository: QuotesRepository,
networksRepository: NetworksRepository,
stakingRepository: StakingRepository,
): GetNodlTokenListUseCase {
return GetNodlTokenListUseCase(currenciesRepository, quotesRepository, networksRepository, stakingRepository)
}
@Provides
@Singleton
fun provideRemoveCurrencyUseCase(
@ -234,7 +222,6 @@ internal object TokensDomainModule {
networksRepository: NetworksRepository,
stakingRepository: StakingRepository,
stakingFeatureToggles: StakingFeatureToggles,
marketsFeatureToggles: MarketsFeatureToggles,
dispatchers: CoroutineDispatcherProvider,
): GetCryptoCurrencyActionsUseCase {
return GetCryptoCurrencyActionsUseCase(
@ -246,7 +233,6 @@ internal object TokensDomainModule {
networksRepository = networksRepository,
stakingRepository = stakingRepository,
stakingFeatureToggles = stakingFeatureToggles,
marketsFeatureToggles = marketsFeatureToggles,
dispatchers = dispatchers,
)
}

View file

@ -13,17 +13,19 @@ import com.tangem.tap.common.extensions.inject
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.store
import com.tangem.tap.tangemSdkManager
import kotlinx.coroutines.flow.MutableStateFlow
class TwinCardsManager(card: CardDTO) {
private val issuersConfigStorage by lazy(mode = LazyThreadSafetyMode.NONE) {
store.inject(DaggerGraphState::issuersConfigStorage)
}
private val firstCardId: String = card.cardId
private val publicKey: String = card.issuer.publicKey.toHexString()
private var currentCardPublicKey: String? = null
private var secondCardPublicKey: String? = null
private val issuerKeyPairFlow = MutableStateFlow<KeyPair?>(value = null)
suspend fun createFirstWallet(message: Message): CompletionResult<CreateWalletResponse> {
val response = tangemSdkManager.createFirstTwinWallet(cardId = firstCardId, initialMessage = message)
@ -70,23 +72,11 @@ class TwinCardsManager(card: CardDTO) {
}
private suspend fun getIssuerKeys(): KeyPair {
issuerKeyPairFlow.value?.let { return it }
val assetLoader = store.inject(DaggerGraphState::assetLoader)
val issuer = assetLoader.loadList<Issuer>(fileName = ISSUERS_FILE_NAME)
.first { it.publicKey == publicKey }
val issuer = issuersConfigStorage.getConfig().first { it.publicKey == publicKey }
return KeyPair(
publicKey = issuer.publicKey.hexToBytes(),
privateKey = issuer.privateKey.hexToBytes(),
).also {
issuerKeyPairFlow.value = it
}
)
}
private companion object {
const val ISSUERS_FILE_NAME = "tangem-app-config/issuers"
}
}
private class Issuer(val privateKey: String, val publicKey: String)
}

View file

@ -4,6 +4,7 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.tap.domain.walletconnect2.domain.WcBlockchainHelper
import com.tangem.tap.domain.walletconnect2.domain.models.Account
import com.tangem.tap.domain.walletconnect2.toggles.WalletConnectFeatureToggles
internal class TangemWcBlockchainHelper(
@ -35,11 +36,12 @@ internal class TangemWcBlockchainHelper(
}
}
override fun networkIdToChainIdOrNull(networkId: String): String? {
override fun networkIdToChainIdOrNull(networkId: String): List<String> {
val blockchain = Blockchain.fromNetworkId(networkId)
val namespace = blockchain?.getCaip2Namespace() ?: return null
val chainId = blockchain.getCaip2ChainId() ?: return null
return "$namespace$CHAIN_SEPARATOR$chainId"
val namespace = blockchain?.getCaip2Namespace() ?: return emptyList()
return blockchain.getCaip2ChainIds().map {
"$namespace$CHAIN_SEPARATOR$it"
}
}
override fun getNamespaceFromFullChainIdOrNull(chainId: String): String? {
@ -52,8 +54,18 @@ internal class TangemWcBlockchainHelper(
return Blockchain.fromNetworkId(networkId)?.fullName
}
private fun Blockchain.getCaip2ChainId(): String? {
if (this.isEvm()) return this.getChainId()?.toString()
override fun chainIdsToAccounts(
walletAddress: String,
chainIds: List<String>,
derivationPath: String?,
): List<Account> {
return chainIds.map { chainId ->
Account(chainId, walletAddress, derivationPath)
}
}
private fun Blockchain.getCaip2ChainIds(): List<String> {
if (this.isEvm()) return listOfNotNull(this.getChainId()?.toString())
return when (this) {
/*
@ -61,13 +73,12 @@ internal class TangemWcBlockchainHelper(
* uncommented is used.
* Docs: https://docs.walletconnect.com/advanced/multichain/chain-list
*
* Blockchain.Solana -> "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
* */
Blockchain.Solana -> "4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ"
Blockchain.SolanaTestnet -> "z4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z"
Blockchain.Polkadot -> "91b171bb158e2d3848fa23a9f1c25182"
Blockchain.Tron -> "0x2b6653dc"
else -> null
Blockchain.Solana -> listOf("5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ")
Blockchain.SolanaTestnet -> listOf("z4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z")
Blockchain.Polkadot -> listOf("91b171bb158e2d3848fa23a9f1c25182")
Blockchain.Tron -> listOf("0x2b6653dc")
else -> emptyList()
}
}

View file

@ -102,7 +102,7 @@ class WalletConnectInteractor(
private suspend fun setupUserChains(userWallet: UserWallet, currencies: List<CryptoCurrency>) {
val accounts = getAccountsForWc(
userWallet = userWallet,
networks = currencies.map { it.network },
networks = currencies.map { it.network }.distinct(),
)
setUserChains(accounts)
handleDeeplinkStack(accounts)
@ -403,18 +403,14 @@ class WalletConnectInteractor(
derivationPath = it.derivationPath.value,
)
}
return walletManagers.mapNotNull {
return walletManagers.flatMap {
val wallet = it.wallet
val chainId = blockchainHelper.networkIdToChainIdOrNull(
wallet.blockchain.toNetworkId(),
val chainIds = blockchainHelper.networkIdToChainIdOrNull(wallet.blockchain.toNetworkId())
blockchainHelper.chainIdsToAccounts(
walletAddress = wallet.address,
chainIds = chainIds,
derivationPath = wallet.publicKey.derivationPath?.rawPath,
)
chainId?.let {
Account(
chainId,
wallet.address,
wallet.publicKey.derivationPath?.rawPath,
)
}
}
}

View file

@ -1,13 +1,17 @@
package com.tangem.tap.domain.walletconnect2.domain
import com.tangem.tap.domain.walletconnect2.domain.models.Account
interface WcBlockchainHelper {
fun chainIdToNetworkIdOrNull(chainId: String): String?
fun chainIdToMissingNetworkNameOrNull(chainId: String): String?
fun networkIdToChainIdOrNull(networkId: String): String?
fun networkIdToChainIdOrNull(networkId: String): List<String>
fun getNamespaceFromFullChainIdOrNull(chainId: String): String?
fun chainIdToFullNameOrNull(chainId: String): String?
fun chainIdsToAccounts(walletAddress: String, chainIds: List<String>, derivationPath: String?): List<Account>
}

View file

@ -223,14 +223,14 @@ internal class WcJrpcRequestsDeserializer @Inject constructor(@SdkMoshi private
WcRequest.AddChain(data = deserializedParams)
}
WcJrpcMethods.SOLANA_SIGN_TX -> {
val tx = moshi.adapter<SolanaTransactionRequest>(SolanaTransactionRequest::class.java)
val tx = moshi.adapter(SolanaTransactionRequest::class.java)
.fromJsonOrNull(params)
?: return customRequest
WcRequest.SolanaSignRequest(data = tx)
}
WcJrpcMethods.SOLANA_SIGN_MESSAGE -> {
val signMessage = moshi.adapter<SolanaSignMessage>(SolanaSignMessage::class.java)
val signMessage = moshi.adapter(SolanaSignMessage::class.java)
.fromJsonOrNull(params)
?: return customRequest
val data = WcSignMessage(

View file

@ -1,12 +1,12 @@
package com.tangem.tap.domain.walletconnect2.domain
import com.tangem.tap.domain.walletconnect.WalletConnectSdkHelper
import com.tangem.tap.domain.walletconnect2.domain.mapper.mapToTransaction
import com.tangem.tap.domain.walletconnect2.domain.models.BnbData
import com.tangem.tap.domain.walletconnect2.domain.models.EthTransactionData
import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectError
import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectEvents
import com.tangem.tap.features.details.redux.walletconnect.WcEthTransactionType
import okio.ByteString.Companion.decodeBase64
internal class WcSessionRequestConverter(
private val blockchainHelper: WcBlockchainHelper,
@ -115,11 +115,11 @@ internal class WcSessionRequestConverter(
)
}
is WcRequest.SolanaSignRequest -> {
val data = request.data.mapToTransaction()
val transaction = request.data.transaction
WcPreparedRequest.SignTransaction(
preparedRequestData = WcGenericTransactionData(
hashToSign = data.getSerializedMessage(),
hashToSign = transaction.prepareSolanaTransaction(),
dAppName = sessionRequest.metaName,
type = TransactionType.SOLANA_TX,
),
@ -132,6 +132,16 @@ internal class WcSessionRequestConverter(
}
}
/**
* Input transaction in Base64 string
*/
private fun String.prepareSolanaTransaction(): ByteArray {
return this.decodeBase64()
?.toByteArray()
?.drop(SOLANA_SIGNATURE_PLACEHOLDER_LENGTH)
?.toByteArray() ?: ByteArray(0)
}
private fun getWalletAddress(request: WcRequest): String? {
return when (request) {
is WcRequest.BnbTrade -> request.data.accountNumber
@ -156,4 +166,8 @@ internal class WcSessionRequestConverter(
it.chainId == sessionRequest.chainId && it.walletAddress.lowercase() == walletAddress?.lowercase()
}?.derivationPath
}
companion object {
private const val SOLANA_SIGNATURE_PLACEHOLDER_LENGTH = 65
}
}

View file

@ -14,6 +14,9 @@ data class SolanaTransactionRequest(
@Json(name = "instructions")
val instructions: List<Instruction>,
@Json(name = "transaction")
val transaction: String,
) : WcRequestData {
@JsonClass(generateAdapter = true)
@ -22,7 +25,7 @@ data class SolanaTransactionRequest(
val programId: String,
@Json(name = "data")
val data: List<Byte>,
val data: String,
@Json(name = "keys")
val keys: List<Key>,

View file

@ -13,7 +13,6 @@ import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository
import com.tangem.tap.domain.walletconnect2.domain.WalletConnectInteractor
import com.tangem.tap.domain.walletconnect2.domain.WcPreparedRequest
import com.tangem.tap.domain.walletconnect2.domain.models.Account
import com.tangem.tap.domain.walletconnect2.domain.models.WalletConnectError
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.proxy.redux.DaggerGraphState
@ -87,18 +86,16 @@ class WalletConnectMiddleware {
is WalletConnectAction.ApproveProposal -> {
scope.launch {
val accounts = getWalletManagers()
.mapNotNull {
.flatMap {
val wallet = it.wallet
val chainId = walletConnectInteractor.blockchainHelper.networkIdToChainIdOrNull(
val chainIds = walletConnectInteractor.blockchainHelper.networkIdToChainIdOrNull(
wallet.blockchain.toNetworkId(),
)
chainId?.let {
Account(
chainId,
wallet.address,
wallet.publicKey.derivationPath?.rawPath,
)
}
walletConnectInteractor.blockchainHelper.chainIdsToAccounts(
walletAddress = wallet.address,
chainIds = chainIds,
derivationPath = wallet.publicKey.derivationPath?.rawPath,
)
}
walletConnectInteractor.approveSessionProposal(accounts)
}

View file

@ -54,6 +54,15 @@ object TradeCryptoMiddleware {
}
private fun proceedBuyAction(state: () -> AppState?, action: TradeCryptoAction.Buy) {
val isOnrampEnabled = store.inject(DaggerGraphState::onrampFeatureToggles).isFeatureEnabled
if (isOnrampEnabled) proceedWithOnramp() else proceedWithLegacyBuyAction(state, action)
}
private fun proceedWithOnramp() {
store.dispatchNavigationAction { push(AppRoute.Onramp) }
}
private fun proceedWithLegacyBuyAction(state: () -> AppState?, action: TradeCryptoAction.Buy) {
val networkAddress = action.cryptoCurrencyStatus.value.networkAddress
?.defaultAddress
?.let(NetworkAddress.Address::value)

View file

@ -1,31 +1,15 @@
package com.tangem.tap.proxy.redux
import com.tangem.core.navigation.email.EmailSender
import com.tangem.domain.card.ScanCardUseCase
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.feature.qrscanning.QrScanningRouter
import com.tangem.features.pushnotifications.api.navigation.PushNotificationsRouter
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.staking.api.navigation.StakingRouter
import com.tangem.features.tester.api.TesterRouter
import com.tangem.features.tokendetails.navigation.TokenDetailsRouter
import com.tangem.features.wallet.navigation.WalletRouter
import com.tangem.tap.domain.walletconnect2.domain.WalletConnectInteractor
import org.rekotlin.Action
sealed interface DaggerGraphAction : Action {
data class SetActivityDependencies(
val testerRouter: TesterRouter,
val scanCardUseCase: ScanCardUseCase,
val walletRouter: WalletRouter,
val walletConnectInteractor: WalletConnectInteractor,
val tokenDetailsRouter: TokenDetailsRouter,
val cardSdkConfigRepository: CardSdkConfigRepository,
val sendRouter: SendRouter,
val qrScanningRouter: QrScanningRouter,
val emailSender: EmailSender,
val stakingRouter: StakingRouter,
val pushNotificationsRouter: PushNotificationsRouter,
) : DaggerGraphAction
}

View file

@ -13,17 +13,9 @@ object DaggerGraphReducer {
private fun internalReduce(action: DaggerGraphAction, state: AppState): DaggerGraphState {
return when (action) {
is DaggerGraphAction.SetActivityDependencies -> state.daggerGraphState.copy(
testerRouter = action.testerRouter,
scanCardUseCase = action.scanCardUseCase,
walletRouter = action.walletRouter,
walletConnectInteractor = action.walletConnectInteractor,
tokenDetailsRouter = action.tokenDetailsRouter,
cardSdkConfigRepository = action.cardSdkConfigRepository,
sendRouter = action.sendRouter,
qrScanningRouter = action.qrScanningRouter,
emailSender = action.emailSender,
stakingRouter = action.stakingRouter,
pushNotificationsRouter = action.pushNotificationsRouter,
)
}
}

View file

@ -1,14 +1,13 @@
package com.tangem.tap.proxy.redux
import com.tangem.TangemSdkLogger
import com.tangem.blockchainsdk.BlockchainSDKFactory
import com.tangem.blockchainsdk.signer.TransactionSignerFactory
import com.tangem.common.routing.AppRouter
import com.tangem.core.navigation.email.EmailSender
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.connection.NetworkConnectionManager
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.apptheme.repository.AppThemeModeRepository
import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository
@ -17,43 +16,31 @@ import com.tangem.domain.card.ScanCardUseCase
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.feedback.GetCardInfoUseCase
import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase
import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.feature.qrscanning.QrScanningRouter
import com.tangem.features.onramp.OnrampFeatureToggles
import com.tangem.features.pushnotifications.api.navigation.PushNotificationsRouter
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.staking.api.navigation.StakingRouter
import com.tangem.features.tester.api.TesterRouter
import com.tangem.features.tokendetails.navigation.TokenDetailsRouter
import com.tangem.features.wallet.navigation.WalletRouter
import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles
import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository
import com.tangem.tap.domain.walletconnect2.domain.WalletConnectInteractor
import com.tangem.tap.domain.walletconnect2.domain.WalletConnectSessionsRepository
import com.tangem.tap.features.home.featuretoggles.HomeFeatureToggles
import com.tangem.tap.proxy.AppStateHolder
import org.rekotlin.StateType
data class DaggerGraphState(
val testerRouter: TesterRouter? = null,
val networkConnectionManager: NetworkConnectionManager? = null,
val cardScanningFeatureToggles: CardScanningFeatureToggles? = null,
val scanCardUseCase: ScanCardUseCase? = null,
val walletRouter: WalletRouter? = null,
val walletConnectRepository: LegacyWalletConnectRepository? = null,
val walletConnectSessionsRepository: WalletConnectSessionsRepository? = null,
val walletConnectInteractor: WalletConnectInteractor? = null,
val tokenDetailsRouter: TokenDetailsRouter? = null,
val scanCardProcessor: ScanCardProcessor? = null,
val cardSdkConfigRepository: CardSdkConfigRepository? = null,
val appCurrencyRepository: AppCurrencyRepository? = null,
@ -62,23 +49,16 @@ data class DaggerGraphState(
val appThemeModeRepository: AppThemeModeRepository? = null,
val balanceHidingRepository: BalanceHidingRepository? = null,
val walletsRepository: WalletsRepository? = null,
val networksRepository: NetworksRepository? = null,
val sendRouter: SendRouter? = null,
val qrScanningRouter: QrScanningRouter? = null,
val currenciesRepository: CurrenciesRepository? = null,
val generalUserWalletsListManager: UserWalletsListManager? = null,
val wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase? = null,
val saveTwinsOnboardingShownUseCase: SaveTwinsOnboardingShownUseCase? = null,
val generateWalletNameUseCase: GenerateWalletNameUseCase? = null,
val cardRepository: CardRepository? = null,
val tangemSdkLogger: TangemSdkLogger? = null,
val settingsRepository: SettingsRepository? = null,
val blockchainSDKFactory: BlockchainSDKFactory? = null,
val emailSender: EmailSender? = null,
val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase? = null,
val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase? = null,
val getCardInfoUseCase: GetCardInfoUseCase? = null,
val assetLoader: AssetLoader? = null,
val issuersConfigStorage: IssuersConfigStorage? = null,
val stakingRouter: StakingRouter? = null,
val urlOpener: UrlOpener? = null,
val shareManager: ShareManager? = null,
@ -87,4 +67,6 @@ data class DaggerGraphState(
val transactionSignerFactory: TransactionSignerFactory? = null,
val homeFeatureToggles: HomeFeatureToggles? = null,
val getUserCountryUseCase: GetUserCountryUseCase? = null,
val onrampFeatureToggles: OnrampFeatureToggles? = null,
val environmentConfigStorage: EnvironmentConfigStorage? = null,
) : StateType

View file

@ -11,6 +11,8 @@ import com.tangem.features.disclaimer.api.components.DisclaimerComponent
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.onramp.component.BuyCryptoComponent
import com.tangem.features.onramp.component.OnrampComponent
import com.tangem.features.pushnotifications.api.navigation.PushNotificationsRouter
import com.tangem.features.send.api.navigation.SendRouter
import com.tangem.features.staking.api.navigation.StakingRouter
@ -46,6 +48,8 @@ internal class ChildFactory @Inject constructor(
private val disclaimerComponentFactory: DisclaimerComponent.Factory,
private val manageTokensComponentFactory: ManageTokensComponent.Factory,
private val marketsTokenDetailsComponentFactory: MarketsTokenDetailsComponent.Factory,
private val onrampComponentFactory: OnrampComponent.Factory,
private val buyCryptoComponentFactory: BuyCryptoComponent.Factory,
private val sendRouter: SendRouter,
private val tokenDetailsRouter: TokenDetailsRouter,
private val walletRouter: WalletRouter,
@ -186,6 +190,20 @@ internal class ChildFactory @Inject constructor(
componentFactory = marketsTokenDetailsComponentFactory,
)
}
is AppRoute.Onramp -> {
route.asComponentChild(
contextProvider = contextProvider(route, contextFactory),
params = OnrampComponent.Params(),
componentFactory = onrampComponentFactory,
)
}
is AppRoute.BuyCrypto -> {
route.asComponentChild(
contextProvider = contextProvider(route, contextFactory),
params = BuyCryptoComponent.Params(userWalletId = route.userWalletId),
componentFactory = buyCryptoComponentFactory,
)
}
}
}

View file

@ -0,0 +1 @@
unqualifiedResLocale=en-US

View file

@ -1,3 +1,5 @@
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
plugins {
alias(deps.plugins.kotlin.android) apply false
alias(deps.plugins.kotlin.jvm) apply false
@ -20,6 +22,16 @@ interface Injected {
val fs: FileSystemOperations
}
// Test Logging
subprojects {
tasks.withType<Test> {
testLogging {
exceptionFormat = TestExceptionFormat.FULL
showStandardStreams = true
}
}
}
val assembleInternalQA by tasks.registering {
group = "build"
description = "Builds internal APK to 'build/outputs' directory"

View file

@ -117,10 +117,6 @@ sealed class AppRoute(val path: String) : Route {
) : AppRoute(path = "/details/security"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
companion object {
const val USER_WALLET_ID_KEY = "userWalletId"
}
}
@Serializable
@ -288,4 +284,12 @@ sealed class AppRoute(val path: String) : Route {
val source: String,
)
}
@Serializable
data object Onramp : AppRoute(path = "/onramp")
@Serializable
data class BuyCrypto(
val userWalletId: UserWalletId,
) : AppRoute(path = "/buy_crypto/${userWalletId.stringValue}")
}

View file

@ -3,13 +3,14 @@ package com.tangem.common.ui.amountScreen.converters
import com.tangem.common.ui.R
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverter
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.AmountSegmentedButtonsConfig
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.BigDecimalFormatter.formatCryptoAmount
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.BigDecimalFormatter.formatFiatAmount
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
@ -49,7 +50,7 @@ class AmountStateConverter(
val appCurrency = appCurrencyProvider()
val status = cryptoCurrencyStatusProvider()
val fiat = formatFiatAmount(status.value.fiatAmount, appCurrency.code, appCurrency.symbol)
val crypto = formatCryptoAmount(status.value.amount, status.currency.symbol, status.currency.decimals)
val crypto = status.value.amount.format { crypto(status.currency) }
val noFeeRate = status.value.fiatRate.isNullOrZero()
return AmountState.Data(

View file

@ -19,16 +19,20 @@ import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData
import com.tangem.core.ui.components.ResizableText
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.format.bigdecimal.anyDecimals
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.BigDecimalFormatter
import java.math.BigDecimal
@Composable
fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDisabled: Boolean, onClick: () -> Unit) {
if (amountState !is AmountState.Data) return
val amount = amountState.amountTextField
val cryptoAmount = BigDecimalFormatter.formatWithSymbol(amount.value, amount.cryptoAmount.currencySymbol)
val cryptoAmount = formatWithSymbol(amount.value, amount.cryptoAmount.currencySymbol)
val fiatAmount = BigDecimalFormatter.formatFiatAmount(
fiatAmount = amount.fiatAmount.value,
fiatCurrencySymbol = amount.fiatAmount.currencySymbol,
@ -77,6 +81,9 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis
}
}
fun formatWithSymbol(amount: String, symbol: String) =
BigDecimal.ZERO.format { crypto(symbol, 0).anyDecimals() }.replace("0", amount)
// region Preview
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)

View file

@ -24,6 +24,8 @@ import com.tangem.core.ui.components.fields.AmountTextField
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.rememberDecimalFormat
@ -92,11 +94,7 @@ private fun AmountSecondary(amountField: AmountFieldModel, appCurrencyCode: Stri
),
) {
val text = if (amountField.isFiatValue) {
BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = secondaryAmount.value,
cryptoCurrency = secondaryAmount.currencySymbol,
decimals = secondaryAmount.decimals,
)
secondaryAmount.value.format { crypto(secondaryAmount.currencySymbol, secondaryAmount.decimals) }
} else {
BigDecimalFormatter.formatFiatAmount(
fiatAmount = secondaryAmount.value,

View file

@ -6,7 +6,9 @@ import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.shorted
import java.math.BigDecimal
sealed class NotificationUM(val config: NotificationConfig) {
@ -267,8 +269,8 @@ sealed class NotificationUM(val config: NotificationConfig) {
subtitle = resourceReference(
R.string.koinos_insufficient_mana_to_send_koin_description,
formatArgs = wrappedList(
BigDecimalFormatter.formatCryptoAmountShorted(mana, "", Blockchain.Koinos.decimals()),
BigDecimalFormatter.formatCryptoAmountShorted(maxMana, "", Blockchain.Koinos.decimals()),
mana.format { crypto("", Blockchain.Koinos.decimals()).shorted() },
maxMana.format { crypto("", Blockchain.Koinos.decimals()).shorted() },
),
),
)
@ -286,11 +288,9 @@ sealed class NotificationUM(val config: NotificationConfig) {
subtitle = resourceReference(
R.string.koinos_mana_exceeds_koin_balance_description,
formatArgs = wrappedList(
BigDecimalFormatter.formatCryptoAmount(
availableKoinForTransfer,
Blockchain.Koinos.currency,
Blockchain.Koinos.decimals(),
),
availableKoinForTransfer.format {
crypto(Blockchain.Koinos.currency, Blockchain.Koinos.decimals())
},
),
),
buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig(

View file

@ -6,7 +6,9 @@ import com.tangem.common.ui.R
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.amountScreen.utils.getFiatString
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.uncapped
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrency
@ -65,10 +67,9 @@ object NotificationsFactory {
if (!isAccountFunded && reserveAmount != null && reserveAmount > sendingAmount) {
add(
NotificationUM.Error.ReserveAmount(
BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = reserveAmount,
cryptoCurrency = cryptoCurrency,
),
reserveAmount.format {
crypto(cryptoCurrency)
},
),
)
}
@ -87,10 +88,7 @@ object NotificationsFactory {
NotificationUM.Error.TransactionLimitError(
cryptoCurrency = cryptoCurrency.name,
utxoLimit = utxoLimit.maxLimit.toPlainString(),
amountLimit = BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = utxoLimit.maxAmount,
cryptoCurrency = cryptoCurrency,
),
amountLimit = utxoLimit.maxAmount.format { crypto(cryptoCurrency) },
onConfirmClick = {
onReduceClick(
utxoLimit.maxAmount,
@ -124,10 +122,7 @@ object NotificationsFactory {
if (existentialDeposit != null && diff >= BigDecimal.ZERO && existentialDeposit > diff) {
add(
NotificationUM.Error.ExistentialDeposit(
deposit = BigDecimalFormatter.formatCryptoAmountUncapped(
cryptoAmount = existentialDeposit,
cryptoCurrency = cryptoCurrency,
),
deposit = existentialDeposit.format { crypto(cryptoCurrency).uncapped() },
onConfirmClick = {
onReduceClick(
existentialDeposit,
@ -155,10 +150,7 @@ object NotificationsFactory {
if (isFeeCoverage) {
add(
NotificationUM.Warning.FeeCoverageNotification(
cryptoAmount = BigDecimalFormatter.formatCryptoAmountUncapped(
cryptoAmount = cryptoDiff,
cryptoCurrency = cryptoCurrency,
),
cryptoAmount = cryptoDiff.format { crypto(cryptoCurrency).uncapped() },
fiatAmount = getFiatString(
value = cryptoDiff,
rate = fiatRate,

View file

@ -4,6 +4,9 @@ import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIco
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.staking.model.stakekit.YieldBalance
@ -80,7 +83,7 @@ class TokenItemStateConverter(
private fun CryptoCurrencyStatus.getFormattedAmount(): String {
val amount = value.amount?.plus(getStakedBalance()) ?: return DASH_SIGN
return BigDecimalFormatter.formatCryptoAmount(amount, currency.symbol, currency.decimals)
return amount.format { crypto(currency) }
}
private fun CryptoCurrencyStatus.getFormattedFiatAmount(): String {
@ -167,10 +170,7 @@ class TokenItemStateConverter(
return if (fiatRate != null && priceChange != null) {
TokenItemState.SubtitleState.CryptoPriceContent(
price = fiatRate.getFormattedCryptoPrice(appCurrency),
priceChangePercent = BigDecimalFormatter.formatPercent(
percent = priceChange,
useAbsoluteValue = true,
),
priceChangePercent = priceChange.format { percent() },
type = priceChange.getPriceChangeType(),
)
} else {

View file

@ -0,0 +1,178 @@
package com.tangem.datasource.api.onramp
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.onramp.models.common.OnrampDestinationDTO
import com.tangem.datasource.api.onramp.models.request.OnrampPairsRequest
import com.tangem.datasource.api.onramp.models.response.OnrampDataResponse
import com.tangem.datasource.api.onramp.models.response.OnrampQuoteResponse
import com.tangem.datasource.api.onramp.models.response.OnrampStatusResponse
import com.tangem.datasource.api.onramp.models.response.model.OnrampCountryDTO
import com.tangem.datasource.api.onramp.models.response.model.OnrampCurrencyDTO
import com.tangem.datasource.api.onramp.models.response.model.OnrampPairDTO
import com.tangem.datasource.api.onramp.models.response.model.PaymentMethodDTO
internal class MockedOnrampApi : OnrampApi {
override suspend fun getCurrencies(): ApiResponse<List<OnrampCurrencyDTO>> = ApiResponse.Success(
COUNTRIES.map(OnrampCountryDTO::defaultCurrency),
)
override suspend fun getCountries(): ApiResponse<List<OnrampCountryDTO>> = ApiResponse.Success(COUNTRIES + RUSSIA)
override suspend fun getCountryByIp(): ApiResponse<OnrampCountryDTO> = ApiResponse.Success(RUSSIA)
override suspend fun getPaymentMethods(): ApiResponse<List<PaymentMethodDTO>> = ApiResponse.Success(
listOf(
PaymentMethodDTO(id = "google", name = "Google Play", image = ""),
PaymentMethodDTO(id = "apple", name = "Apple Pay", image = ""),
PaymentMethodDTO(id = "card", name = "Card", image = ""),
),
)
override suspend fun getPairs(body: OnrampPairsRequest): ApiResponse<List<OnrampPairDTO>> = ApiResponse.Success(
listOf(
OnrampPairDTO(
fromCurrencyCode = "USD",
to = OnrampDestinationDTO(contractAddress = "0xcontract_address", network = "ethereum"),
providers = listOf(),
),
),
)
override suspend fun getQuote(
fromCurrencyCode: String,
toContractAddress: String,
toNetwork: String,
paymentMethod: String,
countryCode: String,
fromAmount: String,
toDecimals: Int,
providerId: String,
): ApiResponse<OnrampQuoteResponse> {
TODO("Not yet implemented")
}
override suspend fun getData(
fromCurrencyCode: String,
toContractAddress: String,
toNetwork: String,
paymentMethod: String,
countryCode: String,
fromAmount: String,
toDecimals: Int,
providerId: String,
toAddress: String,
redirectUrl: String,
language: String?,
theme: String?,
requestId: String,
): ApiResponse<OnrampDataResponse> {
TODO("Not yet implemented")
}
override suspend fun getStatus(txId: String): ApiResponse<OnrampStatusResponse> {
TODO("Not yet implemented")
}
private companion object {
private val RUSSIA = OnrampCountryDTO(
name = "Russia",
code = "RU",
image = "https://hatscripts.github.io/circle-flags/flags/ru.svg",
alpha3 = "RUS",
continent = "",
defaultCurrency = OnrampCurrencyDTO(
name = "Russian ruble",
code = "RUB",
image = "https://hatscripts.github.io/circle-flags/flags/ru.svg",
precision = 2,
),
onrampAvailable = false,
)
private val COUNTRIES = listOf(
OnrampCountryDTO(
name = "United States of America",
code = "USA",
image = "https://hatscripts.github.io/circle-flags/flags/us.svg",
alpha3 = "USA",
continent = "",
defaultCurrency = OnrampCurrencyDTO(
name = "US Dollar",
code = "USD",
image = "https://hatscripts.github.io/circle-flags/flags/us.svg",
precision = 2,
),
onrampAvailable = true,
),
OnrampCountryDTO(
name = "Europe Union",
code = "EU",
image = "https://hatscripts.github.io/circle-flags/flags/eu.svg",
alpha3 = "EUR",
continent = "",
defaultCurrency = OnrampCurrencyDTO(
name = "Euro",
code = "EUR",
image = "https://hatscripts.github.io/circle-flags/flags/eu.svg",
precision = 2,
),
onrampAvailable = true,
),
OnrampCountryDTO(
name = "Great Britain",
code = "GB",
image = "https://hatscripts.github.io/circle-flags/flags/gb.svg",
alpha3 = "GB",
continent = "",
defaultCurrency = OnrampCurrencyDTO(
name = "British Pound Sterling",
code = "GBP",
image = "https://hatscripts.github.io/circle-flags/flags/gb.svg",
precision = 2,
),
onrampAvailable = true,
),
OnrampCountryDTO(
name = "CANADA",
code = "CA",
image = "https://hatscripts.github.io/circle-flags/flags/ca.svg",
alpha3 = "CA",
continent = "",
defaultCurrency = OnrampCurrencyDTO(
name = "Canadian Dollar",
code = "CAD",
image = "https://hatscripts.github.io/circle-flags/flags/ca.svg",
precision = 2,
),
onrampAvailable = true,
),
OnrampCountryDTO(
name = "Hon Kong",
code = "HK",
image = "https://hatscripts.github.io/circle-flags/flags/hk.svg",
alpha3 = "HK",
continent = "",
defaultCurrency = OnrampCurrencyDTO(
name = "Hon Kong Dollar",
code = "HKD",
image = "https://hatscripts.github.io/circle-flags/flags/hk.svg",
precision = 2,
),
onrampAvailable = true,
),
OnrampCountryDTO(
name = "Australia",
code = "AU",
image = "https://hatscripts.github.io/circle-flags/flags/au.svg",
alpha3 = "AU",
continent = "",
defaultCurrency = OnrampCurrencyDTO(
name = "Australian Dollar",
code = "AUD",
image = "https://hatscripts.github.io/circle-flags/flags/au.svg",
precision = 2,
),
onrampAvailable = true,
),
)
}
}

View file

@ -3,7 +3,8 @@ package com.tangem.datasource.api.stakekit
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.stakekit.models.request.*
import com.tangem.datasource.api.stakekit.models.response.EnabledYieldsResponse
import com.tangem.datasource.api.stakekit.models.response.EnterActionResponse
import com.tangem.datasource.api.stakekit.models.response.ActionDTO
import com.tangem.datasource.api.stakekit.models.response.GetActionsResponse
import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.api.stakekit.models.response.model.transaction.StakingGasEstimateDTO
@ -35,14 +36,23 @@ interface StakeKitApi {
@Body body: YieldBalanceRequestBody,
): ApiResponse<List<BalanceDTO>>
@GET("actions")
suspend fun getActions(
@Query("walletAddress") walletAddress: String,
@Query("network") network: String,
@Query("status") status: String,
@Query("sort") sort: String = "createdAtDesc",
@Query("limit") limit: Int = 50,
): ApiResponse<GetActionsResponse>
@POST("actions/enter")
suspend fun createEnterAction(@Body body: ActionRequestBody): ApiResponse<EnterActionResponse>
suspend fun createEnterAction(@Body body: ActionRequestBody): ApiResponse<ActionDTO>
@POST("actions/exit")
suspend fun createExitAction(@Body body: ActionRequestBody): ApiResponse<EnterActionResponse>
suspend fun createExitAction(@Body body: ActionRequestBody): ApiResponse<ActionDTO>
@POST("actions/pending")
suspend fun createPendingAction(@Body body: PendingActionRequestBody): ApiResponse<EnterActionResponse>
suspend fun createPendingAction(@Body body: PendingActionRequestBody): ApiResponse<ActionDTO>
@POST("actions/enter/estimate-gas")
suspend fun estimateGasOnEnter(@Body body: ActionRequestBody): ApiResponse<StakingGasEstimateDTO>

View file

@ -9,7 +9,7 @@ import org.joda.time.DateTime
import java.math.BigDecimal
@JsonClass(generateAdapter = true)
data class EnterActionResponse(
data class ActionDTO(
@Json(name = "id")
val id: String,
@Json(name = "integrationId")

View file

@ -0,0 +1,16 @@
package com.tangem.datasource.api.stakekit.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class GetActionsResponse(
@Json(name = "data")
val data: List<ActionDTO>,
@Json(name = "hasNextPage")
val hasNextPage: Boolean,
@Json(name = "limit")
val limit: Int,
@Json(name = "page")
val page: Int,
)

View file

@ -9,31 +9,31 @@ data class YieldDTO(
@Json(name = "id")
val id: String,
@Json(name = "token")
val token: TokenDTO,
val token: TokenDTO?,
@Json(name = "tokens")
val tokens: List<TokenDTO>,
val tokens: List<TokenDTO>?,
@Json(name = "args")
val args: ArgsDTO,
val args: ArgsDTO?,
@Json(name = "status")
val status: StatusDTO,
val status: StatusDTO?,
@Json(name = "apy")
val apy: BigDecimal,
val apy: BigDecimal?,
@Json(name = "rewardRate")
val rewardRate: Double,
val rewardRate: Double?,
@Json(name = "rewardType")
val rewardType: RewardTypeDTO,
val rewardType: RewardTypeDTO?,
@Json(name = "metadata")
val metadata: MetadataDTO,
val metadata: MetadataDTO?,
@Json(name = "validators")
val validators: List<ValidatorDTO>,
val validators: List<ValidatorDTO>?,
@Json(name = "isAvailable")
val isAvailable: Boolean,
val isAvailable: Boolean?,
) {
@JsonClass(generateAdapter = true)
data class StatusDTO(
@Json(name = "enter")
val enter: Boolean,
val enter: Boolean?,
@Json(name = "exit")
val exit: Boolean?,
)
@ -41,21 +41,21 @@ data class YieldDTO(
@JsonClass(generateAdapter = true)
data class ArgsDTO(
@Json(name = "enter")
val enter: Enter,
val enter: Enter?,
@Json(name = "exit")
val exit: Enter?,
) {
@JsonClass(generateAdapter = true)
data class Enter(
@Json(name = "addresses")
val addresses: Addresses,
val addresses: Addresses?,
@Json(name = "args")
val args: Map<String, AddressArgumentDTO>,
val args: Map<String, AddressArgumentDTO>?,
) {
@JsonClass(generateAdapter = true)
data class Addresses(
@Json(name = "address")
val address: AddressArgumentDTO,
val address: AddressArgumentDTO?,
@Json(name = "additionalAddresses")
val additionalAddresses: Map<String, AddressArgumentDTO>? = null,
)
@ -65,7 +65,7 @@ data class YieldDTO(
@JsonClass(generateAdapter = true)
data class ValidatorDTO(
@Json(name = "address")
val address: String,
val address: String?,
@Json(name = "status")
val status: ValidatorStatusDTO,
@Json(name = "name")
@ -106,51 +106,51 @@ data class YieldDTO(
@JsonClass(generateAdapter = true)
data class MetadataDTO(
@Json(name = "name")
val name: String,
val name: String?,
@Json(name = "logoURI")
val logoUri: String,
val logoUri: String?,
@Json(name = "description")
val description: String,
val description: String?,
@Json(name = "documentation")
val documentation: String?,
@Json(name = "gasFeeToken")
val gasFeeTokenDTO: TokenDTO,
val gasFeeTokenDTO: TokenDTO?,
@Json(name = "token")
val tokenDTO: TokenDTO,
val tokenDTO: TokenDTO?,
@Json(name = "tokens")
val tokensDTO: List<TokenDTO>,
val tokensDTO: List<TokenDTO>?,
@Json(name = "type")
val type: String,
val type: String?,
@Json(name = "rewardSchedule")
val rewardSchedule: RewardScheduleDTO,
val rewardSchedule: RewardScheduleDTO?,
@Json(name = "cooldownPeriod")
val cooldownPeriod: PeriodDTO?,
@Json(name = "warmupPeriod")
val warmupPeriod: PeriodDTO,
val warmupPeriod: PeriodDTO?,
@Json(name = "rewardClaiming")
val rewardClaiming: RewardClaimingDTO,
val rewardClaiming: RewardClaimingDTO?,
@Json(name = "defaultValidator")
val defaultValidator: String?,
@Json(name = "minimumStake")
val minimumStake: Int?,
@Json(name = "supportsMultipleValidators")
val supportsMultipleValidators: Boolean,
val supportsMultipleValidators: Boolean?,
@Json(name = "revshare")
val revshare: EnabledDTO,
val revshare: EnabledDTO?,
@Json(name = "fee")
val fee: EnabledDTO,
val fee: EnabledDTO?,
) {
@JsonClass(generateAdapter = true)
data class PeriodDTO(
@Json(name = "days")
val days: Int,
val days: Int?,
)
@JsonClass(generateAdapter = true)
data class EnabledDTO(
@Json(name = "enabled")
val enabled: Boolean,
val enabled: Boolean?,
)
enum class RewardScheduleDTO {

View file

@ -11,6 +11,7 @@ import com.tangem.datasource.api.common.config.managers.ProdApiConfigsManager
import com.tangem.datasource.api.common.response.ApiResponseCallAdapterFactory
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.markets.TangemTechMarketsApi
import com.tangem.datasource.api.onramp.MockedOnrampApi
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
@ -98,22 +99,24 @@ internal object NetworkModule {
@Provides
@Singleton
fun provideOnrampApi(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
apiConfigsManager: ApiConfigsManager,
appLogsStore: AppLogsStore,
// @NetworkMoshi moshi: Moshi,
// @ApplicationContext context: Context,
// apiConfigsManager: ApiConfigsManager,
// appLogsStore: AppLogsStore,
): OnrampApi {
return createApi(
id = ApiConfig.ID.Express,
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
clientBuilder = {
addInterceptor(
NetworkLogsSaveInterceptor(appLogsStore),
)
},
)
// TODO: Remove when backend will be ready - [REDACTED_TASK_KEY]
return MockedOnrampApi()
// return createApi(
// id = ApiConfig.ID.Express,
// moshi = moshi,
// context = context,
// apiConfigsManager = apiConfigsManager,
// clientBuilder = {
// addInterceptor(
// NetworkLogsSaveInterceptor(appLogsStore),
// )
// },
// )
}
@Provides

View file

@ -1,8 +1,9 @@
package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.token.*
import com.tangem.datasource.local.token.DefaultStakingBalanceStore
import com.tangem.datasource.local.token.StakingBalanceStore
import com.tangem.datasource.local.token.DefaultStakingYieldsStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -11,11 +12,23 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object StakingBalanceStoreModule {
internal object StakingStoreModule {
@Provides
@Singleton
fun provideStakingTokensStore(): StakingYieldsStore {
return DefaultStakingYieldsStore()
}
@Provides
@Singleton
fun provideStakingBalanceStore(): StakingBalanceStore {
return DefaultStakingBalanceStore(dataStore = RuntimeDataStore())
}
@Provides
@Singleton
fun provideStakingActionsStore(): StakingActionsStore {
return DefaultStakingActionsStore(dataStore = RuntimeDataStore())
}
}

View file

@ -1,20 +0,0 @@
package com.tangem.datasource.di
import com.tangem.datasource.local.token.DefaultStakingYieldsStore
import com.tangem.datasource.local.token.StakingYieldsStore
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 StakingTokensStoreModule {
@Provides
@Singleton
fun provideStakingTokensStore(): StakingYieldsStore {
return DefaultStakingYieldsStore()
}
}

View file

@ -4,6 +4,8 @@ import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.local.config.environment.DefaultEnvironmentConfigStorage
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.issuers.DefaultIssuersConfigStorage
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
import com.tangem.datasource.local.config.providers.BlockchainProvidersStorage
import com.tangem.datasource.local.config.providers.DefaultBlockchainProvidersStorage
import com.tangem.datasource.local.config.testnet.DefaultTestnetTokensStorage
@ -30,7 +32,7 @@ internal object ConfigModule {
@Provides
@Singleton
fun providesTestnetTokensStorage(assetLoader: AssetLoader): TestnetTokensStorage {
fun provideTestnetTokensStorage(assetLoader: AssetLoader): TestnetTokensStorage {
return DefaultTestnetTokensStorage(assetLoader)
}
@ -42,4 +44,13 @@ internal object ConfigModule {
runtimeStateStore = RuntimeStateStore(defaultValue = emptyMap()),
)
}
@Provides
@Singleton
fun provideIssuersConfigStorage(assetLoader: AssetLoader): IssuersConfigStorage {
return DefaultIssuersConfigStorage(
assetLoader = assetLoader,
runtimeStateStore = RuntimeStateStore(defaultValue = emptyList()),
)
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.datasource.local.config.issuers
import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.local.config.issuers.models.Issuer
import com.tangem.datasource.local.datastore.RuntimeStateStore
internal class DefaultIssuersConfigStorage(
private val assetLoader: AssetLoader,
private val runtimeStateStore: RuntimeStateStore<List<Issuer>>,
) : IssuersConfigStorage {
override suspend fun getConfig(): List<Issuer> {
val cachedData = runtimeStateStore.get().value
if (cachedData.isNotEmpty()) return cachedData
val issuers = assetLoader.loadList<Issuer>(fileName = ISSUERS_FILE_NAME)
runtimeStateStore.store(value = issuers)
return issuers
}
private companion object {
const val ISSUERS_FILE_NAME = "tangem-app-config/issuers"
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.datasource.local.config.issuers
import com.tangem.datasource.local.config.issuers.models.Issuer
/**
* Storage for list of Twins [Issuer]
*
[REDACTED_AUTHOR]
*/
interface IssuersConfigStorage {
/** Get config */
suspend fun getConfig(): List<Issuer>
}

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.local.config.issuers.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class Issuer(
@Json(name = "privateKey") val privateKey: String,
@Json(name = "publicKey") val publicKey: String,
)

View file

@ -5,6 +5,7 @@ import com.tangem.datasource.local.datastore.core.StringKeyDataStoreDecorator
import com.tangem.domain.tokens.model.NetworkStatus
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.extensions.addOrReplace
import com.tangem.utils.extensions.replaceBy
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -27,4 +28,23 @@ internal class DefaultNetworksStatusesStore(
store(key, newValues)
}
}
override suspend fun storeAll(key: UserWalletId, values: Collection<NetworkStatus>) {
mutex.withLock {
val currentValues = getSyncOrNull(key) ?: emptySet()
val updatedValues = currentValues.toMutableSet()
values.forEach { newValue ->
val isReplaced = updatedValues.replaceBy(newValue) {
it.network == newValue.network
}
if (!isReplaced) {
updatedValues.add(newValue)
}
}
store(key, updatedValues)
}
}
}

View file

@ -11,4 +11,6 @@ interface NetworksStatusesStore {
suspend fun getSyncOrNull(key: UserWalletId): Set<NetworkStatus>?
suspend fun store(key: UserWalletId, value: NetworkStatus)
suspend fun storeAll(key: UserWalletId, values: Collection<NetworkStatus>)
}

View file

@ -0,0 +1,34 @@
package com.tangem.datasource.local.token
import com.tangem.datasource.local.datastore.core.StringKeyDataStore
import com.tangem.domain.staking.model.stakekit.action.StakingAction
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
internal class DefaultStakingActionsStore(
private val dataStore: StringKeyDataStore<List<StakingAction>>,
) : StakingActionsStore {
private val mutex = Mutex()
override fun get(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID): Flow<List<StakingAction>> {
return dataStore.get(composeKey(userWalletId, cryptoCurrencyId))
}
override suspend fun store(
userWalletId: UserWalletId,
cryptoCurrencyId: CryptoCurrency.ID,
items: List<StakingAction>,
) {
mutex.withLock {
dataStore.store(composeKey(userWalletId, cryptoCurrencyId), items)
}
}
private fun composeKey(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID): String {
return userWalletId.stringValue + cryptoCurrencyId.value
}
}

View file

@ -4,13 +4,13 @@ import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO
internal class DefaultStakingYieldsStore : StakingYieldsStore {
private var yields = mutableListOf<YieldDTO>()
private var yields = listOf<YieldDTO>()
override fun get(): List<YieldDTO> {
return yields
}
override fun store(items: List<YieldDTO>) {
yields = items.toMutableList()
yields = items
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.local.token
import com.tangem.domain.staking.model.stakekit.action.StakingAction
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow
interface StakingActionsStore {
fun get(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID): Flow<List<StakingAction>>
suspend fun store(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID, items: List<StakingAction>)
}

View file

@ -5,7 +5,7 @@
},
{
"name": "WC_SOLANA_TX_SIGN_ENABLED",
"version": "undefined"
"version": "5.18.0"
},
{
"name": "STAKING_ENABLED",
@ -22,5 +22,17 @@
{
"name": "MIGRATE_USER_COUNTRY_CODE_ENABLED",
"version": "5.17.0"
},
{
"name": "ONRAMP_ENABLED",
"version": "undefined"
},
{
"name": "MAIN_ACTION_BUTTONS_ENABLED",
"version": "undefined"
},
{
"name": "ONBOARDING_CODE_REFACTORING_ENABLED",
"version": "undefined"
}
]

View file

@ -52,4 +52,9 @@ dependencies {
api(deps.jodatime)
implementation(deps.timber)
implementation(deps.markdown)
/** Tests */
testImplementation(deps.test.junit)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
}

View file

@ -4,10 +4,15 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants
import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.CURRENCY_SPACE
import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode
import com.tangem.core.ui.utils.defaultFormat
import com.tangem.core.ui.utils.formatWithThousands
import timber.log.Timber
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Locale
class AmountVisualTransformation(
private val decimals: Int,
@ -25,13 +30,13 @@ class AmountVisualTransformation(
val formattedText = if (formattedAmount.isNotEmpty() && symbol != null) {
AnnotatedString(
if (currencyCode != null) {
BigDecimalFormatter.formatFiatEditableAmount(
formatFiatEditableAmount(
fiatAmount = formattedAmount,
fiatCurrencyCode = currencyCode,
fiatCurrencySymbol = symbol,
)
} else {
BigDecimalFormatter.formatWithSymbol(formattedAmount, symbol)
formatWithSymbol(formattedAmount, symbol)
},
)
} else {
@ -45,6 +50,28 @@ class AmountVisualTransformation(
)
}
private fun formatFiatEditableAmount(
fiatAmount: String?,
fiatCurrencyCode: String,
fiatCurrencySymbol: String,
locale: Locale = Locale.getDefault(),
): String {
if (fiatAmount == null) return BigDecimalFormatConstants.EMPTY_BALANCE_SIGN
val formatterCurrency = getJavaCurrencyByCode(fiatCurrencyCode)
val numberFormatter = NumberFormat.getCurrencyInstance(locale).apply {
currency = formatterCurrency
}
val formatter = requireNotNull(numberFormatter as? DecimalFormat) {
Timber.e("NumberFormat is null")
return BigDecimalFormatConstants.EMPTY_BALANCE_SIGN
}
return "${formatter.positivePrefix}$fiatAmount${formatter.positiveSuffix}"
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
}
private fun formatWithSymbol(amount: String, symbol: String) = "$amount$CURRENCY_SPACE$symbol"
private class OffsetMappingImpl(
private val text: String,
private val formattedText: AnnotatedString,

View file

@ -24,9 +24,10 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.annotatedReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.BigDecimalFormatter
import java.math.BigDecimal
/**
@ -98,7 +99,7 @@ private fun InputRowImageSelectorPreview(
append(" ")
withStyle(style = SpanStyle(color = TangemTheme.colors.text.accent)) {
append(
BigDecimalFormatter.formatPercent(BigDecimal.ZERO, true),
BigDecimal.ZERO.format { percent() },
)
}
},

View file

@ -2,30 +2,57 @@
package com.tangem.core.ui.components.sheetscaffold
import android.graphics.Bitmap
import android.graphics.BlurMaskFilter
import android.renderscript.Allocation
import android.renderscript.Element
import android.renderscript.RenderScript
import android.renderscript.ScriptIntrinsicBlur
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.*
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastMap
import androidx.compose.ui.util.fastMaxOfOrNull
import androidx.compose.ui.zIndex
import androidx.core.graphics.withSave
import androidx.core.graphics.withTranslation
import com.tangem.core.ui.components.sheetscaffold.TangemSheetValue.*
import com.tangem.core.ui.extensions.softLayerShadow
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.toPx
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.pow
import kotlin.math.roundToInt
/**
@ -51,13 +78,6 @@ import kotlin.math.roundToInt
* [Dp.Unspecified] for a sheet that spans the entire screen width.
* @param sheetShape the shape of the bottom sheet
* @param sheetContainerColor the background color of the bottom sheet
* @param sheetContentColor the preferred content color provided by the bottom sheet to its
* children. Defaults to the matching content color for [sheetContainerColor], or if that is not a
* color from the theme, this will keep the same content color set above the bottom sheet.
* @param sheetTonalElevation when [sheetContainerColor] is [ColorScheme.surface], a translucent
* primary color overlay is applied on top of the container. A higher tonal elevation value will
* result in a darker color in light theme and lighter color in dark theme. See also: [Surface].
* @param sheetShadowElevation the shadow elevation of the bottom sheet
* @param sheetSwipeEnabled whether the sheet swiping is enabled and should react to the user's
* input
* @param topBar top app bar of the screen, typically a [SmallTopAppBar]
@ -81,10 +101,7 @@ fun TangemBottomSheetScaffold(
sheetPeekHeight: Dp,
sheetMaxWidth: Dp = 640.dp,
sheetShape: Shape = TangemTheme.shapes.bottomSheetLarge,
sheetContainerColor: Color = Color.White, // FIXME
sheetContentColor: Color = contentColorFor(sheetContainerColor),
sheetTonalElevation: Dp = 0.dp,
sheetShadowElevation: Dp = 1.dp,
sheetContainerColor: Color = Color.White,
sheetSwipeEnabled: Boolean = true,
topBar: @Composable (() -> Unit)? = null,
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
@ -109,9 +126,6 @@ fun TangemBottomSheetScaffold(
sheetSwipeEnabled = sheetSwipeEnabled,
shape = sheetShape,
containerColor = sheetContainerColor,
contentColor = sheetContentColor,
tonalElevation = sheetTonalElevation,
shadowElevation = sheetShadowElevation,
content = sheetContent,
)
},
@ -178,9 +192,6 @@ private fun StandardBottomSheet(
sheetSwipeEnabled: Boolean,
shape: Shape,
containerColor: Color,
contentColor: Color,
tonalElevation: Dp,
shadowElevation: Dp,
content: @Composable ColumnScope.() -> Unit,
) {
val scope = rememberCoroutineScope()
@ -202,7 +213,7 @@ private fun StandardBottomSheet(
Modifier
}
Surface(
Column(
modifier = Modifier
.widthIn(max = sheetMaxWidth)
.fillMaxWidth()
@ -251,16 +262,20 @@ private fun StandardBottomSheet(
state = state.anchoredDraggableState,
orientation = orientation,
enabled = sheetSwipeEnabled,
),
shape = shape,
color = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
shadowElevation = shadowElevation,
)
.softLayerShadow(
radius = 8.dp,
color = Color.Black.copy(
alpha = if (isSystemInDarkTheme()) .16f else .08f
),
shape = shape,
offset = DpOffset(x = 0.dp, y = (-4).dp),
isAlphaContentClip = true
)
.background(containerColor, shape)
.clip(shape),
) {
Column(Modifier.fillMaxWidth()) {
content()
}
content()
}
}
@ -293,7 +308,7 @@ private fun BottomSheetScaffoldLayout(
),
) {
(topBarMeasurables, bodyMeasurables, bottomSheetMeasurables, snackbarHostMeasurables),
constraints,
constraints,
->
val layoutWidth = constraints.maxWidth
val layoutHeight = constraints.maxHeight
@ -321,7 +336,7 @@ private fun BottomSheetScaffoldLayout(
PartiallyExpanded -> sheetOffset().roundToInt() - snackbarHeight
Expanded,
Hidden,
-> layoutHeight - snackbarHeight
-> layoutHeight - snackbarHeight
}
// Placement order is important for elevation

View file

@ -0,0 +1,39 @@
package com.tangem.core.ui.components.tokenlist
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.components.token.TokenItem
import com.tangem.core.ui.components.tokenlist.internal.NetworkTitleItem
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.resolveReference
/**
* Multi-currency content item
*
* @param state component UI model
* @param isBalanceHidden flag that shows/hides balance
* @param modifier modifier
*
[REDACTED_AUTHOR]
*/
@Composable
fun TokenListItem(state: TokensListItemUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
when (state) {
is TokensListItemUM.NetworkGroupTitle -> {
NetworkTitleItem(networkName = state.name.resolveReference(), modifier = modifier)
}
is TokensListItemUM.Token -> {
TokenItem(
state = state.state,
isBalanceHidden = isBalanceHidden,
modifier = modifier,
)
}
is TokensListItemUM.SearchBar -> {
SearchBar(state = state.searchBarUM, modifier = modifier.padding(all = 12.dp))
}
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.feature.wallet.presentation.common.component
package com.tangem.core.ui.components.tokenlist.internal
import android.content.res.Configuration
import androidx.compose.foundation.background
@ -17,21 +17,34 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.R
import com.tangem.core.ui.components.rows.NetworkTitle
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.feature.wallet.impl.R
import org.burnoutcrew.reorderable.ReorderableLazyListState
import org.burnoutcrew.reorderable.detectReorder
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
/**
* Network title item
*
* @param networkName network name
* @param modifier modifier
*/
@Composable
internal fun NetworkTitleItem(networkName: String, modifier: Modifier = Modifier) {
BaseNetworkTitleItem(networkName = networkName, modifier = modifier)
}
/**
* Draggable network title item
*
* @param networkName network name
* @param reorderableTokenListState reorderable token list state
* @param modifier modifier
*/
@Composable
internal fun DraggableNetworkTitleItem(
fun DraggableNetworkTitleItem(
networkName: String,
reorderableTokenListState: ReorderableLazyListState,
modifier: Modifier = Modifier,

View file

@ -0,0 +1,42 @@
package com.tangem.core.ui.components.tokenlist.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
/** Tokens list item state */
@Immutable
sealed interface TokensListItemUM {
/** Unique ID */
val id: Any
/**
* Search bar item
*
* @property id id
* @property searchBarUM search bar UI model
*/
data class SearchBar(
override val id: Any = "search_bar",
val searchBarUM: SearchBarUM,
) : TokensListItemUM
/**
* Network group title
*
* @property id id
* @property name network group name
*/
data class NetworkGroupTitle(override val id: Int, val name: TextReference) : TokensListItemUM
/**
* Token item
*
* @property state token state
*/
data class Token(val state: TokenItemState) : TokensListItemUM {
override val id: String = state.id
}
}

View file

@ -1,11 +1,13 @@
package com.tangem.core.ui.decorations
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import com.tangem.core.ui.res.TangemTheme
@ -15,46 +17,49 @@ fun Modifier.roundedShapeItemDecoration(
lastIndex: Int,
addDefaultPadding: Boolean = true,
radius: Dp = TangemTheme.dimens.radius16,
backgroundColor: Color? = null,
): Modifier = composed {
val modifier = if (addDefaultPadding) this.padding(horizontal = TangemTheme.dimens.spacing16) else this
val applyTopPadding: @Composable Modifier.() -> Modifier = {
if (addDefaultPadding) {
padding(top = TangemTheme.dimens.spacing12)
} else {
this
}
}
val applyShape: Modifier.(shape: RoundedCornerShape?) -> Modifier = { shape ->
if (backgroundColor != null) {
if (shape != null) {
background(color = backgroundColor, shape = shape)
} else {
background(color = backgroundColor)
}
} else {
if (shape != null) {
clip(shape = shape)
} else {
this
}
}
}
val isSingleItem = currentIndex == 0 && lastIndex == 0
when {
isSingleItem -> {
modifier
.then(
if (addDefaultPadding) {
Modifier.padding(top = TangemTheme.dimens.spacing12)
} else {
Modifier
},
)
.clip(shape = RoundedCornerShape(radius))
.applyTopPadding()
.applyShape(RoundedCornerShape(radius))
}
currentIndex == 0 -> {
modifier
.then(
if (addDefaultPadding) {
Modifier.padding(top = TangemTheme.dimens.spacing12)
} else {
Modifier
},
)
.clip(
shape = RoundedCornerShape(
topStart = radius,
topEnd = radius,
),
)
.applyTopPadding()
.applyShape(RoundedCornerShape(topStart = radius, topEnd = radius))
}
currentIndex == lastIndex -> {
modifier
.clip(
shape = RoundedCornerShape(
bottomStart = radius,
bottomEnd = radius,
),
)
modifier.applyShape(RoundedCornerShape(bottomStart = radius, bottomEnd = radius))
}
else -> modifier
else -> modifier.applyShape(null)
}
}

View file

@ -79,6 +79,7 @@ fun getActiveIconRes(blockchainId: String): Int {
"energy-web-chain", "energy-web-chain/test" -> R.drawable.img_energy_web_22
"energy-web-x", "energy-web-x/test" -> R.drawable.img_energy_web_22
"core", "core/test" -> R.drawable.img_core_22
"casper-network", "casper-network/test" -> R.drawable.img_casper_22
else -> R.drawable.ic_alert_24
}
}
@ -159,6 +160,7 @@ fun getActiveIconResByNetworkId(networkId: String): Int {
"energy-web-chain", "energy-web-chain/test" -> R.drawable.img_energy_web_22
"energy-web-x", "energy-web-x/test" -> R.drawable.img_energy_web_22
"core", "core/test" -> R.drawable.img_core_22
"casper-network", "casper-network/test" -> R.drawable.img_casper_22
else -> R.drawable.ic_alert_24
}
}
@ -236,6 +238,7 @@ fun getActiveIconResByCoinId(coinId: String): Int {
"energy-web-chain", "energy-web-chain/test" -> R.drawable.img_energy_web_22
"energy-web-x", "energy-web-x/test" -> R.drawable.img_energy_web_22
"core", "core/test" -> R.drawable.img_core_22
"casper-network" -> R.drawable.img_casper_22
else -> R.drawable.ic_alert_24
}
}
@ -316,6 +319,7 @@ fun getGreyedOutIconRes(blockchainId: String): Int {
"energy-web-chain", "energy-web-chain/test" -> R.drawable.ic_energy_web_22
"energy-web-x", "energy-web-x/test" -> R.drawable.ic_energy_web_22
"core", "core/test" -> R.drawable.ic_core_22
"casper-network", "casper-network/test" -> R.drawable.ic_casper_22
else -> R.drawable.ic_alert_24
}
}
@ -396,6 +400,7 @@ fun getGreyedOutIconResByNetworkId(networkId: String): Int {
"energy-web-chain", "energy-web-chain/test" -> R.drawable.ic_energy_web_22
"energy-web-x", "energy-web-x/test" -> R.drawable.ic_energy_web_22
"core", "core/test" -> R.drawable.ic_core_22
"casper-network", "casper-network/test" -> R.drawable.ic_casper_22
else -> R.drawable.ic_alert_24
}
}

View file

@ -0,0 +1,95 @@
package com.tangem.core.ui.extensions
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
fun Modifier.softLayerShadow(
radius: Dp = 8.dp,
color: Color = Color.Black.copy(alpha = .23f),
shape: Shape = RectangleShape,
spread: Dp = 0.dp,
offset: DpOffset = DpOffset(x = 0.dp, y = 2.dp),
isAlphaContentClip: Boolean = false,
): Modifier = this.drawWithCache {
val radiusPx = radius.toPx()
require(radiusPx > 0.0F)
val paint = Paint().apply {
this.color = color
asFrameworkPaint().apply {
isDither = true
isAntiAlias = true
setShadowLayer(
radiusPx,
offset.x.toPx(),
offset.y.toPx(),
color.toArgb(),
)
}
}
val shapeOutline = shape.createOutline(
size = size,
layoutDirection = LayoutDirection.Rtl,
density = this,
)
val shapePath = Path().apply {
addOutline(outline = shapeOutline)
}
val drawShadowBlock: DrawScope.() -> Unit = {
drawIntoCanvas { canvas ->
canvas.withSave {
if (spread.value != 0.0F) {
canvas.scale(
sx = spreadScale(
spread = spread.toPx(),
size = size.width,
),
sy = spreadScale(
spread = spread.toPx(),
size = size.height,
),
pivotX = center.x,
pivotY = center.y,
)
}
canvas.drawOutline(
outline = shapeOutline,
paint = paint,
)
}
}
}
onDrawBehind {
if (isAlphaContentClip) {
clipShadowByPath(
path = shapePath,
block = drawShadowBlock,
)
} else {
drawShadowBlock()
}
}
}
@Suppress("UnnecessaryParentheses")
private fun spreadScale(spread: Float, size: Float): Float = 1.0F + ((spread / size) * 2.0F)
private fun DrawScope.clipShadowByPath(path: Path, block: DrawScope.() -> Unit) {
clipPath(
path = path,
clipOp = ClipOp.Difference,
block = block,
)
}

View file

@ -0,0 +1,159 @@
package com.tangem.core.ui.format.bigdecimal
import android.icu.text.CompactDecimalFormat
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.NumberFormat
import java.util.Locale
// == Formatters ==
/**
* Formats the amount in compact format.
* "123456.6" -> "$123.457K"
* "12345.6" -> "$123.046K"
* @param threeDigitsMethod if true, will format the amount always with 3 significant digits
*/
fun BigDecimalFiatFormat.compact(threeDigitsMethod: Boolean = false): BigDecimalFormat = BigDecimalFormat { value ->
if (value < BigDecimal.ONE) {
return@BigDecimalFormat defaultAmount()(value)
}
val rawAmount = formatCompactAmount(
amount = value,
locale = locale,
threeDigitsMethod = threeDigitsMethod,
)
addFiatCurrencySymbolToStringAmount(
amount = rawAmount,
fiatCurrencyCode = fiatCurrencyCode,
fiatCurrencySymbol = fiatCurrencySymbol,
locale = locale,
)
}
/**
* Formats the amount in compact format.
* "123456.6" -> "ETH 123.457K"
* "12345.6" -> "123.046K ETH"
* @param threeDigitsMethod if true, will format the amount always with 3 significant digits
*/
fun BigDecimalCryptoFormat.compact(threeDigitsMethod: Boolean = false): BigDecimalFormat = BigDecimalFormat { value ->
if (value < BigDecimal.ONE) {
return@BigDecimalFormat defaultAmount()(value)
}
val rawAmount = formatCompactAmount(
amount = value,
locale = locale,
threeDigitsMethod = threeDigitsMethod,
)
addFiatCurrencySymbolToStringAmount(
amount = rawAmount,
fiatCurrencyCode = BigDecimalFormatConstants.usdCurrency.currencyCode,
fiatCurrencySymbol = BigDecimalFormatConstants.usdCurrency.symbol,
locale = locale,
).replaceFiatSymbolWithCrypto(
fiatCurrencySymbol = BigDecimalFormatConstants.usdCurrency.symbol,
cryptoCurrencySymbol = symbol,
)
}
/**
* Formats the amount in compact format.
* ex. "123456.6" -> "123.46K", "12345.6" -> "123.05K"
* Negative amount is not supported!
*/
fun BigDecimalFormatScope.rawCompact(locale: Locale = Locale.getDefault()) = BigDecimalFormat { value ->
if (value < BigDecimal.ZERO) {
return@BigDecimalFormat value.toPlainString()
}
formatCompactAmount(
amount = value,
locale = locale,
threeDigitsMethod = false,
)
}
// == Helpers ==
/**
* "123456.6" -> "123.457K"
* "12345.6" -> "123.046K"
* Negative amount is not supported
* @param threeDigitsMethod if true, will format the amount always with 3 significant digits
* @param scale the number of digits to the right of the decimal point
*/
@Suppress("MagicNumber")
private fun formatCompactAmount(
amount: BigDecimal,
locale: Locale = Locale.getDefault(),
threeDigitsMethod: Boolean = false,
): String {
if (threeDigitsMethod) {
val scaledAmount = amount.setScale(0, RoundingMode.HALF_UP)
val digitsCount = scaledAmount.toString().count()
val digitsToFormat = 6 - when (digitsCount % 3) {
0 -> 0
1 -> 2
else -> 1
}
val formatter = CompactDecimalFormat.getInstance(
locale,
CompactDecimalFormat.CompactStyle.SHORT,
).apply {
minimumSignificantDigits = 4
maximumSignificantDigits = digitsToFormat
}
return formatter.format(scaledAmount)
} else {
val scaledAmount = amount.setScale(0, RoundingMode.HALF_UP)
val digitsCount = scaledAmount.toString().count()
val digitsToFormat = 5 - when (digitsCount % 3) {
0 -> 0
1 -> 2
else -> 1
}
val formatter = CompactDecimalFormat.getInstance(
locale,
CompactDecimalFormat.CompactStyle.SHORT,
).apply {
minimumSignificantDigits = 2
maximumSignificantDigits = digitsToFormat
}
return formatter.format(scaledAmount)
}
}
/**
* Adds a proper currency symbol for the provided formatted [amount]
* ex. '10.0k" -> "$10.0k", "string" -> "$string"
*/
private fun addFiatCurrencySymbolToStringAmount(
amount: String,
fiatCurrencyCode: String,
fiatCurrencySymbol: String,
locale: Locale = Locale.getDefault(),
): String {
val sampleAmount = BigDecimal.TEN
val currency = getJavaCurrencyByCode(fiatCurrencyCode)
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
maximumFractionDigits = 0
minimumFractionDigits = 0
this.currency = currency
}
val formatted = formatter.format(sampleAmount)
.replace(currency.getSymbol(locale), fiatCurrencySymbol)
.replace(sampleAmount.toString(), amount)
return formatted
}

View file

@ -0,0 +1,247 @@
package com.tangem.core.ui.format.bigdecimal
import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.CAN_BE_LOWER_SIGN
import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.CRYPTO_FEE_FORMAT_THRESHOLD
import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.CURRENCY_SPACE
import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.FORMAT_THRESHOLD
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.utils.extensions.isNotWhitespace
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale
open class BigDecimalCryptoFormat(
val symbol: String,
val decimals: Int,
val locale: Locale = Locale.getDefault(),
) : BigDecimalFormat {
override fun invoke(value: BigDecimal): String = defaultAmount()(value)
}
class BigDecimalCryptoFormatFull(
val cryptoCurrency: CryptoCurrency,
locale: Locale = Locale.getDefault(),
) : BigDecimalCryptoFormat(
symbol = cryptoCurrency.symbol,
decimals = cryptoCurrency.decimals,
locale = locale,
) {
override fun invoke(value: BigDecimal): String = defaultAmount()(value)
}
// == Initializers ==
fun BigDecimalFormatScope.crypto(
symbol: String,
decimals: Int,
locale: Locale = Locale.getDefault(),
): BigDecimalCryptoFormat {
return BigDecimalCryptoFormat(
symbol = symbol,
decimals = decimals,
locale = locale,
)
}
fun BigDecimalFormatScope.crypto(
cryptoCurrency: CryptoCurrency,
locale: Locale = Locale.getDefault(),
): BigDecimalCryptoFormat {
return BigDecimalCryptoFormat(
symbol = cryptoCurrency.symbol,
decimals = cryptoCurrency.decimals,
locale = locale,
)
}
// == Formatters ==
fun BigDecimalCryptoFormat.defaultAmount() = BigDecimalFormat { value ->
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
currency = usdCurrency
maximumFractionDigits = decimals.coerceAtMost(maximumValue = 8)
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
formatter.format(value)
.replaceFiatSymbolWithCrypto(
fiatCurrencySymbol = usdCurrency.getSymbol(locale),
cryptoCurrencySymbol = symbol,
)
}
fun BigDecimalCryptoFormat.shorted() = BigDecimalFormat { value ->
val formatter = if (value.isMoreThanThreshold()) {
NumberFormat.getCurrencyInstance(locale).apply {
currency = usdCurrency
maximumFractionDigits = 2
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
} else {
NumberFormat.getCurrencyInstance(locale).apply {
currency = usdCurrency
maximumFractionDigits = decimals.coerceAtMost(maximumValue = 6)
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.DOWN
}
}
formatter.format(value)
.replaceFiatSymbolWithCrypto(
fiatCurrencySymbol = usdCurrency.getSymbol(locale),
cryptoCurrencySymbol = symbol,
)
}
/**
* Format for displaying crypto amounts with their original decimals.
*/
fun BigDecimalCryptoFormat.uncapped() = BigDecimalFormat { value ->
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
currency = usdCurrency
maximumFractionDigits = decimals
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
formatter.format(value)
.replaceFiatSymbolWithCrypto(
fiatCurrencySymbol = usdCurrency.getSymbol(locale),
cryptoCurrencySymbol = symbol,
)
}
/**
* Format for displaying crypto amounts with a fixed number of decimals.
*/
fun BigDecimalCryptoFormat.anyDecimals(maxDecimals: Int = decimals, minDecimals: Int = decimals) =
BigDecimalFormat { value ->
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
currency = usdCurrency
maximumFractionDigits = maxDecimals
minimumFractionDigits = minDecimals
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
formatter.format(value)
.replaceFiatSymbolWithCrypto(
fiatCurrencySymbol = usdCurrency.getSymbol(locale),
cryptoCurrencySymbol = symbol,
)
}
/**
* Format for displaying fees.
* If the fee is less than the threshold, it will be displayed as a fixed value "<0.000001 BTC", "<BTC 0.000001".
*/
fun BigDecimalCryptoFormat.fee(canBeLower: Boolean = false) = BigDecimalFormat { value ->
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
currency = usdCurrency
maximumFractionDigits = decimals.coerceAtMost(maximumValue = 6)
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
if (value.lessThanFeeCryptoThreshold()) {
buildString {
append(CAN_BE_LOWER_SIGN)
append(
formatter
.format(CRYPTO_FEE_FORMAT_THRESHOLD)
.replaceFiatSymbolWithCrypto(
fiatCurrencySymbol = usdCurrency.getSymbol(locale),
cryptoCurrencySymbol = symbol,
addStartSpace = true,
),
)
}
} else {
buildString {
if (canBeLower) {
append(CAN_BE_LOWER_SIGN)
}
append(
formatter.format(value)
.replaceFiatSymbolWithCrypto(
fiatCurrencySymbol = usdCurrency.getSymbol(locale),
cryptoCurrencySymbol = symbol,
addStartSpace = canBeLower,
),
)
}
}
}
// == Helpers ==
private fun BigDecimal.isMoreThanThreshold() = this > FORMAT_THRESHOLD
private fun BigDecimal.lessThanFeeCryptoThreshold() = this > BigDecimal.ZERO && this < CRYPTO_FEE_FORMAT_THRESHOLD
private val usdCurrency = Currency.getInstance(Locale.US)
// Replaces fiat currency symbol with crypto currency symbol
// with respect to the position of the symbol and whitespace
internal fun String.replaceFiatSymbolWithCrypto(
fiatCurrencySymbol: String,
cryptoCurrencySymbol: String,
addStartSpace: Boolean = false,
): String {
val str = this
if (str.isEmpty()) return str
return buildString {
when {
str.endsWith(fiatCurrencySymbol) -> {
val withoutSymbol = str.dropLast(fiatCurrencySymbol.length)
if (cryptoCurrencySymbol.isBlank()) {
return withoutSymbol
}
val last = withoutSymbol.lastOrNull() ?: return cryptoCurrencySymbol
append(withoutSymbol)
if (last.isNotWhitespace()) {
append(CURRENCY_SPACE)
}
append(cryptoCurrencySymbol)
}
str.startsWith(fiatCurrencySymbol) -> {
if (addStartSpace) {
append(CURRENCY_SPACE)
}
val withoutSymbol = str.drop(fiatCurrencySymbol.length)
val first = withoutSymbol.firstOrNull()
?: return cryptoCurrencySymbol
if (cryptoCurrencySymbol.isBlank()) {
return withoutSymbol
}
append(cryptoCurrencySymbol)
if (first.isNotWhitespace()) {
append(CURRENCY_SPACE)
}
append(withoutSymbol)
}
else -> append(str)
}
}
}

View file

@ -0,0 +1,144 @@
package com.tangem.core.ui.format.bigdecimal
import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.CAN_BE_LOWER_SIGN
import com.tangem.utils.StringsSigns.TILDE_SIGN
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.NumberFormat
import java.util.Locale
open class BigDecimalFiatFormat(
val fiatCurrencyCode: String,
val fiatCurrencySymbol: String,
val locale: Locale = Locale.getDefault(),
) : BigDecimalFormat {
override fun invoke(p1: BigDecimal): String = error("")
}
// == Initializers ==
fun BigDecimalFormatScope.fiat(
fiatCurrencyCode: String,
fiatCurrencySymbol: String,
locale: Locale = Locale.getDefault(),
): BigDecimalFiatFormat {
return BigDecimalFiatFormat(
fiatCurrencyCode = fiatCurrencyCode,
fiatCurrencySymbol = fiatCurrencySymbol,
locale = locale,
)
}
// == Formatters ==
/**
* Formats fiat amount with default precision.
*/
fun BigDecimalFiatFormat.defaultAmount(): BigDecimalFormat = BigDecimalFormat { value ->
val formatterCurrency = getJavaCurrencyByCode(fiatCurrencyCode)
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
currency = formatterCurrency
maximumFractionDigits = FIAT_MARKET_DEFAULT_DIGITS
minimumFractionDigits = FIAT_MARKET_DEFAULT_DIGITS
roundingMode = RoundingMode.HALF_UP
}
if (value.isLessThanThreshold()) {
buildString {
append(CAN_BE_LOWER_SIGN)
append(
formatter.format(FIAT_FORMAT_THRESHOLD)
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol),
)
}
} else {
formatter.format(value)
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
}
}
/**
* Formats fiat amount with default precision and adds tilde sign
*/
fun BigDecimalFiatFormat.approximateAmount(): BigDecimalFormat = BigDecimalFormat { value ->
val formattedAmount = defaultAmount()(value)
if (value.isLessThanThreshold()) {
formattedAmount
} else {
buildString {
append(TILDE_SIGN)
append(formattedAmount)
}
}
}
/**
* Formats fiat amount with extended precision.
*/
fun BigDecimalFiatFormat.uncapped(): BigDecimalFormat = BigDecimalFormat { value ->
val formatterCurrency = getJavaCurrencyByCode(fiatCurrencyCode)
val digits = if (value.isLessThanThreshold()) {
FIAT_MARKET_EXTENDED_DIGITS
} else {
FIAT_MARKET_DEFAULT_DIGITS
}
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
currency = formatterCurrency
maximumFractionDigits = digits
minimumFractionDigits = FIAT_MARKET_DEFAULT_DIGITS
roundingMode = RoundingMode.HALF_UP
}
formatter.format(value)
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
}
/**
* Formats fiat price with precision calculated based on the value.
* @see getFiatPriceAmountWithScale
*/
fun BigDecimalFiatFormat.price(): BigDecimalFormat = BigDecimalFormat { value ->
val formatterCurrency = getJavaCurrencyByCode(fiatCurrencyCode)
val (priceAmount, finalScale) = getFiatPriceAmountWithScale(value = value)
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
currency = formatterCurrency
maximumFractionDigits = finalScale
minimumFractionDigits = FIAT_MARKET_DEFAULT_DIGITS
roundingMode = RoundingMode.HALF_UP
}
formatter.format(priceAmount)
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
}
// == Helpers ==
private fun BigDecimal.isLessThanThreshold() = this > BigDecimal.ZERO && this < FIAT_FORMAT_THRESHOLD
private fun getFiatPriceAmountWithScale(value: BigDecimal): Pair<BigDecimal, Int> {
return if (value < BigDecimal.ONE) {
val leadingZeroes = value.scale() - value.precision()
val scale = leadingZeroes + FRACTIONAL_PART_LENGTH_AFTER_LEADING_ZEROES
val amount = value
.setScale(scale, RoundingMode.HALF_UP)
.stripTrailingZeros()
amount to amount.scale()
} else {
value to FIAT_MARKET_DEFAULT_DIGITS
}
}
// == Constants ==
private val FIAT_FORMAT_THRESHOLD = BigDecimal("0.01")
private const val FIAT_MARKET_DEFAULT_DIGITS = 2
private const val FIAT_MARKET_EXTENDED_DIGITS = 6
private const val FRACTIONAL_PART_LENGTH_AFTER_LEADING_ZEROES = 4

View file

@ -0,0 +1,29 @@
package com.tangem.core.ui.format.bigdecimal
import java.math.BigDecimal
interface BigDecimalFormatScope {
companion object { val Empty = object : BigDecimalFormatScope {} }
}
fun interface BigDecimalFormat : (BigDecimal) -> String, BigDecimalFormatScope
inline fun BigDecimal.format(block: BigDecimalFormatScope.() -> BigDecimalFormat): String {
return BigDecimalFormatScope.Empty.block()(this)
}
inline fun BigDecimal?.format(
fallbackString: String = BigDecimalFormatConstants.EMPTY_BALANCE_SIGN,
block: BigDecimalFormatScope.() -> BigDecimalFormat,
): String {
if (this == null) return fallbackString
return BigDecimalFormatScope.Empty.block()(this)
}
fun BigDecimal?.format(
format: BigDecimalFormat,
fallbackString: String = BigDecimalFormatConstants.EMPTY_BALANCE_SIGN,
): String {
if (this == null) return fallbackString
return format(this)
}

View file

@ -0,0 +1,20 @@
package com.tangem.core.ui.format.bigdecimal
import com.tangem.utils.StringsSigns.DASH_SIGN
import com.tangem.utils.StringsSigns.LOWER_SIGN
import java.math.BigDecimal
import java.util.Currency
import java.util.Locale
object BigDecimalFormatConstants {
const val EMPTY_BALANCE_SIGN = DASH_SIGN
const val CAN_BE_LOWER_SIGN = LOWER_SIGN
val FORMAT_THRESHOLD = BigDecimal("0.01")
const val CURRENCY_SPACE = '\u00a0'
val CRYPTO_FEE_FORMAT_THRESHOLD = BigDecimal("0.000001")
val usdCurrency: Currency by lazy { Currency.getInstance(Locale.US) }
}

View file

@ -0,0 +1,39 @@
package com.tangem.core.ui.format.bigdecimal
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.NumberFormat
import java.util.Locale
class BigDecimalPercentFormat(
val withoutSign: Boolean = true,
val locale: Locale = Locale.getDefault(),
) : BigDecimalFormat {
override fun invoke(value: BigDecimal): String = default()(value)
}
// == Initializers ==
fun BigDecimalFormatScope.percent(
withoutSign: Boolean = true,
locale: Locale = Locale.getDefault(),
): BigDecimalPercentFormat {
return BigDecimalPercentFormat(
withoutSign = withoutSign,
locale = locale,
)
}
// == Formatters ==
private fun BigDecimalPercentFormat.default(): BigDecimalFormat = BigDecimalFormat { value ->
val formatter = NumberFormat.getPercentInstance(locale).apply {
maximumFractionDigits = 2
minimumFractionDigits = 2
roundingMode = RoundingMode.HALF_UP
}
val valueToFormat = if (withoutSign) value.abs() else value
formatter.format(valueToFormat)
}

View file

@ -0,0 +1,34 @@
package com.tangem.core.ui.format.bigdecimal
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.NumberFormat
import java.util.Locale
open class BigDecimalSimpleFormat(
val decimals: Int,
val locale: Locale = Locale.getDefault(),
) : BigDecimalFormat {
override fun invoke(value: BigDecimal): String = default()(value)
}
// == Initializers ==
fun BigDecimalFormatScope.simple(decimals: Int, locale: Locale = Locale.getDefault()) = BigDecimalSimpleFormat(
decimals = decimals,
locale = locale,
)
// == Formatters ==
fun BigDecimalSimpleFormat.default() = BigDecimalFormat { value ->
val formatter = NumberFormat.getInstance(locale).apply {
maximumFractionDigits = decimals
minimumFractionDigits = 0
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
formatter.format(value)
}

View file

@ -0,0 +1,15 @@
package com.tangem.core.ui.format.bigdecimal
import java.util.Currency
fun getJavaCurrencyByCode(code: String): Currency {
return runCatching { Currency.getInstance(code) }
.getOrElse { e ->
// Currency code is not valid ISO 4217 code
if (e is IllegalArgumentException) {
BigDecimalFormatConstants.usdCurrency
} else {
throw e
}
}
}

View file

@ -1,28 +1,22 @@
package com.tangem.core.ui.utils
import android.icu.text.CompactDecimalFormat
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.utils.StringsSigns.DASH_SIGN
import com.tangem.utils.StringsSigns.LOWER_SIGN
import com.tangem.utils.StringsSigns.TILDE_SIGN
import com.tangem.utils.extensions.isNotWhitespace
import timber.log.Timber
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale
@Suppress("LargeClass")
@Deprecated("Use BigDecimal.format")
object BigDecimalFormatter {
const val EMPTY_BALANCE_SIGN = DASH_SIGN
private const val CAN_BE_LOWER_SIGN = LOWER_SIGN
private val FORMAT_THRESHOLD = BigDecimal("0.01")
private val FIAT_FORMAT_THRESHOLD = BigDecimal("0.01")
private val CRYPTO_FEE_FORMAT_THRESHOLD = BigDecimal("0.000001")
private const val FIAT_MARKET_DEFAULT_DIGITS = 2
private const val FIAT_MARKET_EXTENDED_DIGITS = 6
@ -30,161 +24,7 @@ object BigDecimalFormatter {
private val usdCurrency = Currency.getInstance("USD")
@Deprecated(
"Use formatCryptoAmount2",
replaceWith = ReplaceWith("formatCryptoAmount2"),
)
fun formatCryptoAmount(
cryptoAmount: BigDecimal?,
cryptoCurrency: String,
decimals: Int,
locale: Locale = Locale.getDefault(),
): String {
if (cryptoAmount == null) return EMPTY_BALANCE_SIGN
val formatter = NumberFormat.getNumberInstance(locale).apply {
maximumFractionDigits = decimals.coerceAtMost(maximumValue = 8)
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
return formatter.format(cryptoAmount).let {
if (cryptoCurrency.isEmpty()) {
it
} else {
it + "\u2009$cryptoCurrency"
}
}
}
// Migrate to this method from formatCryptoAmount ([REDACTED_TASK_KEY])
fun formatCryptoAmount2(
cryptoAmount: BigDecimal?,
cryptoCurrency: String,
decimals: Int,
locale: Locale = Locale.getDefault(),
): String {
if (cryptoAmount == null) return EMPTY_BALANCE_SIGN
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
currency = usdCurrency
maximumFractionDigits = decimals.coerceAtMost(maximumValue = 8)
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
return formatter.format(cryptoAmount)
.replaceFiatSymbolWithCrypto(
fiatCurrencySymbol = usdCurrency.symbol,
cryptoCurrencySymbol = cryptoCurrency,
)
}
fun formatCryptoAmountShorted(
cryptoAmount: BigDecimal?,
cryptoCurrency: String,
decimals: Int,
locale: Locale = Locale.getDefault(),
): String {
if (cryptoAmount == null) return EMPTY_BALANCE_SIGN
val formatter = if (cryptoAmount.isMoreThanThreshold()) {
NumberFormat.getNumberInstance(locale).apply {
maximumFractionDigits = 2
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
} else {
NumberFormat.getNumberInstance(locale).apply {
maximumFractionDigits = decimals.coerceAtMost(maximumValue = 6)
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.DOWN
}
}
return formatter.format(cryptoAmount).let {
if (cryptoCurrency.isEmpty()) {
it
} else {
it + "\u2009$cryptoCurrency"
}
}
}
fun formatCryptoAmountUncapped(
cryptoAmount: BigDecimal?,
cryptoCurrency: CryptoCurrency,
locale: Locale = Locale.getDefault(),
): String {
if (cryptoAmount == null) return EMPTY_BALANCE_SIGN
val formatter = NumberFormat.getNumberInstance(locale).apply {
maximumFractionDigits = cryptoCurrency.decimals
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
return formatter.format(cryptoAmount).let {
if (cryptoCurrency.symbol.isEmpty()) {
it
} else {
it + "\u2009${cryptoCurrency.symbol}"
}
}
}
fun formatCryptoFeeAmount(
cryptoAmount: BigDecimal?,
cryptoCurrency: String,
decimals: Int,
canBeLower: Boolean = false,
locale: Locale = Locale.getDefault(),
): String {
if (cryptoAmount == null) return EMPTY_BALANCE_SIGN
val formatter = NumberFormat.getNumberInstance(locale).apply {
maximumFractionDigits = decimals.coerceAtMost(maximumValue = 6)
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
val amountFormatted = if (cryptoAmount.checkCryptoThreshold()) {
buildString {
append(CAN_BE_LOWER_SIGN)
append(
formatter.format(CRYPTO_FEE_FORMAT_THRESHOLD),
)
}
} else {
buildString {
if (canBeLower) {
append(CAN_BE_LOWER_SIGN)
}
append(formatter.format(cryptoAmount))
}
}
return if (cryptoCurrency.isEmpty()) {
amountFormatted
} else {
amountFormatted + "\u2009$cryptoCurrency"
}
}
fun formatCryptoAmount(
cryptoAmount: BigDecimal?,
cryptoCurrency: CryptoCurrency,
locale: Locale = Locale.getDefault(),
): String {
return formatCryptoAmount(cryptoAmount, cryptoCurrency.symbol, cryptoCurrency.decimals, locale)
}
@Deprecated("Use BigDecimal.format")
fun formatFiatAmount(
fiatAmount: BigDecimal?,
fiatCurrencyCode: String,
@ -226,6 +66,7 @@ object BigDecimalFormatter {
}
}
@Deprecated("Use BigDecimal.format")
fun formatFiatAmountUncapped(
fiatAmount: BigDecimal?,
fiatCurrencyCode: String,
@ -251,6 +92,7 @@ object BigDecimalFormatter {
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
}
@Deprecated("Use BigDecimal.format")
fun formatFiatPriceUncapped(
fiatAmount: BigDecimal?,
fiatCurrencyCode: String,
@ -273,6 +115,7 @@ object BigDecimalFormatter {
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
}
@Deprecated("Use BigDecimal.format")
fun getFiatPriceUncappedWithScale(value: BigDecimal): Pair<BigDecimal, Int> {
return if (value < BigDecimal.ONE) {
val leadingZeroes = value.scale() - value.precision()
@ -288,47 +131,6 @@ object BigDecimalFormatter {
}
}
fun formatFiatEditableAmount(
fiatAmount: String?,
fiatCurrencyCode: String,
fiatCurrencySymbol: String,
locale: Locale = Locale.getDefault(),
): String {
if (fiatAmount == null) return EMPTY_BALANCE_SIGN
val formatterCurrency = getCurrency(fiatCurrencyCode)
val numberFormatter = NumberFormat.getCurrencyInstance(locale).apply {
currency = formatterCurrency
}
val formatter = requireNotNull(numberFormatter as? DecimalFormat) {
Timber.e("NumberFormat is null")
return EMPTY_BALANCE_SIGN
}
return "${formatter.positivePrefix}$fiatAmount${formatter.positiveSuffix}"
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
}
fun formatPercent(
percent: BigDecimal,
useAbsoluteValue: Boolean,
locale: Locale = Locale.getDefault(),
maxFractionDigits: Int = 2,
minFractionDigits: Int = 2,
): String {
val formatter = NumberFormat.getPercentInstance(locale).apply {
maximumFractionDigits = maxFractionDigits
minimumFractionDigits = minFractionDigits
roundingMode = RoundingMode.HALF_UP
}
val value = if (useAbsoluteValue) percent.abs() else percent
return formatter.format(value)
}
fun formatWithSymbol(amount: String, symbol: String) = "$amount\u2009$symbol"
private fun BigDecimal.isMoreThanThreshold() = this > FORMAT_THRESHOLD
private fun getCurrency(code: String): Currency {
return runCatching { Currency.getInstance(code) }
.getOrElse { e ->
@ -341,231 +143,5 @@ object BigDecimalFormatter {
}
}
/**
* Adds a proper currency sign for the provided formatted [amount]
* ex. '10.0k" -> "$10.0k", "string" -> "$string"
*/
private fun addCurrencySymbolToStringAmount(
amount: String,
fiatCurrencyCode: String,
fiatCurrencySymbol: String,
locale: Locale = Locale.getDefault(),
): String {
val sampleAmount = BigDecimal.TEN
val currency = getCurrency(fiatCurrencyCode)
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
maximumFractionDigits = 0
minimumFractionDigits = 0
this.currency = currency
}
val formatted = formatter.format(sampleAmount)
.replace(currency.getSymbol(locale), fiatCurrencySymbol)
.replace(sampleAmount.toString(), amount)
return formatted
}
/**
* Adds a proper currency sign for the provided formatted [amount]
* ex. '10.0k" -> "ETH 10.0k", "string" -> "ETH string"
*/
private fun addCryptoCurrencySymbolToStringAmount(
amount: String,
cryptoCurrencySymbol: String,
locale: Locale = Locale.getDefault(),
): String {
val sampleAmount = BigDecimal.TEN
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
maximumFractionDigits = 0
minimumFractionDigits = 0
currency = usdCurrency
}
val formatted = formatter.format(sampleAmount)
.replace(sampleAmount.toString(), amount)
return formatted.replaceFiatSymbolWithCrypto(
fiatCurrencySymbol = usdCurrency.symbol,
cryptoCurrencySymbol = cryptoCurrencySymbol,
)
}
/**
* "123456.6" -> "$123.457K"
* "12345.6" -> "$123.046K"
* Negative amount is not supported
* @param threeDigitsMethod if true, will format the amount always with 3 significant digits
* @param scale the number of digits to the right of the decimal point
*/
@Suppress("MagicNumber")
fun formatCompactFiatAmount(
amount: BigDecimal?,
fiatCurrencyCode: String,
fiatCurrencySymbol: String,
threeDigitsMethod: Boolean = false,
scale: Int = 0,
locale: Locale = Locale.getDefault(),
): String {
if (amount == null) return EMPTY_BALANCE_SIGN
if (amount < BigDecimal.ONE) {
return formatFiatPriceUncapped(
fiatAmount = amount,
fiatCurrencyCode = fiatCurrencyCode,
fiatCurrencySymbol = fiatCurrencySymbol,
locale = locale,
)
}
val rawAmount = formatCompactAmount(
amount = amount,
locale = locale,
threeDigitsMethod = threeDigitsMethod,
scale = scale,
)
return addCurrencySymbolToStringAmount(
amount = rawAmount,
fiatCurrencyCode = fiatCurrencyCode,
fiatCurrencySymbol = fiatCurrencySymbol,
locale = locale,
)
}
/**
* "123456.6" -> "ETH 123.457K"
* "12345.6" -> "123.046K ETH"
* Negative amount is not supported
* @param threeDigitsMethod if true, will format the amount always with 3 significant digits
* @param scale the number of digits to the right of the decimal point
*/
fun formatCompactCryptoAmount(
amount: BigDecimal?,
cryptoCurrencySymbol: String,
threeDigitsMethod: Boolean = false,
decimals: Int = 0,
locale: Locale = Locale.getDefault(),
): String {
if (amount == null) return EMPTY_BALANCE_SIGN
if (amount < BigDecimal.ONE) {
return formatCryptoAmount2(
cryptoAmount = amount,
cryptoCurrency = cryptoCurrencySymbol,
decimals = decimals,
locale = locale,
)
}
val rawAmount = formatCompactAmount(
amount = amount,
locale = locale,
threeDigitsMethod = threeDigitsMethod,
scale = decimals,
)
return addCryptoCurrencySymbolToStringAmount(
amount = rawAmount,
cryptoCurrencySymbol = cryptoCurrencySymbol,
locale = locale,
)
}
/**
* "123456.6" -> "123.457K"
* "12345.6" -> "123.046K"
* Negative amount is not supported
* @param threeDigitsMethod if true, will format the amount always with 3 significant digits
* @param scale the number of digits to the right of the decimal point
*/
@Suppress("MagicNumber")
fun formatCompactAmount(
amount: BigDecimal,
locale: Locale = Locale.getDefault(),
threeDigitsMethod: Boolean = false,
scale: Int = 0,
): String {
if (threeDigitsMethod) {
val scaledAmount = amount.setScale(scale, RoundingMode.HALF_UP)
val digitsCount = scaledAmount.toString().count()
val digitsToFormat = 6 - when (digitsCount % 3) {
0 -> 0
1 -> 2
else -> 1
}
val formatter = CompactDecimalFormat.getInstance(
locale,
CompactDecimalFormat.CompactStyle.SHORT,
).apply {
minimumSignificantDigits = 4
maximumSignificantDigits = digitsToFormat
}
return formatter.format(amount.setScale(scale, RoundingMode.HALF_UP))
} else {
val scaledAmount = amount.setScale(scale, RoundingMode.HALF_UP)
val digitsCount = scaledAmount.toString().count()
val digitsToFormat = 5 - when (digitsCount % 3) {
0 -> 0
1 -> 2
else -> 1
}
val formatter = CompactDecimalFormat.getInstance(
locale,
CompactDecimalFormat.CompactStyle.SHORT,
).apply {
minimumSignificantDigits = 2
maximumSignificantDigits = digitsToFormat
}
return formatter.format(amount.setScale(scale, RoundingMode.HALF_UP))
}
}
// Replaces fiat currency symbol with crypto currency symbol
// with respect to the position of the symbol and whitespace
private fun String.replaceFiatSymbolWithCrypto(fiatCurrencySymbol: String, cryptoCurrencySymbol: String): String {
val str = this
if (str.isEmpty()) return str
return buildString {
when {
str.endsWith(fiatCurrencySymbol) -> {
val withoutSymbol = str.dropLast(fiatCurrencySymbol.length)
val last = withoutSymbol.lastOrNull() ?: return cryptoCurrencySymbol
append(withoutSymbol)
if (last.isNotWhitespace()) {
append("\u2009")
}
append(cryptoCurrencySymbol)
}
str.startsWith(fiatCurrencySymbol) -> {
append(cryptoCurrencySymbol)
val withoutSymbol = str.drop(fiatCurrencySymbol.length)
val first = withoutSymbol.firstOrNull()
?: return cryptoCurrencySymbol
if (first.isNotWhitespace()) {
append("\u2009")
}
append(withoutSymbol)
}
else -> append(str)
}
}
}
private fun BigDecimal.checkFiatThreshold() = this > BigDecimal.ZERO && this < FIAT_FORMAT_THRESHOLD
private fun BigDecimal.checkCryptoThreshold() = this > BigDecimal.ZERO && this < CRYPTO_FEE_FORMAT_THRESHOLD
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<path
android:pathData="M7.746,13.948V8.129C7.746,7.991 7.797,7.854 7.901,7.763C7.983,7.69 8.081,7.653 8.195,7.653H15.5V5.4H15.396C14.708,5.4 14.149,5.961 14.149,6.652V6.673C14.149,7.015 13.886,7.314 13.546,7.323C13.205,7.332 12.91,7.051 12.91,6.702V6.643C12.91,5.957 12.355,5.401 11.672,5.401H7.789C7.351,5.401 6.958,5.5 6.611,5.697C6.264,5.895 5.992,6.168 5.795,6.515C5.599,6.863 5.5,7.257 5.5,7.696V14.354C5.5,14.794 5.599,15.188 5.795,15.535C5.993,15.883 6.264,16.156 6.611,16.354C6.958,16.552 7.351,16.65 7.789,16.65H15.498V14.397H8.22C8.082,14.397 7.946,14.346 7.855,14.242C7.782,14.159 7.745,14.061 7.745,13.946L7.746,13.948Z"
android:fillColor="#000000"/>
</vector>

View file

@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<group>
<clip-path
android:pathData="M11,0L11,0A11,11 0,0 1,22 11L22,11A11,11 0,0 1,11 22L11,22A11,11 0,0 1,0 11L0,11A11,11 0,0 1,11 0z"/>
<path
android:pathData="M11,0L11,0A11,11 0,0 1,22 11L22,11A11,11 0,0 1,11 22L11,22A11,11 0,0 1,0 11L0,11A11,11 0,0 1,11 0z"
android:fillColor="#ffffff"/>
<path
android:pathData="M0,0h22v22h-22z"
android:fillColor="#FF2D2E"/>
<path
android:pathData="M7.746,13.948V8.129C7.746,7.991 7.797,7.854 7.901,7.763C7.983,7.69 8.081,7.653 8.195,7.653H15.5V5.4H15.396C14.708,5.4 14.149,5.961 14.149,6.652V6.673C14.149,7.015 13.886,7.314 13.546,7.323C13.205,7.332 12.91,7.051 12.91,6.702V6.643C12.91,5.957 12.355,5.401 11.672,5.401H7.789C7.351,5.401 6.958,5.5 6.611,5.697C6.264,5.895 5.992,6.168 5.795,6.515C5.599,6.863 5.5,7.257 5.5,7.696V14.354C5.5,14.794 5.599,15.188 5.795,15.535C5.993,15.883 6.264,16.156 6.611,16.354C6.958,16.552 7.351,16.65 7.789,16.65H15.498V14.397H8.22C8.082,14.397 7.946,14.346 7.855,14.242C7.782,14.159 7.745,14.061 7.745,13.946L7.746,13.948Z"
android:fillColor="#ffffff"/>
</group>
</vector>

View file

@ -0,0 +1,407 @@
package com.tangem.core.ui.format.bigdecimal
import com.google.common.truth.Truth
import org.junit.Test
import java.math.BigDecimal
import java.util.Locale
internal class BigDecimalCryptoFormatTest {
private val testLocale = Locale.US
private val testLocale2 = Locale.GERMANY
private val symbol = "BTC"
// === defaultAmount() ===
@Test
fun `defaultAmount (usually used as a user balance)`() {
val testValue = BigDecimal("0.123456789999")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 8,
locale = testLocale,
).defaultAmount()
}
Truth.assertThat(formatted)
.isEqualTo("0.12345679".addSymbolWithSpaceLeft(symbol))
}
@Test
fun `defaultAmount (usually used as a user balance) alter locale`() {
val testValue = BigDecimal("0.123456789999")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 8,
locale = testLocale2,
).defaultAmount()
}
Truth.assertThat(formatted)
.isEqualTo("0,12345679".addSymbolWithSpaceRight(symbol))
}
@Test
fun `defaultAmount decimals more than 8`() {
val testValue = BigDecimal("0.123456789999")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 10,
locale = testLocale,
).defaultAmount()
}
Truth.assertThat(formatted)
.isEqualTo("0.12345679".addSymbolWithSpaceLeft(symbol))
}
@Test
fun `defaultAmount decimals more than 8 (short value)`() {
val testValue = BigDecimal("0.12345")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 10,
locale = testLocale,
).defaultAmount()
}
Truth.assertThat(formatted)
.isEqualTo("0.12345".addSymbolWithSpaceLeft(symbol))
}
@Test
fun `defaultAmount decimals minimal (short value)`() {
val testValue = BigDecimal("0.12345")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 2,
locale = testLocale,
).defaultAmount()
}
Truth.assertThat(formatted)
.isEqualTo("0.12".addSymbolWithSpaceLeft(symbol))
}
@Test
fun `defaultAmount less than 2 decimals`() {
val testValue = BigDecimal("0.12345")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 0,
locale = testLocale,
).defaultAmount()
}
Truth.assertThat(formatted)
.isEqualTo("0.12".addSymbolWithSpaceLeft(symbol))
}
@Test
fun `defaultAmount grouping`() {
val testValue = BigDecimal("12345678.11")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 0,
locale = testLocale,
).defaultAmount()
}
Truth.assertThat(formatted)
.isEqualTo("12,345,678.11".addSymbolWithSpaceLeft(symbol))
}
// === shorted() ===
@Test
fun `shorted amount smoke`() {
val testValue = BigDecimal("50000.126123")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 8,
locale = testLocale,
).shorted()
}
Truth.assertThat(formatted)
.isEqualTo("50,000.13".addSymbolWithSpaceLeft(symbol))
}
@Test
fun `shorted amount decimals less than 2 grouping`() {
val testValue = BigDecimal("50000.126123")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 1,
locale = testLocale,
).shorted()
}
Truth.assertThat(formatted)
.isEqualTo("50,000.13".addSymbolWithSpaceLeft(symbol))
}
@Test
fun `shorted amount less than threshold`() {
val testValue = BigDecimal("0.0034567899")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 4,
locale = testLocale,
).shorted()
}
Truth.assertThat(formatted)
.isEqualTo("0.0034".addSymbolWithSpaceLeft(symbol))
}
@Test
fun `shorted amount less than threshold, more decimals`() {
val testValue = BigDecimal("0.00345678")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 8,
locale = testLocale,
).shorted()
}
Truth.assertThat(formatted)
.isEqualTo("0.003456".addSymbolWithSpaceLeft(symbol))
}
@Test
fun `shorted amount diff locale half up`() {
val testValue = BigDecimal("50000.126123")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 8,
locale = testLocale2,
).shorted()
}
Truth.assertThat(formatted)
.isEqualTo("50.000,13".addSymbolWithSpaceRight(symbol))
}
// === uncapped() ===
@Test
fun `uncapped amount`() {
val testValue = BigDecimal("50000.123412341234")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 10,
locale = testLocale,
).uncapped()
}
Truth.assertThat(formatted)
.isEqualTo("50,000.1234123412".addSymbolWithSpaceLeft(symbol))
}
@Test
fun `uncapped amount diff locale`() {
val testValue = BigDecimal("50000.123412341234")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 10,
locale = testLocale2,
).uncapped()
}
Truth.assertThat(formatted)
.isEqualTo("50.000,1234123412".addSymbolWithSpaceRight(symbol))
}
@Test
fun `uncapped amount half up`() {
val testValue = BigDecimal("50000.12341234125")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 10,
locale = testLocale2,
).uncapped()
}
Truth.assertThat(formatted)
.isEqualTo("50.000,1234123413".addSymbolWithSpaceRight(symbol))
}
@Test
fun `uncapped amount min decimals`() {
val testValue = BigDecimal("50000.12341234125")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 1,
locale = testLocale2,
).uncapped()
}
Truth.assertThat(formatted)
.isEqualTo("50.000,12".addSymbolWithSpaceRight(symbol))
}
// === fee ===
@Test
fun `fee amount`() {
val testValue = BigDecimal("0.000123412341234")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 10,
locale = testLocale,
).fee()
}
Truth.assertThat(formatted)
.isEqualTo("0.000123".addSymbolWithSpaceLeft(symbol))
}
@Test
fun `fee amount diff locale`() {
val testValue = BigDecimal("0.000123412341234")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 10,
locale = testLocale2,
).fee()
}
Truth.assertThat(formatted)
.isEqualTo("0,000123".addSymbolWithSpaceRight(symbol))
}
@Test
fun `fee amount canBeLower true`() {
val testValue = BigDecimal("0.000123412341234")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 10,
locale = testLocale,
).fee(canBeLower = true)
}
Truth.assertThat(formatted)
.isEqualTo("<" + CURRENCY_SPACE_FOR_TESTS + "0.000123".addSymbolWithSpaceLeft(symbol))
}
@Test
fun `fee amount canBeLower true (diff locale)`() {
val testValue = BigDecimal("0.000123412341234")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 10,
locale = testLocale2,
).fee(canBeLower = true)
}
Truth.assertThat(formatted)
.isEqualTo("<" + "0,000123".addSymbolWithSpaceRight(symbol))
}
@Test
fun `fee amount lee than threshold`() {
val testValue = BigDecimal("0.0000001234")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 10,
locale = testLocale,
).fee()
}
Truth.assertThat(formatted)
.isEqualTo("<" + CURRENCY_SPACE_FOR_TESTS + "0.000001".addSymbolWithSpaceLeft(symbol))
}
@Test
fun `fee amount min decimals half up`() {
val testValue = BigDecimal("0.125412341234")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 1,
locale = testLocale,
).fee()
}
Truth.assertThat(formatted)
.isEqualTo("0.13".addSymbolWithSpaceLeft(symbol))
}
// === anyDecimals() ===
@Test
fun `anyDecimals smoke`() {
val testValue = BigDecimal("0.123412341234")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 5,
locale = testLocale,
).anyDecimals()
}
Truth.assertThat(formatted)
.isEqualTo("0.12341".addSymbolWithSpaceLeft(symbol))
}
@Test
fun `anyDecimals zero`() {
val testValue = BigDecimal("0.123412341234")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 0,
locale = testLocale,
).anyDecimals()
}
Truth.assertThat(formatted)
.isEqualTo("0".addSymbolWithSpaceLeft(symbol))
}
}

View file

@ -0,0 +1,297 @@
package com.tangem.core.ui.format.bigdecimal
import com.google.common.truth.Truth
import org.junit.Test
import java.math.BigDecimal
import java.util.Locale
internal class BigDecimalFiatFormatTest {
val testLocale = Locale.US
val testLocale2 = Locale.GERMANY
val usdCurrencyCode = "USD"
val usdSymbol = "$"
private fun String.addUsdSymbolLeft() = usdSymbol + this
// === defaultAmount() ===
@Test
fun `defaultAmount smoke`() {
val testValue = BigDecimal("1234.1234")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale,
).defaultAmount()
}
Truth.assertThat(formatted)
.isEqualTo("1,234.12".addUsdSymbolLeft())
}
@Test
fun `defaultAmount half up`() {
val testValue = BigDecimal("1234.125")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale,
).defaultAmount()
}
Truth.assertThat(formatted)
.isEqualTo("1,234.13".addUsdSymbolLeft())
}
@Test
fun `defaultAmount diff locale`() {
val testValue = BigDecimal("1234.1234")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale2,
).defaultAmount()
}
Truth.assertThat(formatted)
.isEqualTo("1.234,12".addSymbolWithSpaceRight(usdSymbol))
}
@Test
fun `defaultAmount less threshold`() {
val testValue = BigDecimal("0.002234")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale,
).defaultAmount()
}
Truth.assertThat(formatted)
.isEqualTo("<" + "0.01".addUsdSymbolLeft())
}
@Test
fun `defaultAmount less threshold diff locale`() {
val testValue = BigDecimal("0.002234")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale2,
).defaultAmount()
}
Truth.assertThat(formatted)
.isEqualTo("<" + "0,01".addSymbolWithSpaceRight(usdSymbol))
}
// === approximateAmount() ===
@Test
fun `approximateAmount smoke`() {
val testValue = BigDecimal("1234.1234")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale,
).approximateAmount()
}
Truth.assertThat(formatted)
.isEqualTo("~" + "1,234.12".addUsdSymbolLeft())
}
@Test
fun `approximateAmount half up`() {
val testValue = BigDecimal("1234.125")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale,
).approximateAmount()
}
Truth.assertThat(formatted)
.isEqualTo("~" + "1,234.13".addUsdSymbolLeft())
}
@Test
fun `approximateAmount diff locale`() {
val testValue = BigDecimal("1234.1234")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale2,
).approximateAmount()
}
Truth.assertThat(formatted)
.isEqualTo("~" + "1.234,12".addSymbolWithSpaceRight(usdSymbol))
}
@Test
fun `approximateAmount less threshold`() {
val testValue = BigDecimal("0.002234")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale,
).approximateAmount()
}
Truth.assertThat(formatted)
.isEqualTo("<" + "0.01".addUsdSymbolLeft())
}
// === uncapped() ===
@Test
fun `uncapped smoke`() {
val testValue = BigDecimal("1234.1234")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale,
).uncapped()
}
Truth.assertThat(formatted)
.isEqualTo("1,234.12".addUsdSymbolLeft())
}
@Test
fun `uncapped less threshold`() {
val testValue = BigDecimal("0.00121")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale,
).uncapped()
}
Truth.assertThat(formatted)
.isEqualTo("0.00121".addUsdSymbolLeft())
}
@Test
fun `uncapped decimals overflow`() {
val testValue = BigDecimal("0.00123412341234")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale,
).uncapped()
}
Truth.assertThat(formatted)
.isEqualTo("0.001234".addUsdSymbolLeft())
}
// === price() ===
@Test
fun `price smoke`() {
val testValue = BigDecimal("1234.1234")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale,
).price()
}
Truth.assertThat(formatted)
.isEqualTo("1,234.12".addUsdSymbolLeft())
}
@Test
fun `price diff locale`() {
val testValue = BigDecimal("1234.1234")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale2,
).price()
}
Truth.assertThat(formatted)
.isEqualTo("1.234,12".addSymbolWithSpaceRight(usdSymbol))
}
@Test
fun `price less threshold`() {
val testValue = BigDecimal("0.99987")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale,
).price()
}
Truth.assertThat(formatted)
.isEqualTo("0.9999".addUsdSymbolLeft())
}
@Test
fun `price less threshold more decimals strip zeros`() {
val testValue = BigDecimal("0.0000123000")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale,
).price()
}
Truth.assertThat(formatted)
.isEqualTo("0.0000123".addUsdSymbolLeft())
}
@Test
fun `price less threshold too much decimals strip zeros`() {
val testValue = BigDecimal("0.000000000000000000001230001234000")
val formatted = testValue.format {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
locale = testLocale,
).price()
}
Truth.assertThat(formatted)
.isEqualTo("0.00000000000000000000123".addUsdSymbolLeft())
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.core.ui.format.bigdecimal
import com.google.common.truth.Truth
import org.junit.Test
import java.math.BigDecimal
internal class BigDecimalFormatTest {
@Test
fun smoke() {
val value = BigDecimal("1234")
val bgformat = BigDecimalFormat { bg ->
bg.toPlainString() + "!"
}
val expected = "1234!"
Truth.assertThat(
value.format(bgformat),
).isEqualTo(expected)
Truth.assertThat(
value.format { bgformat },
).isEqualTo(expected)
Truth.assertThat(
null.format(fallbackString = "!") { bgformat },
).isEqualTo("!")
}
}

View file

@ -0,0 +1,70 @@
package com.tangem.core.ui.format.bigdecimal
import com.google.common.truth.Truth
import org.junit.Test
import java.math.BigDecimal
import java.util.Locale
internal class BigDecimalPercentFormatTest {
val testLocale = Locale.US
val testLocale2 = Locale.GERMANY
@Test
fun smoke() {
val value = BigDecimal("00.34")
val formatted = value.format {
percent(locale = testLocale)
}
Truth.assertThat(formatted).isEqualTo("34.00%")
}
@Test
fun negative() {
val value = BigDecimal("00.34").negate()
val formatted = value.format {
percent(locale = testLocale)
}
Truth.assertThat(formatted).isEqualTo("34.00%")
}
@Test
fun `negative with sign`() {
val value = BigDecimal("00.34").negate()
val formatted = value.format {
percent(
withoutSign = false,
locale = testLocale,
)
}
Truth.assertThat(formatted).isEqualTo("-34.00%")
}
@Test
fun `default more decimals half up`() {
val value = BigDecimal("00.345678").negate()
val formatted = value.format {
percent(locale = testLocale)
}
Truth.assertThat(formatted).isEqualTo("34.57%")
}
@Test
fun `default diff locale`() {
val value = BigDecimal("00.345678").negate()
val formatted = value.format {
percent(locale = testLocale2)
}
Truth.assertThat(formatted).isEqualTo("34,57".addSymbolWithSpaceRight("%"))
}
}

View file

@ -0,0 +1,7 @@
package com.tangem.core.ui.format.bigdecimal
internal const val CURRENCY_SPACE_FOR_TESTS = '\u00a0'
internal fun String.addSymbolWithSpaceRight(symbol: String): String = "$this$CURRENCY_SPACE_FOR_TESTS$symbol"
internal fun String.addSymbolWithSpaceLeft(symbol: String): String = "$symbol$CURRENCY_SPACE_FOR_TESTS$this"

View file

@ -1,43 +0,0 @@
package com.tangem.utils
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Locale
// todo determine where to place this extensions
fun BigDecimal.toFormattedString(
decimals: Int,
roundingMode: RoundingMode = RoundingMode.DOWN,
locale: Locale = Locale.getDefault(),
): String {
val formatter = NumberFormat.getInstance(locale) as? DecimalFormat
val df = formatter?.apply {
maximumFractionDigits = decimals
minimumFractionDigits = 0
isGroupingUsed = true
this.roundingMode = roundingMode
}
return df?.format(this) ?: this.toPlainString()
}
@Suppress("MagicNumber")
fun BigDecimal.toFormattedCurrencyString(
decimals: Int,
currency: String? = null,
roundingMode: RoundingMode = RoundingMode.DOWN,
limitNumberOfDecimals: Boolean = true,
): String {
val decimalsForRounding = if (limitNumberOfDecimals) {
if (decimals > 8) 8 else decimals
} else {
decimals
}
val formattedAmount = this.toFormattedString(
decimals = decimalsForRounding,
roundingMode = roundingMode,
)
val formattedCurrency = currency?.let { " $it" } ?: ""
return "$formattedAmount$formattedCurrency"
}

View file

@ -11,4 +11,19 @@ interface Converter<I : Any, O : Any?> {
fun convertSet(input: Collection<I>): Set<O> {
return input.mapTo(hashSetOf(), ::convert)
}
fun convertListIgnoreErrors(input: Collection<I>, onError: ((Throwable) -> Unit)? = null): List<O> {
return input.mapNotNull {
try {
convert(it)
} catch (throwable: Throwable) {
onError?.invoke(throwable)
null
}
}
}
fun <T> T?.asMandatory(name: String): T {
return this ?: error("$name must not be null")
}
}

View file

@ -1,6 +1,9 @@
package com.tangem.utils.coroutines
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Job holder. It is automatically finished old job if new one is started
@ -26,4 +29,13 @@ class JobHolder {
fun Job.saveIn(jobHolder: JobHolder): Job = jobHolder.update(job = this)
suspend fun Job.saveInAndJoin(jobHolder: JobHolder) = saveIn(jobHolder).join()
suspend fun Job.saveInAndJoin(jobHolder: JobHolder) = saveIn(jobHolder).join()
fun CoroutineScope.withDebounce(jobHolder: JobHolder, timeMillis: Long = 800L, function: () -> Unit) {
launch {
delay(timeMillis = timeMillis)
function()
}
.saveIn(jobHolder)
}

View file

@ -0,0 +1,30 @@
package com.tangem.data.staking
import com.tangem.datasource.local.token.StakingActionsStore
import com.tangem.domain.staking.model.stakekit.action.StakingAction
import com.tangem.domain.staking.repositories.StakingActionRepository
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
internal class DefaultStakingActionRepository(
private val stakingActionsStore: StakingActionsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : StakingActionRepository {
override suspend fun store(
userWalletId: UserWalletId,
cryptoCurrencyId: CryptoCurrency.ID,
actions: List<StakingAction>,
) {
withContext(dispatchers.io) {
stakingActionsStore.store(userWalletId, cryptoCurrencyId, actions)
}
}
override fun get(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID): Flow<List<StakingAction>> {
return stakingActionsStore.get(userWalletId, cryptoCurrencyId)
}
}

View file

@ -1,32 +0,0 @@
package com.tangem.data.staking
import com.tangem.domain.staking.model.PendingTransaction
import com.tangem.domain.staking.model.stakekit.BalanceItem
import com.tangem.domain.staking.repositories.StakingPendingTransactionRepository
import com.tangem.domain.wallets.models.UserWalletId
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
internal class DefaultStakingPendingTransactionRepository : StakingPendingTransactionRepository {
private val pendingTransactionsMap = ConcurrentHashMap<UserWalletId, CopyOnWriteArrayList<PendingTransaction>>()
override fun saveTransaction(userWalletId: UserWalletId, transaction: PendingTransaction) {
val transactions = pendingTransactionsMap.computeIfAbsent(userWalletId) { CopyOnWriteArrayList() }
transactions.add(transaction)
}
override fun removeTransactions(userWalletId: UserWalletId, transactions: Set<PendingTransaction>) {
pendingTransactionsMap[userWalletId]?.removeAll(transactions)
}
override fun getTransactionsWithBalanceItems(
userWalletId: UserWalletId,
): List<Pair<PendingTransaction, BalanceItem>> {
return pendingTransactionsMap[userWalletId]?.mapNotNull { pendingTransaction: PendingTransaction ->
PendingTransactionItemConverter.convert(pendingTransaction)?.let { balanceItem ->
pendingTransaction to balanceItem
}
} ?: emptyList()
}
}

View file

@ -2,7 +2,6 @@ package com.tangem.data.staking
import android.util.Base64
import arrow.core.getOrElse
import arrow.core.raise.catch
import com.squareup.moshi.Moshi
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
@ -26,21 +25,19 @@ import com.tangem.data.staking.converters.transaction.StakingTransactionTypeConv
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.request.*
import com.tangem.datasource.api.stakekit.models.response.model.NetworkTypeDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionStatusDTO
import com.tangem.datasource.api.stakekit.models.response.model.transaction.tron.TronStakeKitTransaction
import com.tangem.datasource.local.token.StakingBalanceStore
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.domain.core.lce.LceFlow
import com.tangem.domain.core.lce.lceFlow
import com.tangem.domain.staking.model.StakingApproval
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingEntryInfo
import com.tangem.domain.staking.model.stakekit.NetworkType
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.staking.model.stakekit.YieldBalanceList
import com.tangem.domain.staking.model.stakekit.*
import com.tangem.domain.staking.model.stakekit.action.StakingAction
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus
import com.tangem.domain.staking.model.stakekit.action.StakingActionType
import com.tangem.domain.staking.model.stakekit.transaction.ActionParams
import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate
@ -57,6 +54,7 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import timber.log.Timber
@ -103,11 +101,9 @@ internal class DefaultStakingRepository(
private val yieldBalanceConverter = YieldBalanceConverter()
private val yieldBalanceListConverter = YieldBalanceListConverter(yieldBalanceConverter)
private val isYieldBalanceFetching = MutableStateFlow(
value = emptyMap<UserWalletId, Boolean>(),
)
private val tronStakeKitTransactionAdapter by lazy { moshi.adapter(TronStakeKitTransaction::class.java) }
private val networkTypeAdapter by lazy { moshi.adapter(NetworkTypeDTO::class.java) }
private val stakingActionStatusAdapter by lazy { moshi.adapter(StakingActionStatusDTO::class.java) }
override fun getIntegrationKey(cryptoCurrencyId: CryptoCurrency.ID): String = with(cryptoCurrencyId) {
rawNetworkId.plus(rawCurrencyId)
@ -126,7 +122,7 @@ internal class DefaultStakingRepository(
val stakingTokensWithYields = stakeKitApi.getEnabledYields(preferredValidatorsOnly = false)
.getOrThrow()
stakingYieldsStore.store(stakingTokensWithYields.data.filter { it.isAvailable })
stakingYieldsStore.store(stakingTokensWithYields.data.filter { it.isAvailable ?: false })
},
)
}
@ -146,12 +142,46 @@ internal class DefaultStakingRepository(
}
}
override suspend fun getActions(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
networkType: NetworkType,
stakingActionStatus: StakingActionStatus,
): List<StakingAction> {
return withContext(dispatchers.io) {
val address = walletManagersFacade.getDefaultAddress(userWalletId, cryptoCurrency.network).orEmpty()
val networkTypeDto = networkTypeConverter.convertBack(networkType)
val networkTypeString = networkTypeDto.extractJsonName()
val actionStatusDTO = actionStatusConverter.convertBack(stakingActionStatus)
val actionStatusString = actionStatusDTO.extractJsonName()
enterActionResponseConverter.convertListIgnoreErrors(
input = stakeKitApi.getActions(
walletAddress = address,
network = networkTypeString,
status = actionStatusString,
).getOrThrow().data,
onError = { Timber.e("Error converting staking actions list: $it") },
)
}
}
private fun NetworkTypeDTO.extractJsonName(): String {
return networkTypeAdapter.toJson(this).replace("\"", "")
}
private fun StakingActionStatusDTO.extractJsonName(): String {
return stakingActionStatusAdapter.toJson(this).replace("\"", "")
}
override suspend fun getEntryInfo(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): StakingEntryInfo {
return withContext(dispatchers.io) {
val yield = getYield(cryptoCurrencyId, symbol)
StakingEntryInfo(
apr = requireNotNull(yield.validators.maxByOrNull { it.apr.orZero() }?.apr),
apr = requireNotNull(yield.preferredValidators.maxByOrNull { it.apr.orZero() }?.apr),
rewardSchedule = yield.metadata.rewardSchedule,
tokenSymbol = yield.token.symbol,
)
@ -207,23 +237,21 @@ internal class DefaultStakingRepository(
): StakingAction {
return withContext(dispatchers.io) {
val response = when (params.actionCommonType) {
StakingActionCommonType.ENTER -> stakeKitApi.createEnterAction(
StakingActionCommonType.Enter -> stakeKitApi.createEnterAction(
createActionRequestBody(
userWalletId,
network,
params,
),
)
StakingActionCommonType.EXIT -> stakeKitApi.createExitAction(
StakingActionCommonType.Exit -> stakeKitApi.createExitAction(
createActionRequestBody(
userWalletId,
network,
params,
),
)
StakingActionCommonType.PENDING_OTHER,
StakingActionCommonType.PENDING_REWARDS,
-> stakeKitApi.createPendingAction(
is StakingActionCommonType.Pending -> stakeKitApi.createPendingAction(
createPendingActionRequestBody(params),
)
}
@ -239,23 +267,21 @@ internal class DefaultStakingRepository(
): StakingGasEstimate {
return withContext(dispatchers.io) {
val gasEstimateDTO = when (params.actionCommonType) {
StakingActionCommonType.ENTER -> stakeKitApi.estimateGasOnEnter(
StakingActionCommonType.Enter -> stakeKitApi.estimateGasOnEnter(
createActionRequestBody(
userWalletId,
network,
params,
),
)
StakingActionCommonType.EXIT -> stakeKitApi.estimateGasOnExit(
StakingActionCommonType.Exit -> stakeKitApi.estimateGasOnExit(
createActionRequestBody(
userWalletId,
network,
params,
),
)
StakingActionCommonType.PENDING_REWARDS,
StakingActionCommonType.PENDING_OTHER,
-> stakeKitApi.estimateGasOnPending(
is StakingActionCommonType.Pending -> stakeKitApi.estimateGasOnPending(
createPendingActionRequestBody(params),
)
}
@ -389,88 +415,66 @@ internal class DefaultStakingRepository(
refresh: Boolean,
) = withContext(dispatchers.io) {
if (!stakingFeatureToggle.isStakingEnabled) return@withContext
try {
isYieldBalanceFetching.update {
it + (userWalletId to true)
}
cacheRegistry.invokeOnExpire(
key = getYieldBalancesKey(userWalletId),
skipCache = refresh,
block = {
val yields = getEnabledYields()
val availableCurrencies = cryptoCurrencies
.mapNotNull { currency ->
val addresses = walletManagersFacade.getAddresses(userWalletId, currency.network)
val integrationId = integrationIdMap[getIntegrationKey(currency.id)]
if (integrationId != null && yields.any { it.id == integrationId }) {
addresses to integrationId
} else {
null
}
}
.flatMap { (addresses, integrationId) ->
addresses.map { address -> address to integrationId }
}
.map { getBalanceRequestData(it.first.value, it.second) }
.ifEmpty {
cacheRegistry.invalidate(getYieldBalancesKey(userWalletId))
error("No addresses found")
}
val result = stakeKitApi.getMultipleYieldBalances(availableCurrencies).getOrThrow()
cacheRegistry.invokeOnExpire(
key = getYieldBalancesKey(userWalletId),
skipCache = refresh,
block = {
val yields = getEnabledYields().ifEmpty {
Timber.i("No enabled yields for $userWalletId")
stakingBalanceStore.store(userWalletId, emptySet())
stakingBalanceStore.store(userWalletId, result)
},
)
} finally {
isYieldBalanceFetching.update {
it - userWalletId
}
}
return@invokeOnExpire
}
val availableCurrencies = cryptoCurrencies
.mapNotNull { currency ->
val addresses = walletManagersFacade.getAddresses(userWalletId, currency.network)
val integrationId = integrationIdMap[getIntegrationKey(currency.id)]
if (integrationId != null && yields.any { it.id == integrationId }) {
addresses to integrationId
} else {
null
}
}
.flatMap { (addresses, integrationId) ->
addresses.map { address -> address to integrationId }
}
.map { getBalanceRequestData(it.first.value, it.second) }
.ifEmpty {
Timber.i("No yield balances available for $userWalletId")
stakingBalanceStore.store(userWalletId, emptySet())
cacheRegistry.invalidate(getYieldBalancesKey(userWalletId))
return@invokeOnExpire
}
val result = stakeKitApi
.getMultipleYieldBalances(availableCurrencies)
.getOrThrow()
stakingBalanceStore.store(userWalletId, result)
},
)
}
override fun getMultiYieldBalanceFlow(
override fun getMultiYieldBalanceUpdates(
userWalletId: UserWalletId,
cryptoCurrencies: List<CryptoCurrency>,
): Flow<YieldBalanceList> = channelFlow {
if (!stakingFeatureToggle.isStakingEnabled) {
send(YieldBalanceList.Empty)
} else {
launch(dispatchers.io) {
stakingBalanceStore.get(userWalletId)
.collectLatest { send(yieldBalanceListConverter.convert(it)) }
}
stakingBalanceStore.get(userWalletId)
.onEach {
val balances = yieldBalanceListConverter.convert(it)
send(balances)
}
.launchIn(scope = this + dispatchers.io)
withContext(dispatchers.io) {
fetchMultiYieldBalance(
userWalletId,
cryptoCurrencies,
)
}
}
}.cancellable()
override fun getMultiYieldBalanceLce(
userWalletId: UserWalletId,
cryptoCurrencies: List<CryptoCurrency>,
): LceFlow<Throwable, YieldBalanceList> = lceFlow {
if (!stakingFeatureToggle.isStakingEnabled) {
send(YieldBalanceList.Empty)
} else {
launch(dispatchers.io) {
combine(
stakingBalanceStore.get(userWalletId),
isYieldBalanceFetching.map { it.getOrElse(userWalletId) { false } },
) { result, isFetching ->
val balances = yieldBalanceListConverter.convert(result)
send(balances, isStillLoading = isFetching)
}.collect()
}
withContext(dispatchers.io) {
catch(
block = { fetchMultiYieldBalance(userWalletId, cryptoCurrencies, refresh = false) },
catch = { raise(it) },
)
fetchMultiYieldBalance(userWalletId, cryptoCurrencies, refresh = false)
}
}
}
@ -583,9 +587,10 @@ internal class DefaultStakingRepository(
}
private fun getEnabledYields(): List<Yield> {
return stakingYieldsStore
.get()
.map { yieldConverter.convert(it) }
return yieldConverter.convertListIgnoreErrors(
input = stakingYieldsStore.get(),
onError = { Timber.e("Error converting enabled yields list: $it") },
)
}
private fun getBalanceRequestData(address: String, integrationId: String): YieldBalanceRequestBody {
@ -640,7 +645,7 @@ internal class DefaultStakingRepository(
Blockchain.Tron.run { id + toCoinId() } to TRON_INTEGRATION_ID,
Blockchain.Ethereum.id + Blockchain.Polygon.toMigratedCointId() to ETHEREUM_POLYGON_INTEGRATION_ID,
// Blockchain.Ethereum.id + Blockchain.Polygon.toCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID,
// Blockchain.BSC.run { id + toCoinId() } to BINANCE_INTEGRATION_ID,
Blockchain.BSC.run { id + toCoinId() } to BINANCE_INTEGRATION_ID,
// Blockchain.Polkadot.run { id + toCoinId() } to POLKADOT_INTEGRATION_ID,
// Blockchain.Avalanche.run { id + toCoinId() } to AVALANCHE_INTEGRATION_ID,
// Blockchain.Cronos.run { id + toCoinId() } to CRONOS_INTEGRATION_ID,

View file

@ -1,23 +0,0 @@
package com.tangem.data.staking
import com.tangem.domain.staking.model.PendingTransaction
import com.tangem.domain.staking.model.stakekit.BalanceItem
import com.tangem.utils.converter.Converter
import org.joda.time.DateTime
internal object PendingTransactionItemConverter : Converter<PendingTransaction, BalanceItem?> {
override fun convert(value: PendingTransaction): BalanceItem? {
return BalanceItem(
groupId = value.groupId ?: return null,
token = value.token,
type = value.type,
amount = value.amount,
rawCurrencyId = value.rawCurrencyId,
validatorAddress = value.validator?.address,
date = DateTime.now(),
pendingActions = emptyList(),
isPending = true,
)
}
}

View file

@ -17,37 +17,37 @@ class YieldConverter(
override fun convert(value: YieldDTO): Yield {
return Yield(
id = value.id,
token = tokenConverter.convert(value.token),
tokens = value.tokens.map { tokenConverter.convert(it) },
args = convertArgs(value.args),
status = convertStatus(value.status),
apy = value.apy,
rewardRate = value.rewardRate,
rewardType = convertRewardType(value.rewardType),
metadata = convertMetadata(value.metadata),
validators = value.validators
id = value.id.asMandatory("id"),
token = tokenConverter.convert(value.token.asMandatory("token")),
tokens = value.tokens.asMandatory("tokens").map { tokenConverter.convert(it) },
args = convertArgs(value.args.asMandatory("args")),
status = convertStatus(value.status.asMandatory("status")),
apy = value.apy.asMandatory("apy"),
rewardRate = value.rewardRate.asMandatory("rewardRate"),
rewardType = convertRewardType(value.rewardType.asMandatory("rewardType")),
metadata = convertMetadata(value.metadata.asMandatory("metadata")),
validators = value.validators.asMandatory("validators")
.asSequence()
.filter { it.status == ValidatorStatusDTO.ACTIVE }
.map { convertValidator(it) }
.sortedByDescending { it.isStrategicPartner }
.sortedByDescending { it.apr }
.toImmutableList(),
isAvailable = value.isAvailable,
isAvailable = value.isAvailable.asMandatory("isAvailable"),
)
}
private fun convertArgs(argsDTO: YieldDTO.ArgsDTO): Yield.Args {
return Yield.Args(
enter = convertEnter(argsDTO.enter),
enter = convertEnter(argsDTO.enter.asMandatory("enter")),
exit = argsDTO.exit?.let { convertEnter(it) },
)
}
private fun convertEnter(enterDTO: YieldDTO.ArgsDTO.Enter): Yield.Args.Enter {
return Yield.Args.Enter(
addresses = convertAddresses(enterDTO.addresses),
args = enterDTO.args
addresses = convertAddresses(enterDTO.addresses.asMandatory("addresses")),
args = enterDTO.args.asMandatory("args")
.mapKeys { convertArgType(it.key) }
.mapValues { convertAddressArgument(it.value) },
)
@ -55,7 +55,7 @@ class YieldConverter(
private fun convertAddresses(addressesDTO: YieldDTO.ArgsDTO.Enter.Addresses): Yield.Args.Enter.Addresses {
return Yield.Args.Enter.Addresses(
address = convertAddressArgument(addressesDTO.address),
address = convertAddressArgument(addressesDTO.address.asMandatory("address")),
additionalAddresses = addressesDTO.additionalAddresses
?.mapKeys { convertArgType(it.key) }
?.mapValues { convertAddressArgument(it.value) },
@ -73,48 +73,51 @@ class YieldConverter(
private fun convertStatus(statusDTO: YieldDTO.StatusDTO): Yield.Status {
return Yield.Status(
enter = statusDTO.enter,
enter = statusDTO.enter.asMandatory("enter"),
exit = statusDTO.exit,
)
}
private fun convertMetadata(metadataDTO: YieldDTO.MetadataDTO): Yield.Metadata {
return Yield.Metadata(
name = metadataDTO.name,
logoUri = metadataDTO.logoUri,
description = metadataDTO.description,
name = metadataDTO.name.asMandatory("name"),
logoUri = metadataDTO.logoUri.asMandatory("logoUri"),
description = metadataDTO.description.asMandatory("description"),
documentation = metadataDTO.documentation,
gasFeeToken = tokenConverter.convert(metadataDTO.gasFeeTokenDTO),
token = tokenConverter.convert(metadataDTO.tokenDTO),
tokens = metadataDTO.tokensDTO.map { tokenConverter.convert(it) },
type = metadataDTO.type,
rewardSchedule = convertRewardSchedule(metadataDTO.rewardSchedule),
gasFeeToken = tokenConverter.convert(metadataDTO.gasFeeTokenDTO.asMandatory("gasFeeTokenDTO")),
token = tokenConverter.convert(metadataDTO.tokenDTO.asMandatory("tokenDTO")),
tokens = metadataDTO.tokensDTO.asMandatory("tokensDTO").map { tokenConverter.convert(it) },
type = metadataDTO.type.asMandatory("type"),
rewardSchedule = convertRewardSchedule(metadataDTO.rewardSchedule.asMandatory("rewardSchedule")),
cooldownPeriod = metadataDTO.cooldownPeriod?.let { convertPeriod(it) },
warmupPeriod = convertPeriod(metadataDTO.warmupPeriod),
rewardClaiming = convertRewardClaiming(metadataDTO.rewardClaiming),
warmupPeriod = convertPeriod(metadataDTO.warmupPeriod.asMandatory("warmupPeriod")),
rewardClaiming = convertRewardClaiming(metadataDTO.rewardClaiming.asMandatory("rewardClaiming")),
defaultValidator = metadataDTO.defaultValidator,
minimumStake = metadataDTO.minimumStake,
supportsMultipleValidators = metadataDTO.supportsMultipleValidators,
revshare = convertEnabled(metadataDTO.revshare),
fee = convertEnabled(metadataDTO.fee),
supportsMultipleValidators = metadataDTO.supportsMultipleValidators.asMandatory(
"supportsMultipleValidators",
),
revshare = convertEnabled(metadataDTO.revshare.asMandatory("revshare")),
fee = convertEnabled(metadataDTO.fee.asMandatory("fee")),
)
}
private fun convertPeriod(periodDTO: YieldDTO.MetadataDTO.PeriodDTO): Yield.Metadata.Period {
return Yield.Metadata.Period(
days = periodDTO.days,
days = periodDTO.days.asMandatory("days"),
)
}
private fun convertEnabled(enabledDTO: YieldDTO.MetadataDTO.EnabledDTO): Yield.Metadata.Enabled {
return Yield.Metadata.Enabled(
enabled = enabledDTO.enabled,
enabled = enabledDTO.enabled.asMandatory("enabled"),
)
}
private fun convertValidator(validatorDTO: YieldDTO.ValidatorDTO): Yield.Validator {
val address = validatorDTO.address.asMandatory("address")
return Yield.Validator(
address = validatorDTO.address,
address = address,
status = convertValidatorStatus(validatorDTO.status),
name = validatorDTO.name,
image = validatorDTO.image,
@ -177,7 +180,7 @@ class YieldConverter(
}
}
private fun isStrategicPartner(validatorAddress: String, validatorName: String): Boolean {
private fun isStrategicPartner(validatorAddress: String?, validatorName: String): Boolean {
return PARTNERS.any { it == validatorAddress } || PARTNERS_NAMES.any { it.equals(validatorName, true) }
}

View file

@ -2,9 +2,10 @@ package com.tangem.data.staking.converters.action
import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionStatusDTO
import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus
import com.tangem.utils.converter.Converter
import com.tangem.utils.converter.TwoWayConverter
class ActionStatusConverter : TwoWayConverter<StakingActionStatusDTO, StakingActionStatus> {
class ActionStatusConverter : Converter<StakingActionStatusDTO, StakingActionStatus> {
override fun convert(value: StakingActionStatusDTO): StakingActionStatus {
return when (value) {
StakingActionStatusDTO.CANCELED -> StakingActionStatus.CANCELED
@ -16,4 +17,16 @@ class ActionStatusConverter : Converter<StakingActionStatusDTO, StakingActionSta
else -> StakingActionStatus.UNKNOWN
}
}
override fun convertBack(value: StakingActionStatus): StakingActionStatusDTO {
return when (value) {
StakingActionStatus.CANCELED -> StakingActionStatusDTO.CANCELED
StakingActionStatus.CREATED -> StakingActionStatusDTO.CREATED
StakingActionStatus.WAITING_FOR_NEXT -> StakingActionStatusDTO.WAITING_FOR_NEXT
StakingActionStatus.PROCESSING -> StakingActionStatusDTO.PROCESSING
StakingActionStatus.FAILED -> StakingActionStatusDTO.FAILED
StakingActionStatus.SUCCESS -> StakingActionStatusDTO.SUCCESS
else -> StakingActionStatusDTO.UNKNOWN
}
}
}

View file

@ -1,7 +1,7 @@
package com.tangem.data.staking.converters.action
import com.tangem.data.staking.converters.transaction.StakingTransactionConverter
import com.tangem.datasource.api.stakekit.models.response.EnterActionResponse
import com.tangem.datasource.api.stakekit.models.response.ActionDTO
import com.tangem.domain.staking.model.stakekit.action.StakingAction
import com.tangem.utils.converter.Converter
@ -9,9 +9,9 @@ class EnterActionResponseConverter(
private val actionStatusConverter: ActionStatusConverter,
private val stakingActionTypeConverter: StakingActionTypeConverter,
private val transactionConverter: StakingTransactionConverter,
) : Converter<EnterActionResponse, StakingAction> {
) : Converter<ActionDTO, StakingAction> {
override fun convert(value: EnterActionResponse): StakingAction {
override fun convert(value: ActionDTO): StakingAction {
return StakingAction(
id = value.id,
integrationId = value.integrationId,

View file

@ -3,8 +3,8 @@ package com.tangem.data.staking.di
import com.squareup.moshi.Moshi
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.staking.*
import com.tangem.data.staking.DefaultStakingErrorResolver
import com.tangem.data.staking.DefaultStakingPendingTransactionRepository
import com.tangem.data.staking.DefaultStakingRepository
import com.tangem.data.staking.DefaultStakingTransactionHashRepository
import com.tangem.data.staking.converters.error.StakeKitErrorConverter
@ -12,12 +12,10 @@ import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.response.model.error.StakeKitErrorResponse
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.StakingActionsStore
import com.tangem.datasource.local.token.StakingBalanceStore
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.domain.staking.repositories.StakingErrorResolver
import com.tangem.domain.staking.repositories.StakingPendingTransactionRepository
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.staking.repositories.StakingTransactionHashRepository
import com.tangem.domain.staking.repositories.*
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.staking.api.featuretoggles.StakingFeatureToggles
@ -74,8 +72,14 @@ internal object StakingDataModule {
@Provides
@Singleton
fun provideStakingPendingTransactionRepository(): StakingPendingTransactionRepository {
return DefaultStakingPendingTransactionRepository()
fun provideStakingActionRepository(
stakingActionsStore: StakingActionsStore,
dispatchers: CoroutineDispatcherProvider,
): StakingActionRepository {
return DefaultStakingActionRepository(
stakingActionsStore = stakingActionsStore,
dispatchers = dispatchers,
)
}
@Provides

View file

@ -1,6 +1,5 @@
package com.tangem.data.tokens.repository
import arrow.core.raise.catch
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.compatibility.getL2CompatibilityTokenComparison
import com.tangem.blockchainsdk.utils.toCoinId
@ -27,8 +26,6 @@ import com.tangem.datasource.local.preferences.utils.storeObject
import com.tangem.datasource.local.token.ExpressAssetsStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.core.error.DataError
import com.tangem.domain.core.lce.LceFlow
import com.tangem.domain.core.lce.lceFlow
import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
@ -41,7 +38,7 @@ import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import timber.log.Timber
import com.tangem.blockchain.common.FeePaidCurrency as FeePaidSdkCurrency
@ -66,10 +63,6 @@ internal class DefaultCurrenciesRepository(
private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility()
private val customTokensMerger = CustomTokensMerger(tangemTechApi, dispatchers)
private val isMultiCurrencyWalletCurrenciesFetching = MutableStateFlow(
value = emptyMap<UserWalletId, Boolean>(),
)
override suspend fun saveTokens(
userWalletId: UserWalletId,
currencies: List<CryptoCurrency>,
@ -206,19 +199,16 @@ internal class DefaultCurrenciesRepository(
}
}
override fun getWalletCurrenciesUpdates(userWalletId: UserWalletId): LceFlow<Throwable, List<CryptoCurrency>> {
return lceFlow {
val userWallet = catch({ getUserWallet(userWalletId) }) {
raise(it)
}
override fun getWalletCurrenciesUpdates(userWalletId: UserWalletId): Flow<List<CryptoCurrency>> {
return channelFlow {
val userWallet = getUserWallet(userWalletId)
if (userWallet.isMultiCurrency) {
getMultiCurrencyWalletCurrenciesUpdatesLce(userWalletId).collect(::send)
getMultiCurrencyWalletCurrenciesUpdates(userWalletId).collect(::send)
} else {
val currency = catch({ getSingleCurrencyWalletPrimaryCurrency(userWalletId) }) {
raise(it)
}
send(listOf(currency))
val currencies = getSingleCurrencyWalletWithCardCurrencies(userWalletId)
send(currencies)
}
}
}
@ -260,40 +250,14 @@ internal class DefaultCurrenciesRepository(
val userWallet = getUserWallet(userWalletId)
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true)
launch(dispatchers.io) {
getMultiCurrencyWalletCurrencies(userWallet)
.collectLatest(::send)
}
getMultiCurrencyWalletCurrencies(userWallet)
.onEach { send(it) }
.launchIn(scope = this + dispatchers.io)
withContext(dispatchers.io) {
fetchTokensIfCacheExpired(userWallet, refresh = false)
}
}
.cancellable()
}
override fun getMultiCurrencyWalletCurrenciesUpdatesLce(
userWalletId: UserWalletId,
): LceFlow<Throwable, List<CryptoCurrency>> = lceFlow {
val userWallet = getUserWallet(userWalletId)
catch({ ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true) }) {
raise(it)
}
launch(dispatchers.io) {
combine(
getMultiCurrencyWalletCurrencies(userWallet),
isMultiCurrencyWalletCurrenciesFetching.map { it.getOrElse(userWallet.walletId) { false } },
) { currencies, isFetching ->
send(currencies, isStillLoading = isFetching)
}.collect()
}
withContext(dispatchers.io) {
catch({ fetchTokensIfCacheExpired(userWallet, refresh = false) }) {
raise(it)
}
}
}
override suspend fun getMultiCurrencyWalletCurrenciesSync(
@ -385,26 +349,34 @@ internal class DefaultCurrenciesRepository(
override fun isTokensGrouped(userWalletId: UserWalletId): Flow<Boolean> {
return channelFlow {
ensureIsCorrectUserWallet(userWalletId, isMultiCurrencyWalletExpected = true)
val userWallet = getUserWallet(userWalletId)
launch(dispatchers.io) {
if (userWallet.isMultiCurrency) {
getSavedUserTokensResponse(userWalletId)
.map { it.group == UserTokensResponse.GroupType.NETWORK }
.collect(::send)
.map { response -> response.group == UserTokensResponse.GroupType.NETWORK }
.distinctUntilChanged()
.onEach { isGrouped -> send(isGrouped) }
.launchIn(scope = this + dispatchers.io)
} else {
send(element = false)
}
}.cancellable()
}
}
override fun isTokensSortedByBalance(userWalletId: UserWalletId): Flow<Boolean> {
return channelFlow {
ensureIsCorrectUserWallet(userWalletId, isMultiCurrencyWalletExpected = true)
val userWallet = getUserWallet(userWalletId)
launch(dispatchers.io) {
if (userWallet.isMultiCurrency) {
getSavedUserTokensResponse(userWalletId)
.map { it.sort == UserTokensResponse.SortType.BALANCE }
.collect(::send)
.map { response -> response.sort == UserTokensResponse.SortType.BALANCE }
.distinctUntilChanged()
.onEach { isSorted -> send(isSorted) }
.launchIn(scope = this + dispatchers.io)
} else {
send(element = false)
}
}.cancellable()
}
}
override fun isSendBlockedByPendingTransactions(
@ -545,19 +517,7 @@ internal class DefaultCurrenciesRepository(
cacheRegistry.invokeOnExpire(
key = getTokensCacheKey(userWallet.walletId),
skipCache = refresh,
block = {
isMultiCurrencyWalletCurrenciesFetching.update {
it + (userWallet.walletId to true)
}
try {
fetchTokens(userWallet)
} finally {
isMultiCurrencyWalletCurrenciesFetching.update {
it - userWallet.walletId
}
}
},
block = { fetchTokens(userWallet) },
)
}

View file

@ -1,6 +1,5 @@
package com.tangem.data.tokens.repository
import arrow.core.raise.catch
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.address.AddressType
import com.tangem.blockchainsdk.utils.fromNetworkId
@ -15,8 +14,6 @@ import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.core.lce.LceFlow
import com.tangem.domain.core.lce.lceFlow
import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyAddress
@ -28,7 +25,10 @@ import com.tangem.domain.walletmanager.model.UpdateWalletManagerResult
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
@Suppress("LongParameterList")
@ -46,42 +46,16 @@ internal class DefaultNetworksRepository(
private val responseCurrenciesFactory by lazy { ResponseCryptoCurrenciesFactory() }
private val networkStatusFactory by lazy { NetworkStatusFactory() }
private val isNetworkStatusesFetching = MutableStateFlow(
value = emptyMap<UserWalletId, Boolean>(),
)
override fun getNetworkStatusesUpdates(
userWalletId: UserWalletId,
networks: Set<Network>,
): Flow<Set<NetworkStatus>> = channelFlow {
launch(dispatchers.io) {
networksStatusesStore.get(userWalletId)
.collectLatest(::send)
}
networksStatusesStore.get(userWalletId)
.onEach(::send)
.launchIn(scope = this + dispatchers.io)
withContext(dispatchers.io) {
fetchNetworksStatusesIfCacheExpired(userWalletId, networks, false)
}
}
.cancellable()
override fun getNetworkStatusesUpdatesLce(
userWalletId: UserWalletId,
networks: Set<Network>,
): LceFlow<Throwable, Set<NetworkStatus>> = lceFlow {
launch(dispatchers.io) {
combine(
networksStatusesStore.get(userWalletId),
isNetworkStatusesFetching.map { it.getOrElse(userWalletId) { false } },
) { statuses, isFetching ->
send(statuses, isStillLoading = isFetching)
}.collect()
}
withContext(dispatchers.io) {
catch({ fetchNetworksStatusesIfCacheExpired(userWalletId, networks, refresh = false) }) {
raise(it)
}
fetchNetworksStatusesIfCacheExpired(userWalletId, networks, refresh = false)
}
}
@ -127,83 +101,36 @@ internal class DefaultNetworksRepository(
}
}
override suspend fun getNetworkAddress(
userWalletId: UserWalletId,
currency: CryptoCurrency,
): CryptoCurrencyAddress = withContext(dispatchers.io) {
CryptoCurrencyAddress(
cryptoCurrency = currency,
address = walletManagersFacade.getAddresses(userWalletId, currency.network)
.firstOrNull { it.type == AddressType.Default }
?.value.orEmpty(),
)
}
override fun getNetworkAddressFlow(
userWalletId: UserWalletId,
currency: CryptoCurrency,
): Flow<CryptoCurrencyAddress> = channelFlow {
launch(dispatchers.io) {
send(getNetworkAddress(userWalletId, currency))
}
}
override suspend fun getNetworkAddresses(userWalletId: UserWalletId): List<CryptoCurrencyAddress> =
withContext(dispatchers.io) {
// Get list of currencies matching [network]
val currencies = getCurrencies(userWalletId)
// There is no currencies matching given [networks] in [userWalletId]
if (currencies.toList().isEmpty()) return@withContext emptyList()
currencies.toList().map { currency ->
CryptoCurrencyAddress(
cryptoCurrency = currency,
address = walletManagersFacade.getAddresses(userWalletId, currency.network)
.firstOrNull { it.type == AddressType.Default }
?.value.orEmpty(),
)
}
}
override fun getNetworkAddressesFlow(
userWalletId: UserWalletId,
network: Network,
): Flow<List<CryptoCurrencyAddress>> = channelFlow {
launch(dispatchers.io) {
send(getNetworkAddresses(userWalletId, network))
}
}
override fun getNetworkAddressesFlow(userWalletId: UserWalletId): Flow<List<CryptoCurrencyAddress>> = channelFlow {
launch(dispatchers.io) {
send(getNetworkAddresses(userWalletId))
}
}
private suspend fun fetchNetworksStatusesIfCacheExpired(
userWalletId: UserWalletId,
networks: Set<Network>,
refresh: Boolean,
) {
val currencies = getCurrencies(userWalletId, networks)
val networksDeferred = networks.mapNotNull { network ->
fetchNetworkStatusIfCacheExpired(userWalletId, network, currencies, refresh)
) = coroutineScope {
if (refresh) {
val statusesToRefresh = networks.map { NetworkStatus(it, NetworkStatus.Refreshing) }
networksStatusesStore.storeAll(userWalletId, statusesToRefresh)
}
if (networksDeferred.isNotEmpty()) {
try {
isNetworkStatusesFetching.update {
it + (userWalletId to true)
}
val currencies = getCurrencies(userWalletId, networks)
val networksDeferred = networks.mapNotNull { network ->
coroutineScope {
val key = getNetworksStatusesCacheKey(userWalletId, network)
networksDeferred.awaitAll()
} finally {
isNetworkStatusesFetching.update {
it - userWalletId
if (refresh || cacheRegistry.isExpired(key)) {
async {
cacheRegistry.invokeOnExpire(
key = key,
skipCache = refresh,
block = { fetchNetworkStatus(userWalletId, network, currencies) },
)
}
} else {
null
}
}
}
networksDeferred.awaitAll()
}
private suspend fun fetchNetworksPendingTransactions(
@ -222,26 +149,6 @@ internal class DefaultNetworksRepository(
}
}
private suspend fun fetchNetworkStatusIfCacheExpired(
userWalletId: UserWalletId,
network: Network,
currencies: Sequence<CryptoCurrency>,
refresh: Boolean,
): Deferred<Unit>? = coroutineScope {
val key = getNetworksStatusesCacheKey(userWalletId, network)
if (refresh || cacheRegistry.isExpired(key)) {
async {
cacheRegistry.invokeOnExpire(
key = key,
skipCache = refresh,
block = { fetchNetworkStatus(userWalletId, network, currencies) },
)
}
} else {
null
}
}
private suspend fun fetchNetworkStatus(
userWalletId: UserWalletId,
network: Network,

View file

@ -6,10 +6,7 @@ import arrow.core.right
import com.tangem.domain.appcurrency.error.SelectedAppCurrencyError
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEmpty
import kotlinx.coroutines.flow.*
class GetSelectedAppCurrencyUseCase(
private val appCurrencyRepository: AppCurrencyRepository,
@ -21,4 +18,8 @@ class GetSelectedAppCurrencyUseCase(
.catch { emit(SelectedAppCurrencyError.DataError(it).left()) }
.onEmpty { emit(SelectedAppCurrencyError.NoAppCurrencySelected.left()) }
}
suspend fun invokeSync(): Either<SelectedAppCurrencyError, AppCurrency> {
return invoke().firstOrNull() ?: SelectedAppCurrencyError.NoAppCurrencySelected.left()
}
}

View file

@ -1,11 +1,11 @@
package com.tangem.domain.core.lce
import arrow.atomic.AtomicBoolean
import arrow.core.raise.Raise
import com.tangem.domain.core.utils.lceContent
import com.tangem.domain.core.utils.lceError
import com.tangem.domain.core.utils.lceLoading
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.channels.ProducerScope
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
@ -34,20 +34,19 @@ class LceFlowScope<E : Any, C : Any> @PublishedApi internal constructor(
private val ifLoading: suspend LceFlowScope<E, C>.(C?) -> Unit,
) : Raise<E>, CoroutineScope by producerScope {
val isLoading: AtomicBoolean = AtomicBoolean(value = true)
/**
* Sends a error of type [E] within the [ProducerScope] and then closes it for send.
* All subsequent sends will be ignored.
* Sends an error of type [E] within the [ProducerScope] without closing.
*
* This method blocks the coroutine until a error is handled by the receiver.
*
* If the [ProducerScope] is already closed for send (e.g. after rising another error), it just raises [r]
* without closing.
* This method blocks the coroutine until an error is handled by the receiver.
*
* @param r Error to raise.
*/
override fun raise(r: E): Nothing {
isLoading.set(false)
producerScope.trySendBlocking(r.lceError())
producerScope.close()
raise.raise(r.lceError())
}
@ -66,6 +65,8 @@ class LceFlowScope<E : Any, C : Any> @PublishedApi internal constructor(
* @param isStillLoading A flag indicating whether the content is still loading.
*/
suspend fun send(content: C, isStillLoading: Boolean = false) {
isLoading.set(isStillLoading)
val value = if (isStillLoading) {
ifLoading(content)
return
@ -81,13 +82,10 @@ class LceFlowScope<E : Any, C : Any> @PublishedApi internal constructor(
*
* This method suspends until the [Lce] instance is handled by the receiver.
*
* If the [ProducerScope] is closed for send (e.g. after rising a error), it does nothing.
*
* @param value The [Lce] instance to send.
*/
@OptIn(DelicateCoroutinesApi::class)
suspend fun send(value: Lce<E, C>) {
if (producerScope.isClosedForSend) return
isLoading.set(value.isLoading())
producerScope.send(value)
}

View file

@ -1,6 +1,8 @@
package com.tangem.domain.core.lce
import arrow.atomic.Atomic
import arrow.core.Either
import arrow.core.identity
import arrow.core.raise.Raise
import arrow.core.raise.RaiseDSL
import arrow.core.raise.recover
@ -97,6 +99,12 @@ class LceRaise<E : Any> @PublishedApi internal constructor(
is Lce.Content -> content
is Lce.Error -> raise(r = this)
}
@RaiseDSL
fun <C : Any> Either<E, C>.bindEither(): C = fold(
ifLeft = { raise(it) },
ifRight = ::identity,
)
}
/**

View file

@ -1,16 +0,0 @@
package com.tangem.domain.staking.model
import com.tangem.domain.staking.model.stakekit.BalanceType
import com.tangem.domain.staking.model.stakekit.Token
import com.tangem.domain.staking.model.stakekit.Yield
import java.math.BigDecimal
data class PendingTransaction(
val groupId: String?,
val token: Token,
val type: BalanceType,
val amount: BigDecimal,
val rawCurrencyId: String?,
val validator: Yield.Validator?,
val balancesId: Int,
)

View file

@ -3,5 +3,4 @@ package com.tangem.domain.staking.model
data class SubmitHashData(
val transactionHash: String,
val transactionId: String,
val pendingTransaction: PendingTransaction,
)

View file

@ -18,6 +18,11 @@ data class Yield(
val isAvailable: Boolean,
) {
val preferredValidators: List<Validator>
get() = validators.filter { it.preferred }
fun getCurrentToken(rawCurrencyId: String?) = tokens.firstOrNull { rawCurrencyId == it.coinGeckoId } ?: token
@Serializable
data class Status(
val enter: Boolean,
@ -130,8 +135,6 @@ data class Yield(
APR, // simple rate
UNKNOWN,
}
fun getCurrentToken(rawCurrencyId: String?) = tokens.firstOrNull { rawCurrencyId == it.coinGeckoId } ?: token
}
@Serializable

View file

@ -32,14 +32,6 @@ sealed class YieldBalance {
.distinctBy { it.validatorAddress }
.size
}
fun getBalancesUniqueId(): Int {
// need to exclude rewards because their amount may change frequently
return balance.items
.filter { it.type != BalanceType.REWARDS }
.map { it.amount.toString() + it.type.toString() + it.groupId }
.hashCode()
}
}
data object Empty : YieldBalance()

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