Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-19 18:23:22 +03:00
commit ae15bcfc55
211 changed files with 4715 additions and 3641 deletions

View file

@ -152,6 +152,7 @@ dependencies {
implementation(projects.domain.manageTokens)
implementation(projects.domain.nft)
implementation(projects.domain.nft.models)
implementation(projects.domain.offramp)
implementation(projects.domain.onramp)
implementation(projects.domain.promo)
implementation(projects.domain.promo.models)

View file

@ -146,9 +146,7 @@ abstract class BaseTestCase : TestCase(
return ApplicationInjectionExecutionRule(
toggleStates = mapOf(
"SWAP_REDESIGN_ENABLED" to false,
"NEW_ONRAMP_MAIN_ENABLED" to true,
"HOT_WALLET_ENABLED" to true,
"YIELD_SUPPLY_FEATURE_ENABLED" to true,
"ACCOUNTS_FEATURE_ENABLED" to true,
"FEED_ENABLED" to true,
"GASLESS_TRANSACTIONS_ENABLED" to true,

View file

@ -1,9 +1,7 @@
package com.tangem.tests
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeVertical
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreen
@ -22,30 +20,32 @@ class SendTest : BaseTestCase() {
@DisplayName("Send: check fee notification")
@Test
fun checkFeeNotificationTest() {
val currencyName = "POL (ex-MATIC)"
val feeCurrencyName = "Ethereum"
val feeCurrencySymbol = "ETH"
val scenarioName = "eth_network_balance"
val scenarioState = "Empty"
val currencyName = "USDC"
val feeCurrencyName = "Solana"
val feeCurrencySymbol = "SOL"
val balanceScenarioName = "solana_balance"
val tokensScenarioName = "user_tokens_api"
val balanceState = "Empty"
val tokensState = "SolanaUSDC"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(scenarioName)
resetWireMockScenarioState(balanceScenarioName)
resetWireMockScenarioState(tokensScenarioName)
}
).run {
step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") {
setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState)
step("Set WireMock scenario: '$tokensScenarioName' to state: '$tokensState'") {
setWireMockScenarioState(scenarioName = tokensScenarioName, state = tokensState)
}
step("Set WireMock scenario: '$balanceScenarioName' to state: '$balanceState'") {
setWireMockScenarioState(scenarioName = balanceScenarioName, state = balanceState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Swipe up") {
swipeVertical(SwipeDirection.UP)
}
step("Click on token with name: $currencyName") {
onMainScreen { tokenWithTitleAndAddress(currencyName).clickWithAssertion() }
}

View file

@ -23,7 +23,7 @@ class KusamaWarningsTest : BaseTestCase() {
private val tokenName = "Kusama"
private val amountToLeaveLessThanDeposit = "0.300333"
private val amountToLeaveGreaterThanDeposit = "0.1"
private val depositAmount = "KSM 0.000333333333"
private val depositAmount = "KSM 0.000003333"
private val warningTitle = getResourceString(R.string.send_notification_existential_deposit_title)
private val warningMessage = getResourceString(

View file

@ -23,7 +23,7 @@ class PolkadotWarningsTest : BaseTestCase() {
private val tokenName = "Polkadot"
private val amountToLeaveLessThanDeposit = "1.299"
private val amountToLeaveGreaterThanDeposit = "0.2"
private val depositAmount = "DOT 1.00"
private val depositAmount = "DOT 0.01"
private val warningTitle = getResourceString(R.string.send_notification_existential_deposit_title)
private val warningMessage = getResourceString(

View file

@ -48,7 +48,6 @@ import com.tangem.tap.common.analytics.handlers.appsflyer.AppsFlyerClient
import com.tangem.tap.common.log.TangemAppLoggerInitializer
import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@ -120,8 +119,6 @@ interface ApplicationEntryPoint {
fun getOnboardingRepository(): OnboardingRepository
fun getCoroutineDispatcherProvider(): CoroutineDispatcherProvider
fun getExcludedBlockchains(): ExcludedBlockchains
fun getAppLogsStore(): AppLogsStore

View file

@ -44,7 +44,6 @@ import com.tangem.domain.settings.ShouldInitiallyAskPermissionUseCase
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.staking.SendUnsubmittedHashesUseCase
import com.tangem.domain.wallets.usecase.ClearAllHotWalletContextualUnlockUseCase
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.tester.api.TesterMenuLauncher
import com.tangem.google.GoogleServicesHelper
import com.tangem.operations.backup.BackupService
@ -161,9 +160,6 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
@Inject
internal lateinit var clearAllHotWalletContextualUnlockUseCase: ClearAllHotWalletContextualUnlockUseCase
@Inject
internal lateinit var tangemPayFeatureToggles: TangemPayFeatureToggles
private val viewModel: MainViewModel by viewModels()
private lateinit var appThemeModeFlow: SharedFlow<AppThemeMode>

View file

@ -73,10 +73,8 @@ 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.domain.scanCard.CardScanningFeatureToggles
import com.tangem.tap.domain.tasks.product.DerivationsFinder
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.wallet.BuildConfig
import dagger.hilt.EntryPoints
import kotlinx.coroutines.Dispatchers
@ -89,7 +87,6 @@ import timber.log.Timber
lateinit var store: Store<AppState>
val foregroundActivityObserver = ForegroundActivityObserver
internal lateinit var derivationsFinder: DerivationsFinder
open class TangemApplication : Application(), ImageLoaderFactory, Configuration.Provider {
@ -190,9 +187,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
private val onboardingRepository: OnboardingRepository
get() = entryPoint.getOnboardingRepository()
private val dispatchers: CoroutineDispatcherProvider
get() = entryPoint.getCoroutineDispatcherProvider()
private val excludedBlockchains: ExcludedBlockchains
get() = entryPoint.getExcludedBlockchains()
@ -348,11 +342,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
)
}
derivationsFinder = DerivationsFinder(
userTokensResponseStore = userTokensResponseStore,
dispatchers = dispatchers,
)
appStateHolder.mainStore = store
wcInitializeUseCase.init(

View file

@ -6,7 +6,6 @@ import com.tangem.tap.common.redux.legacy.LegacyMiddleware
import com.tangem.tap.features.details.redux.DetailsMiddleware
import com.tangem.tap.features.details.redux.DetailsState
import com.tangem.tap.features.onboarding.products.wallet.redux.BackupMiddleware
import com.tangem.tap.features.wallet.redux.middlewares.TradeCryptoMiddleware
import com.tangem.tap.proxy.redux.DaggerGraphMiddleware
import com.tangem.tap.proxy.redux.DaggerGraphState
import org.rekotlin.Middleware
@ -29,7 +28,6 @@ data class AppState(
AccessCodeRequestPolicyMiddleware().middleware,
DaggerGraphMiddleware.daggerGraphMiddleware,
LegacyMiddleware.legacyMiddleware,
TradeCryptoMiddleware.middleware,
)
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.tap.data
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.offramp.repository.OfframpRepository
import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder
import com.tangem.tap.network.exchangeServices.SellService
/**
* Default implementation of [OfframpRepository]
*
* @property sellService sell service for getting offramp URL
*/
internal class DefaultOfframpRepository(
private val sellService: SellService,
) : OfframpRepository {
override fun getOfframpUrl(
cryptoCurrency: CryptoCurrency,
fiatCurrencyCode: String,
walletAddress: String,
): String? {
return sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = MutableAppThemeModeHolder.isDarkThemeActive,
)
}
}

View file

@ -12,6 +12,7 @@ import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager
import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager
import com.tangem.tap.domain.tasks.product.BlockchainToDeriveFinder
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
@ -40,6 +41,7 @@ internal class TangemSdkManagerModule {
appFinisher: AppFinisher,
sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
analyticsExceptionHandler: AnalyticsExceptionHandler,
blockchainToDeriveFinder: BlockchainToDeriveFinder,
dispatchers: CoroutineDispatcherProvider,
): TangemSdkManager {
return if (BuildConfig.MOCK_DATA_SOURCE) {
@ -56,6 +58,7 @@ internal class TangemSdkManagerModule {
appFinisher = appFinisher,
sendFeedbackEmailUseCase = sendFeedbackEmailUseCase,
analyticsExceptionHandler = analyticsExceptionHandler,
blockchainToDeriveFinder = blockchainToDeriveFinder,
dispatchers = dispatchers,
)
}

View file

@ -1,9 +1,8 @@
package com.tangem.tap.di.domain
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.earn.repository.EarnRepository
import com.tangem.domain.earn.usecase.*
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -19,15 +18,13 @@ object EarnDomainModule {
}
@Provides
fun provideManageEarnNetworksUseCase(
fun provideGetEarnNetworksUseCase(
earnRepository: EarnRepository,
userWalletsListRepository: UserWalletsListRepository,
multiNetworkStatusSupplier: MultiNetworkStatusSupplier,
multiAccountListSupplier: MultiAccountListSupplier,
): GetEarnNetworksUseCase {
return GetEarnNetworksUseCase(
earnRepository = earnRepository,
userWalletsListRepository = userWalletsListRepository,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
multiAccountListSupplier = multiAccountListSupplier,
)
}

View file

@ -1,9 +1,13 @@
package com.tangem.tap.di.domain
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.offramp.repository.OfframpRepository
import com.tangem.domain.onramp.*
import com.tangem.domain.onramp.repositories.*
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.tap.data.DefaultOfframpRepository
import com.tangem.tap.network.exchangeServices.SellService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -275,4 +279,16 @@ internal object OnrampDomainModule {
promoRepository = promoRepository,
)
}
@Provides
@Singleton
fun provideOfframpRepository(sellService: SellService): OfframpRepository {
return DefaultOfframpRepository(sellService)
}
@Provides
@Singleton
fun provideGetOfframpUrlUseCase(offrampRepository: OfframpRepository): GetOfframpUrlUseCase {
return GetOfframpUrlUseCase(offrampRepository)
}
}

View file

@ -53,11 +53,7 @@ import com.tangem.sdk.api.TangemSdkManager
import com.tangem.sdk.api.visa.VisaCardActivationResponse
import com.tangem.sdk.api.visa.VisaCardActivationTaskMode
import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent
import com.tangem.tap.derivationsFinder
import com.tangem.tap.domain.tasks.product.CreateProductWalletTask
import com.tangem.tap.domain.tasks.product.ResetBackupCardTask
import com.tangem.tap.domain.tasks.product.ResetToFactorySettingsTask
import com.tangem.tap.domain.tasks.product.ScanProductTask
import com.tangem.tap.domain.tasks.product.*
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.TangemPaySignWithdrawalHashTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
@ -85,6 +81,7 @@ internal class DefaultTangemSdkManager(
private val appFinisher: AppFinisher,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder,
dispatchers: CoroutineDispatcherProvider,
) : TangemSdkManager {
@ -172,7 +169,7 @@ internal class DefaultTangemSdkManager(
runTaskAsyncReturnOnMain(
runnable = ScanProductTask(
card = null,
derivationsFinder = derivationsFinder,
blockchainToDeriveFinder = blockchainToDeriveFinder,
allowsRequestAccessCodeFromRepository = allowsRequestAccessCodeFromRepository,
visaCardScanHandler = visaCardScanHandler,
visaCoroutineScope = this,

View file

@ -0,0 +1,74 @@
package com.tangem.tap.domain.tasks.product
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.wallets.derivations.BlockchainToDerive
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.tap.features.demo.DemoHelper
import javax.inject.Inject
/**
* Finder of blockchains to derive.
* Returns only saved, default or demo blockchains without any additional logic
* (no cardano/ethereum additions or unnecessary blockchain removals).
*/
class BlockchainToDeriveFinder @Inject constructor(
private val walletAccountsFetcher: WalletAccountsFetcher,
) {
suspend fun find(card: CardDTO): Set<BlockchainToDerive> {
if (!card.settings.isHDWalletAllowed || card.wallets.isEmpty()) return emptySet()
val userWalletId = UserWalletIdBuilder.card(card).build() ?: return emptySet()
val derivationStyle = card.derivationStyleProvider.getDerivationStyle()
val blockchains = getBlockchains(userWalletId).ifEmpty {
if (DemoHelper.isDemoCardId(card.cardId)) {
getDemoBlockchains(derivationStyle, card.cardId)
} else {
getDefaultBlockchains(derivationStyle)
}
}
return blockchains
}
private suspend fun getBlockchains(userWalletId: UserWalletId): Set<BlockchainToDerive> {
return walletAccountsFetcher.getSaved(userWalletId)?.accounts.orEmpty()
.flatMap { accountDTO ->
accountDTO.tokens.orEmpty()
.filter { it.contractAddress == null }
}
.mapNotNull { coin ->
val blockchain = Blockchain.fromNetworkId(coin.networkId) ?: return@mapNotNull null
val derivationPath = coin.derivationPath?.let(::DerivationPath) ?: return@mapNotNull null
BlockchainToDerive(blockchain, derivationPath)
}
.toSet()
}
private fun getDemoBlockchains(derivationStyle: DerivationStyle?, cardId: String): Set<BlockchainToDerive> {
return DemoHelper.config.getDemoBlockchains(cardId).mapToBlockchainsWithDerivations(derivationStyle)
}
private fun getDefaultBlockchains(derivationStyle: DerivationStyle?): Set<BlockchainToDerive> {
val defaultBlockchains = setOf(Blockchain.Bitcoin, Blockchain.Ethereum)
return defaultBlockchains.mapToBlockchainsWithDerivations(derivationStyle)
}
private fun Set<Blockchain>.mapToBlockchainsWithDerivations(
derivationStyle: DerivationStyle?,
): Set<BlockchainToDerive> {
return mapNotNullTo(hashSetOf()) { blockchain ->
val derivationPath = blockchain.derivationPath(derivationStyle) ?: return@mapNotNullTo null
BlockchainToDerive(blockchain, derivationPath)
}
}
}

View file

@ -1,131 +0,0 @@
package com.tangem.tap.domain.tasks.product
import com.tangem.blockchain.blockchains.cardano.CardanoUtils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
import com.tangem.domain.card.common.TapWorkarounds.hasOldStyleDerivation
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
internal data class BlockchainToDerive(
val blockchain: Blockchain,
val derivationPath: DerivationPath?,
)
// FIXME: May be move to DI, currently unnecessary
internal class DerivationsFinder(
private val userTokensResponseStore: UserTokensResponseStore,
private val dispatchers: CoroutineDispatcherProvider,
) {
suspend fun findBlockchainsToDerive(
card: CardDTO,
derivationStyleProvider: DerivationStyleProvider,
): Set<BlockchainToDerive> {
if (!card.settings.isHDWalletAllowed || card.wallets.isEmpty()) return emptySet()
val userWalletId = UserWalletIdBuilder.card(card).build() ?: return emptySet()
val derivationStyle = derivationStyleProvider.getDerivationStyle()
val blockchains = withContext(dispatchers.io) {
getBlockchains(userWalletId)
}.ifEmpty {
if (DemoHelper.isDemoCardId(card.cardId)) {
getDemoBlockchains(derivationStyle, card.cardId)
} else {
getDefaultBlockchains(derivationStyle)
}
}
// we should generate second key for cardano
// because cardano address generation for wallet2 requires keys from 2 derivations
// https://developers.cardano.org/docs/get-started/cardano-serialization-lib/generating-keys/
blockchains.addSecondCardanoDerivationIfPresent()
if (card.settings.isHDWalletAllowed) {
blockchains.addEthereumBlockchains(derivationStyle)
}
// pay attention to this
if (!card.hasOldStyleDerivation) {
blockchains.removeUnnecessaryBlockchains()
}
return blockchains
}
private suspend fun getBlockchains(userWalletId: UserWalletId): MutableSet<BlockchainToDerive> {
val responseTokens = userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId)?.tokens
?: return hashSetOf()
return responseTokens.asSequence()
.filter { it.contractAddress == null }
.mapNotNull { coin ->
val blockchain = Blockchain.fromNetworkId(coin.networkId) ?: return@mapNotNull null
val derivationPath = coin.derivationPath?.let(::DerivationPath)
BlockchainToDerive(blockchain, derivationPath)
}
.toMutableSet()
}
// TODO: Move to user wallet config
private fun getDemoBlockchains(derivationStyle: DerivationStyle?, cardId: String): MutableSet<BlockchainToDerive> {
return DemoHelper.config.getDemoBlockchains(cardId).mapToBlockchainsWithDerivations(derivationStyle)
}
// TODO: Move to user wallet config
private fun getDefaultBlockchains(derivationStyle: DerivationStyle?): MutableSet<BlockchainToDerive> {
val defaultBlockchains = setOf(Blockchain.Bitcoin, Blockchain.Ethereum)
return defaultBlockchains.mapToBlockchainsWithDerivations(derivationStyle)
}
}
private fun MutableSet<BlockchainToDerive>.addEthereumBlockchains(derivationStyle: DerivationStyle?) {
val ethereumBlockchains = setOf(Blockchain.Ethereum, Blockchain.EthereumTestnet)
.mapToBlockchainsWithDerivations(derivationStyle)
addAll(ethereumBlockchains)
}
private fun MutableSet<BlockchainToDerive>.removeUnnecessaryBlockchains() {
val unnecessaryBlockchains = listOf(
Blockchain.BSC, Blockchain.BSCTestnet,
Blockchain.Polygon, Blockchain.PolygonTestnet,
Blockchain.RSK,
Blockchain.Fantom, Blockchain.FantomTestnet,
Blockchain.Avalanche, Blockchain.AvalancheTestnet,
)
removeAll { it.blockchain in unnecessaryBlockchains }
}
private fun MutableSet<BlockchainToDerive>.addSecondCardanoDerivationIfPresent() {
val cardanoDerivation = this
.firstOrNull { it.blockchain == Blockchain.Cardano }
?.derivationPath
?: return
val secondCardanoBlockchain = BlockchainToDerive(
blockchain = Blockchain.Cardano,
derivationPath = CardanoUtils.extendedDerivationPath(cardanoDerivation),
)
add(secondCardanoBlockchain)
}
private fun Set<Blockchain>.mapToBlockchainsWithDerivations(
derivationStyle: DerivationStyle?,
): MutableSet<BlockchainToDerive> {
return mapTo(hashSetOf()) { blockchain ->
BlockchainToDerive(blockchain, blockchain.derivationPath(derivationStyle))
}
}

View file

@ -13,16 +13,14 @@ import com.tangem.common.tlv.Tlv
import com.tangem.common.tlv.TlvDecoder
import com.tangem.crypto.CryptoUtils
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
import com.tangem.data.wallets.derivations.MissedDerivationsFinder
import com.tangem.domain.card.common.TapWorkarounds.isExcluded
import com.tangem.domain.card.common.TapWorkarounds.isNotSupportedInThatRelease
import com.tangem.domain.card.common.TapWorkarounds.isStart2Coin
import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins
import com.tangem.domain.card.common.TapWorkarounds.isVisa
import com.tangem.domain.card.common.TwinsHelper
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.domain.card.common.visa.VisaUtilities
import com.tangem.domain.card.configs.CardConfig
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.CardDTO.Companion.RING_BATCH_IDS
import com.tangem.domain.models.scan.CardDTO.Companion.RING_BATCH_PREFIX
@ -44,11 +42,10 @@ import com.tangem.tap.scope
import com.tangem.tap.store
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.collections.set
internal class ScanProductTask(
private val card: Card?,
private val derivationsFinder: DerivationsFinder?,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder?,
private val visaCardScanHandler: VisaCardScanHandler?,
private val visaCoroutineScope: CoroutineScope?,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles?,
@ -79,7 +76,7 @@ internal class ScanProductTask(
readVisaCard(
session = session,
cardDto = cardDto,
scanWalletProcessor = ScanWalletProcessor(derivationsFinder),
scanWalletProcessor = ScanWalletProcessor(blockchainToDeriveFinder),
callback = callback,
)
return
@ -87,7 +84,7 @@ internal class ScanProductTask(
val commandProcessor = when {
cardDto.isTangemTwins -> ScanTwinProcessor()
else -> ScanWalletProcessor(derivationsFinder)
else -> ScanWalletProcessor(blockchainToDeriveFinder)
}
commandProcessor.proceed(cardDto, session) { processorResult ->
when (processorResult) {
@ -160,7 +157,7 @@ internal class ScanProductTask(
}
private class ScanWalletProcessor(
private val derivationsFinder: DerivationsFinder?,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder?,
) : ProductCommandProcessor<ScanResponse> {
var primaryCard: PrimaryCard? = null
@ -283,7 +280,6 @@ private class ScanWalletProcessor(
callback: (result: CompletionResult<ScanResponse>) -> Unit,
) {
val productType = getWalletProductType(card)
val config = CardConfig.createConfig(card)
scope.launch {
val scanResponse = ScanResponse(
card = card,
@ -291,8 +287,7 @@ private class ScanWalletProcessor(
walletData = session.environment.walletData,
primaryCard = primaryCard,
)
val derivations =
collectDerivations(card, config, scanResponse.derivationStyleProvider)
val derivations = collectDerivations(card, scanResponse)
if (derivations.isEmpty() || !card.settings.isHDWalletAllowed) {
callback(CompletionResult.Success(scanResponse))
return@launch
@ -322,32 +317,13 @@ private class ScanWalletProcessor(
private suspend fun collectDerivations(
card: CardDTO,
config: CardConfig,
derivationStyleProvider: DerivationStyleProvider,
scanResponse: ScanResponse,
): Map<ByteArrayKey, List<DerivationPath>> {
val derivations = mutableMapOf<ByteArrayKey, List<DerivationPath>>()
val blockchains = derivationsFinder
?.findBlockchainsToDerive(card, derivationStyleProvider)
?: return derivations
val blockchains = blockchainToDeriveFinder
?.find(card)
?: return emptyMap()
blockchains.forEach { blockchain ->
val curve = config.primaryCurve(blockchain.blockchain)
val wallet = card.wallets.firstOrNull { it.curve == curve } ?: return@forEach
if (wallet.chainCode == null) return@forEach
val key = wallet.publicKey.toMapKey()
val path = blockchain.derivationPath
if (path != null) {
val addedDerivations = derivations[key]
if (addedDerivations != null) {
derivations[key] = addedDerivations + path
} else {
derivations[key] = listOf(path)
}
}
}
return derivations
return MissedDerivationsFinder(scanResponse).findByBlockchainsToDerive(blockchains)
}
}

View file

@ -25,7 +25,7 @@ class FinalizeTwinTask(
is CompletionResult.Success ->
ScanProductTask(
card = readResult.data,
derivationsFinder = null,
blockchainToDeriveFinder = null,
visaCardScanHandler = null,
visaCoroutineScope = null,
onboardingV2FeatureToggles = null,

View file

@ -88,7 +88,7 @@ internal class DefaultUserWalletsListRepository(
.map { wallets.updateWith(it) }
}
.doOnSuccess { loadedWallets ->
userWallets.update { toUpdate ->
userWallets.update { _ ->
val selectedUserWalletId = selectedUserWalletRepository.get()
selectedUserWallet.value = loadedWallets.firstOrNull { it.walletId == selectedUserWalletId }
?: loadedWallets.firstOrNull()?.also {
@ -240,7 +240,7 @@ internal class DefaultUserWalletsListRepository(
}
}
@Suppress("CyclomaticComplexMethod")
@Suppress("CyclomaticComplexMethod", "LongMethod")
override suspend fun unlock(
userWalletId: UserWalletId,
unlockMethod: UserWalletsListRepository.UnlockMethod,
@ -315,7 +315,13 @@ internal class DefaultUserWalletsListRepository(
sensitiveInformationRepository.getAll(listOf(encryptionKey))
.doOnSuccess { sensitiveInfo ->
updateWallets { it?.updateWith(sensitiveInfo) }
updateWallets { wallets ->
// It is necessary to update derivations because when scanning we obtain the missing keys
wallets?.updateWith(
walletIdToSensitiveInformation = sensitiveInfo,
walletIdToDerivedKeys = mapOf(userWallet.walletId to scanResponse.derivedKeys),
)
}
trackSignInEvent(userWallet, Basic.SignedIn.SignInType.Card)
}
.doOnFailure { error -> raise(UnlockWalletError.UnableToUnlock.RawException(error)) }

View file

@ -1,8 +1,10 @@
package com.tangem.tap.domain.userWalletList.utils
import com.tangem.domain.models.scan.KeyWalletPublicKey
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import com.tangem.tap.domain.userWalletList.model.UserWalletPublicInformation
import com.tangem.tap.domain.userWalletList.model.UserWalletSensitiveInformation
@ -72,7 +74,10 @@ internal fun List<UserWalletPublicInformation>.toUserWallets(): List<UserWallet>
return this.map { it.toUserWallet() }
}
internal fun UserWallet.updateWith(sensitiveInformation: UserWalletSensitiveInformation): UserWallet {
internal fun UserWallet.updateWith(
sensitiveInformation: UserWalletSensitiveInformation,
derivedKeys: Map<KeyWalletPublicKey, ExtendedPublicKeysMap>?,
): UserWallet {
return when (this) {
is UserWallet.Cold -> {
copy(
@ -80,6 +85,7 @@ internal fun UserWallet.updateWith(sensitiveInformation: UserWalletSensitiveInfo
card = scanResponse.card.copy(
wallets = requireNotNull(sensitiveInformation.wallets),
),
derivedKeys = derivedKeys ?: scanResponse.derivedKeys,
// visaCardActivationStatus = sensitiveInformation.visaCardActivationStatus,
),
)
@ -92,14 +98,20 @@ internal fun UserWallet.updateWith(sensitiveInformation: UserWalletSensitiveInfo
internal fun List<UserWallet>.updateWith(
walletIdToSensitiveInformation: Map<UserWalletId, UserWalletSensitiveInformation>,
walletIdToDerivedKeys: Map<UserWalletId, Map<KeyWalletPublicKey, ExtendedPublicKeysMap>>? = null,
): List<UserWallet> {
return if (walletIdToSensitiveInformation.isEmpty()) {
this
} else {
this.map { wallet ->
walletIdToSensitiveInformation[wallet.walletId]
?.let(wallet::updateWith)
?: wallet
val sensitiveInformation = walletIdToSensitiveInformation[wallet.walletId]
val derivedKeys = walletIdToDerivedKeys?.get(wallet.walletId)
if (sensitiveInformation != null) {
wallet.updateWith(sensitiveInformation, derivedKeys)
} else {
wallet
}
}
}
}

View file

@ -1,66 +0,0 @@
package com.tangem.tap.features.wallet.redux.middlewares
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.Analytics
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.tap.common.analytics.events.Token
import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.common.extensions.dispatchOpenUrl
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.store
import org.rekotlin.Middleware
@Deprecated("Will be removed soon")
object TradeCryptoMiddleware {
val middleware: Middleware<AppState> = { _, appState ->
{ nextDispatch ->
{ action ->
if (action is TradeCryptoAction) {
handle(appState, action)
}
nextDispatch(action)
}
}
}
private fun handle(state: () -> AppState?, action: TradeCryptoAction) {
if (DemoHelper.tryHandle(state)) return
when (action) {
is TradeCryptoAction.FinishSelling -> openReceiptUrl(action.transactionId)
is TradeCryptoAction.Sell -> proceedSellAction(action)
}
}
private fun proceedSellAction(action: TradeCryptoAction.Sell) {
val networkAddress = action.cryptoCurrencyStatus.value.networkAddress
?.defaultAddress
?.let(NetworkAddress.Address::value)
?: return
val currency = action.cryptoCurrencyStatus.currency
store.inject(DaggerGraphState::appStateHolder).sellService?.getUrl(
cryptoCurrency = currency,
fiatCurrencyName = action.appCurrencyCode,
walletAddress = networkAddress,
isDarkTheme = MutableAppThemeModeHolder.isDarkThemeActive,
)?.let { url ->
store.dispatchOpenUrl(url)
Analytics.send(Token.Withdraw.ScreenOpened())
}
}
private fun openReceiptUrl(transactionId: String) {
store.dispatchNavigationAction(AppRouter::pop)
val sellService = store.inject(DaggerGraphState::appStateHolder).sellService
sellService?.getSellCryptoReceiptUrl(transactionId = transactionId)
?.let(store::dispatchOpenUrl)
}
}

View file

@ -21,6 +21,4 @@ interface SellService {
walletAddress: String,
isDarkTheme: Boolean,
): String?
fun getSellCryptoReceiptUrl(transactionId: String): String?
}

View file

@ -177,14 +177,6 @@ class MoonPayService(
return uri.build().toString()
}
override fun getSellCryptoReceiptUrl(transactionId: String): String {
return Uri.Builder()
.scheme(SCHEME)
.authority(URL_SELL)
.appendPath("transaction_receipt")
.appendQueryParameter("transactionId", transactionId).build().toString()
}
private fun createSignature(data: String): String {
val sha256Hmac = Mac.getInstance("HmacSHA256")
val secretKey = SecretKeySpec(secretKeyProvider().toByteArray(), "HmacSHA256")

View file

@ -0,0 +1,134 @@
package com.tangem.tap.data
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder
import com.tangem.tap.network.exchangeServices.SellService
import io.mockk.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultOfframpRepositoryTest {
private val sellService: SellService = mockk()
private val repository = DefaultOfframpRepository(sellService)
private val cryptoCurrency: CryptoCurrency = mockk()
private val fiatCurrencyCode = "USD"
private val walletAddress = "0x1234567890abcdef"
@BeforeEach
fun setUp() {
mockkObject(MutableAppThemeModeHolder)
}
@AfterEach
fun tearDown() {
clearMocks(sellService)
unmockkObject(MutableAppThemeModeHolder)
}
@Test
fun `getOfframpUrl should return url when sellService returns url with light theme`() {
// Arrange
val expectedUrl = "https://moonpay.com/sell?address=$walletAddress&theme=light"
every { MutableAppThemeModeHolder.isDarkThemeActive } returns false
every {
sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = false,
)
} returns expectedUrl
// Act
val result = repository.getOfframpUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = fiatCurrencyCode,
walletAddress = walletAddress,
)
// Assert
assertThat(result).isEqualTo(expectedUrl)
verify(exactly = 1) {
sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = false,
)
}
}
@Test
fun `getOfframpUrl should return url when sellService returns url with dark theme`() {
// Arrange
val expectedUrl = "https://moonpay.com/sell?address=$walletAddress&theme=dark"
every { MutableAppThemeModeHolder.isDarkThemeActive } returns true
every {
sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = true,
)
} returns expectedUrl
// Act
val result = repository.getOfframpUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = fiatCurrencyCode,
walletAddress = walletAddress,
)
// Assert
assertThat(result).isEqualTo(expectedUrl)
verify(exactly = 1) {
sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = true,
)
}
}
@Test
fun `getOfframpUrl should return null when sellService returns null`() {
// Arrange
every { MutableAppThemeModeHolder.isDarkThemeActive } returns false
every {
sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = false,
)
} returns null
// Act
val result = repository.getOfframpUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = fiatCurrencyCode,
walletAddress = walletAddress,
)
// Assert
assertThat(result).isNull()
verify(exactly = 1) {
sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = false,
)
}
}
}

View file

@ -0,0 +1,252 @@
package com.tangem.tap.domain.tasks.product
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.wallets.derivations.BlockchainToDerive
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class BlockchainToDeriveFinderTest {
private val walletAccountsFetcher = mockk<WalletAccountsFetcher>()
private val finder = BlockchainToDeriveFinder(
walletAccountsFetcher = walletAccountsFetcher,
)
@AfterEach
fun tearDown() {
clearMocks(walletAccountsFetcher)
}
@Test
fun `GIVEN card is not HD wallet THEN return empty set`() = runTest {
// Arrange
val card = mockk<CardDTO> {
every { this@mockk.settings.isHDWalletAllowed } returns false
}
// Act
val actual = finder.find(card)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `GIVEN card has empty wallets THEN return empty set`() = runTest {
// Arrange
val card = mockk<CardDTO> {
every { this@mockk.settings.isHDWalletAllowed } returns true
every { this@mockk.wallets } returns emptyList()
}
// Act
val actual = finder.find(card)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `GIVEN saved bitcoin THEN return only bitcoin`() = runTest {
// Arrange
val card = createCardDTO()
val response = createResponse(Blockchain.Bitcoin)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns response
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Bitcoin),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN empty store and common demo card THEN return demo blockchains`() = runTest {
// Arrange
val demoCardId = "AC01000000045754"
val card = createCardDTO(cardId = demoCardId)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns null
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Bitcoin),
createExpected(Blockchain.Ethereum),
createExpected(Blockchain.Dogecoin),
createExpected(Blockchain.Solana),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN empty store and DE00 demo card THEN return demo blockchains`() = runTest {
// Arrange
val demoCardId = "DE00"
val card = createCardDTO(cardId = demoCardId)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns null
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Bitcoin),
createExpected(Blockchain.Ethereum),
createExpected(Blockchain.Dogecoin),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN empty store THEN return default blockchains`() = runTest {
// Arrange
val card = createCardDTO()
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns null
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Bitcoin),
createExpected(Blockchain.Ethereum),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN saved cardano THEN return only cardano`() = runTest {
// Arrange
val card = createCardDTO()
val response = createResponse(Blockchain.Cardano)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns response
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Cardano),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN saved eth-like blockchains THEN return all saved blockchains without filtering`() = runTest {
// Arrange
val card = createCardDTO()
val blockchains = listOf(Blockchain.Ethereum, Blockchain.BSC, Blockchain.Polygon)
val response = createResponse(*blockchains.toTypedArray())
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns response
// Act
val actual = finder.find(card)
// Assert
val expected = blockchains.mapTo(hashSetOf(), ::createExpected)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
private fun createCardDTO(cardId: String = "0001", batchId: String = "AC10"): CardDTO {
val wallet = mockk<CardDTO.Wallet> {
every { this@mockk.publicKey } returns byteArrayOf(0)
}
return mockk<CardDTO> {
every { this@mockk.cardId } returns cardId
every { this@mockk.batchId } returns batchId
every { this@mockk.settings.isHDWalletAllowed } returns true
every { this@mockk.settings.isKeysImportAllowed } returns true
every { this@mockk.firmwareVersion } returns CardDTO.FirmwareVersion(
major = 6,
minor = 33,
patch = 0,
type = com.tangem.common.card.FirmwareVersion.FirmwareType.Release,
)
every { this@mockk.wallets } returns listOf(wallet)
}
}
private fun createResponse(vararg blockchains: Blockchain): GetWalletAccountsResponse {
val tokens = blockchains.map { blockchain ->
mockk<UserTokensResponse.Token> {
every { this@mockk.networkId } returns blockchain.toNetworkId()
every { this@mockk.derivationPath } returns blockchain.getDerivationPath().rawPath
every { this@mockk.contractAddress } returns null
}
}
val account = mockk<WalletAccountDTO> {
every { this@mockk.tokens } returns tokens
}
return mockk {
every { this@mockk.accounts } returns listOf(account)
}
}
private fun createExpected(
blockchain: Blockchain,
derivationPath: DerivationPath = blockchain.getDerivationPath(),
): BlockchainToDerive {
return BlockchainToDerive(blockchain = blockchain, derivationPath = derivationPath)
}
private fun Blockchain.getDerivationPath(): DerivationPath {
return derivationPath(DerivationStyle.V3)!!
}
private companion object {
// for byteArrayOf(0)
val userWalletId = UserWalletId("41448576B8DA24C7D8F5F0F79863D20D7D8312A7F9E50D3248304136DDB7AAD7")
}
}

View file

@ -1,6 +1,3 @@
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import java.util.concurrent.ConcurrentHashMap
plugins {
alias(deps.plugins.kotlin.android) apply false
alias(deps.plugins.kotlin.jvm) apply false
@ -33,83 +30,13 @@ interface Injected {
val fs: FileSystemOperations
}
data class TestStats(
val total: Long = 0,
val passed: Long = 0,
val failed: Long = 0,
val skipped: Long = 0,
)
val testResultsByModule = ConcurrentHashMap<String, TestStats>()
// Test task to run unit tests for debug/googleDebug variant (Android) and all JVM modules
val unitTest by tasks.registering {
group = "verification"
description = "Run unit tests for debug/googleDebug variant and all JVM modules"
doLast {
if (testResultsByModule.isNotEmpty()) {
val totalStats = testResultsByModule.values.fold(TestStats()) { acc, stats ->
TestStats(
total = acc.total + stats.total,
passed = acc.passed + stats.passed,
failed = acc.failed + stats.failed,
skipped = acc.skipped + stats.skipped,
)
}
println("\n" + "=".repeat(80))
println("TEST SUMMARY")
println("=".repeat(80))
testResultsByModule.toSortedMap().forEach { (module, stats) ->
println(" $module: ${stats.total} tests (${stats.passed} passed, ${stats.failed} failed, ${stats.skipped} skipped)")
}
println("-".repeat(80))
println("TOTAL: ${totalStats.total} tests in ${testResultsByModule.size} modules")
println(" Passed: ${totalStats.passed}")
println(" Failed: ${totalStats.failed}")
println(" Skipped: ${totalStats.skipped}")
println("=".repeat(80))
}
}
}
// Test Logging and testCI dependencies
subprojects {
tasks.withType<Test>().configureEach {
println("Test task scheduled: $path")
testLogging {
exceptionFormat = TestExceptionFormat.FULL
showStandardStreams = true
afterSuite(KotlinClosure2<TestDescriptor, TestResult, Unit>({ desc, result ->
if (desc.parent == null) { // will match the outermost suite
testResultsByModule[path] = TestStats(
total = result.testCount,
passed = result.successfulTestCount,
failed = result.failedTestCount,
skipped = result.skippedTestCount,
)
val output =
"Results: ${result.resultType} (${result.testCount} tests, ${result.successfulTestCount} passed, ${result.failedTestCount} failed, ${result.skippedTestCount} skipped)"
val startItem = "| "
val endItem = " |"
val repeatLength = startItem.length + output.length + endItem.length
println(
"\n" + "-".repeat(repeatLength) + "\n" + startItem + output + endItem + "\n" + "-".repeat(
repeatLength
)
)
}
}))
}
}
// Register testCI dependencies
// App module
plugins.withId("com.android.application") {
afterEvaluate {

View file

@ -9,6 +9,8 @@ import kotlin.coroutines.suspendCoroutine
object TangemSiteUrlBuilder {
const val NOTE_MIGRATION_URL = "https://tangem.com/en/?promocode=Note10"
suspend fun getUtmTags(campaign: String?): String {
val langCode = Locale.getDefault().language
val utmCampaignPart = campaign?.let { "&utm_campaign=$it-$langCode" }.orEmpty()

View file

@ -73,7 +73,7 @@ object MockScanResponseFactory {
CardDTO.Wallet(
CardWallet(
publicKey = curve.name.toByteArray(), // IMPORTANT: public key must equal to curve name
chainCode = null,
chainCode = ByteArray(32), // chainCode must not be null for HD wallets
curve = curve,
settings = createSettings(),
totalSignedHashes = null,

View file

@ -47,7 +47,7 @@ class AccountCryptoPortfolioItemStateConverter(
)
return TokenItemState.Content(
id = account.accountId.toItemId(),
iconState = AccountIconItemStateConverter.convert(this),
iconState = AccountIconItemStateConverter().convert(this),
titleState = TokenItemState.TitleState.Content(
text = accountName.toUM().value,
),
@ -73,7 +73,7 @@ class AccountCryptoPortfolioItemStateConverter(
private fun Account.CryptoPortfolio.mapToLoadingState(): TokenItemState.Content {
return TokenItemState.Content(
id = account.accountId.toItemId(),
iconState = AccountIconItemStateConverter.convert(account),
iconState = AccountIconItemStateConverter().convert(account),
titleState = TokenItemState.TitleState.Content(
text = accountName.toUM().value,
),
@ -95,7 +95,7 @@ class AccountCryptoPortfolioItemStateConverter(
private fun Account.CryptoPortfolio.mapToUnreachableState(): TokenItemState.Unreachable {
return TokenItemState.Unreachable(
id = account.accountId.toItemId(),
iconState = AccountIconItemStateConverter.convert(account),
iconState = AccountIconItemStateConverter().convert(account),
titleState = TokenItemState.TitleState.Content(
text = accountName.toUM().value,
),

View file

@ -1,11 +1,14 @@
package com.tangem.common.ui.account
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.utils.converter.Converter
object AccountIconItemStateConverter : Converter<Account, CurrencyIconState.CryptoPortfolio> {
class AccountIconItemStateConverter(
val size: AccountIconSize = AccountIconSize.Default,
) : Converter<Account, CurrencyIconState.CryptoPortfolio> {
override fun convert(value: Account): CurrencyIconState.CryptoPortfolio = when (value) {
is Account.CryptoPortfolio -> when {
@ -13,11 +16,13 @@ object AccountIconItemStateConverter : Converter<Account, CurrencyIconState.Cryp
char = value.accountName.toUM().value,
color = value.icon.color.getUiColor(),
isGrayscale = false,
size = size,
)
else -> CurrencyIconState.CryptoPortfolio.Icon(
resId = value.icon.value.getResId(),
color = value.icon.color.getUiColor(),
isGrayscale = false,
size = size,
)
}
is Account.Payment -> TODO("[REDACTED_JIRA]")

View file

@ -1,17 +1,35 @@
package com.tangem.common.ui.notifications
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.ds.TangemPagerIndicator
import com.tangem.core.ui.ds.message.TangemMessage
import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.ds.message.TangemMessageUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
fun LazyListScope.notifications(
notifications: ImmutableList<NotificationUM>,
@ -106,8 +124,107 @@ fun LazyListScope.notifications(
contentColor = contentColor,
modifier = modifier
.padding(top = topPadding)
.animateItem(),
.animateItem(null, null, null),
)
},
)
}
}
/**
* Displays a list of notifications in a stacked manner using a HorizontalPager.
* If there are multiple notifications, a PagerIndicator is shown below the notifications.
*
* @param notifications List of TangemMessageUM objects to be displayed.
* @param containerColor Color to be used for the background of the notifications.
* @param modifier Optional Modifier for the notifications.
*/
fun LazyListScope.stackedNotifications(
notifications: ImmutableList<TangemMessageUM>?,
containerColor: Color,
modifier: Modifier = Modifier,
) {
item {
if (!notifications.isNullOrEmpty()) {
val notificationsPagerState = rememberPagerState(
pageCount = { notifications.size },
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = TangemTheme.dimens2.x2),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
HorizontalPager(
state = notificationsPagerState,
modifier = Modifier
.fillMaxSize()
.animateItem(null, null, null),
) { page ->
TangemMessage(
messageUM = notifications[page],
contentColor = containerColor,
modifier = modifier,
)
}
if (notifications.size > 1) {
TangemPagerIndicator(
pagerState = notificationsPagerState,
)
}
}
}
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun StackedNotifications_Preview(
@PreviewParameter(StackedNotificationsPreviewProvider::class) params: ImmutableList<TangemMessageUM>,
) {
TangemThemePreviewRedesign {
val contentColor = TangemTheme.colors2.surface.level1
LazyColumn(
modifier = Modifier
.background(contentColor)
.padding(16.dp),
) {
stackedNotifications(
notifications = params,
containerColor = contentColor,
)
}
}
}
private class StackedNotificationsPreviewProvider : PreviewParameterProvider<ImmutableList<TangemMessageUM>> {
override val values: Sequence<ImmutableList<TangemMessageUM>>
get() = sequenceOf(
persistentListOf(
TangemMessageUM(
id = "0",
title = stringReference("First notification"),
subtitle = stringReference("This is the first notification"),
messageEffect = TangemMessageEffect.Magic,
),
),
persistentListOf(
TangemMessageUM(
id = "0",
title = stringReference("First notification"),
subtitle = stringReference("This is the first notification"),
messageEffect = TangemMessageEffect.Magic,
),
TangemMessageUM(
id = "1",
title = stringReference("Second notification"),
subtitle = stringReference("This is the second notification"),
messageEffect = TangemMessageEffect.Card,
),
),
)
}
// endregion

View file

@ -0,0 +1,17 @@
package com.tangem.core.analytics.models.event
import com.tangem.core.analytics.models.AnalyticsEvent
/**
* Offramp (withdraw/sell) analytics events
*/
sealed class OfframpAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Token / Withdraw", event = event, params = params) {
/**
* Withdraw screen opened event
*/
data object ScreenOpened : OfframpAnalyticsEvent("Withdraw Screen Opened")
}

View file

@ -7,14 +7,7 @@
"name": "VISA_ONBOARDING_ENABLED",
"version": "undefined"
},
{
"name": "STAKING_TON_ENABLED",
"version": "5.28.0"
},
{
"name": "STAKING_CARDANO_ENABLED",
"version": "5.31.1"
},
{
"name": "STAKING_ETH_ENABLED",
"version": "undefined"
@ -31,22 +24,6 @@
"name": "HOT_WALLET_CREATION_RESTRICTION_ENABLED",
"version": "5.32.0"
},
{
"name": "TANGEM_PAY_ENABLED",
"version": "5.31.0"
},
{
"name": "YIELD_SUPPLY_FEATURE_ENABLED",
"version": "5.30.0"
},
{
"name": "YIELD_SUPPLY_PENDING_TRANSACTIONS_ENABLED",
"version": "5.33.0"
},
{
"name": "NEW_ONRAMP_MAIN_ENABLED",
"version": "5.31.0"
},
{
"name": "ACCOUNTS_FEATURE_ENABLED",
"version": "5.33.0"

View file

@ -20,6 +20,7 @@ import com.tangem.core.ui.res.TangemThemePreview
fun TangemPullToRefreshContainer(
config: PullToRefreshConfig,
modifier: Modifier = Modifier,
indicatorModifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val state = rememberPullToRefreshState()
@ -32,7 +33,7 @@ fun TangemPullToRefreshContainer(
modifier = modifier,
indicator = {
Indicator(
modifier = Modifier.align(Alignment.TopCenter),
modifier = indicatorModifier.align(Alignment.TopCenter),
isRefreshing = config.isRefreshing,
state = state,
containerColor = TangemTheme.colors.background.tertiary,

View file

@ -0,0 +1,370 @@
package com.tangem.core.ui.ds
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.roundToInt
private const val ANIMATION_DURATION = 300
private const val MAX_VISIBLE_DOTS = 5
private const val MIN_HIDDEN_FOR_SMALL_DOT = 2
private const val MIN_DISTANCE_FOR_SMALL_DOT = 3
private const val MIN_DISTANCE_FOR_HINT_DOT = 2
private val SPACING = 4.dp
private val CURRENT_DOT_SIZE = DpSize(16.dp, 8.dp)
private val NORMAL_DOT_SIZE = DpSize(8.dp, 8.dp)
private val HINT_DOT_SIZE = DpSize(6.dp, 6.dp)
private val SMALL_DOT_SIZE = DpSize(4.dp, 4.dp)
/**
* // TODO Cleanup and document this code, it's quite complex and has some "magic numbers" that need explanation.
*
* A pager indicator that adapts to the number of pages and the current page index.
*
* For 5 or fewer pages, it shows all dots with the current page highlighted.
* For more than 5 pages, it shows a sliding window of 5 dots with size and opacity indicating position.
*
* @param pagerState state of the pager to observe
* @param activeIndicatorColor color for the active page indicator
* @param inactiveIndicatorColor color for the inactive page indicators
* @param modifier modifier for styling
*/
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun TangemPagerIndicator(
pagerState: PagerState,
modifier: Modifier = Modifier,
activeIndicatorColor: Color = TangemTheme.colors2.graphic.neutral.primary,
inactiveIndicatorColor: Color = TangemTheme.colors2.graphic.neutral.tertiary,
) {
val totalPages = pagerState.pageCount
val currentIndex = pagerState.currentPage
if (totalPages == 0) return
val density = LocalDensity.current
val (targetLower, targetUpper) = getWindowBounds(totalPages, currentIndex)
var displayLower by remember { mutableIntStateOf(targetLower) }
var displayUpper by remember { mutableIntStateOf(targetUpper) }
var prevTargetLower by remember { mutableIntStateOf(targetLower) }
val slideOffset = remember { Animatable(0f) }
var isSliding by remember { mutableStateOf(false) }
var slideDirection by remember { mutableIntStateOf(0) }
val fadeProgress = remember { Animatable(0f) }
var fadeJob by remember { mutableStateOf<Job?>(null) }
LaunchedEffect(targetLower) {
if (targetLower != prevTargetLower && totalPages > MAX_VISIBLE_DOTS) {
fadeJob?.cancel()
slideOffset.stop()
fadeProgress.stop()
val dir = if (targetLower > prevTargetLower) 1 else -1
val edgeDotSize = with(density) { (HINT_DOT_SIZE.width + SPACING).toPx() }
val halfEdge = edgeDotSize / 2
isSliding = true
slideDirection = dir
fadeProgress.snapTo(0f)
if (dir > 0) {
displayLower = prevTargetLower
displayUpper = targetUpper
slideOffset.snapTo(halfEdge)
} else {
displayLower = targetLower
displayUpper = prevTargetLower + MAX_VISIBLE_DOTS
slideOffset.snapTo(-halfEdge)
}
prevTargetLower = targetLower
fadeJob = launch {
fadeProgress.animateTo(1f, tween(ANIMATION_DURATION))
}
slideOffset.animateTo(
if (dir > 0) -halfEdge else halfEdge,
tween(ANIMATION_DURATION),
)
displayLower = targetLower
displayUpper = targetUpper
slideOffset.snapTo(0f)
isSliding = false
slideDirection = 0
}
}
val visibleIndices = (displayLower until displayUpper).toList()
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
Row(
modifier = Modifier.offset {
IntOffset(slideOffset.value.roundToInt(), 0)
},
horizontalArrangement = Arrangement.spacedBy(SPACING),
verticalAlignment = Alignment.CenterVertically,
) {
visibleIndices.forEach { index ->
val dotAlpha = when {
!isSliding -> 1f
slideDirection > 0 && index == displayLower -> 1f - fadeProgress.value
slideDirection > 0 && index == displayUpper - 1 -> fadeProgress.value
slideDirection < 0 && index == displayUpper - 1 -> 1f - fadeProgress.value
slideDirection < 0 && index == displayLower -> fadeProgress.value
else -> 1f
}
key(index) {
Dot(
index = index,
currentIndex = currentIndex,
totalPages = totalPages,
activeColor = activeIndicatorColor,
inactiveColor = inactiveIndicatorColor,
modifier = Modifier.graphicsLayer { alpha = dotAlpha },
)
}
}
}
}
}
private fun getWindowBounds(totalPages: Int, currentIndex: Int): Pair<Int, Int> {
if (totalPages <= MAX_VISIBLE_DOTS) {
return 0 to totalPages
}
val lowerBound = when {
currentIndex <= 1 -> 0
currentIndex >= totalPages - 2 -> totalPages - MAX_VISIBLE_DOTS
else -> currentIndex - 2
}
val upperBound = min(lowerBound + MAX_VISIBLE_DOTS, totalPages)
return lowerBound to upperBound
}
private fun getDotSize(index: Int, currentIndex: Int, totalPages: Int): DpSize {
if (index == currentIndex) {
return CURRENT_DOT_SIZE
}
if (totalPages <= MAX_VISIBLE_DOTS) {
return NORMAL_DOT_SIZE
}
val params = DotSizeParams.create(index, currentIndex, totalPages)
return params.calculateSize()
}
private class DotSizeParams private constructor(
val posInWindow: Int,
val currentPosInWindow: Int,
val hiddenLeft: Int,
val hiddenRight: Int,
val distanceFromCurrent: Int,
) {
private val lastPos = MAX_VISIBLE_DOTS - 1
private val isCentered = currentPosInWindow == 2 && hiddenLeft >= 1 && hiddenRight >= 1
fun calculateSize(): DpSize = when {
isCentered -> getCenteredSize()
hiddenRight >= 1 -> getRightEdgeSize()
hiddenLeft >= 1 -> getLeftEdgeSize()
else -> NORMAL_DOT_SIZE
}
private fun getCenteredSize(): DpSize = when (posInWindow) {
0, lastPos -> HINT_DOT_SIZE
else -> NORMAL_DOT_SIZE
}
private fun getRightEdgeSize(): DpSize {
val isLastPos = posInWindow == lastPos
val isSecondToLast = posInWindow == lastPos - 1
val hasExtraHidden = hiddenRight >= MIN_HIDDEN_FOR_SMALL_DOT
val isFarFromCurrent = distanceFromCurrent >= MIN_DISTANCE_FOR_SMALL_DOT
val isModerateDistance = distanceFromCurrent >= MIN_DISTANCE_FOR_HINT_DOT
return when {
isLastPos && hasExtraHidden && isFarFromCurrent -> SMALL_DOT_SIZE
isLastPos && isModerateDistance -> HINT_DOT_SIZE
isSecondToLast && hasExtraHidden && isModerateDistance -> HINT_DOT_SIZE
else -> NORMAL_DOT_SIZE
}
}
private fun getLeftEdgeSize(): DpSize {
val isFirstPos = posInWindow == 0
val isSecondPos = posInWindow == 1
val hasExtraHidden = hiddenLeft >= MIN_HIDDEN_FOR_SMALL_DOT
val isFarFromCurrent = distanceFromCurrent >= MIN_DISTANCE_FOR_SMALL_DOT
val isModerateDistance = distanceFromCurrent >= MIN_DISTANCE_FOR_HINT_DOT
return when {
isFirstPos && hasExtraHidden && isFarFromCurrent -> SMALL_DOT_SIZE
isFirstPos && isModerateDistance -> HINT_DOT_SIZE
isSecondPos && hasExtraHidden && isModerateDistance -> HINT_DOT_SIZE
else -> NORMAL_DOT_SIZE
}
}
companion object {
fun create(index: Int, currentIndex: Int, totalPages: Int): DotSizeParams {
val (windowStart, windowEnd) = getWindowBounds(totalPages, currentIndex)
val posInWindow = index - windowStart
val currentPosInWindow = currentIndex - windowStart
return DotSizeParams(
posInWindow = posInWindow,
currentPosInWindow = currentPosInWindow,
hiddenLeft = windowStart,
hiddenRight = totalPages - windowEnd,
distanceFromCurrent = abs(posInWindow - currentPosInWindow),
)
}
}
}
@Composable
private fun Dot(
index: Int,
currentIndex: Int,
totalPages: Int,
activeColor: Color,
inactiveColor: Color,
modifier: Modifier = Modifier,
) {
val isActive = index == currentIndex
val size = getDotSize(index, currentIndex, totalPages)
val animSpec = tween<Dp>(ANIMATION_DURATION)
val colorSpec = tween<Color>(ANIMATION_DURATION)
val animatedWidth by animateDpAsState(size.width, animSpec, label = "w$index")
val animatedHeight by animateDpAsState(size.height, animSpec, label = "h$index")
val animatedColor by animateColorAsState(
targetValue = if (isActive) activeColor else inactiveColor,
animationSpec = colorSpec,
label = "c$index",
)
val shape = RoundedCornerShape(animatedHeight / 2)
Box(
modifier = modifier
.width(animatedWidth)
.height(animatedHeight)
.background(animatedColor, shape),
)
}
@Preview(showBackground = true)
@Composable
private fun PagerIndicatorPreview() {
TangemThemePreviewRedesign {
Column(
Modifier
.background(TangemTheme.colors.background.primary)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
listOf(0, 1, 2, 3, 4).forEach { page ->
TangemPagerIndicator(rememberPagerState(page) { 5 })
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun PagerIndicator6ItemsPreview() {
TangemThemePreviewRedesign {
Column(
Modifier
.background(TangemTheme.colors.background.primary)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
listOf(0, 1, 2, 3, 4, 5).forEach { page ->
TangemPagerIndicator(rememberPagerState(page) { 6 })
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun PagerIndicator7ItemsPreview() {
TangemThemePreviewRedesign {
Column(
Modifier
.background(TangemTheme.colors.background.primary)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
listOf(0, 1, 2, 3, 4, 5, 6).forEach { page ->
TangemPagerIndicator(rememberPagerState(page) { 7 })
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun PagerIndicator10ItemsPreview() {
TangemThemePreviewRedesign {
Column(
Modifier
.background(TangemTheme.colors.background.primary)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9).forEach { page ->
TangemPagerIndicator(rememberPagerState(page) { 10 })
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun PagerIndicatorSmallCountsPreview() {
TangemThemePreviewRedesign {
Column(
Modifier
.background(TangemTheme.colors.background.primary)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
TangemPagerIndicator(rememberPagerState(0) { 1 })
TangemPagerIndicator(rememberPagerState(1) { 2 })
TangemPagerIndicator(rememberPagerState(1) { 3 })
}
}
}

View file

@ -22,11 +22,11 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.R
import com.tangem.core.ui.components.flicker
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState
import com.tangem.core.ui.ds.button.*
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -58,7 +58,15 @@ fun TangemMessage(
if (messageUM.iconUM != null) {
TangemIcon(
tangemIconUM = messageUM.iconUM,
modifier = Modifier.size(TangemTheme.dimens2.x8),
modifier = Modifier
.align(
if (messageUM.buttonsUM.isEmpty()) {
Alignment.CenterVertically
} else {
Alignment.Top
},
)
.size(TangemTheme.dimens2.x7),
)
}
},
@ -342,6 +350,7 @@ private class TangemMessagePreviewProvider : PreviewParameterProvider<TangemMess
id = "1",
title = stringReference("Title text"),
subtitle = stringReference("Subtext"),
iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24),
messageEffect = TangemMessageEffect.None,
isCentered = true,
),
@ -350,6 +359,7 @@ private class TangemMessagePreviewProvider : PreviewParameterProvider<TangemMess
title = stringReference("Title text"),
subtitle = stringReference("Subtext"),
messageEffect = TangemMessageEffect.Magic,
iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24),
isCentered = false,
buttonsUM = persistentListOf(
TangemMessageButtonUM(
@ -405,9 +415,8 @@ private fun TangemMessage2_Preview() {
content = {
Box(
modifier = Modifier
.size(TangemTheme.dimens2.x10)
.size(TangemTheme.dimens2.x7)
.clip(RoundedCornerShape(TangemTheme.dimens2.x2))
.flicker(isFlickering = true)
.background(TangemTheme.colors2.text.neutral.primary),
)
},

View file

@ -1,5 +1,6 @@
package com.tangem.core.ui.format.bigdecimal
import com.tangem.core.ui.extensions.*
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
@ -9,6 +10,7 @@ import com.tangem.utils.StringsSigns.NON_BREAKING_SPACE
import com.tangem.utils.extensions.isNotWhitespace
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale
@ -34,6 +36,17 @@ class BigDecimalCryptoFormatFull(
override fun invoke(value: BigDecimal): String = defaultAmount()(value)
}
open class BigDecimalCryptoFormatStyled(
val symbol: String,
val decimals: Int,
val spanStyleReference: SpanStyleReference,
val locale: Locale = Locale.getDefault(),
val shouldIgnoreSymbolPosition: Boolean = false,
) : BigDecimalFormatStyled {
override fun invoke(value: BigDecimal): TextReference = defaultAmount(spanStyleReference)(value)
}
// == Initializers ==
fun BigDecimalFormatScope.crypto(
@ -61,6 +74,35 @@ fun BigDecimalFormatScope.crypto(
)
}
fun BigDecimalFormatScope.cryptoStyled(
symbol: String,
decimals: Int,
spanStyleReference: SpanStyleReference,
locale: Locale = Locale.getDefault(),
): BigDecimalCryptoFormatStyled {
return BigDecimalCryptoFormatStyled(
symbol = symbol,
decimals = decimals,
spanStyleReference = spanStyleReference,
locale = locale,
)
}
fun BigDecimalFormatScope.cryptoStyled(
cryptoCurrency: CryptoCurrency,
spanStyleReference: SpanStyleReference,
ignoreSymbolPosition: Boolean = false,
locale: Locale = Locale.getDefault(),
): BigDecimalCryptoFormatStyled {
return BigDecimalCryptoFormatStyled(
symbol = cryptoCurrency.symbol,
decimals = cryptoCurrency.decimals,
spanStyleReference = spanStyleReference,
shouldIgnoreSymbolPosition = ignoreSymbolPosition,
locale = locale,
)
}
// == Formatters ==
fun BigDecimalCryptoFormat.defaultAmount() = BigDecimalFormat { value ->
@ -88,6 +130,51 @@ fun BigDecimalCryptoFormat.defaultAmount() = BigDecimalFormat { value ->
}
}
fun BigDecimalCryptoFormatStyled.defaultAmount(spanStyleReference: SpanStyleReference) =
BigDecimalFormatStyled { value ->
if (shouldIgnoreSymbolPosition) {
val formatter = NumberFormat.getInstance(locale).apply {
maximumFractionDigits = decimals.coerceAtMost(maximumValue = 8)
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
val formattedAmount = formatter.format(value)
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it) } ?: formattedAmount.length
combinedReference(
stringReference(formattedAmount.take(separatorIndex)),
styledStringReference(formattedAmount.drop(separatorIndex), spanStyleReference),
stringReference(NON_BREAKING_SPACE + symbol),
)
} else {
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
currency = usdCurrency
maximumFractionDigits = decimals.coerceAtMost(maximumValue = 8)
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
val formattedAmount = formatter.format(value)
.replaceFiatSymbolWithCrypto(
fiatCurrencySymbol = usdCurrency.getSymbol(locale),
cryptoCurrencySymbol = symbol,
)
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it) } ?: formattedAmount.length
combinedReference(
stringReference(formattedAmount.take(separatorIndex)),
styledStringReference(formattedAmount.drop(separatorIndex), spanStyleReference),
)
}
}
fun BigDecimalCryptoFormat.shorted() = BigDecimalFormat { value ->
val formatter = if (value.isMoreThanThreshold()) {
NumberFormat.getCurrencyInstance(locale).apply {

View file

@ -1,9 +1,11 @@
package com.tangem.core.ui.format.bigdecimal
import com.tangem.core.ui.extensions.*
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.DecimalFormat
import java.text.NumberFormat
import java.util.Locale
@ -15,8 +17,16 @@ open class BigDecimalFiatFormat(
override fun invoke(value: BigDecimal): String = defaultAmount()(value)
}
// == Initializers ==
open class BigDecimalFiatFormatStyled(
val fiatCurrencyCode: String,
val fiatCurrencySymbol: String,
val spanStyleReference: SpanStyleReference,
val locale: Locale = Locale.getDefault(),
) : BigDecimalFormatStyled {
override fun invoke(value: BigDecimal): TextReference = defaultAmount(spanStyleReference)(value)
}
//region == Initializers ==
fun BigDecimalFormatScope.fiat(
fiatCurrencyCode: String,
fiatCurrencySymbol: String,
@ -29,7 +39,20 @@ fun BigDecimalFormatScope.fiat(
)
}
// == Formatters ==
fun BigDecimalFormatScope.fiat(
fiatCurrencyCode: String,
fiatCurrencySymbol: String,
spanStyleReference: SpanStyleReference,
locale: Locale = Locale.getDefault(),
): BigDecimalFiatFormatStyled {
return BigDecimalFiatFormatStyled(
fiatCurrencyCode = fiatCurrencyCode,
fiatCurrencySymbol = fiatCurrencySymbol,
spanStyleReference = spanStyleReference,
locale = locale,
)
}
// endregion == Formatters ==
/**
* Formats fiat amount with default precision.
@ -58,6 +81,38 @@ fun BigDecimalFiatFormat.defaultAmount(): BigDecimalFormat = BigDecimalFormat {
}
}
fun BigDecimalFiatFormatStyled.defaultAmount(spanStyleReference: SpanStyleReference) = BigDecimalFormatStyled { 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
}
val formattingAmount = if (value.isLessThanThreshold()) {
FIAT_FORMAT_THRESHOLD
} else {
value
}
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
val formattedAmount = formatter.format(formattingAmount)
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it) } ?: formattedAmount.length
val wholePart = formattedAmount.take(separatorIndex)
val fractionalPart = formattedAmount.drop(separatorIndex)
combinedReference(
if (formattingAmount.isLessThanThreshold()) stringReference(CAN_BE_LOWER_SIGN) else TextReference.EMPTY,
stringReference(wholePart),
styledStringReference(fractionalPart, spanStyleReference),
)
}
/**
* Formats fiat amount with default precision and adds tilde sign
*/

View file

@ -1,17 +1,27 @@
package com.tangem.core.ui.format.bigdecimal
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import java.math.BigDecimal
interface BigDecimalFormatScope {
companion object { val Empty = object : BigDecimalFormatScope {} }
companion object {
val Empty = object : BigDecimalFormatScope {}
}
}
fun interface BigDecimalFormat : (BigDecimal) -> String, BigDecimalFormatScope
fun interface BigDecimalFormatStyled : (BigDecimal) -> TextReference, BigDecimalFormatScope
inline fun BigDecimal.format(block: BigDecimalFormatScope.() -> BigDecimalFormat): String {
return BigDecimalFormatScope.Empty.block()(this)
}
inline fun BigDecimal.formatStyled(block: BigDecimalFormatScope.() -> BigDecimalFormatStyled): TextReference {
return BigDecimalFormatScope.Empty.block()(this)
}
inline fun BigDecimal?.format(
fallbackString: String = BigDecimalFormatConstants.EMPTY_BALANCE_SIGN,
block: BigDecimalFormatScope.() -> BigDecimalFormat,
@ -20,10 +30,26 @@ inline fun BigDecimal?.format(
return BigDecimalFormatScope.Empty.block()(this)
}
inline fun BigDecimal?.formatStyled(
fallbackString: String = BigDecimalFormatConstants.EMPTY_BALANCE_SIGN,
block: BigDecimalFormatScope.() -> BigDecimalFormatStyled,
): TextReference {
if (this == null) return stringReference(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)
}
fun BigDecimal?.format(
format: BigDecimalFormatStyled,
fallbackString: String = BigDecimalFormatConstants.EMPTY_BALANCE_SIGN,
): TextReference {
if (this == null) return stringReference(fallbackString)
return format(this)
}

View file

@ -16,6 +16,7 @@ object TangemColorPalette {
val Dark4 = Color(0xFF3B3B3B)
val Dark5 = Color(0xFF303030)
val Dark6 = Color(0xFF1E1E1E)
val Dark7 = Color(0xFF171717)
// endregion Dark
// region Dark Alpha

View file

@ -122,8 +122,8 @@ private fun lightThemeColors2(): TangemColors2 {
val surface = TangemColors2.Surface(
level1 = TangemColorPalette.White,
level2 = TangemColorPalette.Light1V2,
level3 = TangemColorPalette.Light1V2,
level4 = TangemColorPalette.White,
level3 = TangemColorPalette.White,
level4 = TangemColorPalette.Light1V2,
)
val controls = TangemColors2.Controls(
backgroundChecked = TangemColorPalette.Dark6,
@ -270,8 +270,8 @@ private fun darkThemeColors2(): TangemColors2 {
borderPrimary = TangemColorPalette.Light4,
)
val surface = TangemColors2.Surface(
level1 = TangemColorPalette.Dark6,
level2 = TangemColorPalette.Black,
level1 = TangemColorPalette.Black,
level2 = TangemColorPalette.Dark7,
level3 = TangemColorPalette.Dark6,
level4 = TangemColorPalette.Dark5,
)

View file

@ -49,7 +49,9 @@ import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import timber.log.Timber
@ -161,10 +163,6 @@ internal class DefaultStakeKitRepository(
private fun getAvailableStakeKitIntegrationsIds(): List<StakingIntegrationID.StakeKit> {
return StakingIntegrationID.StakeKit.entries
// load all integrations for now and filter in use cases if needed
// .filterNot {
// it.blockchain == Blockchain.Cardano && !stakingFeatureToggles.isCardanoStakingEnabled
// }
}
private fun NetworkTypeDTO.extractJsonName(): String {

View file

@ -7,7 +7,6 @@ import com.tangem.data.staking.store.StakeKitBalancesStore
import com.tangem.domain.card.common.TapWorkarounds.isWallet2
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingAvailability
@ -16,7 +15,6 @@ import com.tangem.domain.staking.repositories.P2PEthPoolRepository
import com.tangem.domain.staking.repositories.StakeKitRepository
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.lib.crypto.BlockchainUtils.isCardano
import com.tangem.lib.crypto.BlockchainUtils.isSolana
@ -34,7 +32,6 @@ internal class DefaultStakingRepository(
private val dispatchers: CoroutineDispatcherProvider,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val stakingFeatureToggles: StakingFeatureToggles,
private val walletManagersFacade: WalletManagersFacade,
) : StakingRepository {
override fun getStakingAvailability(
@ -42,7 +39,7 @@ internal class DefaultStakingRepository(
cryptoCurrency: CryptoCurrency,
): Flow<StakingAvailability> {
return channelFlow {
if (!checkFeatureToggleEnabled(userWalletId, cryptoCurrency)) {
if (!checkFeatureToggleEnabled(cryptoCurrency)) {
send(StakingAvailability.Unavailable)
return@channelFlow
}
@ -78,7 +75,7 @@ internal class DefaultStakingRepository(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): StakingAvailability {
if (!checkFeatureToggleEnabled(userWalletId, cryptoCurrency)) {
if (!checkFeatureToggleEnabled(cryptoCurrency)) {
return StakingAvailability.Unavailable
}
@ -118,31 +115,14 @@ internal class DefaultStakingRepository(
}
}
private suspend fun checkFeatureToggleEnabled(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean {
private fun checkFeatureToggleEnabled(cryptoCurrency: CryptoCurrency): Boolean {
return when (cryptoCurrency.network.id.toBlockchain()) {
Blockchain.TON -> stakingFeatureToggles.isTonStakingEnabled
Blockchain.Ethereum -> {
when (cryptoCurrency) {
is CryptoCurrency.Coin -> stakingFeatureToggles.isEthStakingEnabled
is CryptoCurrency.Token -> true
}
}
Blockchain.Cardano -> {
val address = walletManagersFacade.getDefaultAddress(userWalletId, cryptoCurrency.network).orEmpty()
val balance = stakingBalanceStoreV2.getSyncOrNull(
userWalletId = userWalletId,
stakingId = StakingID(
integrationId = StakingIntegrationID.create(currencyId = cryptoCurrency.id)?.value
?: return false,
address = address,
),
)
if ((balance as? StakingBalance.Data.StakeKit)?.balance?.items?.isNotEmpty() == true) {
return true
} else {
stakingFeatureToggles.isCardanoStakingEnabled
}
}
else -> true
}
}

View file

@ -61,7 +61,6 @@ internal object StakingDataModule {
dispatchers: CoroutineDispatcherProvider,
getUserWalletUseCase: GetUserWalletUseCase,
stakingFeatureToggles: StakingFeatureToggles,
walletManagersFacade: WalletManagersFacade,
): StakingRepository {
return DefaultStakingRepository(
stakeKitRepository = stakeKitRepository,
@ -69,7 +68,6 @@ internal object StakingDataModule {
stakingBalanceStoreV2 = stakeKitBalancesStore,
dispatchers = dispatchers,
getUserWalletUseCase = getUserWalletUseCase,
walletManagersFacade = walletManagersFacade,
stakingFeatureToggles = stakingFeatureToggles,
)
}

View file

@ -7,12 +7,6 @@ internal class DefaultStakingFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : StakingFeatureToggles {
override val isTonStakingEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "STAKING_TON_ENABLED")
override val isCardanoStakingEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("STAKING_CARDANO_ENABLED")
override val isEthStakingEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("STAKING_ETH_ENABLED")
}

View file

@ -41,8 +41,6 @@ dependencies {
/** Feature API - remove after removing [HotWalletFeatureToggles] */
implementation(projects.features.hotWallet.api)
/** Feature API - remove after removing [TangemPayFeatureToggles] */
implementation(projects.features.tangempay.details.api)
/** Project - Utils */
implementation(projects.core.utils)

View file

@ -0,0 +1,101 @@
package com.tangem.data.wallets.derivations
import com.tangem.common.card.EllipticCurve
import com.tangem.domain.card.common.TapWorkarounds.hasOldStyleDerivation
import com.tangem.domain.models.scan.KeyWalletPublicKey
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.config.ColdCurvesConfig
import com.tangem.domain.wallets.config.CurvesConfig
import com.tangem.domain.wallets.config.curvesConfig
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.operations.derivation.ExtendedPublicKeysMap
/**
* Source of derivations data
*/
internal sealed interface DerivationsSource {
val isHDWalletAllowed: Boolean
val hasOldStyleDerivation: Boolean
val curvesConfig: CurvesConfig
val derivationStyleProvider: DerivationStyleProvider
fun getWalletPublicKey(curve: EllipticCurve): ByteArray?
fun getDerivedKeys(publicKey: KeyWalletPublicKey): ExtendedPublicKeysMap
data class FromUserWallet(val userWallet: UserWallet) : DerivationsSource {
override val isHDWalletAllowed: Boolean
get() = when (userWallet) {
is UserWallet.Cold -> userWallet.scanResponse.card.settings.isHDWalletAllowed
is UserWallet.Hot -> true
}
override val hasOldStyleDerivation: Boolean
get() = when (userWallet) {
is UserWallet.Cold -> userWallet.scanResponse.card.hasOldStyleDerivation
is UserWallet.Hot -> false
}
override val curvesConfig: CurvesConfig
get() = userWallet.curvesConfig
override val derivationStyleProvider: DerivationStyleProvider
get() = userWallet.derivationStyleProvider
override fun getWalletPublicKey(curve: EllipticCurve): ByteArray? {
return when (userWallet) {
is UserWallet.Cold -> userWallet.scanResponse.getWalletPublicKey(curve)
is UserWallet.Hot -> userWallet.wallets
?.firstOrNull { it.curve == curve && it.chainCode != null }
?.publicKey
}
}
override fun getDerivedKeys(publicKey: KeyWalletPublicKey): ExtendedPublicKeysMap {
return when (userWallet) {
is UserWallet.Cold -> userWallet.scanResponse.getDerivedKeys(publicKey)
is UserWallet.Hot -> {
val derivedKeys = userWallet.wallets
?.firstOrNull { it.publicKey.contentEquals(publicKey.bytes) }
?.derivedKeys
.orEmpty()
ExtendedPublicKeysMap(derivedKeys)
}
}
}
}
data class FromScanResponse(val scanResponse: ScanResponse) : DerivationsSource {
override val isHDWalletAllowed: Boolean
get() = scanResponse.card.settings.isHDWalletAllowed
override val hasOldStyleDerivation: Boolean
get() = scanResponse.card.hasOldStyleDerivation
override val curvesConfig: CurvesConfig
get() = ColdCurvesConfig(scanResponse.card)
override val derivationStyleProvider: DerivationStyleProvider
get() = scanResponse.derivationStyleProvider
override fun getWalletPublicKey(curve: EllipticCurve): ByteArray? {
return scanResponse.getWalletPublicKey(curve)
}
override fun getDerivedKeys(publicKey: KeyWalletPublicKey): ExtendedPublicKeysMap {
return scanResponse.getDerivedKeys(publicKey)
}
}
}
private fun ScanResponse.getWalletPublicKey(curve: EllipticCurve): ByteArray? {
return card.wallets.firstOrNull { it.curve == curve && it.chainCode != null }
?.publicKey
}
private fun ScanResponse.getDerivedKeys(publicKey: KeyWalletPublicKey): ExtendedPublicKeysMap {
return derivedKeys[publicKey] ?: ExtendedPublicKeysMap(emptyMap())
}

View file

@ -3,45 +3,80 @@ package com.tangem.data.wallets.derivations
import com.tangem.blockchain.blockchains.cardano.CardanoUtils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.common.card.EllipticCurve
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.extensions.toMapKey
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.scan.KeyWalletPublicKey
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.config.curvesConfig
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import kotlin.collections.forEach
private typealias DerivationData = Pair<ByteArrayKey, List<DerivationPath>>
internal typealias Derivations = Map<ByteArrayKey, List<DerivationPath>>
/**
* Data class representing a blockchain with its derivation path
*/
data class BlockchainToDerive(
val blockchain: Blockchain,
val derivationPath: DerivationPath,
)
/**
* Finder of missed derivations
*
* @property userWallet User wallet to find derivations for
* @property source Source of derivations data (UserWallet or ScanResponse)
*
[REDACTED_AUTHOR]
*/
internal class MissedDerivationsFinder(private val userWallet: UserWallet) {
class MissedDerivationsFinder private constructor(private val source: DerivationsSource) {
/**
* Secondary constructor for backward compatibility with UserWallet
*/
constructor(userWallet: UserWallet) : this(DerivationsSource.FromUserWallet(userWallet))
/**
* Secondary constructor for ScanResponse
*/
constructor(scanResponse: ScanResponse) : this(DerivationsSource.FromScanResponse(scanResponse))
/** Find missed derivations for given currencies [currencies] */
fun find(currencies: List<CryptoCurrency>): Derivations {
return currencies.map { it.network }.let(::findByNetworks)
}
/** Find missed derivations for given [Network] list */
fun findByNetworks(networks: List<Network>): Derivations {
val blockchainsToDerive = networks.mapNotNull { network ->
val blockchain = network.toBlockchain()
val derivationPath = network.derivationPath.value?.let(::DerivationPath)
?: return@mapNotNull null
BlockchainToDerive(blockchain, derivationPath)
}
return findByBlockchainsToDerive(blockchainsToDerive)
}
/** Find missed derivations for given [BlockchainToDerive] list */
fun findByBlockchainsToDerive(blockchainsToDerive: Collection<BlockchainToDerive>): Derivations {
val enrichedBlockchains = blockchainsToDerive.enrichBlockchains()
return findDerivationsInternal(enrichedBlockchains)
}
/**
* Common implementation for finding derivations
*/
private fun findDerivationsInternal(items: Collection<BlockchainToDerive>): Derivations {
return buildMap<ByteArrayKey, MutableList<DerivationPath>> {
networks
.mapToNewDerivations()
items
.mapNotNull(::mapToNewDerivation)
.forEach { data ->
val current = this[data.first]
if (current != null) {
current.addAll(data.second)
current.distinct()
this[data.first] = current.distinct().toMutableList()
} else {
this[data.first] = data.second.toMutableList()
}
@ -49,31 +84,17 @@ internal class MissedDerivationsFinder(private val userWallet: UserWallet) {
}
}
private fun List<Network>.mapToNewDerivations(): List<DerivationData> {
return mapNotNull { network ->
val blockchain = network.toBlockchain()
val curve = userWallet.curvesConfig.primaryCurve(blockchain) ?: return@mapNotNull null
/**
* Maps a single BlockchainToDerive to derivation data (public key -> derivation paths)
*/
private fun mapToNewDerivation(input: BlockchainToDerive): DerivationData? {
val curve = source.curvesConfig.primaryCurve(input.blockchain) ?: return null
if (!input.blockchain.getSupportedCurves().contains(curve)) return null
val walletPublicKey = when (userWallet) {
is UserWallet.Cold -> {
val wallet = userWallet.scanResponse.card.wallets.firstOrNull { it.curve == curve }
wallet?.publicKey
}
is UserWallet.Hot -> {
val wallet = userWallet.wallets?.firstOrNull { it.curve == curve }
wallet?.publicKey
}
}
val publicKey = source.getWalletPublicKey(curve) ?: return null
walletPublicKey?.let {
findNewDerivations(curve = curve, publicKey = it, network = network)
}
}
}
private fun findNewDerivations(curve: EllipticCurve, publicKey: ByteArray, network: Network): DerivationData? {
val derivationCandidates = network
.getDerivationCandidates(curve)
val derivationCandidates = input.blockchain
.getDerivationCandidates(input.derivationPath)
.ifEmpty { return null }
.filterAlreadyDerivedKeys(publicKey.toMapKey())
.ifEmpty { return null }
@ -81,59 +102,63 @@ internal class MissedDerivationsFinder(private val userWallet: UserWallet) {
return publicKey.toMapKey() to derivationCandidates
}
private fun Network.getDerivationCandidates(curve: EllipticCurve): List<DerivationPath> {
val blockchain = this.toBlockchain()
/**
* Gets all possible derivation paths for a blockchain
*/
private fun Blockchain.getDerivationCandidates(derivationPath: DerivationPath): List<DerivationPath> {
return buildList {
add(blockchain.getDerivationPath(curve = curve))
add(blockchain.getCustomDerivationPath(curve = curve, network = this@getDerivationCandidates))
add(blockchain.getCardanoDerivationPathIfNeeded(network = this@getDerivationCandidates))
// Default derivation path for blockchain
add(getDerivationPath())
// The specified derivation path (can be either default or custom)
add(derivationPath)
// Extended Cardano derivation path if needed
add(getCardanoExtendedDerivationPath(derivationPath))
}
.filterNotNull()
.distinct()
}
private fun Blockchain.getDerivationPath(curve: EllipticCurve): DerivationPath? {
return if (getSupportedCurves().contains(curve)) {
derivationPath(style = userWallet.derivationStyleProvider.getDerivationStyle())
} else {
null
}
private fun Blockchain.getDerivationPath(): DerivationPath? {
return derivationPath(style = source.derivationStyleProvider.getDerivationStyle())
}
private fun Blockchain.getCustomDerivationPath(curve: EllipticCurve, network: Network): DerivationPath? {
return if (getSupportedCurves().contains(curve)) {
network.derivationPath.value?.let(::DerivationPath)
} else {
null
}
}
private fun Blockchain.getCardanoDerivationPathIfNeeded(network: Network): DerivationPath? {
return if (this == Blockchain.Cardano) {
network.derivationPath.value?.let {
CardanoUtils.extendedDerivationPath(derivationPath = DerivationPath(it))
}
} else {
null
}
private fun Blockchain.getCardanoExtendedDerivationPath(customDerivationPath: DerivationPath): DerivationPath? {
if (this != Blockchain.Cardano) return null
return CardanoUtils.extendedDerivationPath(derivationPath = customDerivationPath)
}
private fun List<DerivationPath>.filterAlreadyDerivedKeys(publicKey: KeyWalletPublicKey): List<DerivationPath> {
val alreadyDerivedPaths = getAlreadyDerivedKeys(publicKey)
val alreadyDerivedPaths = source.getDerivedKeys(publicKey).keys.toList()
return filterNot(alreadyDerivedPaths::contains)
}
private fun getAlreadyDerivedKeys(publicKey: KeyWalletPublicKey): List<DerivationPath> {
val extendedPublicKeysMap = when (userWallet) {
is UserWallet.Cold -> userWallet.scanResponse.derivedKeys[publicKey] ?: ExtendedPublicKeysMap(emptyMap())
is UserWallet.Hot -> {
val wallets = userWallet.wallets ?: return emptyList()
wallets.firstOrNull { it.publicKey.contentEquals(publicKey.bytes) }?.derivedKeys
?: ExtendedPublicKeysMap(emptyMap())
}
// region Blockchain enrichment logic
/**
* Enriches blockchains collection:
* - Adds Ethereum if HD wallet is allowed
* - Removes unnecessary blockchains that share derivation path with Ethereum (for cards without old style derivation)
*/
private fun Collection<BlockchainToDerive>.enrichBlockchains(): Collection<BlockchainToDerive> {
if (!source.isHDWalletAllowed) return this
val derivationStyle = source.derivationStyleProvider.getDerivationStyle()
val ethereumDerivationPath = Blockchain.Ethereum.derivationPath(derivationStyle) ?: return this
val withEthereum = this + BlockchainToDerive(Blockchain.Ethereum, ethereumDerivationPath)
// For cards with old style derivation, keep all blockchains
if (source.hasOldStyleDerivation) {
return withEthereum.distinct()
}
return extendedPublicKeysMap.keys.toList()
// For new cards: filter out blockchains with same derivation path as Ethereum (except Ethereum itself)
return withEthereum
.filter { it.derivationPath != ethereumDerivationPath || it.blockchain == Blockchain.Ethereum }
.distinct()
}
// endregion
}

View file

@ -47,18 +47,11 @@ class DefaultHotWalletAccessCodeAttemptsRepository @Inject constructor(
hotWalletId = hotWalletId,
auth = true,
)
val noAuthAttemptId = HotWalletAccessCodeAttemptsRepository.AttemptId(
hotWalletId = hotWalletId,
auth = false,
)
appPreferencesStore.editData {
it.remove(PreferencesKeys.getHotWalletUnlockAttemptsKey(authAttemptId.attemptIdKey()))
it.remove(PreferencesKeys.getHotWalletUnlockAttemptsKey(noAuthAttemptId.attemptIdKey()))
it.remove(PreferencesKeys.getHotWalletUnlockBootKey(authAttemptId.attemptIdKey()))
it.remove(PreferencesKeys.getHotWalletUnlockBootKey(noAuthAttemptId.attemptIdKey()))
it.remove(PreferencesKeys.getHotWalletUnlockDeadlineKey(authAttemptId.attemptIdKey()))
it.remove(PreferencesKeys.getHotWalletUnlockDeadlineKey(noAuthAttemptId.attemptIdKey()))
appPreferencesStore.editData { data ->
data.remove(PreferencesKeys.getHotWalletUnlockAttemptsKey(authAttemptId.attemptIdKey()))
data.remove(PreferencesKeys.getHotWalletUnlockBootKey(authAttemptId.attemptIdKey()))
data.remove(PreferencesKeys.getHotWalletUnlockDeadlineKey(authAttemptId.attemptIdKey()))
}
}
@ -119,13 +112,21 @@ class DefaultHotWalletAccessCodeAttemptsRepository @Inject constructor(
}
else -> {
val remaining = remainingSeconds(deadlineElapsed, bootStored)
Attempts.WithDelay(count, remaining)
val newCount = if (id.auth) {
count
} else {
MAX_FAST_FORWARD_ATTEMPTS
}
Attempts.WithDelay(newCount, remaining)
}
}
}
private fun HotWalletAccessCodeAttemptsRepository.AttemptId.attemptIdKey(): String {
return "${hotWalletId.value}_$auth"
// Regarding [REDACTED_TASK_KEY], the attempts counter must be shared between modes (auth vs signing).
// To provide backward compatibility, we use the same keys but read attempts in auth mode for security reasons.
val isAuthMode = true
return "${hotWalletId.value}_$isAuthMode"
}
private fun currentBootCount(): Int = Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT, 0)

View file

@ -14,11 +14,13 @@ import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.card.configs.MultiWalletCardConfig
import com.tangem.domain.card.configs.Wallet2CardConfig
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import org.junit.Test
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class MissedDerivationsFinderTest {
@Test
@ -97,9 +99,8 @@ internal class MissedDerivationsFinderTest {
val currencies = MockCryptoCurrencyFactory(userWallet).cardano.let(::listOf)
val actual = finder.find(currencies)
Truth.assertThat(actual).containsExactly(
ByteArrayKey(EllipticCurve.Ed25519.name.toByteArray()),
listOf(
val expected = mapOf(
ByteArrayKey(EllipticCurve.Ed25519.name.toByteArray()) to listOf(
DerivationConfigV2.derivations(Blockchain.Cardano).values.first(),
CardanoUtils.extendedDerivationPath(
derivationPath = DerivationPath(
@ -108,7 +109,12 @@ internal class MissedDerivationsFinderTest {
),
),
),
ByteArrayKey(EllipticCurve.Secp256k1.name.toByteArray()) to listOf(
DerivationConfigV2.derivations(Blockchain.Ethereum).values.first(),
),
)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
}
@Test

View file

@ -8,7 +8,7 @@ dependencies {
api(projects.domain.core)
api(projects.domain.models)
api(projects.core.pagination)
implementation(projects.domain.account)
implementation(projects.domain.common)
implementation(projects.domain.networks)
implementation(deps.kotlin.serialization)
}

View file

@ -1,26 +1,26 @@
package com.tangem.domain.earn.usecase
import arrow.core.Either
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.earn.repository.EarnRepository
import com.tangem.domain.models.earn.EarnNetwork
import com.tangem.domain.models.earn.EarnNetworks
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.networks.multi.MultiNetworkStatusProducer
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
* Observes earn networks with [EarnNetwork.isAdded] enriched from user's wallets
* via [multiNetworkStatusSupplier]. Single entry point for all/mine filtering.
* Observes earn networks with [EarnNetwork.isAdded] enriched from user's active (non-archived)
* accounts via [multiAccountListSupplier]. Single entry point for all/mine filtering.
*
* Uses [MultiAccountListSupplier] so that only networks from active accounts are considered;
* archived accounts are not included in [AccountList.accounts].
*/
class GetEarnNetworksUseCase(
private val earnRepository: EarnRepository,
private val userWalletsListRepository: UserWalletsListRepository,
private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier,
private val multiAccountListSupplier: MultiAccountListSupplier,
) {
operator fun invoke(): Flow<EarnNetworks> {
@ -36,24 +36,13 @@ class GetEarnNetworksUseCase(
}.distinctUntilChanged()
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeMyNetworkIds(): Flow<Set<String>> {
return userWalletsListRepository.userWallets
.map { it.orEmpty() }
.flatMapLatest { wallets ->
val activeWallets = wallets
.filterNot(UserWallet::isLocked)
.filter(UserWallet::isMultiCurrency)
if (activeWallets.isEmpty()) {
flowOf(emptySet())
} else {
val flows = activeWallets.map { wallet ->
multiNetworkStatusSupplier(
MultiNetworkStatusProducer.Params(userWalletId = wallet.walletId),
).map { statuses -> statuses.map { it.network.backendId }.toSet() }
}
combine(flows) { arrays -> arrays.flatMap { it }.toSet() }
}
return multiAccountListSupplier()
.map { accountLists ->
accountLists
.flatMap(AccountList::flattenCurrencies)
.map { it.network.backendId }
.toSet()
}
}
}

1
domain/offramp/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,18 @@
plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Domain modules */
api(projects.domain.core)
api(projects.domain.models)
/** Test libraries */
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
}

View file

@ -0,0 +1,41 @@
package com.tangem.domain.offramp
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.offramp.repository.OfframpRepository
/**
* Use case for getting offramp (sell crypto) URL
*
* @property offrampRepository repository for offramp operations
*/
class GetOfframpUrlUseCase(
private val offrampRepository: OfframpRepository,
) {
operator fun invoke(cryptoCurrencyStatus: CryptoCurrencyStatus, appCurrencyCode: String): Either<Error, String> =
either {
val walletAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value
ensure(walletAddress != null) { Error.WalletAddressNotFound }
val url = offrampRepository.getOfframpUrl(
cryptoCurrency = cryptoCurrencyStatus.currency,
fiatCurrencyCode = appCurrencyCode,
walletAddress = walletAddress,
)
ensure(url != null) { Error.UrlNotAvailable }
url
}
/** Offramp use case errors */
sealed class Error {
/** Wallet address not found in currency status */
data object WalletAddressNotFound : Error()
/** Offramp URL is not available for this currency */
data object UrlNotAvailable : Error()
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.domain.offramp.repository
import com.tangem.domain.models.currency.CryptoCurrency
/**
* Repository for offramp (sell crypto) operations
*/
interface OfframpRepository {
/**
* Get offramp (sell) URL for the given cryptocurrency
*
* @param cryptoCurrency crypto currency to sell
* @param fiatCurrencyCode fiat currency code (e.g., "USD", "EUR")
* @param walletAddress wallet address for the refund
* @return URL for offramp service or null if not available
*/
fun getOfframpUrl(cryptoCurrency: CryptoCurrency, fiatCurrencyCode: String, walletAddress: String): String?
}

View file

@ -0,0 +1,127 @@
package com.tangem.domain.offramp
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.offramp.repository.OfframpRepository
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class GetOfframpUrlUseCaseTest {
private val offrampRepository: OfframpRepository = mockk()
private val useCase = GetOfframpUrlUseCase(offrampRepository)
private val cryptoCurrency: CryptoCurrency = mockk()
private val appCurrencyCode = "USD"
private val walletAddress = "0x1234567890abcdef"
private val expectedUrl = "https://moonpay.com/sell?address=$walletAddress"
@BeforeEach
fun resetMocks() {
clearMocks(offrampRepository)
}
@Test
fun `invoke should return url when wallet address and url are available`() {
// Arrange
val cryptoCurrencyStatus = createCryptoCurrencyStatus(walletAddress = walletAddress)
every {
offrampRepository.getOfframpUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = appCurrencyCode,
walletAddress = walletAddress,
)
} returns expectedUrl
// Act
val result = useCase(cryptoCurrencyStatus, appCurrencyCode)
// Assert
assertThat(result.isRight()).isTrue()
assertThat(result.getOrNull()).isEqualTo(expectedUrl)
verify(exactly = 1) {
offrampRepository.getOfframpUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = appCurrencyCode,
walletAddress = walletAddress,
)
}
}
@Test
fun `invoke should return WalletAddressNotFound error when network address is null`() {
// Arrange
val cryptoCurrencyStatus = createCryptoCurrencyStatus(networkAddress = null)
// Act
val result = useCase(cryptoCurrencyStatus, appCurrencyCode)
// Assert
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isEqualTo(GetOfframpUrlUseCase.Error.WalletAddressNotFound)
verify(exactly = 0) {
offrampRepository.getOfframpUrl(any(), any(), any())
}
}
@Test
fun `invoke should return UrlNotAvailable error when repository returns null`() {
// Arrange
val cryptoCurrencyStatus = createCryptoCurrencyStatus(walletAddress = walletAddress)
every {
offrampRepository.getOfframpUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = appCurrencyCode,
walletAddress = walletAddress,
)
} returns null
// Act
val result = useCase(cryptoCurrencyStatus, appCurrencyCode)
// Assert
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isEqualTo(GetOfframpUrlUseCase.Error.UrlNotAvailable)
verify(exactly = 1) {
offrampRepository.getOfframpUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = appCurrencyCode,
walletAddress = walletAddress,
)
}
}
private fun createCryptoCurrencyStatus(
walletAddress: String? = null,
networkAddress: NetworkAddress? = null,
): CryptoCurrencyStatus {
val resolvedNetworkAddress = networkAddress ?: walletAddress?.let { address ->
mockk<NetworkAddress> {
every { defaultAddress } returns mockk {
every { value } returns address
}
}
}
val statusValue: CryptoCurrencyStatus.Value = mockk {
every { this@mockk.networkAddress } returns resolvedNetworkAddress
}
return mockk {
every { currency } returns cryptoCurrency
every { value } returns statusValue
}
}
}

View file

@ -1,7 +1,5 @@
package com.tangem.domain.staking.toggles
interface StakingFeatureToggles {
val isTonStakingEnabled: Boolean
val isCardanoStakingEnabled: Boolean
val isEthStakingEnabled: Boolean
}

View file

@ -1,14 +0,0 @@
package com.tangem.domain.tokens.legacy
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import org.rekotlin.Action
sealed class TradeCryptoAction : Action {
data class FinishSelling(val transactionId: String) : TradeCryptoAction()
data class Sell(
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val appCurrencyCode: String,
) : TradeCryptoAction()
}

View file

@ -26,8 +26,6 @@ dependencies {
implementation(projects.domain.wallets.models)
implementation(projects.features.swap.domain)
/** Feature API - remove after removing [TangemPayFeatureToggles] */
implementation(projects.features.tangempay.details.api)
/** Security */
implementation(deps.spongecastle.core)

View file

@ -42,6 +42,7 @@ dependencies {
implementation(projects.domain.feedback.models)
implementation(projects.domain.manageTokens)
implementation(projects.domain.markets)
implementation(projects.domain.offramp)
implementation(projects.domain.onramp.models)
implementation(projects.domain.staking.models)
implementation(projects.domain.tokens)
@ -56,12 +57,6 @@ dependencies {
implementation(projects.domain.yieldSupply)
implementation(projects.domain.earn)
// FIXME [REDACTED_TASK_KEY]
// Remove the "Buy" and "Sell" actions from the redux middleware.
// Instead, create some kind of interface for such cases.
/* Redux -_- */
implementation(projects.domain.legacy)
implementation(deps.reKotlin)
/* Compose */
implementation(deps.compose.coil)

View file

@ -2,9 +2,12 @@ package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
@ -12,9 +15,8 @@ import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
@ -30,7 +32,9 @@ internal class TokenActionsHandler @AssistedInject constructor(
private val router: Router,
private val clipboardManager: ClipboardManager,
private val uiMessageSender: UiMessageSender,
private val reduxStateHolder: ReduxStateHolder,
private val getOfframpUrlUseCase: GetOfframpUrlUseCase,
private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler,
@Assisted private val currentAppCurrency: Provider<AppCurrency>,
@Assisted private val onHandleQuickAction: (HandledQuickAction) -> Unit,
private val isDemoCardUseCase: IsDemoCardUseCase,
@ -104,12 +108,13 @@ internal class TokenActionsHandler @AssistedInject constructor(
}
private fun onSellClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
reduxStateHolder.dispatch(
TradeCryptoAction.Sell(
cryptoCurrencyStatus = cryptoCurrencyData.status,
appCurrencyCode = currentAppCurrency().code,
),
)
getOfframpUrlUseCase(
cryptoCurrencyStatus = cryptoCurrencyData.status,
appCurrencyCode = currentAppCurrency().code,
).onRight { url ->
urlOpener.openUrl(url)
analyticsEventHandler.send(OfframpAnalyticsEvent.ScreenOpened)
}
}
private fun onExchangeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {

View file

@ -32,12 +32,10 @@ import com.tangem.features.feed.model.earn.filters.state.EarnFilterNetworkUMConv
import com.tangem.features.feed.model.earn.filters.state.EarnFilterTypeConverter
import com.tangem.features.feed.model.earn.filters.state.EarnFilterTypeUMConverter
import com.tangem.features.feed.model.earn.state.EarnStateController
import com.tangem.features.feed.model.earn.state.transformers.EarnFilterSelectedStateTransformer
import com.tangem.features.feed.model.earn.state.transformers.UpdateBestOpportunitiesStateTransformer
import com.tangem.features.feed.model.earn.state.transformers.UpdateEarnUMInitialStateTransformer
import com.tangem.features.feed.model.earn.state.transformers.UpdateMostlyUsedStateTransformer
import com.tangem.features.feed.model.earn.state.transformers.*
import com.tangem.features.feed.model.earn.statemanager.EarnListBatchFlowManager
import com.tangem.features.feed.model.earn.statemanager.EarnListStateManager
import com.tangem.features.feed.ui.earn.state.EarnBestOpportunitiesUM
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import com.tangem.features.feed.ui.earn.state.EarnFilterTypeUM
import com.tangem.features.feed.ui.earn.state.EarnUM
@ -69,8 +67,8 @@ internal class EarnModel @Inject constructor(
private val earnNetworks = MutableStateFlow<EarnNetworks>(Either.Right(emptyList()))
private val earnListConfigProvider = Provider {
createEarnTokensListConfig(
selectedTypeFilter = stateController.value.selectedTypeFilter,
selectedNetworkFilter = stateController.value.selectedNetworkFilter,
selectedTypeFilter = stateController.value.earnFilterUM.selectedTypeFilter,
selectedNetworkFilter = stateController.value.earnFilterUM.selectedNetworkFilter,
earnNetworks = earnNetworks.value,
)
}
@ -104,7 +102,6 @@ internal class EarnModel @Inject constructor(
init {
updateInitialState()
fetchEarnNetworks()
fetchTopEarnTokens()
subscribeOnStoredFilters()
subscribeOnNetworks()
subscribeOnBatchFlow()
@ -117,20 +114,23 @@ internal class EarnModel @Inject constructor(
batchFlowManager.initialLoadingError,
batchFlowManager.paginationStatus,
) { items, error, paginationStatus ->
val hasActiveFilters = state.value.selectedTypeFilter != EarnFilterTypeUM.All ||
state.value.selectedNetworkFilter !is EarnFilterNetworkUM.AllNetworks
error?.let(::handleBestOpportunitiesErrorAnalytics)
val hasActiveFilters = state.value.earnFilterUM.selectedTypeFilter != EarnFilterTypeUM.All ||
state.value.earnFilterUM.selectedNetworkFilter !is EarnFilterNetworkUM.AllNetworks
EarnListStateManager.calculateState(
items = items,
error = error,
paginationStatus = paginationStatus,
hasActiveFilters = hasActiveFilters,
onRetryClick = { batchFlowManager.reload() },
onRetryClick = {
batchFlowManager.reload()
reloadEarnNetworks()
},
onLoadMore = { batchFlowManager.loadMore() },
onClearFiltersClick = ::onClearFiltersClick,
)
}.onEach { bestOpportunitiesState ->
) to error
}.onEach { (bestOpportunitiesState, error) ->
stateController.update(UpdateBestOpportunitiesStateTransformer(bestOpportunitiesState))
error?.let(::handleBestOpportunitiesErrorAnalytics)
}.launchIn(modelScope)
}
@ -156,23 +156,27 @@ internal class EarnModel @Inject constructor(
private fun subscribeOnStoredFilters() {
modelScope.launch(dispatchers.default) {
getEarnFilterUseCase()
.collect { filter ->
val typeFilterUM = EarnFilterTypeConverter().convert(filter.earnFilterType)
val networkFilterUM = EarnFilterNetworkConverter().convert(filter.earnFilterNetwork)
stateController.update(
EarnFilterSelectedStateTransformer(
filterType = typeFilterUM,
filterNetwork = networkFilterUM,
),
)
batchFlowManager.reload()
}
combine(
getEarnFilterUseCase(),
earnNetworks,
) { filter, networks ->
val typeFilterUM = EarnFilterTypeConverter().convert(filter.earnFilterType)
val networkFilterUM = EarnFilterNetworkConverter().convert(filter.earnFilterNetwork)
stateController.update(
EarnFilterSelectedStateTransformer(
filterType = typeFilterUM,
filterNetwork = networkFilterUM,
earnNetworks = networks,
),
)
batchFlowManager.reload()
}.collect()
}
}
private fun fetchTopEarnTokens() {
modelScope.launch(dispatchers.default) {
stateController.update(UpdateMostlyUsedStateLoadingTransformer())
fetchTopEarnTokensUseCase()
}
}
@ -183,13 +187,21 @@ internal class EarnModel @Inject constructor(
}
}
private fun reloadEarnNetworks() {
modelScope.launch(dispatchers.default) {
if (earnNetworks.value.isLeft()) {
fetchEarnNetworks()
}
}
}
/* start of clicks area */
private fun onTypeFilterClick() {
val currentState = state.value
bottomSheetNavigation.activate(
FeedBottomSheetRoute.TypeFilter(
params = EarnTypeFilterComponent.Params(
selectedFilter = EarnFilterTypeUMConverter().convert(currentState.selectedTypeFilter),
selectedFilter = EarnFilterTypeUMConverter().convert(currentState.earnFilterUM.selectedTypeFilter),
onFilterSelected = ::onTypeFilterOptionSelected,
onDismiss = { bottomSheetNavigation.dismiss() },
),
@ -210,7 +222,7 @@ internal class EarnModel @Inject constructor(
}
private fun createNetworkFilters(): List<EarnFilterNetwork> {
val selectedFilter = state.value.selectedNetworkFilter
val selectedFilter = state.value.earnFilterUM.selectedNetworkFilter
return buildList {
add(
EarnFilterNetwork.AllNetworks(
@ -277,11 +289,14 @@ internal class EarnModel @Inject constructor(
modelScope.launch(dispatchers.default) {
setEarnFilterUseCase(
EarnFilter(
earnFilterNetwork = EarnFilterNetworkUMConverter().convert(state.value.selectedNetworkFilter),
earnFilterNetwork = EarnFilterNetworkUMConverter().convert(
value = state.value.earnFilterUM.selectedNetworkFilter,
),
earnFilterType = type,
),
)
bottomSheetNavigation.dismiss()
reloadEarnNetworks()
}
}
@ -290,7 +305,7 @@ internal class EarnModel @Inject constructor(
setEarnFilterUseCase(
EarnFilter(
earnFilterNetwork = filter,
earnFilterType = EarnFilterTypeUMConverter().convert(state.value.selectedTypeFilter),
earnFilterType = EarnFilterTypeUMConverter().convert(state.value.earnFilterUM.selectedTypeFilter),
),
)
}
@ -319,11 +334,13 @@ internal class EarnModel @Inject constructor(
is ApiResponseError.HttpException -> error.code.numericCode to error.message.orEmpty()
else -> null to ""
}
analyticsEventHandler.send(
EarnAnalyticsEvent.BestOpportunitiesLoadError(
code = code,
message = message,
),
)
if (state.value.bestOpportunities !is EarnBestOpportunitiesUM.Error) {
analyticsEventHandler.send(
EarnAnalyticsEvent.BestOpportunitiesLoadError(
code = code,
message = message,
),
)
}
}
}

View file

@ -23,7 +23,9 @@ internal fun createEarnTokensListConfig(
earnNetworks.fold(
ifLeft = { null },
ifRight = { networks ->
networks.filter(EarnNetwork::isAdded).map(EarnNetwork::networkId)
networks.filter(EarnNetwork::isAdded)
.map(EarnNetwork::networkId)
.ifEmpty { listOf(NO_ONE_NETWORK) }
},
)
}
@ -34,4 +36,9 @@ internal fun createEarnTokensListConfig(
networks = networks,
isForEarn = isForEarn,
)
}
}
/**
* This id means that backend has to return empty result
*/
private const val NO_ONE_NETWORK = "-1"

View file

@ -26,8 +26,10 @@ internal class EarnStateController @Inject constructor() {
return EarnUM(
mostlyUsed = EarnListUM.Loading,
bestOpportunities = EarnBestOpportunitiesUM.Loading,
selectedTypeFilter = EarnFilterTypeUM.All,
selectedNetworkFilter = EarnFilterNetworkUM.AllNetworks(isSelected = true),
earnFilterUM = EarnFilterUM(
selectedTypeFilter = EarnFilterTypeUM.All,
selectedNetworkFilter = EarnFilterNetworkUM.AllNetworks(isSelected = true),
),
onBackClick = {},
onNetworkFilterClick = {},
onTypeFilterClick = {},

View file

@ -1,18 +1,24 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.domain.models.earn.EarnNetworks
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import com.tangem.features.feed.ui.earn.state.EarnFilterTypeUM
import com.tangem.features.feed.ui.earn.state.EarnUM
internal class EarnFilterSelectedStateTransformer(
private val earnNetworks: EarnNetworks,
private val filterType: EarnFilterTypeUM,
private val filterNetwork: EarnFilterNetworkUM,
) : EarnUMTransformer {
override fun transform(prevState: EarnUM): EarnUM {
return prevState.copy(
selectedTypeFilter = filterType,
selectedNetworkFilter = filterNetwork,
earnFilterUM = prevState.earnFilterUM.copy(
selectedTypeFilter = filterType,
selectedNetworkFilter = filterNetwork,
isNetworkFilterEnabled = earnNetworks.isRight(),
isTypeFilterEnabled = true,
),
)
}
}

View file

@ -1,13 +0,0 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import com.tangem.features.feed.ui.earn.state.EarnUM
internal class EarnNetworkFilterSelectedStateTransformer(
private val filter: EarnFilterNetworkUM,
) : EarnUMTransformer {
override fun transform(prevState: EarnUM): EarnUM {
return prevState.copy(selectedNetworkFilter = filter)
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.earn.state.EarnUM
internal class UpdateMostlyUsedStateLoadingTransformer : EarnUMTransformer {
override fun transform(prevState: EarnUM): EarnUM {
return prevState.copy(mostlyUsed = EarnListUM.Loading)
}
}

View file

@ -91,12 +91,7 @@ internal fun EarnContent(state: EarnUM, modifier: Modifier = Modifier) {
SpacerH(12.dp)
BestOpportunitiesFilters(
state = state.bestOpportunities,
selectedNetworkFilterText = when (state.selectedNetworkFilter) {
is EarnFilterNetworkUM.AllNetworks -> TextReference.Res(R.string.earn_filter_all_networks)
is EarnFilterNetworkUM.MyNetworks -> TextReference.Res(R.string.earn_filter_my_networks)
is EarnFilterNetworkUM.Network -> TextReference.Str(state.selectedNetworkFilter.text)
},
selectedTypeFilterText = state.selectedTypeFilterText,
earnFilterUM = state.earnFilterUM,
onNetworkFilterClick = state.onNetworkFilterClick,
onTypeFilterClick = state.onTypeFilterClick,
)
@ -151,10 +146,12 @@ private fun MostlyUsedContent(state: EarnListUM, onScroll: () -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 16.dp,
vertical = 12.dp,
),
.padding(horizontal = 16.dp, vertical = 12.dp)
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.padding(vertical = 32.dp, horizontal = 12.dp),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(onRetryClick = st.onRetryClicked)
@ -218,17 +215,14 @@ private fun MostlyUsedCard(item: EarnListItemUM, onClick: () -> Unit, modifier:
@Composable
private fun BestOpportunitiesFilters(
state: EarnBestOpportunitiesUM,
selectedNetworkFilterText: TextReference,
selectedTypeFilterText: TextReference,
earnFilterUM: EarnFilterUM,
onNetworkFilterClick: () -> Unit,
onTypeFilterClick: () -> Unit,
) {
when (state) {
is EarnBestOpportunitiesUM.Loading -> FilterButtonsShimmer()
else -> FilterButtons(
selectedNetworkFilterText = selectedNetworkFilterText,
selectedTypeFilterText = selectedTypeFilterText,
isEnabled = state is EarnBestOpportunitiesUM.Content || state is EarnBestOpportunitiesUM.EmptyFiltered,
earnFilterUM = earnFilterUM,
onNetworkFilterClick = onNetworkFilterClick,
onTypeFilterClick = onTypeFilterClick,
)
@ -307,9 +301,7 @@ private fun LazyListScope.bestOpportunitiesItems(state: EarnBestOpportunitiesUM)
@Composable
private fun FilterButtons(
selectedNetworkFilterText: TextReference,
selectedTypeFilterText: TextReference,
isEnabled: Boolean,
earnFilterUM: EarnFilterUM,
onNetworkFilterClick: () -> Unit,
onTypeFilterClick: () -> Unit,
modifier: Modifier = Modifier,
@ -319,10 +311,14 @@ private fun FilterButtons(
) {
SecondarySmallButton(
config = SmallButtonConfig(
text = selectedNetworkFilterText,
text = when (earnFilterUM.selectedNetworkFilter) {
is EarnFilterNetworkUM.AllNetworks -> TextReference.Res(R.string.earn_filter_all_networks)
is EarnFilterNetworkUM.MyNetworks -> TextReference.Res(R.string.earn_filter_my_networks)
is EarnFilterNetworkUM.Network -> TextReference.Str(earnFilterUM.selectedNetworkFilter.text)
},
onClick = onNetworkFilterClick,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_chevron_24),
isEnabled = isEnabled,
isEnabled = earnFilterUM.isNetworkFilterEnabled,
),
)
@ -330,10 +326,10 @@ private fun FilterButtons(
SecondarySmallButton(
config = SmallButtonConfig(
text = selectedTypeFilterText,
text = earnFilterUM.selectedTypeFilter.text,
onClick = onTypeFilterClick,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_chevron_24),
isEnabled = isEnabled,
isEnabled = earnFilterUM.isTypeFilterEnabled,
),
)
}
@ -496,7 +492,7 @@ private fun EarnContentLoadingPreview() {
) {
EarnContent(
state = previewEarnUM(
mostlyUsed = EarnListUM.Loading,
mostlyUsed = EarnListUM.Error(onRetryClicked = {}),
bestOpportunities = EarnBestOpportunitiesUM.Loading,
),
)
@ -588,8 +584,12 @@ private fun previewEarnUM(
): EarnUM = EarnUM(
mostlyUsed = mostlyUsed,
bestOpportunities = bestOpportunities,
selectedTypeFilter = EarnFilterTypeUM.All,
selectedNetworkFilter = EarnFilterNetworkUM.AllNetworks(isSelected = true),
earnFilterUM = EarnFilterUM(
selectedTypeFilter = EarnFilterTypeUM.All,
selectedNetworkFilter = EarnFilterNetworkUM.AllNetworks(isSelected = true),
isTypeFilterEnabled = true,
isNetworkFilterEnabled = true,
),
onBackClick = {},
onNetworkFilterClick = {},
onTypeFilterClick = {},

View file

@ -0,0 +1,11 @@
package com.tangem.features.feed.ui.earn.state
import androidx.compose.runtime.Immutable
@Immutable
internal data class EarnFilterUM(
val selectedTypeFilter: EarnFilterTypeUM,
val selectedNetworkFilter: EarnFilterNetworkUM,
val isTypeFilterEnabled: Boolean = true,
val isNetworkFilterEnabled: Boolean = true,
)

View file

@ -1,20 +1,14 @@
package com.tangem.features.feed.ui.earn.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal data class EarnUM(
val mostlyUsed: EarnListUM,
val bestOpportunities: EarnBestOpportunitiesUM,
val selectedTypeFilter: EarnFilterTypeUM,
val selectedNetworkFilter: EarnFilterNetworkUM,
val earnFilterUM: EarnFilterUM,
val onBackClick: () -> Unit,
val onNetworkFilterClick: () -> Unit,
val onTypeFilterClick: () -> Unit,
val onSliderScroll: () -> Unit,
) {
val selectedTypeFilterText: TextReference
get() = selectedTypeFilter.text
}
)

View file

@ -1,6 +1,7 @@
package com.tangem.features.feed.ui.feed.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -8,12 +9,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.extensions.TextReference
@Composable
internal fun Header(onSeeAllClick: () -> Unit, isLoading: Boolean = false, title: @Composable () -> Unit) {
internal fun Header(
onSeeAllClick: () -> Unit,
isLoading: Boolean,
shouldShowSeeAll: Boolean,
title: @Composable () -> Unit,
) {
AnimatedContent(isLoading) { animatedState ->
Row(
modifier = Modifier
@ -25,13 +32,18 @@ internal fun Header(onSeeAllClick: () -> Unit, isLoading: Boolean = false, title
if (animatedState) {
RectangleShimmer(modifier = Modifier.size(width = 104.dp, height = 18.dp))
} else {
title()
SecondarySmallButton(
config = SmallButtonConfig(
text = TextReference.Res(R.string.common_see_all),
onClick = onSeeAllClick,
),
)
Box(modifier = Modifier.weight(1f)) {
title()
}
SpacerW(8.dp)
AnimatedVisibility(shouldShowSeeAll) {
SecondarySmallButton(
config = SmallButtonConfig(
text = TextReference.Res(R.string.common_see_all),
onClick = onSeeAllClick,
),
)
}
}
}
}

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.R
@ -33,10 +34,13 @@ internal fun EarnBlock(onSeeAllClick: () -> Unit, earnListUM: EarnListUM?, modif
text = stringResourceSafe(R.string.markets_earn_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
},
onSeeAllClick = onSeeAllClick,
isLoading = earnListUM is EarnListUM.Loading,
shouldShowSeeAll = earnListUM is EarnListUM.Content,
)
SpacerH(12.dp)

View file

@ -4,12 +4,7 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@ -19,6 +14,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.markets.MarketsListItem
@ -55,9 +51,13 @@ internal fun MarketBlock(marketChart: MarketChartUM?, feedListCallbacks: FeedLis
text = stringResourceSafe(R.string.markets_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
},
onSeeAllClick = { feedListCallbacks.onMarketOpenClick(SortByTypeUM.Rating) },
shouldShowSeeAll = currentChart is MarketChartUM.Content,
isLoading = currentChart is MarketChartUM.Loading,
)
SpacerH(12.dp)
@ -88,9 +88,13 @@ internal fun MarketPulseBlock(marketChartConfig: MarketChartConfig, feedListCall
text = stringResourceSafe(R.string.markets_pulse_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
},
onSeeAllClick = { onSeeAllClick() },
shouldShowSeeAll = true,
isLoading = marketChartConfig.marketCharts[marketChartConfig.currentSortByType] is MarketChartUM.Loading,
)
LazyRow(

View file

@ -8,8 +8,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@ -19,6 +17,7 @@ import androidx.compose.ui.layout.onFirstVisible
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.news.ArticleCard
@ -65,12 +64,6 @@ internal fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trend
@Composable
private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) {
val listState = rememberLazyListState()
val articlesReadStatus = remember(news.content) {
news.content.map { it.isViewed }
}
LaunchedEffect(articlesReadStatus) {
listState.requestScrollToItem(0)
}
Column {
Header(
title = {
@ -104,10 +97,14 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
}
},
style = TangemTheme.typography.subtitle1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
},
onSeeAllClick = { feedListCallbacks.onOpenAllNews(false) },
isLoading = news.newsUMState == NewsUMState.LOADING,
shouldShowSeeAll = news.newsUMState == NewsUMState.CONTENT,
)
SpacerH(12.dp)
@ -133,7 +130,7 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
) {
itemsIndexed(
items = news.content,
key = { _, article -> article.id },
key = { index, _ -> index },
contentType = { _, _ -> "article" },
) { index, article ->
val articleModifier = if (index == FOURTH_ITEM_INDEX) {
@ -181,10 +178,14 @@ private fun NewsErrorBlock(onRetryClick: () -> Unit) {
text = stringResourceSafe(R.string.common_news),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
},
onSeeAllClick = {},
shouldShowSeeAll = false,
isLoading = false,
)
SpacerH(12.dp)
BlockCard(

View file

@ -9,7 +9,6 @@ import dagger.assisted.AssistedInject
/**
* Mocking it for release/external builds to exclude SumSub dependency
* This will never be called if the FT [isTangemPayEnabled] is off
*/
@Suppress("UnusedPrivateProperty")
internal class MockKycComponent @AssistedInject constructor(

View file

@ -36,6 +36,7 @@ dependencies {
implementation(projects.domain.feedback.models)
implementation(projects.domain.manageTokens)
implementation(projects.domain.markets)
implementation(projects.domain.offramp)
implementation(projects.domain.onramp.models)
implementation(projects.domain.staking.models)
implementation(projects.domain.staking)
@ -49,12 +50,6 @@ dependencies {
implementation(projects.domain.yieldSupply.models)
implementation(projects.domain.yieldSupply)
// FIXME [REDACTED_TASK_KEY]
// Remove the "Buy" and "Sell" actions from the redux middleware.
// Instead, create some kind of interface for such cases.
/* Redux -_- */
implementation(projects.domain.legacy)
implementation(deps.reKotlin)
/* Compose */
implementation(deps.compose.coil)

View file

@ -2,9 +2,12 @@ package com.tangem.features.markets.portfolio.impl.model
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
@ -12,9 +15,8 @@ import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.portfolio.impl.loader.PortfolioData
@ -30,7 +32,9 @@ internal class TokenActionsHandler @AssistedInject constructor(
private val router: Router,
private val clipboardManager: ClipboardManager,
private val uiMessageSender: UiMessageSender,
private val reduxStateHolder: ReduxStateHolder,
private val getOfframpUrlUseCase: GetOfframpUrlUseCase,
private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler,
@Assisted private val currentAppCurrency: Provider<AppCurrency>,
@Assisted private val onHandleQuickAction: (HandledQuickAction) -> Unit,
private val isDemoCardUseCase: IsDemoCardUseCase,
@ -104,12 +108,13 @@ internal class TokenActionsHandler @AssistedInject constructor(
}
private fun onSellClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
reduxStateHolder.dispatch(
TradeCryptoAction.Sell(
cryptoCurrencyStatus = cryptoCurrencyData.status,
appCurrencyCode = currentAppCurrency().code,
),
)
getOfframpUrlUseCase(
cryptoCurrencyStatus = cryptoCurrencyData.status,
appCurrencyCode = currentAppCurrency().code,
).onRight { url ->
urlOpener.openUrl(url)
analyticsEventHandler.send(OfframpAnalyticsEvent.ScreenOpened)
}
}
private fun onExchangeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {

View file

@ -42,8 +42,8 @@ dependencies {
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.card)
implementation(projects.domain.demo)
implementation(projects.domain.legacy)
implementation(projects.domain.models)
implementation(projects.domain.offramp)
implementation(projects.domain.onramp)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)

View file

@ -1,7 +1,7 @@
package com.tangem.features.onramp.alloffers.entity
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM
internal interface AllOffersIntents {

View file

@ -10,9 +10,9 @@ import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.*
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM
import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.main.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.main.entity.OnrampOfferUM
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns.MINUS
import kotlinx.collections.immutable.toImmutableList

View file

@ -4,7 +4,7 @@ import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodStatus
import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM
import com.tangem.features.onramp.main.entity.OnrampOfferUM
import kotlinx.collections.immutable.ImmutableList
internal sealed interface AllOffersStateUM {

View file

@ -12,7 +12,7 @@ import com.tangem.features.onramp.alloffers.entity.AllOffersIntents
import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM
import com.tangem.features.onramp.alloffers.entity.AllOffersStateFactory
import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.Job

View file

@ -32,10 +32,10 @@ import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM
import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM
import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM
import com.tangem.features.onramp.mainv2.ui.Offer
import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.main.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.main.entity.OnrampOfferUM
import com.tangem.features.onramp.main.ui.Offer
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList

View file

@ -33,10 +33,10 @@ import com.tangem.domain.onramp.model.PaymentMethodType
import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM
import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.mainv2.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM
import com.tangem.features.onramp.mainv2.ui.TimingBlock
import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.main.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.main.entity.OnrampOfferUM
import com.tangem.features.onramp.main.ui.TimingBlock
import com.tangem.features.onramp.paymentmethod.ui.PaymentMethodIcon
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

View file

@ -8,15 +8,16 @@ import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.essenty.lifecycle.subscribe
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.features.onramp.alloffers.AllOffersComponent
import com.tangem.features.onramp.confirmresidency.ConfirmResidencyComponent
import com.tangem.features.onramp.main.entity.OnrampMainBottomSheetConfig
import com.tangem.features.onramp.main.model.OnrampMainComponentModel
import com.tangem.features.onramp.main.ui.OnrampMainComponentContent
import com.tangem.features.onramp.providers.SelectProviderComponent
import com.tangem.features.onramp.main.ui.OnrampMainScreen
import com.tangem.features.onramp.selectcurrency.SelectCurrencyComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -27,10 +28,15 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
@Assisted private val params: OnrampMainComponent.Params,
private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory,
private val selectCurrencyComponentFactory: SelectCurrencyComponent.Factory,
private val selectProviderComponentFactory: SelectProviderComponent.Factory,
private val allOffersComponentFactory: AllOffersComponent.Factory,
) : OnrampMainComponent, AppComponentContext by appComponentContext {
private val model: OnrampMainComponentModel = getOrCreateModel(params)
init {
lifecycle.subscribe(onStop = model::onStop)
}
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = null,
@ -43,7 +49,7 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
val state by model.state.collectAsState()
val bottomSheet by bottomSheetSlot.subscribeAsState()
OnrampMainComponentContent(modifier = modifier, state = state)
OnrampMainScreen(modifier = modifier, state = state)
bottomSheet.child?.instance?.BottomSheet()
}
@ -57,7 +63,7 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
country = config.country,
isLaunchSepa = params.isLaunchSepa,
isLaunchSepa = false,
onDismiss = {
model.bottomSheetNavigation.dismiss()
model.handleOnrampAvailable()
@ -72,14 +78,14 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
is OnrampMainBottomSheetConfig.ProvidersList -> selectProviderComponentFactory.create(
is OnrampMainBottomSheetConfig.AllOffers -> allOffersComponentFactory.create(
context = childByContext(componentContext),
params = SelectProviderComponent.Params(
onProviderClick = model::onProviderSelected,
onDismiss = model.bottomSheetNavigation::dismiss,
selectedProviderId = config.selectedProviderId,
selectedPaymentMethod = config.selectedPaymentMethod,
params = AllOffersComponent.Params(
userWallet = model.userWallet,
cryptoCurrency = params.cryptoCurrency,
onDismiss = model.bottomSheetNavigation::dismiss,
openRedirectPage = params.openRedirectPage,
amountCurrencyCode = config.amountCurrencyCode,
),
)
}

View file

@ -15,7 +15,6 @@ internal interface OnrampMainComponent : ComposableContentComponent {
val source: OnrampSource,
val openSettings: () -> Unit,
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
val isLaunchSepa: Boolean,
)
interface Factory : ComponentFactory<Params, OnrampMainComponent>

View file

@ -1,20 +0,0 @@
package com.tangem.features.onramp.main.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.onramp.main.model.OnrampMainComponentModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface OnrampMainComponentModelModule {
@Binds
@IntoMap
@ClassKey(OnrampMainComponentModel::class)
fun bindOnrampSelectCountryModel(model: OnrampMainComponentModel): Model
}

View file

@ -1,11 +1,15 @@
package com.tangem.features.onramp.main.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.onramp.main.DefaultOnrampMainComponent
import com.tangem.features.onramp.main.OnrampMainComponent
import com.tangem.features.onramp.main.model.OnrampMainComponentModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@ -15,4 +19,9 @@ internal interface OnrampMainComponentModule {
@Binds
@Singleton
fun bindOnrampMainComponentFactory(factory: DefaultOnrampMainComponent.Factory): OnrampMainComponent.Factory
@Binds
@IntoMap
@ClassKey(OnrampMainComponentModel::class)
fun bindOnrampMainComponentModel(model: OnrampMainComponentModel): Model
}

View file

@ -1,25 +0,0 @@
package com.tangem.features.onramp.main.entity
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.extensions.TextReference
internal data class OnrampAmountBlockUM(
val currencyUM: OnrampCurrencyUM,
val amountFieldModel: AmountFieldModel,
val secondaryFieldModel: OnrampAmountSecondaryFieldUM,
)
internal data class OnrampCurrencyUM(
val code: String,
val iconUrl: String?,
val precision: Int,
val onClick: () -> Unit,
)
@Immutable
internal sealed interface OnrampAmountSecondaryFieldUM {
data object Loading : OnrampAmountSecondaryFieldUM
data class Content(val amount: TextReference) : OnrampAmountSecondaryFieldUM
data class Error(val error: TextReference) : OnrampAmountSecondaryFieldUM
}

View file

@ -1,17 +1,17 @@
package com.tangem.features.onramp.mainv2.entity
package com.tangem.features.onramp.main.entity
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
internal data class OnrampNewAmountBlockUM(
val currencyUM: OnrampNewCurrencyUM,
internal data class OnrampAmountBlockUM(
val currencyUM: OnrampCurrencyUM,
val amountFieldModel: AmountFieldModel,
val secondaryFieldModel: OnrampSecondaryFieldErrorUM,
)
internal data class OnrampNewCurrencyUM(
internal data class OnrampCurrencyUM(
val unit: String,
val code: String,
val iconUrl: String?,
@ -25,9 +25,9 @@ internal sealed interface OnrampSecondaryFieldErrorUM {
data class Error(val error: TextReference) : OnrampSecondaryFieldErrorUM
}
internal sealed interface OnrampV2AmountButtonUMState {
data class Loaded(val amountButtons: ImmutableList<OnrampAmountButtonUM>) : OnrampV2AmountButtonUMState
data object None : OnrampV2AmountButtonUMState
internal sealed interface OnrampAmountButtonUMState {
data class Loaded(val amountButtons: ImmutableList<OnrampAmountButtonUM>) : OnrampAmountButtonUMState
data object None : OnrampAmountButtonUMState
}
internal data class OnrampAmountButtonUM(

View file

@ -2,12 +2,15 @@ package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
interface OnrampIntents {
fun onAmountValueChanged(value: String, isValuePasted: Boolean)
internal interface OnrampIntents {
fun onAmountValueChanged(value: String)
fun openSettings()
fun openCurrenciesList()
fun onBuyClick(quote: OnrampProviderWithQuote.Data)
fun onBuyClick(
quote: OnrampProviderWithQuote.Data,
onrampOfferAdvantagesUM: OnrampOfferAdvantagesUM,
categoryUM: OnrampOfferCategoryUM,
)
fun openProviders()
fun onRefresh()
fun onLinkClick(link: String)
}

View file

@ -1,10 +0,0 @@
package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampAmount
import com.tangem.domain.onramp.model.OnrampPaymentMethod
data class OnrampLastUpdate(
val fromAmount: OnrampAmount,
val countryCode: String,
val paymentMethod: OnrampPaymentMethod,
)

View file

@ -1,11 +1,10 @@
package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import kotlinx.serialization.Serializable
@Serializable
internal sealed interface OnrampMainBottomSheetConfig {
sealed interface OnrampMainBottomSheetConfig {
@Serializable
data class ConfirmResidency(val country: OnrampCountry) : OnrampMainBottomSheetConfig
@ -13,8 +12,5 @@ internal sealed interface OnrampMainBottomSheetConfig {
data object CurrenciesList : OnrampMainBottomSheetConfig
@Serializable
data class ProvidersList(
val selectedProviderId: String,
val selectedPaymentMethod: OnrampPaymentMethod,
) : OnrampMainBottomSheetConfig
data class AllOffers(val amountCurrencyCode: String) : OnrampMainBottomSheetConfig
}

View file

@ -4,55 +4,29 @@ import androidx.compose.runtime.Immutable
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.onramp.impl.R
@Immutable
internal sealed interface OnrampMainComponentUM {
val topBarConfig: OnrampMainTopBarUM
val buyButtonConfig: BuyButtonConfig
val errorNotification: NotificationUM?
data class InitialLoading(
val currency: String,
val onClose: () -> Unit,
val openSettings: () -> Unit,
override val errorNotification: NotificationUM? = null,
) : OnrampMainComponentUM {
override val topBarConfig: OnrampMainTopBarUM = OnrampMainTopBarUM(
title = combinedReference(resourceReference(R.string.common_buy), stringReference(" $currency")),
startButtonUM = TopAppBarButtonUM.Back(
onBackClicked = onClose,
enabled = true,
),
endButtonUM = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_more_vertical_24,
onClicked = openSettings,
isEnabled = false,
),
)
override val buyButtonConfig: BuyButtonConfig = BuyButtonConfig(
text = resourceReference(R.string.common_buy),
onClick = {},
isEnabled = false,
)
}
override val topBarConfig: OnrampMainTopBarUM,
override val errorNotification: NotificationUM?,
) : OnrampMainComponentUM
data class Content(
override val topBarConfig: OnrampMainTopBarUM,
override val buyButtonConfig: BuyButtonConfig,
override val errorNotification: NotificationUM?,
val amountBlockState: OnrampAmountBlockUM,
val providerBlockState: OnrampProviderBlockUM,
val offersBlockState: OnrampOffersBlockUM,
val onrampAmountButtonUMState: OnrampAmountButtonUMState,
) : OnrampMainComponentUM
}
internal data class BuyButtonConfig(
val text: TextReference,
val onClick: () -> Unit,
val isEnabled: Boolean,
internal data class OnrampMainTopBarUM(
val title: TextReference,
val startButtonUM: TopAppBarButtonUM,
val endButtonUM: TopAppBarButtonUM,
)

View file

@ -1,10 +0,0 @@
package com.tangem.features.onramp.main.entity
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
internal data class OnrampMainTopBarUM(
val title: TextReference,
val startButtonUM: TopAppBarButtonUM,
val endButtonUM: TopAppBarButtonUM,
)

View file

@ -1,4 +1,4 @@
package com.tangem.features.onramp.mainv2.entity
package com.tangem.features.onramp.main.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference

View file

@ -1,18 +0,0 @@
package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampPaymentMethod
sealed class OnrampProviderBlockUM {
data object Empty : OnrampProviderBlockUM()
data object Loading : OnrampProviderBlockUM()
data class Content(
val providerId: String,
val paymentMethod: OnrampPaymentMethod,
val providerName: String,
val termsOfUseLink: String?,
val privacyPolicyLink: String?,
val isBestRate: Boolean,
val onLinkClick: (String) -> Unit,
val onClick: () -> Unit,
) : OnrampProviderBlockUM()
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampPaymentMethod
sealed interface OnrampProvidersUM {
data object Empty : OnrampProvidersUM
data object Loading : OnrampProvidersUM
data class Content(
val providerId: String,
val paymentMethod: OnrampPaymentMethod,
) : OnrampProvidersUM
}

View file

@ -1,24 +1,24 @@
package com.tangem.features.onramp.mainv2.entity.converter
package com.tangem.features.onramp.main.entity.converter
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.features.onramp.mainv2.entity.*
import com.tangem.features.onramp.mainv2.entity.factory.OnrampAmountButtonUMStateFactory
import com.tangem.features.onramp.main.entity.*
import com.tangem.features.onramp.main.entity.factory.OnrampAmountButtonUMStateFactory
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
internal class OnrampV2AmountFieldChangeConverter(
private val currentStateProvider: Provider<OnrampV2MainComponentUM>,
internal class OnrampAmountFieldChangeConverter(
private val currentStateProvider: Provider<OnrampMainComponentUM>,
private val onrampAmountButtonUMStateFactory: OnrampAmountButtonUMStateFactory,
private val onrampIntents: OnrampV2Intents,
) : Converter<String, OnrampV2MainComponentUM> {
private val onrampIntents: OnrampIntents,
) : Converter<String, OnrampMainComponentUM> {
override fun convert(value: String): OnrampV2MainComponentUM {
override fun convert(value: String): OnrampMainComponentUM {
val state = currentStateProvider()
if (state !is OnrampV2MainComponentUM.Content) return state
if (state !is OnrampMainComponentUM.Content) return state
if (value.isEmpty()) return state.emptyState()
@ -36,13 +36,13 @@ internal class OnrampV2AmountFieldChangeConverter(
amountFieldModel = amountFieldModel,
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
),
onrampAmountButtonUMState = OnrampV2AmountButtonUMState.None,
onrampAmountButtonUMState = OnrampAmountButtonUMState.None,
offersBlockState = OnrampOffersBlockUM.Loading,
errorNotification = null,
)
}
private fun OnrampV2MainComponentUM.Content.emptyState(): OnrampV2MainComponentUM.Content {
private fun OnrampMainComponentUM.Content.emptyState(): OnrampMainComponentUM.Content {
val amountFieldModel = amountBlockState.amountFieldModel.copy(
value = "",
fiatValue = "",

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