Updated on 2026-08-14

This commit is contained in:
Tangem 2024-12-19 22:17:32 +05:00
parent 550f404055
commit ff42bcfcb5
6 changed files with 123 additions and 44 deletions

View file

@ -249,6 +249,7 @@ internal class DefaultOnrampRepository(
maxFromAmount = convertToAmount(response.maxFromAmount, cryptoCurrency), maxFromAmount = convertToAmount(response.maxFromAmount, cryptoCurrency),
paymentMethod = paymentMethod, paymentMethod = paymentMethod,
provider = provider, provider = provider,
countryCode = response.countryCode,
) )
}, },
onError = { error -> onError = { error ->
@ -257,6 +258,7 @@ internal class DefaultOnrampRepository(
paymentMethod = paymentMethod, paymentMethod = paymentMethod,
provider = provider, provider = provider,
fromOnrampAmount = fromOnrampAmount, fromOnrampAmount = fromOnrampAmount,
countryCode = country.code,
) )
}, },
) )
@ -454,6 +456,7 @@ internal class DefaultOnrampRepository(
paymentMethod: OnrampPaymentMethod, paymentMethod: OnrampPaymentMethod,
provider: OnrampProvider, provider: OnrampProvider,
fromOnrampAmount: OnrampAmount, fromOnrampAmount: OnrampAmount,
countryCode: String,
) = if (error is ApiResponseError.HttpException) { ) = if (error is ApiResponseError.HttpException) {
val onrampError = onrampErrorConverter.convert(value = error.errorBody.orEmpty()) val onrampError = onrampErrorConverter.convert(value = error.errorBody.orEmpty())
if (onrampError is OnrampError.AmountError) { if (onrampError is OnrampError.AmountError) {
@ -462,6 +465,7 @@ internal class DefaultOnrampRepository(
provider = provider, provider = provider,
fromAmount = fromOnrampAmount, fromAmount = fromOnrampAmount,
error = onrampError, error = onrampError,
countryCode = countryCode,
) )
} else { } else {
Timber.w(error, "Unable to fetch onramp quotes for ${provider.id}. $error") Timber.w(error, "Unable to fetch onramp quotes for ${provider.id}. $error")
@ -470,6 +474,7 @@ internal class DefaultOnrampRepository(
provider = provider, provider = provider,
fromAmount = fromOnrampAmount, fromAmount = fromOnrampAmount,
error = onrampError, error = onrampError,
countryCode = countryCode,
) )
} }
} else { } else {

View file

@ -7,11 +7,13 @@ sealed class OnrampQuote {
abstract val paymentMethod: OnrampPaymentMethod abstract val paymentMethod: OnrampPaymentMethod
abstract val provider: OnrampProvider abstract val provider: OnrampProvider
abstract val fromAmount: OnrampAmount abstract val fromAmount: OnrampAmount
abstract val countryCode: String
data class Data( data class Data(
override val paymentMethod: OnrampPaymentMethod, override val paymentMethod: OnrampPaymentMethod,
override val provider: OnrampProvider, override val provider: OnrampProvider,
override val fromAmount: OnrampAmount, override val fromAmount: OnrampAmount,
override val countryCode: String,
val toAmount: OnrampAmount, val toAmount: OnrampAmount,
val minFromAmount: OnrampAmount, val minFromAmount: OnrampAmount,
val maxFromAmount: OnrampAmount, val maxFromAmount: OnrampAmount,
@ -21,6 +23,7 @@ sealed class OnrampQuote {
override val paymentMethod: OnrampPaymentMethod, override val paymentMethod: OnrampPaymentMethod,
override val provider: OnrampProvider, override val provider: OnrampProvider,
override val fromAmount: OnrampAmount, override val fromAmount: OnrampAmount,
override val countryCode: String,
val error: OnrampError.AmountError, val error: OnrampError.AmountError,
) : OnrampQuote() ) : OnrampQuote()
@ -28,6 +31,7 @@ sealed class OnrampQuote {
override val paymentMethod: OnrampPaymentMethod, override val paymentMethod: OnrampPaymentMethod,
override val provider: OnrampProvider, override val provider: OnrampProvider,
override val fromAmount: OnrampAmount, override val fromAmount: OnrampAmount,
override val countryCode: String,
val error: OnrampError, val error: OnrampError,
) : OnrampQuote() ) : OnrampQuote()
} }

View file

@ -0,0 +1,8 @@
package com.tangem.features.onramp.main.entity
import com.tangem.domain.onramp.model.OnrampAmount
data class OnrampLastUpdate(
val lastAmount: OnrampAmount,
val lastCountryString: String,
)

View file

@ -49,8 +49,11 @@ internal class OnrampStateFactory(
fun getOnrampErrorState(onrampError: OnrampError): OnrampMainComponentUM { fun getOnrampErrorState(onrampError: OnrampError): OnrampMainComponentUM {
return when (onrampError) { return when (onrampError) {
OnrampError.PairsNotFound -> getNoPairsErrorState() OnrampError.PairsNotFound -> getNoPairsErrorState()
is OnrampError.DataError -> getErrorState(onrampError.code) is OnrampError.DataError -> getErrorState(
is OnrampError.DomainError -> getErrorState() errorCode = onrampError.code,
onRefresh = onrampIntents::onRefresh,
)
is OnrampError.DomainError -> getErrorState(onRefresh = onrampIntents::onRefresh)
is OnrampError.AmountError.TooBigError, is OnrampError.AmountError.TooBigError,
is OnrampError.AmountError.TooSmallError, is OnrampError.AmountError.TooSmallError,
OnrampError.RedirectError.VerificationFailed, OnrampError.RedirectError.VerificationFailed,
@ -74,7 +77,7 @@ internal class OnrampStateFactory(
) )
} }
fun getErrorState(errorCode: String? = null): OnrampMainComponentUM { fun getErrorState(errorCode: String? = null, onRefresh: () -> Unit): OnrampMainComponentUM {
val state = currentStateProvider() val state = currentStateProvider()
val endButton = state.topBarConfig.endButtonUM.copy(enabled = true) val endButton = state.topBarConfig.endButtonUM.copy(enabled = true)
@ -89,13 +92,13 @@ internal class OnrampStateFactory(
providerBlockState = OnrampProviderBlockUM.Empty, providerBlockState = OnrampProviderBlockUM.Empty,
errorNotification = NotificationUM.Warning.OnrampErrorNotification( errorNotification = NotificationUM.Warning.OnrampErrorNotification(
errorCode = errorCode, errorCode = errorCode,
onRefresh = onrampIntents::onRefresh, onRefresh = onRefresh,
), ),
) )
is OnrampMainComponentUM.InitialLoading -> state.copy( is OnrampMainComponentUM.InitialLoading -> state.copy(
errorNotification = NotificationUM.Warning.OnrampErrorNotification( errorNotification = NotificationUM.Warning.OnrampErrorNotification(
errorCode = errorCode, errorCode = errorCode,
onRefresh = onrampIntents::onRefresh, onRefresh = onRefresh,
), ),
) )
} }

View file

@ -14,15 +14,18 @@ import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampQuote import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.error.OnrampError import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.tokens.model.AmountType import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.* import com.tangem.features.onramp.main.entity.*
import com.tangem.features.onramp.providers.entity.SelectProviderResult import com.tangem.features.onramp.providers.entity.SelectProviderResult
import com.tangem.utils.Provider import com.tangem.utils.Provider
import com.tangem.utils.extensions.isSingleItem
internal class OnrampAmountStateFactory( internal class OnrampAmountStateFactory(
private val currentStateProvider: Provider<OnrampMainComponentUM>, private val currentStateProvider: Provider<OnrampMainComponentUM>,
private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsEventHandler: AnalyticsEventHandler,
private val onrampIntents: OnrampIntents, private val onrampIntents: OnrampIntents,
private val cryptoCurrency: CryptoCurrency,
) { ) {
private val onrampAmountFieldChangeConverter = OnrampAmountFieldChangeConverter( private val onrampAmountFieldChangeConverter = OnrampAmountFieldChangeConverter(
@ -62,11 +65,12 @@ internal class OnrampAmountStateFactory(
return currentState.copy( return currentState.copy(
amountBlockState = amountState.copy(secondaryFieldModel = OnrampAmountSecondaryFieldUM.Loading), amountBlockState = amountState.copy(secondaryFieldModel = OnrampAmountSecondaryFieldUM.Loading),
providerBlockState = OnrampProviderBlockUM.Loading,
buyButtonConfig = currentState.buyButtonConfig.copy(enabled = false), buyButtonConfig = currentState.buyButtonConfig.copy(enabled = false),
) )
} }
fun getAmountSecondaryUpdatedState(quote: OnrampQuote, isBestRate: Boolean): OnrampMainComponentUM { fun getAmountSecondaryUpdatedState(quote: OnrampQuote): OnrampMainComponentUM {
val currentState = currentStateProvider() val currentState = currentStateProvider()
if (currentState !is OnrampMainComponentUM.Content) return currentState if (currentState !is OnrampMainComponentUM.Content) return currentState
@ -75,9 +79,9 @@ internal class OnrampAmountStateFactory(
return currentState.copy( return currentState.copy(
amountBlockState = amountState.copy( amountBlockState = amountState.copy(
amountFieldModel = amountState.amountFieldModel.copy(isError = false),
secondaryFieldModel = quote.toSecondaryFieldUiModel(amountState) ?: amountState.secondaryFieldModel, secondaryFieldModel = quote.toSecondaryFieldUiModel(amountState) ?: amountState.secondaryFieldModel,
), ),
providerBlockState = quote.toProviderBlockState(isBestRate),
buyButtonConfig = currentState.buyButtonConfig.copy( buyButtonConfig = currentState.buyButtonConfig.copy(
enabled = quote is OnrampQuote.Data, enabled = quote is OnrampQuote.Data,
onClick = { onClick = {
@ -96,6 +100,32 @@ internal class OnrampAmountStateFactory(
) )
} }
fun getUpdatedProviderState(selectedQuote: OnrampQuote, quotes: List<OnrampQuote>): OnrampMainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampMainComponentUM.Content) return currentState
analyticsEventHandler.send(
OnrampAnalyticsEvent.ProviderCalculated(
providerName = selectedQuote.provider.info.name,
tokenSymbol = cryptoCurrency.symbol,
paymentMethod = selectedQuote.paymentMethod.name,
),
)
val bestProvider = selectedQuote as? OnrampQuote.Data
val isMultipleQuotes = !quotes.isSingleItem()
val isOtherQuotesHasData = quotes
.filter { it.paymentMethod == selectedQuote.paymentMethod }
.filterNot { it == bestProvider }
.any { it is OnrampQuote.Data }
val isBestProvider = selectedQuote == bestProvider && isMultipleQuotes && isOtherQuotesHasData
return currentState.copy(
providerBlockState = selectedQuote.toProviderBlockState(isBestProvider),
)
}
fun getAmountSecondaryUpdatedState( fun getAmountSecondaryUpdatedState(
providerResult: SelectProviderResult, providerResult: SelectProviderResult,
isBestRate: Boolean, isBestRate: Boolean,

View file

@ -25,10 +25,7 @@ import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.OnrampMainComponent import com.tangem.features.onramp.main.OnrampMainComponent
import com.tangem.features.onramp.main.entity.OnrampIntents import com.tangem.features.onramp.main.entity.*
import com.tangem.features.onramp.main.entity.OnrampMainBottomSheetConfig
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
import com.tangem.features.onramp.main.entity.OnrampProviderBlockUM
import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory
import com.tangem.features.onramp.main.entity.factory.amount.OnrampAmountStateFactory import com.tangem.features.onramp.main.entity.factory.amount.OnrampAmountStateFactory
import com.tangem.features.onramp.providers.entity.SelectProviderResult import com.tangem.features.onramp.providers.entity.SelectProviderResult
@ -38,15 +35,13 @@ import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.PeriodicTask import com.tangem.utils.coroutines.PeriodicTask
import com.tangem.utils.coroutines.SingleTaskScheduler import com.tangem.utils.coroutines.SingleTaskScheduler
import com.tangem.utils.extensions.isSingleItem
import com.tangem.utils.isNullOrZero import com.tangem.utils.isNullOrZero
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.math.BigDecimal
import javax.inject.Inject import javax.inject.Inject
@Suppress("LongParameterList") @Suppress("LongParameterList", "LargeClass")
internal class OnrampMainComponentModel @Inject constructor( internal class OnrampMainComponentModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsEventHandler: AnalyticsEventHandler,
@ -75,6 +70,7 @@ internal class OnrampMainComponentModel @Inject constructor(
currentStateProvider = Provider { _state.value }, currentStateProvider = Provider { _state.value },
analyticsEventHandler = analyticsEventHandler, analyticsEventHandler = analyticsEventHandler,
onrampIntents = this, onrampIntents = this,
cryptoCurrency = params.cryptoCurrency,
) )
private val selectedUserWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } private val selectedUserWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId }
private val _state: MutableStateFlow<OnrampMainComponentUM> = MutableStateFlow( private val _state: MutableStateFlow<OnrampMainComponentUM> = MutableStateFlow(
@ -87,7 +83,7 @@ internal class OnrampMainComponentModel @Inject constructor(
val state: StateFlow<OnrampMainComponentUM> get() = _state.asStateFlow() val state: StateFlow<OnrampMainComponentUM> get() = _state.asStateFlow()
val bottomSheetNavigation: SlotNavigation<OnrampMainBottomSheetConfig> = SlotNavigation() val bottomSheetNavigation: SlotNavigation<OnrampMainBottomSheetConfig> = SlotNavigation()
private val lastAmount = mutableStateOf(BigDecimal.ZERO) private val lastUpdateState = mutableStateOf<OnrampLastUpdate?>(null)
init { init {
sendScreenOpenAnalytics() sendScreenOpenAnalytics()
@ -161,7 +157,11 @@ internal class OnrampMainComponentModel @Inject constructor(
} }
private suspend fun updatePairsAndQuotes() { private suspend fun updatePairsAndQuotes() {
_state.update { amountStateFactory.getAmountSecondaryLoadingState() } val state = state.value as? OnrampMainComponentUM.Content
if (!state?.amountBlockState?.amountFieldModel?.fiatValue.isNullOrEmpty()) {
_state.update { amountStateFactory.getAmountSecondaryLoadingState() }
}
fetchPairsUseCase.invoke(params.cryptoCurrency).fold( fetchPairsUseCase.invoke(params.cryptoCurrency).fold(
ifLeft = ::handleOnrampError, ifLeft = ::handleOnrampError,
ifRight = { _state.update { amountStateFactory.getAmountSecondaryResetState() } }, ifRight = { _state.update { amountStateFactory.getAmountSecondaryResetState() } },
@ -200,7 +200,6 @@ internal class OnrampMainComponentModel @Inject constructor(
private fun subscribeToQuotesUpdate() { private fun subscribeToQuotesUpdate() {
getOnrampQuotesUseCase.invoke() getOnrampQuotesUseCase.invoke()
.distinctUntilChanged()
.conflate() .conflate()
.onEach { maybeQuotes -> .onEach { maybeQuotes ->
maybeQuotes.fold( maybeQuotes.fold(
@ -282,42 +281,67 @@ internal class OnrampMainComponentModel @Inject constructor(
private fun handleQuoteResult(quotes: List<OnrampQuote>) { private fun handleQuoteResult(quotes: List<OnrampQuote>) {
sendOnrampQuotesErrorAnalytic(quotes) sendOnrampQuotesErrorAnalytic(quotes)
val quote = quotes.firstOrNull { it !is OnrampQuote.Error } val quote = selectOrUpdateQuote(quotes)
if (quote == null) { if (quote == null) {
_state.update { stateFactory.getErrorState() } _state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) }
lastAmount.value = BigDecimal.ZERO lastUpdateState.value = null
return return
} }
val bestProvider = quote as? OnrampQuote.Data if (checkLastInputState(quote)) {
val isMultipleQuotes = !quotes.isSingleItem() lastUpdateState.value = OnrampLastUpdate(
val isOtherQuotesHasData = quotes quote.fromAmount,
.filter { it.paymentMethod == quote.paymentMethod } quote.countryCode,
.filterNot { it == bestProvider } )
.any { it is OnrampQuote.Data }
val hasBestProvider = isMultipleQuotes && isOtherQuotesHasData
val isBestProvider = quote == bestProvider && hasBestProvider
if (lastAmount.value != quote.fromAmount.value) {
lastAmount.value = quote.fromAmount.value
if (quote is OnrampQuote.Data) {
analyticsEventHandler.send(
OnrampAnalyticsEvent.ProviderCalculated(
providerName = quote.provider.info.name,
tokenSymbol = params.cryptoCurrency.symbol,
paymentMethod = quote.paymentMethod.name,
),
)
}
_state.update { _state.update {
amountStateFactory.getAmountSecondaryUpdatedState( amountStateFactory.getUpdatedProviderState(selectedQuote = quote, quotes = quotes)
quote = quote,
isBestRate = isBestProvider,
)
} }
} }
_state.update { amountStateFactory.getAmountSecondaryUpdatedState(quote = quote) }
}
/**
* !!! Important quote selection logic !!!
* Selects or updated quote based on input data (amount, country, currency).
* If input data has changed select new best quote, otherwise last selected quote.
* If last selected quote on same input data is in an error state, select next best quote
* If new best quote or next best quote does not exist (i.e. Error state) select nothing.
*/
private fun selectOrUpdateQuote(quotes: List<OnrampQuote>): OnrampQuote? {
val quoteToCheck = quotes.firstOrNull { it !is OnrampQuote.Error }
// Check if amount, country or currency has changed
return if (checkLastInputState(quoteToCheck)) {
quoteToCheck
} else {
val state = state.value as? OnrampMainComponentUM.Content
val providerState = state?.providerBlockState as? OnrampProviderBlockUM.Content
// Get current selected quote to update
val lastSelectedQuote = quotes.firstOrNull {
it.provider.id == providerState?.providerId &&
it.paymentMethod.id == providerState.paymentMethod.id
}
// Check if selected updated quote is not error
if (lastSelectedQuote is OnrampQuote.Error) {
quoteToCheck
} else {
lastSelectedQuote
}
}
}
private fun onRetryQuotes() {
_state.update {
(it as? OnrampMainComponentUM.Content)?.copy(
errorNotification = null,
providerBlockState = OnrampProviderBlockUM.Loading,
) ?: it
}
startLoadingQuotes()
} }
private fun showDemoWarning() { private fun showDemoWarning() {
@ -380,6 +404,11 @@ internal class OnrampMainComponentModel @Inject constructor(
} }
} }
private fun checkLastInputState(quote: OnrampQuote?): Boolean {
return lastUpdateState.value?.lastAmount != quote?.fromAmount ||
lastUpdateState.value?.lastCountryString != quote?.countryCode
}
private companion object { private companion object {
const val UPDATE_DELAY = 10_000L const val UPDATE_DELAY = 10_000L
} }