Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-07 15:49:08 +02:00
parent b7ea0b5d2c
commit c6c7cf61ab
14 changed files with 241 additions and 90 deletions

View file

@ -3,6 +3,7 @@ package com.tangem.common.ui.markets
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
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),
modifier = Modifier
.size(TangemTheme.dimens2.x10)
.clip(RoundedCornerShape(8.dp))
.layoutId(layoutId = TangemRowLayoutId.HEAD),
)

View file

@ -1,6 +1,7 @@
package com.tangem.domain.onramp.analytics
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_DESCRIPTION
import com.tangem.core.analytics.models.AnalyticsParam.Key.PAYMENT_METHOD
@ -208,4 +209,17 @@ sealed class OnrampAnalyticsEvent(
event = "Button - All Offers",
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,
),
)
}

View file

@ -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
*

View file

@ -50,14 +50,6 @@ internal class CommonActionsFactory(
null
}
val onrampUnavailabilityReasonDeferred = async {
getOnrampUnavailabilityReason(
userWallet = userWallet,
currency = cryptoCurrencyStatus.currency,
requirementsDeferred = requirementsDeferred,
)
}
val sendUnavailabilityReasonDeferred = async {
getSendUnavailabilityReason(userWalletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus)
}
@ -99,7 +91,7 @@ internal class CommonActionsFactory(
// endregion
// region Buy
addBuyAction(reason = onrampUnavailabilityReasonDeferred.await())
addBuyAction(reason = ScenarioUnavailabilityReason.None)
// endregion
// region Sell

View file

@ -52,14 +52,6 @@ internal class OutdatedDataActionsFactory(
null
}
val onrampUnavailabilityReasonDeferred = async {
getOnrampUnavailabilityReason(
userWallet = userWallet,
currency = cryptoCurrencyStatus.currency,
requirementsDeferred = requirementsDeferred,
)
}
val sendUnavailabilityReasonDeferred = if (sources.networkSource == StatusSource.ACTUAL) {
async {
getSendUnavailabilityReason(
@ -87,7 +79,7 @@ internal class OutdatedDataActionsFactory(
// endregion
// region Buy
addBuyAction(reason = onrampUnavailabilityReasonDeferred.await())
addBuyAction(reason = ScenarioUnavailabilityReason.None)
// endregion
// region Stake

View file

@ -35,14 +35,6 @@ internal class UnreachableActionsFactory(
null
}
val onrampUnavailabilityReasonDeferred = async {
getOnrampUnavailabilityReason(
userWallet = userWallet,
currency = cryptoCurrencyStatus.currency,
requirementsDeferred = requirementsDeferred,
)
}
val hideTokenUnavailabilityReason = getTokenHideUnavailabilityReason(userWallet)
// endregion
@ -52,7 +44,7 @@ internal class UnreachableActionsFactory(
// endregion
// region Buy
addBuyAction(reason = onrampUnavailabilityReasonDeferred.await())
addBuyAction(reason = ScenarioUnavailabilityReason.None)
// endregion
// region Receive

View file

@ -82,4 +82,8 @@ dependencies {
/** Other */
implementation(deps.decompose.ext.compose)
implementation(deps.kotlin.immutable.collections)
/** Tests */
testImplementation(projects.test.core)
testImplementation(projects.common.test)
}

View file

@ -3,6 +3,7 @@ package com.tangem.features.onramp.main.entity
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.ds.message.TangemMessageUM
import com.tangem.core.ui.extensions.TextReference
@Immutable
@ -11,9 +12,12 @@ internal sealed interface OnrampMainComponentUM {
val topBarConfig: OnrampMainTopBarUM
val errorNotification: NotificationUM?
val buyNotSupportedMessage: TangemMessageUM?
data class InitialLoading(
override val topBarConfig: OnrampMainTopBarUM,
override val errorNotification: NotificationUM?,
override val buyNotSupportedMessage: TangemMessageUM? = null,
) : OnrampMainComponentUM
data class Content(
@ -22,6 +26,7 @@ internal sealed interface OnrampMainComponentUM {
val amountBlockState: OnrampAmountBlockUM,
val offersBlockState: OnrampOffersBlockUM,
val onrampAmountButtonUMState: OnrampAmountButtonUMState,
override val buyNotSupportedMessage: TangemMessageUM? = null,
) : OnrampMainComponentUM
}

View file

@ -7,10 +7,12 @@ import androidx.compose.ui.text.input.KeyboardType
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
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.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.ds.message.TangemMessageIconPosition
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.onramp.model.OnrampCurrency
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.utils.Provider
import java.math.BigDecimal
import com.tangem.core.ui.R as CoreUiR
internal class OnrampStateFactory(
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 {
val state = currentStateProvider()
val contentState = state as? OnrampMainComponentUM.Content ?: return state

View file

@ -2,6 +2,7 @@ package com.tangem.features.onramp.main.model
import com.arkivanov.decompose.router.slot.SlotNavigation
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.decompose.model.Model
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.ui.components.fields.InputManager
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.onramp.*
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
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.OnrampQuote
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.features.onramp.main.OnrampMainComponent
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.runSuspendCatching
import com.tangem.utils.isNullOrZero
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass")
@ -46,6 +50,7 @@ internal class OnrampMainComponentModel @Inject constructor(
private val fetchQuotesUseCase: OnrampFetchQuotesUseCase,
private val getOnrampQuotesUseCase: GetOnrampQuotesUseCase,
private val fetchPairsUseCase: OnrampFetchPairsUseCase,
private val rampStateManager: RampStateManager,
private val amountInputManager: InputManager,
private val getOnrampOffersUseCase: GetOnrampOffersUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase,
@ -222,6 +227,8 @@ internal class OnrampMainComponentModel @Inject constructor(
}
private fun handleOnrampAvailability(availability: OnrampAvailability) {
// "Buy not supported" notification has priority over the residency flow.
if (state.value.buyNotSupportedMessage != null) return
when (availability) {
is OnrampAvailability.Available -> Unit
is OnrampAvailability.ConfirmResidency,
@ -274,23 +281,30 @@ internal class OnrampMainComponentModel @Inject constructor(
ifLeft = ::handleOnrampError,
ifRight = { country ->
if (country == null) return@onEach
state.update { prevState ->
when (prevState) {
is OnrampMainComponentUM.Content -> {
amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency)
}
is OnrampMainComponentUM.InitialLoading -> {
stateFactory.getReadyState(country.defaultCurrency, params.initialFiatAmount)
}
}
// Resolve token-level buy support BEFORE emitting any Content state, so an
// unsupported token never briefly shows an enabled amount field — otherwise it
// would grab focus and flash the keyboard before being disabled.
if (isTokenNotSupportedForBuy()) {
showBuyNotSupported(country)
} else {
state.update { prevState -> getCountryUpdatedState(prevState, country) }
updatePairsAndQuotes()
}
updatePairsAndQuotes()
},
)
}
.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() {
getOnrampQuotesUseCase.invoke()
.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) {
TangemLogger.e(onrampError.toString())
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>) {
quotes.forEach { errorState ->
when (errorState) {

View file

@ -125,7 +125,9 @@ private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: Strin
)
LaunchedEffect(key1 = Unit) {
requester.requestFocus()
if (!amountField.isError) {
requester.requestFocus()
}
}
}

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.appbar.TangemTopAppBar
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.res.TangemTheme
import com.tangem.core.ui.utils.WindowInsetsZero
@ -75,7 +76,7 @@ private fun InitialLoading(state: OnrampMainComponentUM.InitialLoading, modifier
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
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)
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)
}
}

View file

@ -23,7 +23,6 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.tokens.GetAssetRequirementsUseCase
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onramp.impl.R
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 isAvailable = when (params.filterOperation) {
OnrampOperation.BUY -> {
isAvailableForBuy
} // unreachable state is available for Buy operation
OnrampOperation.BUY -> true
OnrampOperation.SELL -> isNotUnreachable
OnrampOperation.SWAP -> {
isNotUnreachable && isAvailableForBuy
@ -295,12 +292,7 @@ internal class OnrampTokenListModel @Inject constructor(
private suspend fun checkAvailabilityByOperation(status: CryptoCurrencyStatus): Boolean {
return when (params.filterOperation) {
OnrampOperation.BUY -> {
rampStateManager.availableForBuy(
userWallet = userWallet,
cryptoCurrency = status.currency,
).isAvailable()
}
OnrampOperation.BUY -> true
OnrampOperation.SELL -> {
rampStateManager.availableForSell(
userWalletId = userWallet.walletId,
@ -318,8 +310,4 @@ internal class OnrampTokenListModel @Inject constructor(
}
}
}
private fun ScenarioUnavailabilityReason.isAvailable(): Boolean {
return this == ScenarioUnavailabilityReason.None
}
}

View file

@ -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 = "$",
)
}
}