Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-04 10:40:41 +02:00
parent 3a5144403b
commit eeb74486ef
5 changed files with 89 additions and 186 deletions

View file

@ -237,11 +237,7 @@ enum class TangemMessageEffect(val isAnimatable: Boolean) {
/** Applies a message effect background to the [Modifier] based on the provided [messageEffect] and [radius] */
@Composable
internal fun Modifier.messageEffectBackground(
messageEffect: TangemMessageEffect,
radius: Dp,
contentColor: Color,
): Modifier {
fun Modifier.messageEffectBackground(messageEffect: TangemMessageEffect, radius: Dp, contentColor: Color): Modifier {
val isInDarkTheme = LocalIsInDarkTheme.current
val borderGradientColors = remember(messageEffect, isInDarkTheme) { messageEffect.getBorderGradient(isInDarkTheme) }
val gradientColors = remember(messageEffect, isInDarkTheme) { messageEffect.getColorGradient(isInDarkTheme) }

View file

@ -85,7 +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.txhistory.usecase.GetFixedTxHistoryItemsUseCase
import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase
@ -197,7 +197,7 @@ internal class TokenDetailsModel @Inject constructor(
private val swapFeedbackUseCase: SwapFeedbackUseCase,
private val swapFeatureToggles: SwapFeatureToggles,
private val quickTopUpBlockFactory: QuickTopUpBlockFactory,
private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase,
private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase,
private val checkOnrampAvailabilityUseCase: CheckOnrampAvailabilityUseCase,
) : Model(),
TokenDetailsClickIntents,
@ -1424,12 +1424,18 @@ internal class TokenDetailsModel @Inject constructor(
emit(null)
return@flow
}
val txCount = getTxHistoryItemsCountUseCase(userWalletId, cryptoCurrency)
val isHistoryEmpty = getFixedTxHistoryItemsUseCase.getSync(
userWalletId = userWalletId,
currency = cryptoCurrency,
).fold(
ifLeft = { true },
ifRight = { it.isEmpty() },
)
val availability = checkOnrampAvailabilityUseCase(userWallet)
emit(
quickTopUpBlockFactory.build(
currencyStatus = status,
isTxHistoryEmpty = txCount,
isHistoryEmpty = isHistoryEmpty,
onrampAvailability = availability,
onPresetClick = ::onQuickTopUpClick,
onOtherClick = ::onQuickTopUpOtherClick,

View file

@ -7,7 +7,6 @@ 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
@ -21,7 +20,7 @@ internal class QuickTopUpBlockFactory @Inject constructor(
fun build(
currencyStatus: CryptoCurrencyStatus,
isTxHistoryEmpty: Either<TxHistoryStateError, Int>,
isHistoryEmpty: Boolean,
onrampAvailability: Either<OnrampError, OnrampAvailability>,
onPresetClick: (BigDecimal, String) -> Unit,
onOtherClick: () -> Unit,
@ -31,10 +30,6 @@ internal class QuickTopUpBlockFactory @Inject constructor(
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()) {

View file

@ -1,102 +1,75 @@
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.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.ds.button.PrimaryInverseTangemButton
import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.ds.message.messageEffectBackground
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 com.tangem.utils.StringsSigns
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),
) {
Box(modifier = modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.matchParentSize()
.messageEffectBackground(
messageEffect = TangemMessageEffect.Magic,
radius = TangemTheme.dimens2.x6,
contentColor = TangemTheme.colors2.surface.level3,
),
)
Column(
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
modifier = Modifier
.fillMaxWidth()
.padding(TangemTheme.dimens2.x3),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x4),
) {
val textColor = TangemTheme.colors.text.primary1
val titleColor = TangemTheme.colors2.text.neutral.primary
Text(
text = combinedReference(
stringReference("${StringsSigns.LIGHTNING} "),
resourceReference(R.string.quick_top_up_title),
).resolveReference(),
style = TangemTheme.typography.subtitle1.copy(fontWeight = FontWeight.SemiBold),
style = TangemTheme.typography2.bodySemibold16,
modifier = Modifier.graphicsLayer {
colorFilter = ColorFilter.tint(textColor, BlendMode.SrcIn)
colorFilter = ColorFilter.tint(titleColor, BlendMode.SrcIn)
},
)
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing6),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1_5),
) {
state.amounts.fastForEach { amountUM ->
Button(
PrimaryInverseTangemButton(
text = amountUM.displayValue,
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),
)
}
size = TangemButtonSize.X9,
shape = TangemButtonShape.Rounded,
)
}
}
}
@ -104,6 +77,7 @@ internal fun QuickTopUpBlock(state: QuickTopUpBlockUM, modifier: Modifier = Modi
}
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun QuickTopUpBlock_Preview() {
TangemThemePreviewRedesign {
@ -129,7 +103,7 @@ private fun QuickTopUpBlock_Preview() {
),
),
),
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
modifier = Modifier.padding(TangemTheme.dimens2.x3),
)
}
}

View file

@ -11,7 +11,6 @@ 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
@ -70,10 +69,6 @@ internal class QuickTopUpBlockFactoryTest {
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 {
@ -83,7 +78,7 @@ internal class QuickTopUpBlockFactoryTest {
val result = disabledFactory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
isHistoryEmpty = true,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
@ -96,7 +91,7 @@ internal class QuickTopUpBlockFactoryTest {
fun `returns null when balance is non-zero`() {
val result = factory.build(
currencyStatus = nonZeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
isHistoryEmpty = true,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
@ -106,10 +101,27 @@ internal class QuickTopUpBlockFactoryTest {
}
@Test
fun `returns null when history has transactions`() {
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,
isHistoryEmpty = true,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when history is not empty`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = histWithItems,
isHistoryEmpty = false,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
@ -122,7 +134,7 @@ internal class QuickTopUpBlockFactoryTest {
fun `returns null when onramp is not available`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
isHistoryEmpty = true,
onrampAvailability = notSupported.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
@ -131,11 +143,24 @@ internal class QuickTopUpBlockFactoryTest {
assertThat(result).isNull()
}
@Test
fun `returns null when onramp availability is error`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isHistoryEmpty = true,
onrampAvailability = OnrampError.DataError(code = "error", description = null).left(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when currency is not USD or EUR`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
isHistoryEmpty = true,
onrampAvailability = OnrampAvailability.Available(
country = countryMock,
currency = gbpCurrency,
@ -151,7 +176,7 @@ internal class QuickTopUpBlockFactoryTest {
fun `returns block with USD presets when all conditions met`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
isHistoryEmpty = true,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
@ -178,7 +203,7 @@ internal class QuickTopUpBlockFactoryTest {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
isHistoryEmpty = true,
onrampAvailability = availableEur.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
@ -195,19 +220,6 @@ internal class QuickTopUpBlockFactoryTest {
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(
@ -224,7 +236,7 @@ internal class QuickTopUpBlockFactoryTest {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
isHistoryEmpty = true,
onrampAvailability = confirmResidency.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
@ -240,86 +252,6 @@ internal class QuickTopUpBlockFactoryTest {
).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(
@ -336,7 +268,7 @@ internal class QuickTopUpBlockFactoryTest {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
isHistoryEmpty = true,
onrampAvailability = confirmResidency.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},