Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-29 18:25:31 +02:00
parent 8b69fb70dc
commit 6a664f4a52
25 changed files with 762 additions and 7 deletions

View file

@ -5,6 +5,7 @@ import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.SignIn
import com.tangem.domain.card.analytics.IntroductionProcess
import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent
class HotWalletContextInterceptor(
val parent: ParamsInterceptor? = null,
@ -18,6 +19,7 @@ class HotWalletContextInterceptor(
is SignIn.ButtonAddWallet,
is SignIn.ButtonUnlockAllWithBiometric,
is IntroductionProcess.ButtonScanCard,
is TokenScreenAnalyticsEvent.ButtonQuickTopUp,
-> false
is SignIn.ErrorBiometricUpdated -> !event.isFromUnlockAll
else -> true

View file

@ -216,6 +216,7 @@ internal class ChildFactory @Inject constructor(
userWalletId = route.userWalletId,
cryptoCurrency = route.currency,
source = route.source,
initialFiatAmount = route.initialFiatAmount,
),
componentFactory = onrampComponentFactory,
)

View file

@ -297,6 +297,7 @@ sealed class AppRoute(val path: String) : Route {
val source: OnrampSource,
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val initialFiatAmount: SerializedBigDecimal? = null,
) : AppRoute(path = "/onramp/${userWalletId.stringValue}/${currency.symbol}"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
}

View file

@ -328,6 +328,7 @@ sealed class AnalyticsParam {
const val WALLET_TYPE = "Wallet Type"
const val BACKUPED = "Backuped"
const val MEMO = "Memo"
const val VALUE = "Value"
}
}

View file

@ -114,5 +114,9 @@
{
"name": "AND_15438_BACKEND_AUTHENTICATION_ENABLED",
"version": "undefined"
},
{
"name": "AND_15258_QUICK_TOP_UP_ENABLED",
"version": "undefined"
}
]

View file

@ -18,4 +18,5 @@ object StringsSigns {
const val PASSWORD_VISUAL_CHAR = '\u2022'
const val APPROXIMATE = ""
const val WHITE_SPACE = " "
const val LIGHTNING = ""
}

View file

@ -5,8 +5,10 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FR
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACTION
import com.tangem.core.analytics.models.AnalyticsParam.Key.BALANCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.CURRENCY
import com.tangem.core.analytics.models.AnalyticsParam.Key.STATUS
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
import com.tangem.core.analytics.models.AnalyticsParam.Key.VALUE
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
/**
@ -181,6 +183,21 @@ sealed class TokenScreenAnalyticsEvent(
params = mapOf("Token" to token),
)
class ButtonQuickTopUp(
token: String,
blockchain: String,
currency: String,
value: String,
) : TokenScreenAnalyticsEvent(
event = "Quick Top Up Button",
params = mapOf(
TOKEN_PARAM to token,
BLOCKCHAIN to blockchain,
CURRENCY to currency,
VALUE to value,
),
)
companion object {
const val AVAILABLE = "Available"
private const val UNAVAILABLE = "Unavailable"

View file

@ -5,6 +5,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.models.wallet.UserWalletId
import java.math.BigDecimal
interface OnrampComponent : ComposableContentComponent {
@ -12,6 +13,7 @@ interface OnrampComponent : ComposableContentComponent {
val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency,
val source: OnrampSource,
val initialFiatAmount: BigDecimal? = null,
)
interface Factory : ComponentFactory<Params, OnrampComponent>

View file

@ -6,6 +6,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampSource
import java.math.BigDecimal
internal interface OnrampMainComponent : ComposableContentComponent {
@ -15,6 +16,7 @@ internal interface OnrampMainComponent : ComposableContentComponent {
val source: OnrampSource,
val openSettings: () -> Unit,
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
val initialFiatAmount: BigDecimal? = null,
)
interface Factory : ComponentFactory<Params, OnrampMainComponent>

View file

@ -51,7 +51,7 @@ internal class OnrampStateFactory(
)
}
fun getReadyState(currency: OnrampCurrency): OnrampMainComponentUM.Content {
fun getReadyState(currency: OnrampCurrency, initialFiatAmount: BigDecimal? = null): OnrampMainComponentUM.Content {
val state = currentStateProvider()
val endButton = when (val button = state.topBarConfig.endButtonUM) {
@ -59,7 +59,7 @@ internal class OnrampStateFactory(
is TopAppBarButtonUM.Text -> button.copy(isEnabled = true)
}
val initialAmountBlockState = getInitialAmountBlockState(currency)
val initialAmountBlockState = getInitialAmountBlockState(currency, initialFiatAmount)
return OnrampMainComponentUM.Content(
topBarConfig = state.topBarConfig.copy(endButtonUM = endButton),
@ -136,7 +136,10 @@ internal class OnrampStateFactory(
)
}
private fun getInitialAmountBlockState(currency: OnrampCurrency): OnrampAmountBlockUM {
private fun getInitialAmountBlockState(
currency: OnrampCurrency,
initialFiatAmount: BigDecimal? = null,
): OnrampAmountBlockUM {
return OnrampAmountBlockUM(
currencyUM = OnrampCurrencyUM(
code = currency.code,
@ -146,8 +149,8 @@ internal class OnrampStateFactory(
unit = currency.unit,
),
amountFieldModel = AmountFieldModel(
value = "",
fiatValue = "",
value = initialFiatAmount?.toPlainString().orEmpty(),
fiatValue = initialFiatAmount?.toPlainString().orEmpty(),
onValueChange = onrampIntents::onAmountValueChanged,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.None,
@ -156,7 +159,7 @@ internal class OnrampStateFactory(
keyboardActions = KeyboardActions(),
isFiatValue = true,
cryptoAmount = BigDecimal.ZERO.convertToAmount(cryptoCurrency),
fiatAmount = BigDecimal.ZERO.convertToFiatAmount(currency),
fiatAmount = (initialFiatAmount ?: BigDecimal.ZERO).convertToFiatAmount(currency),
isError = false,
isWarning = false,
error = TextReference.EMPTY,

View file

@ -274,7 +274,7 @@ internal class OnrampMainComponentModel @Inject constructor(
amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency)
}
is OnrampMainComponentUM.InitialLoading -> {
stateFactory.getReadyState(country.defaultCurrency)
stateFactory.getReadyState(country.defaultCurrency, params.initialFiatAmount)
}
}
}

View file

@ -82,6 +82,7 @@ internal class DefaultOnrampComponent @AssistedInject constructor(
),
)
},
initialFiatAmount = params.initialFiatAmount,
),
)
is OnrampChild.RedirectPage -> onrampRedirectComponentFactory.create(

View file

@ -0,0 +1,5 @@
package com.tangem.features.tokendetails
interface TokenDetailsFeatureToggles {
val isQuickTopUpEnabled: Boolean
}

View file

@ -0,0 +1,15 @@
package com.tangem.feature.tokendetails
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.tokendetails.TokenDetailsFeatureToggles
import javax.inject.Inject
internal class DefaultTokenDetailsFeatureToggles @Inject constructor(
featureTogglesManager: FeatureTogglesManager,
) : TokenDetailsFeatureToggles {
override val isQuickTopUpEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15258_QUICK_TOP_UP_ENABLED,
)
}

View file

@ -0,0 +1,21 @@
package com.tangem.feature.tokendetails.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.feature.tokendetails.DefaultTokenDetailsFeatureToggles
import com.tangem.features.tokendetails.TokenDetailsFeatureToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object TokenDetailsFeatureModule {
@Provides
@Singleton
fun provideTokenDetailsFeatureToggles(featureTogglesManager: FeatureTogglesManager): TokenDetailsFeatureToggles {
return DefaultTokenDetailsFeatureToggles(featureTogglesManager)
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceSegmentedButtonConfig
import java.math.BigDecimal
@Suppress("TooManyFunctions")
interface TokenDetailsClickIntents {
@ -71,6 +72,8 @@ interface TokenDetailsClickIntents {
fun onBalanceSelect(config: TokenBalanceSegmentedButtonConfig)
fun onQuickTopUpClick(amount: BigDecimal, currencyCode: String)
fun onYieldInfoClick()
// region Clore migration
@ -168,6 +171,8 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents {
override fun onYieldInfoClick() { /* no op */ }
override fun onQuickTopUpClick(amount: BigDecimal, currencyCode: String) { /* no op */ }
override fun onCopyAddress(): TextReference? {
/* no op */
return null

View file

@ -64,6 +64,7 @@ import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.CheckOnrampAvailabilityUseCase
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.staking.GetStakingAvailabilityUseCase
import com.tangem.domain.staking.GetStakingEntryInfoUseCase
@ -84,6 +85,7 @@ import com.tangem.domain.transaction.error.OpenTrustlineError
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase
@ -103,6 +105,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsStateController
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.QuickTopUpBlockFactory
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.InitializeWithCryptoCurrencyTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer
@ -132,6 +135,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass", "TooManyFunctions", "PropertyUsedBeforeDeclaration")
@ -192,6 +196,9 @@ internal class TokenDetailsModel @Inject constructor(
private val redesignStateController: TokenDetailsStateController,
private val swapFeedbackUseCase: SwapFeedbackUseCase,
private val swapFeatureToggles: SwapFeatureToggles,
private val quickTopUpBlockFactory: QuickTopUpBlockFactory,
private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase,
private val checkOnrampAvailabilityUseCase: CheckOnrampAvailabilityUseCase,
) : Model(),
TokenDetailsClickIntents,
YieldSupplyDepositedWarningComponent.ModelCallback {
@ -304,6 +311,7 @@ internal class TokenDetailsModel @Inject constructor(
handleBalanceHiding()
checkForActionUpdates()
handleNavigationParam()
observeQuickTopUpBlock()
}
private fun initButtons() {
@ -584,6 +592,43 @@ internal class TokenDetailsModel @Inject constructor(
}
}
override fun onQuickTopUpClick(amount: BigDecimal, currencyCode: String) {
analyticsEventsHandler.send(
TokenScreenAnalyticsEvent.ButtonQuickTopUp(
token = cryptoCurrency.symbol,
blockchain = cryptoCurrency.network.name,
currency = currencyCode,
value = amount.toInt().toString(),
),
)
appRouter.push(
AppRoute.Onramp(
source = OnrampSource.TOKEN_DETAILS,
userWalletId = userWalletId,
currency = cryptoCurrency,
initialFiatAmount = amount,
),
)
}
private fun onQuickTopUpOtherClick() {
analyticsEventsHandler.send(
TokenScreenAnalyticsEvent.ButtonWithParams.ButtonBuy(
token = cryptoCurrency.symbol,
blockchain = cryptoCurrency.network.name,
status = ScenarioUnavailabilityReason.None.toReasonAnalyticsText(),
derivationIndex = getAccountIndexOrNull(),
),
)
appRouter.push(
AppRoute.Onramp(
source = OnrampSource.TOKEN_DETAILS,
userWalletId = userWalletId,
currency = cryptoCurrency,
),
)
}
override fun onBuyCoinClick(cryptoCurrency: CryptoCurrency) {
analyticsEventsHandler.send(
TokenScreenAnalyticsEvent.ButtonWithParams.ButtonBuy(
@ -1363,6 +1408,38 @@ internal class TokenDetailsModel @Inject constructor(
observeRedesignStakingNotification()
}
private fun observeQuickTopUpBlock() {
getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency)
.map { it.status }
.distinctUntilChanged()
.flatMapLatest { status ->
flow {
val amount = status.value.amount
if (amount == null || !amount.isZero()) {
emit(null)
return@flow
}
val txCount = getTxHistoryItemsCountUseCase(userWalletId, cryptoCurrency)
val availability = checkOnrampAvailabilityUseCase(userWallet)
emit(
quickTopUpBlockFactory.build(
currencyStatus = status,
isTxHistoryEmpty = txCount,
onrampAvailability = availability,
onPresetClick = ::onQuickTopUpClick,
onOtherClick = ::onQuickTopUpOtherClick,
),
)
}
}
.onEach { block ->
uiState.value = uiState.value.copy(quickTopUpBlock = block)
redesignStateController.update { state -> state.copy(quickTopUpBlock = block) }
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun observeRedesignStakingNotification() {
val statusFlow = getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency)
.map { it.status }

View file

@ -0,0 +1,17 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class QuickTopUpBlockUM(
val amounts: ImmutableList<QuickTopUpAmountUM>,
) {
@Immutable
data class QuickTopUpAmountUM(
val displayValue: TextReference,
val onClick: () -> Unit,
val isOther: Boolean = false,
)
}

View file

@ -15,4 +15,5 @@ internal data class TokenDetailsState(
val pullToRefreshConfig: PullToRefreshConfig,
val isBalanceHidden: Boolean,
val isMarketPriceAvailable: Boolean,
val quickTopUpBlock: QuickTopUpBlockUM? = null,
)

View file

@ -24,6 +24,7 @@ internal data class TokenDetailsUM(
val addFundsUM: AddFundsUM,
val transferUM: TransferUM,
val zeroBalanceActionsUM: ZeroBalanceActionsUM,
val quickTopUpBlock: QuickTopUpBlockUM? = null,
)
@Immutable

View file

@ -0,0 +1,79 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
import arrow.core.Either
import com.tangem.core.res.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.onramp.model.OnrampAvailability
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.txhistory.models.TxHistoryStateError
import com.tangem.feature.tokendetails.presentation.tokendetails.state.QuickTopUpBlockUM
import com.tangem.features.tokendetails.TokenDetailsFeatureToggles
import com.tangem.utils.extensions.isZero
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
import javax.inject.Inject
internal class QuickTopUpBlockFactory @Inject constructor(
private val featureToggles: TokenDetailsFeatureToggles,
) {
fun build(
currencyStatus: CryptoCurrencyStatus,
isTxHistoryEmpty: Either<TxHistoryStateError, Int>,
onrampAvailability: Either<OnrampError, OnrampAvailability>,
onPresetClick: (BigDecimal, String) -> Unit,
onOtherClick: () -> Unit,
): QuickTopUpBlockUM? {
if (!featureToggles.isQuickTopUpEnabled) return null
val amount = currencyStatus.value.amount
if (amount == null || !amount.isZero()) return null
val isHistoryEmpty = isTxHistoryEmpty.fold(
ifLeft = { it is TxHistoryStateError.EmptyTxHistories },
ifRight = { it == 0 },
)
if (!isHistoryEmpty) return null
val currency = when (val availability = onrampAvailability.getOrNull()) {
is OnrampAvailability.Available -> availability.currency
is OnrampAvailability.ConfirmResidency -> {
if (!availability.country.onrampAvailable) return null
availability.country.defaultCurrency
}
else -> return null
}
val presets = when (currency.code) {
USD_CODE -> USD_PRESETS
EUR_CODE -> EUR_PRESETS
else -> return null
}
val presetAmounts = presets.map { value ->
QuickTopUpBlockUM.QuickTopUpAmountUM(
displayValue = stringReference("${currency.unit}$value"),
onClick = { onPresetClick(BigDecimal(value), currency.code) },
)
}
val otherAmount = QuickTopUpBlockUM.QuickTopUpAmountUM(
displayValue = resourceReference(R.string.quick_top_up_chip_other),
onClick = onOtherClick,
isOther = true,
)
return QuickTopUpBlockUM(
amounts = (presetAmounts + otherAmount).toImmutableList(),
)
}
private companion object {
const val USD_CODE = "USD"
const val EUR_CODE = "EUR"
val USD_PRESETS = listOf(50, 200, 700)
val EUR_PRESETS = listOf(50, 200, 650)
}
}

View file

@ -0,0 +1,135 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.util.fastForEach
import com.tangem.core.res.R
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.utils.StringsSigns
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.feature.tokendetails.presentation.tokendetails.state.QuickTopUpBlockUM
import kotlinx.collections.immutable.persistentListOf
private val quickTopUpGradientBrush = Brush.linearGradient(
colors = listOf(Color(0xFFEDE5F3), Color(0xFFD7EDD9)),
start = Offset(0f, 0f),
end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY),
)
private val quickTopUpBorderBrush = Brush.sweepGradient(
listOf(
Color(0x0D000000),
Color(0x26000000),
Color(0x0D000000),
Color(0x26000000),
),
)
@Composable
internal fun QuickTopUpBlock(state: QuickTopUpBlockUM, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(TangemTheme.dimens.radius20)
Box(
modifier = modifier
.fillMaxWidth()
.clip(shape)
.background(brush = quickTopUpGradientBrush)
.border(width = 1.dp, brush = quickTopUpBorderBrush, shape = shape),
) {
Column(
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
) {
val textColor = TangemTheme.colors.text.primary1
Text(
text = combinedReference(
stringReference("${StringsSigns.LIGHTNING} "),
resourceReference(R.string.quick_top_up_title),
).resolveReference(),
style = TangemTheme.typography.subtitle1.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.graphicsLayer {
colorFilter = ColorFilter.tint(textColor, BlendMode.SrcIn)
},
)
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing6),
) {
state.amounts.fastForEach { amountUM ->
Button(
onClick = amountUM.onClick,
shape = CircleShape,
contentPadding = PaddingValues(
horizontal = TangemTheme.dimens.spacing12,
vertical = TangemTheme.dimens.spacing0,
),
colors = ButtonDefaults.buttonColors(
containerColor = TangemTheme.colors.background.primary,
contentColor = TangemTheme.colors.text.primary1,
),
modifier = Modifier.heightIn(TangemTheme.dimens.size36),
elevation = null,
) {
Text(
text = amountUM.displayValue.resolveReference(),
style = TangemTheme.typography.subtitle1.copy(fontWeight = FontWeight.SemiBold),
)
}
}
}
}
}
}
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_NO)
@Composable
private fun QuickTopUpBlock_Preview() {
TangemThemePreviewRedesign {
QuickTopUpBlock(
state = QuickTopUpBlockUM(
amounts = persistentListOf(
QuickTopUpBlockUM.QuickTopUpAmountUM(
displayValue = stringReference("\$50"),
onClick = {},
),
QuickTopUpBlockUM.QuickTopUpAmountUM(
displayValue = stringReference("\$200"),
onClick = {},
),
QuickTopUpBlockUM.QuickTopUpAmountUM(
displayValue = stringReference("\$700"),
onClick = {},
),
QuickTopUpBlockUM.QuickTopUpAmountUM(
displayValue = resourceReference(R.string.quick_top_up_chip_other),
onClick = {},
isOther = true,
),
),
),
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
)
}
}

View file

@ -226,6 +226,14 @@ private fun TokenDetailsBody(
modifier = expressTransactionModifier,
)
}
tokenDetailsUM.quickTopUpBlock?.let { quickTopUpBlock ->
item(key = "quick_top_up_block") {
QuickTopUpBlock(
state = quickTopUpBlock,
modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x0),
)
}
}
with(txHistoryComponent) {
txHistoryContent(listState = listState, state = txHistoryState)
}

View file

@ -160,6 +160,15 @@ internal fun TokenDetailsScreenLegacy(
)
}
state.quickTopUpBlock?.let { quickTopUpBlock ->
item(key = "quick_top_up_block") {
QuickTopUpBlock(
state = quickTopUpBlock,
modifier = itemModifier,
)
}
}
with(txHistoryComponent) {
txHistoryContentLegacy(listState = listState, state = txHistoryComponentState)
}

View file

@ -0,0 +1,347 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.core.res.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.onramp.model.OnrampAvailability
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.txhistory.models.TxHistoryStateError
import com.tangem.features.tokendetails.TokenDetailsFeatureToggles
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class QuickTopUpBlockFactoryTest {
private val featureToggles: TokenDetailsFeatureToggles = mockk {
every { isQuickTopUpEnabled } returns true
}
private val factory = QuickTopUpBlockFactory(featureToggles)
private val zeroBalanceStatus: CryptoCurrencyStatus = mockk {
every { value } returns mockk<CryptoCurrencyStatus.Loaded> {
every { amount } returns BigDecimal.ZERO
}
}
private val nonZeroBalanceStatus: CryptoCurrencyStatus = mockk {
every { value } returns mockk<CryptoCurrencyStatus.Loaded> {
every { amount } returns BigDecimal.TEN
}
}
private val usdCurrency = OnrampCurrency(
name = "US Dollar",
code = "USD",
image = null,
precision = 2,
unit = "$",
)
private val eurCurrency = OnrampCurrency(
name = "Euro",
code = "EUR",
image = null,
precision = 2,
unit = "",
)
private val gbpCurrency = OnrampCurrency(
name = "British Pound",
code = "GBP",
image = null,
precision = 2,
unit = "£",
)
private val countryMock: OnrampCountry = mockk(relaxed = true)
private val availableUsd: OnrampAvailability = OnrampAvailability.Available(
country = countryMock,
currency = usdCurrency,
)
private val notSupported: OnrampAvailability = OnrampAvailability.NotSupported(country = countryMock)
private val emptyHistory = TxHistoryStateError.EmptyTxHistories.left()
private val histWithItems = 5.right()
private val histRightZero = 0.right()
@Test
fun `returns null when feature toggle is disabled`() {
val disabledToggles: TokenDetailsFeatureToggles = mockk {
every { isQuickTopUpEnabled } returns false
}
val disabledFactory = QuickTopUpBlockFactory(disabledToggles)
val result = disabledFactory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when balance is non-zero`() {
val result = factory.build(
currencyStatus = nonZeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when history has transactions`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = histWithItems,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when onramp is not available`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = notSupported.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when currency is not USD or EUR`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = OnrampAvailability.Available(
country = countryMock,
currency = gbpCurrency,
).right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns block with USD presets when all conditions met`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNotNull()
val amounts = result!!.amounts
assertThat(amounts.map { it.displayValue }).containsExactly(
stringReference("$50"),
stringReference("$200"),
stringReference("$700"),
resourceReference(R.string.quick_top_up_chip_other),
).inOrder()
assertThat(amounts.last().isOther).isTrue()
assertThat(amounts.take(3).all { !it.isOther }).isTrue()
}
@Test
fun `returns block with EUR presets`() {
val availableEur = OnrampAvailability.Available(
country = countryMock,
currency = eurCurrency,
)
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = availableEur.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNotNull()
val amounts = result!!.amounts
assertThat(amounts.map { it.displayValue }).containsExactly(
stringReference("€50"),
stringReference("€200"),
stringReference("€650"),
resourceReference(R.string.quick_top_up_chip_other),
).inOrder()
assertThat(amounts.last().isOther).isTrue()
}
@Test
fun `returns block when history count is right zero (boundary case)`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = histRightZero,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNotNull()
}
@Test
fun `returns block when ConfirmResidency and country supports onramp with USD`() {
val usdCountry = OnrampCountry(
id = "us",
name = "United States",
code = "US",
image = "",
alpha3 = "USA",
continent = "America",
defaultCurrency = usdCurrency,
onrampAvailable = true,
)
val confirmResidency = OnrampAvailability.ConfirmResidency(country = usdCountry)
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = confirmResidency.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNotNull()
val amounts = result!!.amounts
assertThat(amounts.map { it.displayValue }).containsExactly(
stringReference("$50"),
stringReference("$200"),
stringReference("$700"),
resourceReference(R.string.quick_top_up_chip_other),
).inOrder()
}
@Test
fun `returns null when onramp availability is error`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = OnrampError.DataError(code = "error", description = null).left(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when balance is loading (amount is null)`() {
val loadingStatus: CryptoCurrencyStatus = mockk {
every { value } returns CryptoCurrencyStatus.Loading
}
val result = factory.build(
currencyStatus = loadingStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when tx history is not implemented`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = TxHistoryStateError.TxHistoryNotImplemented.left(),
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when tx history fetch fails with data error`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = TxHistoryStateError.DataError(RuntimeException("network error")).left(),
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when ConfirmResidency with non-USD or EUR default currency`() {
val gbpCountry = OnrampCountry(
id = "gb",
name = "United Kingdom",
code = "GB",
image = "",
alpha3 = "GBR",
continent = "Europe",
defaultCurrency = gbpCurrency,
onrampAvailable = true,
)
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = OnrampAvailability.ConfirmResidency(country = gbpCountry).right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when ConfirmResidency but country does not support onramp`() {
val restrictedCountry = OnrampCountry(
id = "kp",
name = "North Korea",
code = "KP",
image = "",
alpha3 = "PRK",
continent = "Asia",
defaultCurrency = usdCurrency,
onrampAvailable = false,
)
val confirmResidency = OnrampAvailability.ConfirmResidency(country = restrictedCountry)
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = confirmResidency.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
}