Updated on 2026-08-14
This commit is contained in:
commit
ae15bcfc55
211 changed files with 4715 additions and 3641 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class FinalizeTwinTask(
|
|||
is CompletionResult.Success ->
|
||||
ScanProductTask(
|
||||
card = readResult.data,
|
||||
derivationsFinder = null,
|
||||
blockchainToDeriveFinder = null,
|
||||
visaCardScanHandler = null,
|
||||
visaCoroutineScope = null,
|
||||
onboardingV2FeatureToggles = null,
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,4 @@ interface SellService {
|
|||
walletAddress: String,
|
||||
isDarkTheme: Boolean,
|
||||
): String?
|
||||
|
||||
fun getSellCryptoReceiptUrl(transactionId: String): String?
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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]")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
1
domain/offramp/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
18
domain/offramp/build.gradle.kts
Normal file
18
domain/offramp/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
package com.tangem.domain.staking.toggles
|
||||
|
||||
interface StakingFeatureToggles {
|
||||
val isTonStakingEnabled: Boolean
|
||||
val isCardanoStakingEnabled: Boolean
|
||||
val isEthStakingEnabled: Boolean
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue