Updated on 2026-08-14
This commit is contained in:
parent
b7ea0b5d2c
commit
c6c7cf61ab
14 changed files with 241 additions and 90 deletions
|
|
@ -3,6 +3,7 @@ package com.tangem.common.ui.markets
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
|
@ -73,6 +74,7 @@ fun MarketListItemContentV2(model: MarketsListItemUM, modifier: Modifier = Modif
|
||||||
tangemIconUM = TangemIconUM.Url(model.iconUrl, fallbackRes = R.drawable.ic_custom_token_44),
|
tangemIconUM = TangemIconUM.Url(model.iconUrl, fallbackRes = R.drawable.ic_custom_token_44),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(TangemTheme.dimens2.x10)
|
.size(TangemTheme.dimens2.x10)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.layoutId(layoutId = TangemRowLayoutId.HEAD),
|
.layoutId(layoutId = TangemRowLayoutId.HEAD),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.tangem.domain.onramp.analytics
|
package com.tangem.domain.onramp.analytics
|
||||||
|
|
||||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||||
|
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
|
||||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
|
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
|
||||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_DESCRIPTION
|
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_DESCRIPTION
|
||||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.PAYMENT_METHOD
|
import com.tangem.core.analytics.models.AnalyticsParam.Key.PAYMENT_METHOD
|
||||||
|
|
@ -208,4 +209,17 @@ sealed class OnrampAnalyticsEvent(
|
||||||
event = "Button - All Offers",
|
event = "Button - All Offers",
|
||||||
params = emptyMap(),
|
params = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class NoticeBuyNotSupported(
|
||||||
|
private val source: OnrampSource,
|
||||||
|
private val tokenSymbol: String,
|
||||||
|
private val blockchain: String,
|
||||||
|
) : OnrampAnalyticsEvent(
|
||||||
|
event = "Notice - Buy Not Supported",
|
||||||
|
params = mapOf(
|
||||||
|
SOURCE to source.analyticsName,
|
||||||
|
TOKEN_PARAM to tokenSymbol,
|
||||||
|
BLOCKCHAIN to blockchain,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -54,37 +54,6 @@ internal open class BaseActionsFactory(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines the unavailability reason for the BUY action
|
|
||||||
*
|
|
||||||
* @param userWallet the user's cold wallet
|
|
||||||
* @param currency the cryptocurrency to check
|
|
||||||
* @param requirementsDeferred a deferred object containing the asset requirements condition
|
|
||||||
*/
|
|
||||||
protected suspend fun getOnrampUnavailabilityReason(
|
|
||||||
userWallet: UserWallet,
|
|
||||||
currency: CryptoCurrency,
|
|
||||||
requirementsDeferred: Deferred<AssetRequirementsCondition?>?,
|
|
||||||
): ScenarioUnavailabilityReason {
|
|
||||||
// Start2Coin (S2C) are legacy single-currency cards that do not support buying crypto in-app
|
|
||||||
// (historically only Receive/Send were offered for them).
|
|
||||||
if (userWallet is UserWallet.Cold && userWallet.cardTypesResolver.isStart2Coin()) {
|
|
||||||
return ScenarioUnavailabilityReason.BuyUnavailable(currency.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
val onrampUnavailabilityReason = rampStateManager.availableForBuy(
|
|
||||||
userWallet = userWallet,
|
|
||||||
cryptoCurrency = currency,
|
|
||||||
)
|
|
||||||
val shouldCheckAssetRequirements =
|
|
||||||
onrampUnavailabilityReason == ScenarioUnavailabilityReason.None && requirementsDeferred != null
|
|
||||||
return if (shouldCheckAssetRequirements) {
|
|
||||||
getReceiveScenario(requirementsDeferred.await())
|
|
||||||
} else {
|
|
||||||
onrampUnavailabilityReason
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines the unavailability reason for the SEND action
|
* Determines the unavailability reason for the SEND action
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -50,14 +50,6 @@ internal class CommonActionsFactory(
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val onrampUnavailabilityReasonDeferred = async {
|
|
||||||
getOnrampUnavailabilityReason(
|
|
||||||
userWallet = userWallet,
|
|
||||||
currency = cryptoCurrencyStatus.currency,
|
|
||||||
requirementsDeferred = requirementsDeferred,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val sendUnavailabilityReasonDeferred = async {
|
val sendUnavailabilityReasonDeferred = async {
|
||||||
getSendUnavailabilityReason(userWalletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus)
|
getSendUnavailabilityReason(userWalletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus)
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +91,7 @@ internal class CommonActionsFactory(
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Buy
|
// region Buy
|
||||||
addBuyAction(reason = onrampUnavailabilityReasonDeferred.await())
|
addBuyAction(reason = ScenarioUnavailabilityReason.None)
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Sell
|
// region Sell
|
||||||
|
|
|
||||||
|
|
@ -52,14 +52,6 @@ internal class OutdatedDataActionsFactory(
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val onrampUnavailabilityReasonDeferred = async {
|
|
||||||
getOnrampUnavailabilityReason(
|
|
||||||
userWallet = userWallet,
|
|
||||||
currency = cryptoCurrencyStatus.currency,
|
|
||||||
requirementsDeferred = requirementsDeferred,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val sendUnavailabilityReasonDeferred = if (sources.networkSource == StatusSource.ACTUAL) {
|
val sendUnavailabilityReasonDeferred = if (sources.networkSource == StatusSource.ACTUAL) {
|
||||||
async {
|
async {
|
||||||
getSendUnavailabilityReason(
|
getSendUnavailabilityReason(
|
||||||
|
|
@ -87,7 +79,7 @@ internal class OutdatedDataActionsFactory(
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Buy
|
// region Buy
|
||||||
addBuyAction(reason = onrampUnavailabilityReasonDeferred.await())
|
addBuyAction(reason = ScenarioUnavailabilityReason.None)
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Stake
|
// region Stake
|
||||||
|
|
|
||||||
|
|
@ -35,14 +35,6 @@ internal class UnreachableActionsFactory(
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val onrampUnavailabilityReasonDeferred = async {
|
|
||||||
getOnrampUnavailabilityReason(
|
|
||||||
userWallet = userWallet,
|
|
||||||
currency = cryptoCurrencyStatus.currency,
|
|
||||||
requirementsDeferred = requirementsDeferred,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val hideTokenUnavailabilityReason = getTokenHideUnavailabilityReason(userWallet)
|
val hideTokenUnavailabilityReason = getTokenHideUnavailabilityReason(userWallet)
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
|
|
@ -52,7 +44,7 @@ internal class UnreachableActionsFactory(
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Buy
|
// region Buy
|
||||||
addBuyAction(reason = onrampUnavailabilityReasonDeferred.await())
|
addBuyAction(reason = ScenarioUnavailabilityReason.None)
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Receive
|
// region Receive
|
||||||
|
|
|
||||||
|
|
@ -82,4 +82,8 @@ dependencies {
|
||||||
/** Other */
|
/** Other */
|
||||||
implementation(deps.decompose.ext.compose)
|
implementation(deps.decompose.ext.compose)
|
||||||
implementation(deps.kotlin.immutable.collections)
|
implementation(deps.kotlin.immutable.collections)
|
||||||
|
|
||||||
|
/** Tests */
|
||||||
|
testImplementation(projects.test.core)
|
||||||
|
testImplementation(projects.common.test)
|
||||||
}
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package com.tangem.features.onramp.main.entity
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import com.tangem.common.ui.notifications.NotificationUM
|
import com.tangem.common.ui.notifications.NotificationUM
|
||||||
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
|
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
|
||||||
|
import com.tangem.core.ui.ds.message.TangemMessageUM
|
||||||
import com.tangem.core.ui.extensions.TextReference
|
import com.tangem.core.ui.extensions.TextReference
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
@ -11,9 +12,12 @@ internal sealed interface OnrampMainComponentUM {
|
||||||
val topBarConfig: OnrampMainTopBarUM
|
val topBarConfig: OnrampMainTopBarUM
|
||||||
val errorNotification: NotificationUM?
|
val errorNotification: NotificationUM?
|
||||||
|
|
||||||
|
val buyNotSupportedMessage: TangemMessageUM?
|
||||||
|
|
||||||
data class InitialLoading(
|
data class InitialLoading(
|
||||||
override val topBarConfig: OnrampMainTopBarUM,
|
override val topBarConfig: OnrampMainTopBarUM,
|
||||||
override val errorNotification: NotificationUM?,
|
override val errorNotification: NotificationUM?,
|
||||||
|
override val buyNotSupportedMessage: TangemMessageUM? = null,
|
||||||
) : OnrampMainComponentUM
|
) : OnrampMainComponentUM
|
||||||
|
|
||||||
data class Content(
|
data class Content(
|
||||||
|
|
@ -22,6 +26,7 @@ internal sealed interface OnrampMainComponentUM {
|
||||||
val amountBlockState: OnrampAmountBlockUM,
|
val amountBlockState: OnrampAmountBlockUM,
|
||||||
val offersBlockState: OnrampOffersBlockUM,
|
val offersBlockState: OnrampOffersBlockUM,
|
||||||
val onrampAmountButtonUMState: OnrampAmountButtonUMState,
|
val onrampAmountButtonUMState: OnrampAmountButtonUMState,
|
||||||
|
override val buyNotSupportedMessage: TangemMessageUM? = null,
|
||||||
) : OnrampMainComponentUM
|
) : OnrampMainComponentUM
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,12 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||||
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
|
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
|
||||||
import com.tangem.common.ui.notifications.NotificationUM
|
import com.tangem.common.ui.notifications.NotificationUM
|
||||||
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
|
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
|
||||||
import com.tangem.core.ui.extensions.TextReference
|
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||||
import com.tangem.core.ui.extensions.combinedReference
|
import com.tangem.core.ui.ds.message.TangemMessageEffect
|
||||||
import com.tangem.core.ui.extensions.resourceReference
|
import com.tangem.core.ui.ds.message.TangemMessageIconPosition
|
||||||
import com.tangem.core.ui.extensions.stringReference
|
import com.tangem.core.ui.ds.message.TangemMessageUM
|
||||||
|
import com.tangem.core.ui.extensions.*
|
||||||
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.onramp.model.OnrampCurrency
|
import com.tangem.domain.onramp.model.OnrampCurrency
|
||||||
import com.tangem.domain.onramp.model.error.OnrampError
|
import com.tangem.domain.onramp.model.error.OnrampError
|
||||||
|
|
@ -21,6 +23,7 @@ import com.tangem.features.onramp.impl.R
|
||||||
import com.tangem.features.onramp.main.entity.*
|
import com.tangem.features.onramp.main.entity.*
|
||||||
import com.tangem.utils.Provider
|
import com.tangem.utils.Provider
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
import com.tangem.core.ui.R as CoreUiR
|
||||||
|
|
||||||
internal class OnrampStateFactory(
|
internal class OnrampStateFactory(
|
||||||
private val currentStateProvider: Provider<OnrampMainComponentUM>,
|
private val currentStateProvider: Provider<OnrampMainComponentUM>,
|
||||||
|
|
@ -120,6 +123,42 @@ internal class OnrampStateFactory(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getBuyNotSupportedState(state: OnrampMainComponentUM = currentStateProvider()): OnrampMainComponentUM {
|
||||||
|
val message = buildBuyNotSupportedMessage()
|
||||||
|
|
||||||
|
return when (state) {
|
||||||
|
is OnrampMainComponentUM.Content -> state.copy(
|
||||||
|
buyNotSupportedMessage = message,
|
||||||
|
errorNotification = null,
|
||||||
|
offersBlockState = OnrampOffersBlockUM.Empty,
|
||||||
|
onrampAmountButtonUMState = OnrampAmountButtonUMState.None,
|
||||||
|
amountBlockState = state.amountBlockState.copy(
|
||||||
|
amountFieldModel = state.amountBlockState.amountFieldModel.copy(isError = true),
|
||||||
|
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
is OnrampMainComponentUM.InitialLoading -> state.copy(
|
||||||
|
buyNotSupportedMessage = message,
|
||||||
|
errorNotification = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildBuyNotSupportedMessage(): TangemMessageUM = TangemMessageUM(
|
||||||
|
id = "buy_not_supported",
|
||||||
|
title = resourceReference(
|
||||||
|
id = R.string.onramp_token_is_not_supported_banner_title,
|
||||||
|
formatArgs = wrappedList(cryptoCurrency.name),
|
||||||
|
),
|
||||||
|
subtitle = resourceReference(R.string.onramp_token_is_not_supported_banner_subtitle),
|
||||||
|
messageEffect = TangemMessageEffect.None,
|
||||||
|
iconUM = TangemIconUM.Icon(
|
||||||
|
iconRes = CoreUiR.drawable.ic_attention_default_24,
|
||||||
|
tintReference = { TangemTheme.colors2.graphic.status.attention },
|
||||||
|
),
|
||||||
|
iconPosition = TangemMessageIconPosition.Leading,
|
||||||
|
)
|
||||||
|
|
||||||
private fun getNoPairsErrorState(): OnrampMainComponentUM {
|
private fun getNoPairsErrorState(): OnrampMainComponentUM {
|
||||||
val state = currentStateProvider()
|
val state = currentStateProvider()
|
||||||
val contentState = state as? OnrampMainComponentUM.Content ?: return state
|
val contentState = state as? OnrampMainComponentUM.Content ?: return state
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.tangem.features.onramp.main.model
|
||||||
|
|
||||||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||||
import com.arkivanov.decompose.router.slot.activate
|
import com.arkivanov.decompose.router.slot.activate
|
||||||
|
import com.arkivanov.decompose.router.slot.dismiss
|
||||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||||
import com.tangem.core.decompose.model.Model
|
import com.tangem.core.decompose.model.Model
|
||||||
import com.tangem.core.decompose.model.ParamsContainer
|
import com.tangem.core.decompose.model.ParamsContainer
|
||||||
|
|
@ -9,12 +10,15 @@ import com.tangem.core.decompose.navigation.Router
|
||||||
import com.tangem.core.decompose.ui.UiMessageSender
|
import com.tangem.core.decompose.ui.UiMessageSender
|
||||||
import com.tangem.core.ui.components.fields.InputManager
|
import com.tangem.core.ui.components.fields.InputManager
|
||||||
import com.tangem.domain.demo.IsDemoCardUseCase
|
import com.tangem.domain.demo.IsDemoCardUseCase
|
||||||
|
import com.tangem.domain.exchange.RampStateManager
|
||||||
import com.tangem.domain.onramp.*
|
import com.tangem.domain.onramp.*
|
||||||
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
|
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
|
||||||
import com.tangem.domain.onramp.model.OnrampAvailability
|
import com.tangem.domain.onramp.model.OnrampAvailability
|
||||||
|
import com.tangem.domain.onramp.model.OnrampCountry
|
||||||
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
|
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.ScenarioUnavailabilityReason
|
||||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||||
import com.tangem.features.onramp.main.OnrampMainComponent
|
import com.tangem.features.onramp.main.OnrampMainComponent
|
||||||
import com.tangem.features.onramp.main.entity.*
|
import com.tangem.features.onramp.main.entity.*
|
||||||
|
|
@ -30,9 +34,9 @@ import com.tangem.utils.coroutines.PeriodicTask
|
||||||
import com.tangem.utils.coroutines.SingleTaskScheduler
|
import com.tangem.utils.coroutines.SingleTaskScheduler
|
||||||
import com.tangem.utils.coroutines.runSuspendCatching
|
import com.tangem.utils.coroutines.runSuspendCatching
|
||||||
import com.tangem.utils.isNullOrZero
|
import com.tangem.utils.isNullOrZero
|
||||||
|
import com.tangem.utils.logging.TangemLogger
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import com.tangem.utils.logging.TangemLogger
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@Suppress("LongParameterList", "LargeClass")
|
@Suppress("LongParameterList", "LargeClass")
|
||||||
|
|
@ -46,6 +50,7 @@ internal class OnrampMainComponentModel @Inject constructor(
|
||||||
private val fetchQuotesUseCase: OnrampFetchQuotesUseCase,
|
private val fetchQuotesUseCase: OnrampFetchQuotesUseCase,
|
||||||
private val getOnrampQuotesUseCase: GetOnrampQuotesUseCase,
|
private val getOnrampQuotesUseCase: GetOnrampQuotesUseCase,
|
||||||
private val fetchPairsUseCase: OnrampFetchPairsUseCase,
|
private val fetchPairsUseCase: OnrampFetchPairsUseCase,
|
||||||
|
private val rampStateManager: RampStateManager,
|
||||||
private val amountInputManager: InputManager,
|
private val amountInputManager: InputManager,
|
||||||
private val getOnrampOffersUseCase: GetOnrampOffersUseCase,
|
private val getOnrampOffersUseCase: GetOnrampOffersUseCase,
|
||||||
private val isDemoCardUseCase: IsDemoCardUseCase,
|
private val isDemoCardUseCase: IsDemoCardUseCase,
|
||||||
|
|
@ -222,6 +227,8 @@ internal class OnrampMainComponentModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleOnrampAvailability(availability: OnrampAvailability) {
|
private fun handleOnrampAvailability(availability: OnrampAvailability) {
|
||||||
|
// "Buy not supported" notification has priority over the residency flow.
|
||||||
|
if (state.value.buyNotSupportedMessage != null) return
|
||||||
when (availability) {
|
when (availability) {
|
||||||
is OnrampAvailability.Available -> Unit
|
is OnrampAvailability.Available -> Unit
|
||||||
is OnrampAvailability.ConfirmResidency,
|
is OnrampAvailability.ConfirmResidency,
|
||||||
|
|
@ -274,23 +281,30 @@ internal class OnrampMainComponentModel @Inject constructor(
|
||||||
ifLeft = ::handleOnrampError,
|
ifLeft = ::handleOnrampError,
|
||||||
ifRight = { country ->
|
ifRight = { country ->
|
||||||
if (country == null) return@onEach
|
if (country == null) return@onEach
|
||||||
state.update { prevState ->
|
// Resolve token-level buy support BEFORE emitting any Content state, so an
|
||||||
when (prevState) {
|
// unsupported token never briefly shows an enabled amount field — otherwise it
|
||||||
is OnrampMainComponentUM.Content -> {
|
// would grab focus and flash the keyboard before being disabled.
|
||||||
amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency)
|
if (isTokenNotSupportedForBuy()) {
|
||||||
}
|
showBuyNotSupported(country)
|
||||||
is OnrampMainComponentUM.InitialLoading -> {
|
} else {
|
||||||
stateFactory.getReadyState(country.defaultCurrency, params.initialFiatAmount)
|
state.update { prevState -> getCountryUpdatedState(prevState, country) }
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updatePairsAndQuotes()
|
updatePairsAndQuotes()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.launchIn(modelScope)
|
.launchIn(modelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getCountryUpdatedState(
|
||||||
|
prevState: OnrampMainComponentUM,
|
||||||
|
country: OnrampCountry,
|
||||||
|
): OnrampMainComponentUM = when (prevState) {
|
||||||
|
is OnrampMainComponentUM.Content -> amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency)
|
||||||
|
is OnrampMainComponentUM.InitialLoading ->
|
||||||
|
stateFactory.getReadyState(country.defaultCurrency, params.initialFiatAmount)
|
||||||
|
}
|
||||||
|
|
||||||
private fun subscribeToQuotesUpdate() {
|
private fun subscribeToQuotesUpdate() {
|
||||||
getOnrampQuotesUseCase.invoke()
|
getOnrampQuotesUseCase.invoke()
|
||||||
.conflate()
|
.conflate()
|
||||||
|
|
@ -356,11 +370,43 @@ internal class OnrampMainComponentModel @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun isTokenNotSupportedForBuy(): Boolean {
|
||||||
|
// Token-level "cannot be bought", independent of country: the asset is either flagged as
|
||||||
|
// not onrampable (BuyUnavailable) or absent from the express asset list (AssetNotFound).
|
||||||
|
// Transient express states (loading/unreachable) are NOT treated as "not supported".
|
||||||
|
val reason = rampStateManager.availableForBuy(
|
||||||
|
userWallet = userWallet,
|
||||||
|
cryptoCurrency = params.cryptoCurrency,
|
||||||
|
)
|
||||||
|
return reason is ScenarioUnavailabilityReason.BuyUnavailable ||
|
||||||
|
reason is ScenarioUnavailabilityReason.AssetNotFound
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleOnrampError(onrampError: OnrampError) {
|
private fun handleOnrampError(onrampError: OnrampError) {
|
||||||
TangemLogger.e(onrampError.toString())
|
TangemLogger.e(onrampError.toString())
|
||||||
state.update { stateFactory.getOnrampErrorState(onrampError) }
|
state.update { stateFactory.getOnrampErrorState(onrampError) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showBuyNotSupported(country: OnrampCountry) {
|
||||||
|
if (state.value.buyNotSupportedMessage != null) return
|
||||||
|
|
||||||
|
analyticsEventHandler.send(
|
||||||
|
OnrampAnalyticsEvent.NoticeBuyNotSupported(
|
||||||
|
source = params.source,
|
||||||
|
tokenSymbol = params.cryptoCurrency.symbol,
|
||||||
|
blockchain = params.cryptoCurrency.network.name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
quotesTaskScheduler.cancelTask()
|
||||||
|
// "Not supported" has priority: hide the residency bottom sheet if it was already shown.
|
||||||
|
bottomSheetNavigation.dismiss()
|
||||||
|
// Emit the not-supported state in a single update built from the ready state, so the amount
|
||||||
|
// field never appears enabled first (no focus/keyboard flash).
|
||||||
|
state.update { prevState ->
|
||||||
|
stateFactory.getBuyNotSupportedState(getCountryUpdatedState(prevState, country))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun sendOnrampQuotesErrorAnalytic(quotes: List<OnrampQuote>) {
|
private fun sendOnrampQuotesErrorAnalytic(quotes: List<OnrampQuote>) {
|
||||||
quotes.forEach { errorState ->
|
quotes.forEach { errorState ->
|
||||||
when (errorState) {
|
when (errorState) {
|
||||||
|
|
|
||||||
|
|
@ -125,9 +125,11 @@ private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: Strin
|
||||||
)
|
)
|
||||||
|
|
||||||
LaunchedEffect(key1 = Unit) {
|
LaunchedEffect(key1 = Unit) {
|
||||||
|
if (!amountField.isError) {
|
||||||
requester.requestFocus()
|
requester.requestFocus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OnrampAmountSecondary(state: OnrampSecondaryFieldErrorUM.Error) {
|
private fun OnrampAmountSecondary(state: OnrampSecondaryFieldErrorUM.Error) {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import com.tangem.core.ui.components.RectangleShimmer
|
import com.tangem.core.ui.components.RectangleShimmer
|
||||||
import com.tangem.core.ui.components.appbar.TangemTopAppBar
|
import com.tangem.core.ui.components.appbar.TangemTopAppBar
|
||||||
import com.tangem.core.ui.components.notifications.Notification
|
import com.tangem.core.ui.components.notifications.Notification
|
||||||
|
import com.tangem.core.ui.ds.message.TangemMessage
|
||||||
import com.tangem.core.ui.extensions.resolveReference
|
import com.tangem.core.ui.extensions.resolveReference
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
import com.tangem.core.ui.utils.WindowInsetsZero
|
import com.tangem.core.ui.utils.WindowInsetsZero
|
||||||
|
|
@ -75,7 +76,7 @@ private fun InitialLoading(state: OnrampMainComponentUM.InitialLoading, modifier
|
||||||
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
|
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
|
||||||
) {
|
) {
|
||||||
OnrampAmountContentLoading()
|
OnrampAmountContentLoading()
|
||||||
if (state.errorNotification != null) Notification(config = state.errorNotification.config)
|
OnrampNotifications(state = state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,6 +135,16 @@ private fun Content(state: OnrampMainComponentUM.Content, modifier: Modifier = M
|
||||||
|
|
||||||
OnrampOffersContent(state = state.offersBlockState)
|
OnrampOffersContent(state = state.offersBlockState)
|
||||||
|
|
||||||
if (state.errorNotification != null) Notification(config = state.errorNotification.config)
|
OnrampNotifications(state = state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OnrampNotifications(state: OnrampMainComponentUM, modifier: Modifier = Modifier) {
|
||||||
|
val buyNotSupportedMessage = state.buyNotSupportedMessage
|
||||||
|
val errorNotification = state.errorNotification
|
||||||
|
when {
|
||||||
|
buyNotSupportedMessage != null -> TangemMessage(messageUM = buyNotSupportedMessage, modifier = modifier)
|
||||||
|
errorNotification != null -> Notification(config = errorNotification.config, modifier = modifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -23,7 +23,6 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||||
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
|
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
|
||||||
import com.tangem.domain.settings.usercountry.models.UserCountry
|
import com.tangem.domain.settings.usercountry.models.UserCountry
|
||||||
import com.tangem.domain.tokens.GetAssetRequirementsUseCase
|
import com.tangem.domain.tokens.GetAssetRequirementsUseCase
|
||||||
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
|
|
||||||
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.swap.entity.AccountAvailabilityUM
|
import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM
|
||||||
|
|
@ -270,9 +269,7 @@ internal class OnrampTokenListModel @Inject constructor(
|
||||||
val isNotUnreachable = status.value !is CryptoCurrencyStatus.Unreachable
|
val isNotUnreachable = status.value !is CryptoCurrencyStatus.Unreachable
|
||||||
|
|
||||||
val isAvailable = when (params.filterOperation) {
|
val isAvailable = when (params.filterOperation) {
|
||||||
OnrampOperation.BUY -> {
|
OnrampOperation.BUY -> true
|
||||||
isAvailableForBuy
|
|
||||||
} // unreachable state is available for Buy operation
|
|
||||||
OnrampOperation.SELL -> isNotUnreachable
|
OnrampOperation.SELL -> isNotUnreachable
|
||||||
OnrampOperation.SWAP -> {
|
OnrampOperation.SWAP -> {
|
||||||
isNotUnreachable && isAvailableForBuy
|
isNotUnreachable && isAvailableForBuy
|
||||||
|
|
@ -295,12 +292,7 @@ internal class OnrampTokenListModel @Inject constructor(
|
||||||
|
|
||||||
private suspend fun checkAvailabilityByOperation(status: CryptoCurrencyStatus): Boolean {
|
private suspend fun checkAvailabilityByOperation(status: CryptoCurrencyStatus): Boolean {
|
||||||
return when (params.filterOperation) {
|
return when (params.filterOperation) {
|
||||||
OnrampOperation.BUY -> {
|
OnrampOperation.BUY -> true
|
||||||
rampStateManager.availableForBuy(
|
|
||||||
userWallet = userWallet,
|
|
||||||
cryptoCurrency = status.currency,
|
|
||||||
).isAvailable()
|
|
||||||
}
|
|
||||||
OnrampOperation.SELL -> {
|
OnrampOperation.SELL -> {
|
||||||
rampStateManager.availableForSell(
|
rampStateManager.availableForSell(
|
||||||
userWalletId = userWallet.walletId,
|
userWalletId = userWallet.walletId,
|
||||||
|
|
@ -318,8 +310,4 @@ internal class OnrampTokenListModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ScenarioUnavailabilityReason.isAvailable(): Boolean {
|
|
||||||
return this == ScenarioUnavailabilityReason.None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package com.tangem.features.onramp.main.entity.factory
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||||
|
import com.tangem.core.ui.ds.message.TangemMessageEffect
|
||||||
|
import com.tangem.core.ui.extensions.resourceReference
|
||||||
|
import com.tangem.core.ui.extensions.stringReference
|
||||||
|
import com.tangem.core.ui.extensions.wrappedList
|
||||||
|
import com.tangem.domain.onramp.model.OnrampCurrency
|
||||||
|
import com.tangem.features.onramp.impl.R
|
||||||
|
import com.tangem.features.onramp.main.entity.OnrampAmountButtonUMState
|
||||||
|
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
|
||||||
|
import com.tangem.features.onramp.main.entity.OnrampOffersBlockUM
|
||||||
|
import com.tangem.features.onramp.main.entity.OnrampSecondaryFieldErrorUM
|
||||||
|
import com.tangem.utils.Provider
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
internal class OnrampStateFactoryTest {
|
||||||
|
|
||||||
|
private lateinit var currentState: OnrampMainComponentUM
|
||||||
|
|
||||||
|
private val cryptoCurrency = MockCryptoCurrencyFactory().ethereum
|
||||||
|
|
||||||
|
private val factory = OnrampStateFactory(
|
||||||
|
currentStateProvider = Provider { currentState },
|
||||||
|
onrampAmountButtonUMStateFactory = OnrampAmountButtonUMStateFactory(),
|
||||||
|
cryptoCurrency = cryptoCurrency,
|
||||||
|
onrampIntents = mockk(relaxed = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN initial loading with error WHEN getBuyNotSupportedState THEN shows None message and clears error`() {
|
||||||
|
// Arrange
|
||||||
|
currentState = factory.getInitialState(currency = "BTC", onClose = {}, openSettings = {})
|
||||||
|
.copy(errorNotification = mockk())
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = factory.getBuyNotSupportedState()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val message = result.buyNotSupportedMessage
|
||||||
|
assertThat(message).isNotNull()
|
||||||
|
assertThat(message!!.messageEffect).isEqualTo(TangemMessageEffect.None)
|
||||||
|
assertThat(message.title).isEqualTo(
|
||||||
|
resourceReference(
|
||||||
|
id = R.string.onramp_token_is_not_supported_banner_title,
|
||||||
|
formatArgs = wrappedList(cryptoCurrency.name),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertThat(message.subtitle).isEqualTo(
|
||||||
|
resourceReference(R.string.onramp_token_is_not_supported_banner_subtitle),
|
||||||
|
)
|
||||||
|
assertThat(result.errorNotification).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN content with errors WHEN getBuyNotSupportedState THEN message has priority and other errors hidden`() {
|
||||||
|
// Arrange
|
||||||
|
currentState = factory.getInitialState(currency = "BTC", onClose = {}, openSettings = {})
|
||||||
|
val content = factory.getReadyState(currency = USD_CURRENCY) as OnrampMainComponentUM.Content
|
||||||
|
currentState = content.copy(
|
||||||
|
errorNotification = mockk(),
|
||||||
|
offersBlockState = OnrampOffersBlockUM.Loading,
|
||||||
|
onrampAmountButtonUMState = OnrampAmountButtonUMState.Loaded(persistentListOf()),
|
||||||
|
amountBlockState = content.amountBlockState.copy(
|
||||||
|
amountFieldModel = content.amountBlockState.amountFieldModel.copy(isError = true),
|
||||||
|
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Error(stringReference("error")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = factory.getBuyNotSupportedState() as OnrampMainComponentUM.Content
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result.buyNotSupportedMessage).isNotNull()
|
||||||
|
assertThat(result.errorNotification).isNull()
|
||||||
|
assertThat(result.offersBlockState).isEqualTo(OnrampOffersBlockUM.Empty)
|
||||||
|
assertThat(result.onrampAmountButtonUMState).isEqualTo(OnrampAmountButtonUMState.None)
|
||||||
|
assertThat(result.amountBlockState.secondaryFieldModel).isEqualTo(OnrampSecondaryFieldErrorUM.Empty)
|
||||||
|
// Amount input is locked (disabled via isError) — like the unsupported-country case.
|
||||||
|
assertThat(result.amountBlockState.amountFieldModel.isError).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val USD_CURRENCY = OnrampCurrency(
|
||||||
|
name = "US Dollar",
|
||||||
|
code = "USD",
|
||||||
|
image = null,
|
||||||
|
precision = 2,
|
||||||
|
unit = "$",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue