diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt index 25a92979ef..578611fb04 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt @@ -249,6 +249,7 @@ internal class DefaultOnrampRepository( maxFromAmount = convertToAmount(response.maxFromAmount, cryptoCurrency), paymentMethod = paymentMethod, provider = provider, + countryCode = response.countryCode, ) }, onError = { error -> @@ -257,6 +258,7 @@ internal class DefaultOnrampRepository( paymentMethod = paymentMethod, provider = provider, fromOnrampAmount = fromOnrampAmount, + countryCode = country.code, ) }, ) @@ -454,6 +456,7 @@ internal class DefaultOnrampRepository( paymentMethod: OnrampPaymentMethod, provider: OnrampProvider, fromOnrampAmount: OnrampAmount, + countryCode: String, ) = if (error is ApiResponseError.HttpException) { val onrampError = onrampErrorConverter.convert(value = error.errorBody.orEmpty()) if (onrampError is OnrampError.AmountError) { @@ -462,6 +465,7 @@ internal class DefaultOnrampRepository( provider = provider, fromAmount = fromOnrampAmount, error = onrampError, + countryCode = countryCode, ) } else { Timber.w(error, "Unable to fetch onramp quotes for ${provider.id}. $error") @@ -470,6 +474,7 @@ internal class DefaultOnrampRepository( provider = provider, fromAmount = fromOnrampAmount, error = onrampError, + countryCode = countryCode, ) } } else { diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampQuote.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampQuote.kt index c36638db32..dde5f19064 100644 --- a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampQuote.kt +++ b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampQuote.kt @@ -7,11 +7,13 @@ sealed class OnrampQuote { abstract val paymentMethod: OnrampPaymentMethod abstract val provider: OnrampProvider abstract val fromAmount: OnrampAmount + abstract val countryCode: String data class Data( override val paymentMethod: OnrampPaymentMethod, override val provider: OnrampProvider, override val fromAmount: OnrampAmount, + override val countryCode: String, val toAmount: OnrampAmount, val minFromAmount: OnrampAmount, val maxFromAmount: OnrampAmount, @@ -21,6 +23,7 @@ sealed class OnrampQuote { override val paymentMethod: OnrampPaymentMethod, override val provider: OnrampProvider, override val fromAmount: OnrampAmount, + override val countryCode: String, val error: OnrampError.AmountError, ) : OnrampQuote() @@ -28,6 +31,7 @@ sealed class OnrampQuote { override val paymentMethod: OnrampPaymentMethod, override val provider: OnrampProvider, override val fromAmount: OnrampAmount, + override val countryCode: String, val error: OnrampError, ) : OnrampQuote() } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampLastUpdate.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampLastUpdate.kt new file mode 100644 index 0000000000..bc6113303c --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampLastUpdate.kt @@ -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, +) \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt index 136405af33..18a7bb1727 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampStateFactory.kt @@ -49,8 +49,11 @@ internal class OnrampStateFactory( fun getOnrampErrorState(onrampError: OnrampError): OnrampMainComponentUM { return when (onrampError) { OnrampError.PairsNotFound -> getNoPairsErrorState() - is OnrampError.DataError -> getErrorState(onrampError.code) - is OnrampError.DomainError -> getErrorState() + is OnrampError.DataError -> getErrorState( + errorCode = onrampError.code, + onRefresh = onrampIntents::onRefresh, + ) + is OnrampError.DomainError -> getErrorState(onRefresh = onrampIntents::onRefresh) is OnrampError.AmountError.TooBigError, is OnrampError.AmountError.TooSmallError, 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 endButton = state.topBarConfig.endButtonUM.copy(enabled = true) @@ -89,13 +92,13 @@ internal class OnrampStateFactory( providerBlockState = OnrampProviderBlockUM.Empty, errorNotification = NotificationUM.Warning.OnrampErrorNotification( errorCode = errorCode, - onRefresh = onrampIntents::onRefresh, + onRefresh = onRefresh, ), ) is OnrampMainComponentUM.InitialLoading -> state.copy( errorNotification = NotificationUM.Warning.OnrampErrorNotification( errorCode = errorCode, - onRefresh = onrampIntents::onRefresh, + onRefresh = onRefresh, ), ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/amount/OnrampAmountStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/amount/OnrampAmountStateFactory.kt index 93b66cbc95..2a443efd36 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/amount/OnrampAmountStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/amount/OnrampAmountStateFactory.kt @@ -14,15 +14,18 @@ import com.tangem.domain.onramp.model.OnrampProviderWithQuote import com.tangem.domain.onramp.model.OnrampQuote import com.tangem.domain.onramp.model.error.OnrampError 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.main.entity.* import com.tangem.features.onramp.providers.entity.SelectProviderResult import com.tangem.utils.Provider +import com.tangem.utils.extensions.isSingleItem internal class OnrampAmountStateFactory( private val currentStateProvider: Provider, private val analyticsEventHandler: AnalyticsEventHandler, private val onrampIntents: OnrampIntents, + private val cryptoCurrency: CryptoCurrency, ) { private val onrampAmountFieldChangeConverter = OnrampAmountFieldChangeConverter( @@ -62,11 +65,12 @@ internal class OnrampAmountStateFactory( return currentState.copy( amountBlockState = amountState.copy(secondaryFieldModel = OnrampAmountSecondaryFieldUM.Loading), + providerBlockState = OnrampProviderBlockUM.Loading, buyButtonConfig = currentState.buyButtonConfig.copy(enabled = false), ) } - fun getAmountSecondaryUpdatedState(quote: OnrampQuote, isBestRate: Boolean): OnrampMainComponentUM { + fun getAmountSecondaryUpdatedState(quote: OnrampQuote): OnrampMainComponentUM { val currentState = currentStateProvider() if (currentState !is OnrampMainComponentUM.Content) return currentState @@ -75,9 +79,9 @@ internal class OnrampAmountStateFactory( return currentState.copy( amountBlockState = amountState.copy( + amountFieldModel = amountState.amountFieldModel.copy(isError = false), secondaryFieldModel = quote.toSecondaryFieldUiModel(amountState) ?: amountState.secondaryFieldModel, ), - providerBlockState = quote.toProviderBlockState(isBestRate), buyButtonConfig = currentState.buyButtonConfig.copy( enabled = quote is OnrampQuote.Data, onClick = { @@ -96,6 +100,32 @@ internal class OnrampAmountStateFactory( ) } + fun getUpdatedProviderState(selectedQuote: OnrampQuote, quotes: List): 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( providerResult: SelectProviderResult, isBestRate: Boolean, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt index 865ec89ec6..f53b13e685 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt @@ -25,10 +25,7 @@ import com.tangem.domain.onramp.model.error.OnrampError import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.main.OnrampMainComponent -import com.tangem.features.onramp.main.entity.OnrampIntents -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.* import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory import com.tangem.features.onramp.main.entity.factory.amount.OnrampAmountStateFactory 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.PeriodicTask import com.tangem.utils.coroutines.SingleTaskScheduler -import com.tangem.utils.extensions.isSingleItem import com.tangem.utils.isNullOrZero import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber -import java.math.BigDecimal import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") internal class OnrampMainComponentModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, @@ -75,6 +70,7 @@ internal class OnrampMainComponentModel @Inject constructor( currentStateProvider = Provider { _state.value }, analyticsEventHandler = analyticsEventHandler, onrampIntents = this, + cryptoCurrency = params.cryptoCurrency, ) private val selectedUserWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } private val _state: MutableStateFlow = MutableStateFlow( @@ -87,7 +83,7 @@ internal class OnrampMainComponentModel @Inject constructor( val state: StateFlow get() = _state.asStateFlow() val bottomSheetNavigation: SlotNavigation = SlotNavigation() - private val lastAmount = mutableStateOf(BigDecimal.ZERO) + private val lastUpdateState = mutableStateOf(null) init { sendScreenOpenAnalytics() @@ -161,7 +157,11 @@ internal class OnrampMainComponentModel @Inject constructor( } 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( ifLeft = ::handleOnrampError, ifRight = { _state.update { amountStateFactory.getAmountSecondaryResetState() } }, @@ -200,7 +200,6 @@ internal class OnrampMainComponentModel @Inject constructor( private fun subscribeToQuotesUpdate() { getOnrampQuotesUseCase.invoke() - .distinctUntilChanged() .conflate() .onEach { maybeQuotes -> maybeQuotes.fold( @@ -282,42 +281,67 @@ internal class OnrampMainComponentModel @Inject constructor( private fun handleQuoteResult(quotes: List) { sendOnrampQuotesErrorAnalytic(quotes) - val quote = quotes.firstOrNull { it !is OnrampQuote.Error } + val quote = selectOrUpdateQuote(quotes) if (quote == null) { - _state.update { stateFactory.getErrorState() } - lastAmount.value = BigDecimal.ZERO + _state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) } + lastUpdateState.value = null return } - val bestProvider = quote as? OnrampQuote.Data - val isMultipleQuotes = !quotes.isSingleItem() - val isOtherQuotesHasData = quotes - .filter { it.paymentMethod == quote.paymentMethod } - .filterNot { it == bestProvider } - .any { it is OnrampQuote.Data } - val hasBestProvider = isMultipleQuotes && isOtherQuotesHasData + if (checkLastInputState(quote)) { + lastUpdateState.value = OnrampLastUpdate( + quote.fromAmount, + quote.countryCode, + ) - 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 { - amountStateFactory.getAmountSecondaryUpdatedState( - quote = quote, - isBestRate = isBestProvider, - ) + amountStateFactory.getUpdatedProviderState(selectedQuote = quote, quotes = quotes) } } + _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? { + 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() { @@ -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 { const val UPDATE_DELAY = 10_000L }