Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-28 17:24:23 +04:00
commit e6ec52e35a
83 changed files with 1846 additions and 301 deletions

View file

@ -21,6 +21,8 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository.LockMethod
import com.tangem.domain.common.wallets.error.*
import com.tangem.domain.hotwallet.repository.HotWalletRepository
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.*
import com.tangem.domain.wallets.R
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
@ -126,6 +128,8 @@ internal class DefaultUserWalletsListRepository(
canOverride: Boolean,
): Either<SaveWalletError, UserWallet> = either {
if (canOverride.not() && userWallets.value?.any { it.walletId == userWallet.walletId } == true) {
// the wallet was rebuilt from a fresh scan — reconcile the stored card state before rejecting
(userWallet as? UserWallet.Cold)?.let { refreshStoredCardState(scanResponse = it.scanResponse) }
raise(SaveWalletError.WalletAlreadySaved(messageId = R.string.user_wallet_list_error_wallet_already_saved))
}
@ -321,6 +325,8 @@ internal class DefaultUserWalletsListRepository(
raise(UnlockWalletError.ScannedCardWalletNotMatched)
}
refreshStoredCardState(scanResponse)
val encryptionKey = UserWalletEncryptionKey(
walletId = userWallet.walletId,
encryptionKey = scanResponse.encryptionKey ?: raise(UnlockWalletError.UnableToUnlock.Empty),
@ -448,6 +454,49 @@ internal class DefaultUserWalletsListRepository(
}
}
/**
* Refreshes the persisted card state of an already saved wallet from a freshly scanned card.
*
* Heals a stale backup status e.g. when backup was finalized on another device or the app was
* terminated before the post-backup update was persisted. A scan of the same physical card is
* the ground truth and is applied as is. A scan of another card of the same wallet refreshes the
* state too, except when the stored card is [CardDTO.BackupStatus.CardLinked] its backup is in
* progress, so the status is preserved until the same card is scanned again.
*/
private suspend fun refreshStoredCardState(scanResponse: ScanResponse) {
val walletId = UserWalletIdBuilder.scanResponse(scanResponse).build() ?: return
val storedWallet = userWallets.value?.find { it.walletId == walletId } as? UserWallet.Cold ?: return
val storedCard = storedWallet.scanResponse.card
val scannedCard = scanResponse.card
val isUpToDate = storedCard.backupStatus == scannedCard.backupStatus &&
storedCard.isAccessCodeSet == scannedCard.isAccessCodeSet
if (isUpToDate) return
// another card of the wallet must not override the stored card's in-progress backup state
val isAnotherCard = storedCard.cardId != scannedCard.cardId
val isBackupInProgress = storedCard.backupStatus is CardDTO.BackupStatus.CardLinked
if (isAnotherCard && isBackupInProgress) return
val updatedWallet = storedWallet.copy(
scanResponse = storedWallet.scanResponse.copy(
card = storedCard.copy(
backupStatus = scannedCard.backupStatus,
isAccessCodeSet = scannedCard.isAccessCodeSet,
),
),
)
if (savePersistentInformation()) {
publicInformationRepository.save(updatedWallet, canOverride = true)
}
updateWallets { wallets ->
wallets?.addOrReplace(updatedWallet) { it.walletId == walletId }
}
}
private suspend fun checkForUpgradeAndDeleteHotWalletIfNeeded(
newUserWallet: UserWallet,
oldUserWallet: UserWallet,

View file

@ -3,13 +3,18 @@ package com.tangem.tap.domain.userWalletList.repository
import com.google.common.truth.Truth.assertThat
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemError
import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.common.wallets.UserWalletSelectedHandler
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.hotwallet.repository.HotWalletRepository
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
@ -144,4 +149,162 @@ internal class DefaultUserWalletsListRepositoryTest {
assertThat(result.isLeft()).isTrue()
verify(exactly = 0) { trackingContextProxy.eraseContext() }
}
@Test
fun `GIVEN stale backup status WHEN duplicate save rejected THEN stored card state refreshed`() = runTest {
// Arrange
val storedWallet = MockUserWalletFactory.create(staleScanResponse)
val freshWallet = MockUserWalletFactory.create(freshScanResponse)
repository.userWallets.value = listOf(storedWallet)
coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit)
// Act
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
// Assert
assertThat(result.isLeft()).isTrue()
val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold
assertThat(updatedWallet.scanResponse.card.backupStatus)
.isEqualTo(CardDTO.BackupStatus.Active(cardCount = 1))
assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isTrue()
coVerify(exactly = 1) { publicInformationRepository.save(any(), true) }
}
@Test
fun `GIVEN locked wallet with stale backup status WHEN unlock with scanned card THEN stored card state refreshed`() =
runTest {
// Arrange
val storedWallet = MockUserWalletFactory.create(staleScanResponse).let { wallet ->
wallet.copy(
scanResponse = wallet.scanResponse.copy(
card = wallet.scanResponse.card.copy(wallets = emptyList()),
),
)
}
repository.userWallets.value = listOf(storedWallet)
coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit)
coEvery { sensitiveInformationRepository.getAll(any()) } returns CompletionResult.Success(emptyMap())
// Act
val result = repository.unlock(
userWalletId = storedWallet.walletId,
unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(
scanResponse = freshScanResponse,
source = AnalyticsParam.ScreensSources.SignIn,
),
)
// Assert
assertThat(result.isRight()).isTrue()
val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold
assertThat(updatedWallet.scanResponse.card.backupStatus)
.isEqualTo(CardDTO.BackupStatus.Active(cardCount = 1))
assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isTrue()
coVerify(exactly = 1) { publicInformationRepository.save(any(), true) }
}
@Test
fun `GIVEN active card of backup set scanned WHEN duplicate save rejected THEN stored card state refreshed`() =
runTest {
// Arrange
val storedWallet = MockUserWalletFactory.create(staleScanResponse)
val otherCardScanResponse = freshScanResponse.copy(
card = freshScanResponse.card.copy(cardId = "OTHER-CARD"),
)
val freshWallet = MockUserWalletFactory.create(otherCardScanResponse)
repository.userWallets.value = listOf(storedWallet)
coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit)
// Act
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
// Assert
assertThat(result.isLeft()).isTrue()
val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold
assertThat(updatedWallet.scanResponse.card.cardId).isEqualTo(storedWallet.scanResponse.card.cardId)
assertThat(updatedWallet.scanResponse.card.backupStatus)
.isEqualTo(CardDTO.BackupStatus.Active(cardCount = 1))
assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isTrue()
coVerify(exactly = 1) { publicInformationRepository.save(any(), true) }
}
@Test
fun `GIVEN no backup card of same wallet scanned WHEN duplicate save rejected THEN stored status downgraded`() =
runTest {
// Arrange
val storedWallet = MockUserWalletFactory.create(freshScanResponse)
val newCardScanResponse = staleScanResponse.copy(
card = staleScanResponse.card.copy(cardId = "SAME-SEED-NEW-CARD"),
)
val freshWallet = MockUserWalletFactory.create(newCardScanResponse)
repository.userWallets.value = listOf(storedWallet)
coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit)
// Act
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
// Assert
assertThat(result.isLeft()).isTrue()
val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold
assertThat(updatedWallet.scanResponse.card.cardId).isEqualTo(storedWallet.scanResponse.card.cardId)
assertThat(updatedWallet.scanResponse.card.backupStatus).isEqualTo(CardDTO.BackupStatus.NoBackup)
assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isFalse()
coVerify(exactly = 1) { publicInformationRepository.save(any(), true) }
}
@Test
fun `GIVEN stored card linked status WHEN duplicate save with another card rejected THEN status preserved`() =
runTest {
// Arrange
val cardLinkedScanResponse = staleScanResponse.copy(
card = staleScanResponse.card.copy(backupStatus = CardDTO.BackupStatus.CardLinked(cardCount = 1)),
)
val storedWallet = MockUserWalletFactory.create(cardLinkedScanResponse)
val otherCardScanResponse = freshScanResponse.copy(
card = freshScanResponse.card.copy(cardId = "OTHER-CARD"),
)
val freshWallet = MockUserWalletFactory.create(otherCardScanResponse)
repository.userWallets.value = listOf(storedWallet)
// Act
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
// Assert
assertThat(result.isLeft()).isTrue()
assertThat(repository.userWallets.value).containsExactly(storedWallet)
coVerify(exactly = 0) { publicInformationRepository.save(any(), any()) }
}
@Test
fun `GIVEN stored card state is actual WHEN duplicate save rejected THEN nothing persisted`() = runTest {
// Arrange
val storedWallet = MockUserWalletFactory.create(freshScanResponse)
val freshWallet = MockUserWalletFactory.create(freshScanResponse)
repository.userWallets.value = listOf(storedWallet)
// Act
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
// Assert
assertThat(result.isLeft()).isTrue()
assertThat(repository.userWallets.value).containsExactly(storedWallet)
coVerify(exactly = 0) { publicInformationRepository.save(any(), any()) }
}
private companion object {
val staleScanResponse = MockScanResponseFactory.create(
cardConfig = GenericCardConfig(maxWalletCount = 2),
derivedKeys = emptyMap(),
).let { scanResponse ->
scanResponse.copy(card = scanResponse.card.copy(backupStatus = CardDTO.BackupStatus.NoBackup))
}
val freshScanResponse = staleScanResponse.copy(
card = staleScanResponse.card.copy(
backupStatus = CardDTO.BackupStatus.Active(cardCount = 1),
isAccessCodeSet = true,
),
)
}
}

View file

@ -110,5 +110,17 @@
{
"name": "AND_16204_POLYMARKET_ENABLED",
"version": "undefined"
},
{
"name": "TWI_1638_VA_MVP0_ENABLED",
"version": "6.0.1"
},
{
"name": "TWI_1637_CASHBACK_AND_REACTIVATION_CAMPAIGNS_ENABLED",
"version": "6.0.1"
},
{
"name": "TWI_1522_MARKETING_BANNERS_ENABLED",
"version": "6.0.1"
}
]

View file

@ -323,7 +323,9 @@ fun BoxScope.FooterOverlay(
.fillMaxWidth()
.align(Alignment.BottomCenter),
) {
if (gradientHeight > 0.dp) {
val isGradientDisplayed = gradientHeight > 0.dp
if (isGradientDisplayed) {
Fade(
backgroundColor = fadeMax,
height = gradientHeight,
@ -333,7 +335,7 @@ fun BoxScope.FooterOverlay(
modifier = Modifier
.fillMaxWidth()
.height(measuredFooterHeight ?: 0.dp)
.background(fadeMax),
.background(if (isGradientDisplayed) fadeMax else Color.Transparent),
)
}
}

View file

@ -5,9 +5,13 @@ package com.tangem.core.ui.ds2.messagebanner
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.RippleAlpha
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalRippleConfiguration
import androidx.compose.material3.RippleConfiguration
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Alignment
@ -29,6 +33,7 @@ import com.tangem.core.ui.ds2.glowring.TangemGlowRing
import com.tangem.core.ui.ds2.surface.TangemSurface
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
@ -40,11 +45,15 @@ import com.tangem.core.ui.res.generated.icons.ic_cross_circle_20_filled
* Design-system v2 (DS3) **Message Banner** low-level slot API: a [content] block above an
* optional action-button row. For the common title/description layout, prefer the `title` overload.
*
* Version: 1.2
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5475-7680&m=dev)
*
* @param variant Visual appearance background color + glow ring.
* @param showGlowRing Whether the glow ring is drawn around the banner. `false` shows only the
* background.
* @param onClick Makes the whole banner clickable. Honored only when no action buttons are present ignored while
* [secondaryButton] or [primaryButton] is set.
* @param secondaryButton Start action. `null` hides it.
* @param primaryButton End action. `null` hides it.
* @param content The banner body above the buttons.
@ -54,26 +63,33 @@ fun TangemMessageBanner(
modifier: Modifier = Modifier,
variant: TangemMessageBanner.Variant = TangemMessageBanner.Variant.Default,
showGlowRing: Boolean = true,
onClick: (() -> Unit)? = null,
secondaryButton: TangemMessageBanner.Button? = null,
primaryButton: TangemMessageBanner.Button? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val tokens = variant.tokens()
val isClickable = onClick != null && secondaryButton == null && primaryButton == null
Box(modifier = modifier) {
TangemSurface(
modifier = Modifier.fillMaxWidth(),
color = tokens.background,
shape = RoundedCornerShape(28.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
WithMessageBannerRipple(enabled = isClickable) {
TangemSurface(
modifier = Modifier.fillMaxWidth(),
color = tokens.background,
shape = RoundedCornerShape(28.dp),
) {
content()
MessageBannerButtons(secondaryButton = secondaryButton, primaryButton = primaryButton)
Column(
modifier = Modifier
.fillMaxWidth()
.conditionalCompose(isClickable) {
clickableSingle(role = Role.Button) { onClick?.invoke() }
}
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
content()
MessageBannerButtons(secondaryButton = secondaryButton, primaryButton = primaryButton)
}
}
}
if (showGlowRing) {
@ -90,6 +106,8 @@ fun TangemMessageBanner(
* Design-system v2 (DS3) **Message Banner** title/description header with optional slots and an
* action-button row.
*
* Version: 1.2
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5475-7680&m=dev)
*
* @param title Banner headline.
@ -97,6 +115,8 @@ fun TangemMessageBanner(
* @param contentAlign Horizontal alignment of the text block.
* @param showGlowRing Whether the glow ring is drawn around the banner. `false` shows only the
* background.
* @param onClick Makes the whole banner clickable. Honored only when no action buttons are present ignored while
* [secondaryButton] or [primaryButton] is set.
* @param description Secondary line under the [title]. `null` hides it.
* @param secondaryButton Start action. `null` hides it.
* @param primaryButton End action. `null` hides it.
@ -112,6 +132,7 @@ fun TangemMessageBanner(
variant: TangemMessageBanner.Variant = TangemMessageBanner.Variant.Default,
contentAlign: TangemMessageBanner.ContentAlign = TangemMessageBanner.ContentAlign.Start,
showGlowRing: Boolean = true,
onClick: (() -> Unit)? = null,
description: TextReference? = null,
secondaryButton: TangemMessageBanner.Button? = null,
primaryButton: TangemMessageBanner.Button? = null,
@ -123,6 +144,7 @@ fun TangemMessageBanner(
modifier = modifier,
variant = variant,
showGlowRing = showGlowRing,
onClick = onClick,
secondaryButton = secondaryButton,
primaryButton = primaryButton,
) {
@ -252,7 +274,7 @@ private fun MessageBannerTextWrapper(
)
}
extraBottomSlot?.let { slot ->
Column(modifier = Modifier.padding(top = 12.dp)) { slot() }
Column(modifier = Modifier.padding(top = 8.dp)) { slot() }
}
}
}
@ -388,6 +410,29 @@ fun TangemMessageBanner.CloseButton(
)
}
/** Overrides the ripple for a clickable banner; pass-through when [enabled] is `false`. */
@Composable
private fun WithMessageBannerRipple(enabled: Boolean, content: @Composable () -> Unit) {
if (enabled) {
CompositionLocalProvider(LocalRippleConfiguration provides messageBannerRipple(), content = content)
} else {
content()
}
}
/** Press ripple of a clickable banner — the `color/interaction/press/static-light` token. */
@Composable
@ReadOnlyComposable
private fun messageBannerRipple(): RippleConfiguration = RippleConfiguration(
color = TangemTheme.colors3.interaction.press.staticLight,
rippleAlpha = RippleAlpha(
draggedAlpha = 0f,
focusedAlpha = 0f,
hoveredAlpha = 0.05f,
pressedAlpha = 0.1f,
),
)
/** Resolved appearance tokens for a [TangemMessageBanner.Variant]. */
private data class MessageBannerTokens(val background: Color, val glowRing: TangemGlowRing.Variant)
@ -450,6 +495,12 @@ private fun TangemMessageBannerPreview() {
},
primaryButton = TangemMessageBanner.Button(text = stringReference("Invite friends"), onClick = {}),
)
TangemMessageBanner(
modifier = Modifier.fillMaxWidth(),
title = stringReference("Clickable banner"),
description = stringReference("Whole banner is tappable when no buttons are set."),
onClick = {},
)
}
}

View file

@ -145,7 +145,8 @@ fun BigDecimalCryptoFormatStyled.defaultAmount(spanStyleReference: SpanStyleRefe
val formattedAmount = formatter.format(value)
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it) } ?: formattedAmount.length
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it).takeIf { i -> i >= 0 } }
?: formattedAmount.length
combinedReference(
stringReference(formattedAmount.take(separatorIndex)),
@ -167,8 +168,9 @@ fun BigDecimalCryptoFormatStyled.defaultAmount(spanStyleReference: SpanStyleRefe
cryptoCurrencySymbol = symbol,
)
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it) } ?: formattedAmount.length
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.monetaryDecimalSeparator
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it).takeIf { i -> i >= 0 } }
?: formattedAmount.length
combinedReference(
stringReference(formattedAmount.take(separatorIndex)),

View file

@ -97,7 +97,7 @@ fun BigDecimalFiatFormatStyled.defaultAmount(spanStyleReference: SpanStyleRefere
value.zeroIfRoundsToZero(FIAT_MARKET_DEFAULT_DIGITS)
}
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.monetaryDecimalSeparator
val currencySymbol = formatterCurrency.getSymbol(locale)
val rawFormatted = formatter.format(formattingAmount)
@ -200,7 +200,7 @@ private fun BigDecimalFiatFormatStyled.price(spanStyleReference: SpanStyleRefere
roundingMode = RoundingMode.HALF_UP
}
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.monetaryDecimalSeparator
val currencySymbol = formatterCurrency.getSymbol(locale)
val rawFormatted = formatter.format(priceAmount)

View file

@ -1,8 +1,13 @@
package com.tangem.core.ui.format.bigdecimal
import androidx.compose.ui.text.SpanStyle
import com.google.common.truth.Truth
import com.tangem.core.ui.extensions.SpanStyleReference
import com.tangem.core.ui.extensions.TextReference
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Locale
internal class BigDecimalCryptoFormatTest {
@ -10,6 +15,7 @@ internal class BigDecimalCryptoFormatTest {
private val testLocale = Locale.US
private val testLocale2 = Locale.GERMANY
private val symbol = "BTC"
private val spanStyleStub = SpanStyleReference { SpanStyle() }
// === defaultAmount() ===
@ -125,6 +131,39 @@ internal class BigDecimalCryptoFormatTest {
.isEqualTo("12,345,678.11".addSymbolWithSpaceLeft(symbol))
}
// === defaultAmount() styled ===
@Test
fun `GIVEN locale with distinct monetary separator WHEN styled defaultAmount THEN fraction split without crash`() {
// Arrange
// Regression: fr_CH plain separator is ',' but currency output uses '.' — indexOf(',') returned -1,
// and formattedAmount.take(-1) threw IllegalArgumentException
val swissLocale = Locale("fr", "CH")
val symbols = (NumberFormat.getCurrencyInstance(swissLocale) as DecimalFormat).decimalFormatSymbols
Truth.assertThat(symbols.monetaryDecimalSeparator).isNotEqualTo(symbols.decimalSeparator)
val testValue = BigDecimal("12.34")
// Act
val formatted = testValue.formatStyled {
cryptoStyled(
symbol = symbol,
decimals = 8,
spanStyleReference = spanStyleStub,
locale = swissLocale,
)
}
// Assert
val refs = (formatted as TextReference.Combined).refs.data
Truth.assertThat(refs).hasSize(2)
Truth.assertThat((refs[0] as TextReference.Str).value).isEqualTo("12")
val fraction = refs[1] as TextReference.StyledStr
Truth.assertThat(fraction.value).startsWith("${symbols.monetaryDecimalSeparator}34")
Truth.assertThat(fraction.value).endsWith(symbol)
}
// === shorted() ===
@Test

View file

@ -1,11 +1,16 @@
package com.tangem.core.ui.format.bigdecimal
import androidx.compose.ui.text.SpanStyle
import com.google.common.truth.Truth
import com.tangem.core.ui.extensions.SpanStyleReference
import com.tangem.core.ui.extensions.TextReference
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import java.math.BigDecimal
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Locale
internal class BigDecimalFiatFormatTest {
@ -16,6 +21,8 @@ internal class BigDecimalFiatFormatTest {
val usdCurrencyCode = "USD"
val usdSymbol = "$"
private val spanStyleStub = SpanStyleReference { SpanStyle() }
private fun String.addUsdSymbolLeft() = usdSymbol + this
// === defaultAmount() ===
@ -132,6 +139,40 @@ internal class BigDecimalFiatFormatTest {
.isEqualTo("-" + "0.01".addUsdSymbolLeft())
}
// === defaultAmount() styled ===
@Test
fun `GIVEN locale with distinct monetary separator WHEN styled defaultAmount THEN fraction split at monetary separator`() {
// Arrange
// fr_CH plain separator is ',' but currency output uses '.' — searching for the plain one
// failed to split the amount into whole and styled fractional parts
val swissLocale = Locale("fr", "CH")
val symbols = (NumberFormat.getCurrencyInstance(swissLocale) as DecimalFormat).decimalFormatSymbols
Truth.assertThat(symbols.monetaryDecimalSeparator).isNotEqualTo(symbols.decimalSeparator)
val testValue = BigDecimal("12.34")
// Act
val formatted = testValue.formatStyled {
fiat(
fiatCurrencyCode = usdCurrencyCode,
fiatCurrencySymbol = usdSymbol,
spanStyleReference = spanStyleStub,
locale = swissLocale,
)
}
// Assert
val refs = (formatted as TextReference.Combined).refs.data
Truth.assertThat(refs).hasSize(3)
Truth.assertThat(refs[0]).isEqualTo(TextReference.EMPTY)
Truth.assertThat((refs[1] as TextReference.Str).value).isEqualTo("12")
val fraction = refs[2] as TextReference.StyledStr
Truth.assertThat(fraction.value).startsWith("${symbols.monetaryDecimalSeparator}34")
Truth.assertThat(fraction.value).endsWith(usdSymbol)
}
// === approximateAmount() ===
@Test

View file

@ -2,17 +2,19 @@
Generates Kotlin (Jetpack Compose) source files from design tokens and icons defined in the `ds-tokens` git submodule.
## Making sure submodule is at the pinned commit
***For the most cases*** (a fresh checkout, or making sure the submodule is at the pinned commit), use:
```bash
git submodule update --init --recursive
```
## Updating tokens
> **Note:** You only need `git submodule update --remote` when you want to pull **new** design tokens
> from the remote `ds-tokens` repository. If you're just regenerating Kotlin from the tokens already
> checked out (e.g. changing the generation script), **skip step 1** — don't run it without the need,
> as it moves the submodule pointer to the latest remote commit and pulls in unrelated token changes.
>
> For all other cases (a fresh checkout, or making sure the submodule is at the pinned commit), use:
> ```bash
> git submodule update --init --recursive
> ```
> This checks out the submodule at the commit already recorded in the repo, without pulling anything new.
1. *(Only if you need newer tokens)* Update the `ds-tokens` submodule to the latest commit:

View file

@ -371,10 +371,14 @@ internal interface TangemPayDataModule {
fun provideCreateVirtualAccountOrderUseCase(
onboardingRepository: OnboardingRepository,
pollingUseCase: StartTangemPayOrderPollingUseCase,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
appCoroutineScope: AppCoroutineScope,
): CreateVirtualAccountOrderUseCase {
return CreateVirtualAccountOrderUseCase(
onboardingRepository = onboardingRepository,
pollingUseCase = pollingUseCase,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
appCoroutineScope = appCoroutineScope,
)
}

View file

@ -115,6 +115,10 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
logger.i("invoke() end ${params.userWalletId}: isRight=${result.isRight()}")
}
override suspend fun markVirtualAccountProcessing(userWalletId: UserWalletId) {
paymentAccountStatusesStore.markVirtualAccountProcessing(userWalletId)
}
private suspend fun proceedHasTangemPayResult(
account: Account.Payment,
hasTangemPay: Boolean,
@ -460,9 +464,16 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
/**
* Resolves the Virtual Account on-ramp dimension (VA MVP0, TWI-1638). Gated by the feature toggle.
* If a product instance with [SpecificationDataType.ACCOUNT] exists, eagerly fetches its bank credentials
* ([VirtualAccountOnramp.Available]); otherwise surfaces [VirtualAccountOnramp.Eligible] when the wallet has
* the `VISA_VIRTUAL_ACCOUNT` eligibility channel (fetched fresh via the user token), else `null`.
*
* Resolution order:
* 1. A product instance with [SpecificationDataType.ACCOUNT] exists clears any stale persisted VA order id
* (idempotent) and eagerly fetches its bank credentials ([VirtualAccountOnramp.Available], or
* [VirtualAccountOnramp.BankCredentialsError] on failure).
* 2. Otherwise, a VA order id is persisted locally checks its status via `getOrderData`:
* NEW/PROCESSING/COMPLETED (or a lookup failure) surface [VirtualAccountOnramp.Processing]; CANCELED
* clears the persisted id and falls through to eligibility.
* 3. Otherwise (or after a CANCELED order) surfaces [VirtualAccountOnramp.Eligible] when the wallet has
* the `VISA_VIRTUAL_ACCOUNT` eligibility channel (fetched fresh via the user token), else `null`.
*/
private suspend fun CustomerInfo.resolveVirtualAccountOnramp(userWalletId: UserWalletId): VirtualAccountOnramp? {
if (!virtualAccountFeatureToggles.isVaMvp0Enabled) return null
@ -471,10 +482,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
it.specificationDataType == SpecificationDataType.ACCOUNT
}
if (accountInstance != null) {
// Order provisioned into an ACCOUNT product instance — drop the in-flight order hint (idempotent).
onboardingRepository.clearVirtualAccountOrderId(userWalletId)
return onboardingRepository.getBankCredentials(userWalletId, accountInstance.id).fold(
ifLeft = { error ->
logger.e("getBankCredentials failed for ${accountInstance.id}: $error")
null
VirtualAccountOnramp.BankCredentialsError
},
ifRight = { credentials ->
VirtualAccountOnramp.Available(
@ -485,6 +498,32 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
)
}
val vaOrderId = onboardingRepository.getVirtualAccountOrderId(userWalletId)
if (vaOrderId != null) {
return customerOrderRepository.getOrderData(userWalletId = userWalletId, orderId = vaOrderId).fold(
ifLeft = { error ->
logger.e("getOrderData(va) failed for $vaOrderId: $error")
VirtualAccountOnramp.Processing
},
ifRight = { orderData ->
when (orderData.status) {
OrderStatus.CANCELED -> {
onboardingRepository.clearVirtualAccountOrderId(userWalletId)
resolveEligibility(userWalletId)
}
OrderStatus.NEW,
OrderStatus.PROCESSING,
OrderStatus.COMPLETED,
-> VirtualAccountOnramp.Processing
}
},
)
}
return resolveEligibility(userWalletId)
}
private suspend fun resolveEligibility(userWalletId: UserWalletId): VirtualAccountOnramp? {
return onboardingRepository.fetchCustomerEligibility(userWalletId).fold(
ifLeft = { error ->
logger.e("fetchCustomerEligibility failed for $userWalletId: $error")

View file

@ -207,6 +207,13 @@ internal class DefaultOnboardingRepository @Inject constructor(
}
}
override suspend fun clearVirtualAccountOrderId(userWalletId: UserWalletId) {
withContext(dispatcherProvider.io) {
val customerWalletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
tangemPayStorage.clearVirtualAccountOrderId(customerWalletAddress)
}
}
private fun getUserWallet(userWalletId: UserWalletId): UserWallet {
return userWalletsListRepository.userWallets.value?.firstOrNull { it.walletId == userWalletId }
?: error("no userWallet found")

View file

@ -8,6 +8,7 @@ import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.runSuspendCatching
@ -89,6 +90,28 @@ internal class PaymentAccountStatusesStore(
}
}
/**
* Optimistically marks the cached VA on-ramp as [VirtualAccountOnramp.Processing] so the UI reflects a
* read-modify-write atomically inside [RuntimeSharedStore.update] to avoid a lost update racing with a
* concurrent [store]/[updateStatusSource] call. No-op (no write) when there is no cached entry for
* [userWalletId], or when its value isn't [PaymentAccountStatusValue.Loaded]. Not persisted, mirroring
* [updateStatusSource].
*/
suspend fun markVirtualAccountProcessing(userWalletId: UserWalletId) {
logger.i("markVirtualAccountProcessing($userWalletId)")
runtimeStore.update(emptyMap()) { stored ->
stored.toMutableMap().apply {
val paymentAccountStatus = this[userWalletId.stringValue] ?: return@update stored
val loaded = paymentAccountStatus.value as? PaymentAccountStatusValue.Loaded ?: return@update stored
val newValue = paymentAccountStatus.copy(
value = loaded.copy(virtualAccount = VirtualAccountOnramp.Processing),
)
put(key = userWalletId.stringValue, value = newValue)
}
}
}
suspend fun store(userWalletId: UserWalletId, status: AccountStatus.Payment) {
logger.i("store($userWalletId): valueType=${status.value::class.simpleName}")
coroutineScope {

View file

@ -113,6 +113,14 @@ internal class MockAwareOnboardingRepository @Inject constructor(
real.storeVirtualAccountOrderId(userWalletId, vaOrderId)
}
override suspend fun clearVirtualAccountOrderId(userWalletId: UserWalletId) {
if (isMockMode) {
mockVaOrderIds.remove(userWalletId)
return
}
real.clearVirtualAccountOrderId(userWalletId)
}
// The "existing Tangem Pay customer" gate (decides whether an active Payment account — and accounts mode —
// appears). Delegates to WireMock's checkCustomerWalletId via the real repo (static token, no signing), so it
// is driven by the `tangem_pay_eligibility` scenario: `Started` (default) → 404/NotPaeraCustomer → no account;

View file

@ -2,8 +2,15 @@ package com.tangem.data.pay.flow
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.data.pay.store.WalletIdWithPaymentStatus
import com.tangem.data.pay.store.WalletIdWithPaymentStatusDM
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.BankCredentials
import com.tangem.domain.models.account.PaymentAccountStatusValue
@ -22,6 +29,8 @@ import com.tangem.domain.pay.TangemPayCurrencyFactory
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.OrderData
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.*
import com.tangem.domain.pay.usecase.GetTangemPayTariffPlanStateUseCase
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
@ -29,6 +38,8 @@ import com.tangem.domain.visa.error.VisaApiError
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
@ -226,7 +237,39 @@ internal class DefaultPaymentAccountStatusFetcherTest {
.map { it.value }
.filterIsInstance<PaymentAccountStatusValue.Loaded>()
.lastOrNull()
return requireNotNull(loaded) { "Expected at least one Loaded status to be stored; stored: ${map { it.value::class.simpleName }}" }
return requireNotNull(
loaded,
) { "Expected at least one Loaded status to be stored; stored: ${map { it.value::class.simpleName }}" }
}
/** Builds a [PaymentAccountStatusValue.Loaded] fixture with every field defaulted except [virtualAccount]. */
private fun loadedFixture(virtualAccount: VirtualAccountOnramp? = null): PaymentAccountStatusValue.Loaded {
val token: CryptoCurrency.Token = mockk(relaxed = true)
return PaymentAccountStatusValue.Loaded(
source = StatusSource.ACTUAL,
customerId = "cust_1",
depositAddress = "0xdeposit",
balance = PaymentAccountStatusValue.Balance(
fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = BigDecimal.TEN,
currency = "USD",
),
cryptoBalance = PaymentAccountStatusValue.CryptoBalance(
id = "usdc",
chainId = 137L,
depositAddress = "0xdeposit",
tokenContractAddress = "0xcontract",
balance = BigDecimal.TEN,
),
availableForWithdrawal = BigDecimal.TEN,
),
cryptoCurrency = token,
cards = emptyList(),
fiatRate = null,
error = null,
virtualAccount = virtualAccount,
tariffPlan = null
)
}
@Nested
@ -258,6 +301,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
)
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.clearVirtualAccountOrderId(userWalletId) } just Runs
coEvery {
onboardingRepository.getBankCredentials(userWalletId, "pi_account")
} returns Either.Right(bankCredentialsFixture)
@ -274,10 +318,11 @@ internal class DefaultPaymentAccountStatusFetcherTest {
bankCredentials = bankCredentialsFixture,
),
)
coVerify(exactly = 1) { onboardingRepository.clearVirtualAccountOrderId(userWalletId) }
}
@Test
fun `GIVEN toggle on and ACCOUNT instance but bank credentials fetch fails WHEN invoke THEN virtualAccount is null`() =
fun `GIVEN toggle on and ACCOUNT instance but bank credentials fetch fails WHEN invoke THEN virtualAccount is Error`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(
@ -285,6 +330,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
)
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.clearVirtualAccountOrderId(userWalletId) } just Runs
coEvery {
onboardingRepository.getBankCredentials(userWalletId, "pi_account")
} returns VisaApiError.UnknownWithoutCode.left()
@ -295,7 +341,8 @@ internal class DefaultPaymentAccountStatusFetcherTest {
// Assert
val loaded = storedStatuses.lastLoaded()
assertThat(loaded.virtualAccount).isNull()
assertThat(loaded.virtualAccount).isEqualTo(VirtualAccountOnramp.BankCredentialsError)
coVerify(exactly = 1) { onboardingRepository.clearVirtualAccountOrderId(userWalletId) }
}
@Test
@ -305,6 +352,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns null
coEvery {
onboardingRepository.fetchCustomerEligibility(userWalletId)
} returns Either.Right(listOf(TangemPayEligibilityType.VISA_VIRTUAL_ACCOUNT))
@ -325,6 +373,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns null
coEvery {
onboardingRepository.fetchCustomerEligibility(userWalletId)
} returns VisaApiError.UnknownWithoutCode.left()
@ -337,6 +386,168 @@ internal class DefaultPaymentAccountStatusFetcherTest {
val loaded = storedStatuses.lastLoaded()
assertThat(loaded.virtualAccount).isNull()
}
@Test
fun `GIVEN no instance and va order PROCESSING WHEN invoke THEN virtualAccount is Processing`() = runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns "va-1"
coEvery {
customerOrderRepository.getOrderData(userWalletId, "va-1")
} returns OrderData(customerId = "c1", status = OrderStatus.PROCESSING, withdrawTxHash = null).right()
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
assertThat(storedStatuses.lastLoaded().virtualAccount).isEqualTo(VirtualAccountOnramp.Processing)
}
@Test
fun `GIVEN no instance and va order COMPLETED but instance absent WHEN invoke THEN virtualAccount is Processing`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns "va-1"
coEvery {
customerOrderRepository.getOrderData(userWalletId, "va-1")
} returns OrderData(customerId = "c1", status = OrderStatus.COMPLETED, withdrawTxHash = null).right()
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
assertThat(storedStatuses.lastLoaded().virtualAccount).isEqualTo(VirtualAccountOnramp.Processing)
}
@Test
fun `GIVEN no instance and va getOrderData fails WHEN invoke THEN virtualAccount is Processing`() = runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns "va-1"
coEvery {
customerOrderRepository.getOrderData(userWalletId, "va-1")
} returns VisaApiError.UnknownWithoutCode.left()
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
assertThat(storedStatuses.lastLoaded().virtualAccount).isEqualTo(VirtualAccountOnramp.Processing)
}
@Test
fun `GIVEN no instance and va order CANCELED WHEN invoke THEN id cleared and falls back to eligibility`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns "va-1"
coEvery {
customerOrderRepository.getOrderData(userWalletId, "va-1")
} returns OrderData(customerId = "c1", status = OrderStatus.CANCELED, withdrawTxHash = null).right()
coEvery { onboardingRepository.clearVirtualAccountOrderId(userWalletId) } just Runs
coEvery {
onboardingRepository.fetchCustomerEligibility(userWalletId)
} returns Either.Right(listOf(TangemPayEligibilityType.VISA_VIRTUAL_ACCOUNT))
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
coVerify(exactly = 1) { onboardingRepository.clearVirtualAccountOrderId(userWalletId) }
assertThat(storedStatuses.lastLoaded().virtualAccount).isEqualTo(VirtualAccountOnramp.Eligible)
}
}
/**
* [markVirtualAccountProcessing] now delegates entirely to the atomic
* [PaymentAccountStatusesStore.markVirtualAccountProcessing] (read-modify-write happens inside the store's
* `runtimeStore.update` lambda, see [REDACTED_TASK_KEY] review). A mocked store can't exercise that internal branching,
* so these tests wire the fetcher to a real [PaymentAccountStatusesStore] (real [RuntimeSharedStore] +
* in-memory persistence fake) and assert on its resulting state exercising the delegate wiring and the
* store's atomic logic together.
*/
@Nested
inner class MarkVirtualAccountProcessing {
private val runtimeStore = RuntimeSharedStore<WalletIdWithPaymentStatus>()
private val persistenceStore = MockStateDataStore<WalletIdWithPaymentStatusDM>(default = emptyMap())
private val converter: PaymentAccountStatusValueDMConverter = mockk(relaxed = true)
private val realStore = PaymentAccountStatusesStore(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
converter = converter,
scope = TestAppCoroutineScope(),
)
private val realFetcher = DefaultPaymentAccountStatusFetcher(
paymentAccountStatusesStore = realStore,
onboardingRepository = onboardingRepository,
customerOrderRepository = customerOrderRepository,
deviceSecurity = deviceSecurity,
dispatchers = dispatchers,
tangemPayCurrencyFactory = tangemPayCurrencyFactory,
eligibilityManager = eligibilityManager,
reissueCardRepository = reissueCardRepository,
singleQuoteSupplier = singleQuoteSupplier,
closeCardRepository = closeCardRepository,
cardDetailsRepository = cardDetailsRepository,
issueCardRepository = issueCardRepository,
virtualAccountFeatureToggles = virtualAccountFeatureToggles,
tangemPayFeatureToggles = tangemPayFeatureToggles,
getTangemPayTariffPlanStateUseCase = getTangemPayTariffPlanStateUseCase,
)
private val account = Account.Payment(userWalletId = userWalletId)
@Test
fun `GIVEN cached Loaded with eligible onramp WHEN mark THEN virtualAccount becomes Processing`() = runTest {
// Arrange
val loaded = loadedFixture(virtualAccount = VirtualAccountOnramp.Eligible)
realStore.store(userWalletId, AccountStatus.Payment(account = account, value = loaded))
// Act
realFetcher.markVirtualAccountProcessing(userWalletId)
// Assert
val updated = realStore.getSyncOrNull(userWalletId)?.value
assertThat(updated).isEqualTo(loaded.copy(virtualAccount = VirtualAccountOnramp.Processing))
}
@Test
fun `GIVEN no cached value WHEN mark THEN store stays empty`() = runTest {
// Act
realFetcher.markVirtualAccountProcessing(userWalletId)
// Assert
assertThat(realStore.getSyncOrNull(userWalletId)).isNull()
}
@Test
fun `GIVEN cached non-Loaded value WHEN mark THEN value stays unchanged`() = runTest {
// Arrange
val issuingCard = PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
realStore.store(userWalletId, AccountStatus.Payment(account = account, value = issuingCard))
// Act
realFetcher.markVirtualAccountProcessing(userWalletId)
// Assert
assertThat(realStore.getSyncOrNull(userWalletId)?.value).isEqualTo(issuingCard)
}
}
@Nested

View file

@ -21,4 +21,21 @@ sealed interface VirtualAccountOnramp {
val productInstanceId: String,
val bankCredentials: BankCredentials,
) : VirtualAccountOnramp
/**
* A VA on-ramp order has been submitted and is being provisioned (order status NEW/PROCESSING, or
* COMPLETED before the ACCOUNT product instance appears). The bank-transfer entry point stays visible;
* tapping it shows the "Preparing your banking details" bottom sheet. Transient never persisted,
* re-resolved on the next status fetch, cleared once the ACCOUNT instance appears or the order is canceled.
*/
@Serializable
data object Processing : VirtualAccountOnramp
/**
* VA product instance exists, but its bank credentials failed to load. The bank-transfer entry point
* stays visible; tapping it surfaces a retryable "couldn't load banking details" error instead of the
* requisites. Transient never persisted, re-resolved on the next status fetch.
*/
@Serializable
data object BankCredentialsError : VirtualAccountOnramp
}

View file

@ -2,6 +2,8 @@ package com.tangem.domain.pay.flow
import arrow.core.Either
import com.tangem.domain.core.flow.FlowFetcher
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.models.wallet.UserWalletId
interface PaymentAccountStatusFetcher : FlowFetcher<PaymentAccountStatusFetcher.Params> {
@ -10,5 +12,12 @@ interface PaymentAccountStatusFetcher : FlowFetcher<PaymentAccountStatusFetcher.
return invoke(Params(userWalletId))
}
/**
* Optimistically marks the cached VA on-ramp as [VirtualAccountOnramp.Processing] so the UI reflects a
* cached [PaymentAccountStatusValue.Loaded].
*/
suspend fun markVirtualAccountProcessing(userWalletId: UserWalletId)
data class Params(val userWalletId: UserWalletId)
}

View file

@ -42,6 +42,8 @@ interface OnboardingRepository {
suspend fun storeVirtualAccountOrderId(userWalletId: UserWalletId, vaOrderId: String)
suspend fun clearVirtualAccountOrderId(userWalletId: UserWalletId)
suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean>
suspend fun checkCustomerEligibility(): List<TangemPayEligibilityType>

View file

@ -3,10 +3,13 @@ package com.tangem.domain.pay.usecase
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.coroutines.AppCoroutineScope
import kotlinx.coroutines.launch
import java.util.UUID
/**
@ -20,6 +23,8 @@ import java.util.UUID
class CreateVirtualAccountOrderUseCase(
private val onboardingRepository: OnboardingRepository,
private val pollingUseCase: StartTangemPayOrderPollingUseCase,
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
private val appCoroutineScope: AppCoroutineScope,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
@ -33,10 +38,15 @@ class CreateVirtualAccountOrderUseCase(
idempotencyKey = UUID.randomUUID().toString(),
).bind()
onboardingRepository.storeVirtualAccountOrderId(userWalletId = userWalletId, vaOrderId = vaOrderId)
pollingUseCase.invoke(
order = TangemPayOrderInfo(orderId = vaOrderId, orderStatus = OrderStatus.NEW),
userWalletId = userWalletId,
)
// Optimistically flip the cached on-ramp to Processing so the UI shows "Preparing" immediately
// (no wait for the poll/refetch to confirm).
paymentAccountStatusFetcher.markVirtualAccountProcessing(userWalletId)
appCoroutineScope.launch {
pollingUseCase.invoke(
order = TangemPayOrderInfo(orderId = vaOrderId, orderStatus = OrderStatus.NEW),
userWalletId = userWalletId,
)
}
}
}
}

View file

@ -4,8 +4,10 @@ import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.test.core.TestAppCoroutineScope
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
@ -16,7 +18,14 @@ internal class CreateVirtualAccountOrderUseCaseTest {
private val onboardingRepository: OnboardingRepository = mockk(relaxUnitFun = true)
private val pollingUseCase: StartTangemPayOrderPollingUseCase = mockk(relaxed = true)
private val useCase = CreateVirtualAccountOrderUseCase(onboardingRepository, pollingUseCase)
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk(relaxUnitFun = true)
private val useCase = CreateVirtualAccountOrderUseCase(
onboardingRepository = onboardingRepository,
pollingUseCase = pollingUseCase,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
appCoroutineScope = TestAppCoroutineScope(),
)
private val userWalletId = UserWalletId("1234567890ABCDEF")
private val paymentAccountAddress = "0xcollateral"
@ -31,6 +40,7 @@ internal class CreateVirtualAccountOrderUseCaseTest {
coVerify(exactly = 0) { onboardingRepository.createVirtualAccountOrder(any(), any(), any()) }
coVerify(exactly = 0) { onboardingRepository.storeVirtualAccountOrderId(any(), any()) }
coVerify(exactly = 0) { pollingUseCase.invoke(any(), any()) }
coVerify(exactly = 0) { paymentAccountStatusFetcher.markVirtualAccountProcessing(any()) }
}
@Test
@ -45,6 +55,7 @@ internal class CreateVirtualAccountOrderUseCaseTest {
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { onboardingRepository.storeVirtualAccountOrderId(userWalletId, "new-id") }
coVerify(exactly = 1) { pollingUseCase.invoke(any(), userWalletId) }
coVerify(exactly = 1) { paymentAccountStatusFetcher.markVirtualAccountProcessing(userWalletId) }
}
@Test
@ -59,5 +70,6 @@ internal class CreateVirtualAccountOrderUseCaseTest {
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
coVerify(exactly = 0) { onboardingRepository.storeVirtualAccountOrderId(any(), any()) }
coVerify(exactly = 0) { pollingUseCase.invoke(any(), any()) }
coVerify(exactly = 0) { paymentAccountStatusFetcher.markVirtualAccountProcessing(any()) }
}
}

View file

@ -44,6 +44,7 @@ dependencies {
/** Tangem libraries */
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain)
/** Common */
api(projects.common.routing)

View file

@ -1,5 +1,6 @@
package com.tangem.features.commonfeatures.impl.choosetoken.converter
import arrow.core.toNonEmptyListOrNull
import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter
import com.tangem.common.ui.account.TokensListPortfolioItemConverter
import com.tangem.common.ui.account.toUM
@ -22,6 +23,7 @@ import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.domain.tokens.operations.TotalFiatBalanceCalculator
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData
@ -101,8 +103,9 @@ internal class ChooseTokenListItemConverter(
private fun AccountStatus.CryptoPortfolio.toPortfolioItem(
params: TokenConverterParams.Account,
): TokensListItemUM.Portfolio {
val tokenList: TokenList = this.tokenList
val account: Account.CryptoPortfolio = this.account
val displayedStatus = filterForDisplay()
val account: Account.CryptoPortfolio = displayedStatus.account
val displayedTokenList: TokenList = displayedStatus.tokenList
val isExpanded = isSearchingState || params.expandedAccounts.contains(account.accountId)
val onItemClick: (Account.CryptoPortfolio) -> Unit = { clickedAccount ->
onAccountItemClick(clickedAccount, isExpanded)
@ -116,10 +119,9 @@ internal class ChooseTokenListItemConverter(
fiatAmountStateProvider = { fiatBalance -> fiatAmountStateProvider(fiatBalance, isExpanded) },
subtitle2StateProvider = { _ -> null },
)
val accountItem = converter.convert(tokenList.totalFiatBalance)
val tokenConverter = tokenStatusConverter(this)
val tokensListState = convertTokenList(tokenConverter, tokenList, this)
val items = tokensListState.tokensList
val accountItem = converter.convert(displayedTokenList.totalFiatBalance)
val items = displayedTokenList.toUmData(tokenStatusConverter(this)).tokensList
return TokensListPortfolioItemConverter(
tokenItemUM = accountItem,
isExpanded = isExpanded,
@ -128,29 +130,47 @@ internal class ChooseTokenListItemConverter(
).convert(Unit)
}
private fun AccountStatus.CryptoPortfolio.filterForDisplay(): AccountStatus.CryptoPortfolio {
val filteredTokenList = filterTokenList(tokenList, this)
return copy(
account = account.copy(cryptoCurrencies = filteredTokenList.flattenCurrencies().map { it.currency }),
tokenList = filteredTokenList,
)
}
private fun TokenList.recalculateBalance(): TokenList {
val statuses = flattenCurrencies().toNonEmptyListOrNull() ?: return this
val total = TotalFiatBalanceCalculator.calculate(statuses)
return when (this) {
TokenList.Empty -> this
is TokenList.Ungrouped -> copy(totalFiatBalance = total)
is TokenList.GroupedByNetwork -> copy(totalFiatBalance = total)
}
}
private fun convertTokenList(
tokenConverter: TokenItemStateConverter,
tokenListParam: TokenList,
account: AccountStatus.CryptoPortfolio,
): TokenListUMData {
return when (val tokenList = filterTokenList(tokenListParam, account)) {
is TokenList.Empty -> TokenListUMData.EmptyList
is TokenList.GroupedByNetwork -> TokenListUMData.TokenList(
tokensList = tokenList.toGroupedItems(tokenConverter).toPersistentList(),
totalTokensCount = tokenList.flattenCurrencies().size,
)
is TokenList.Ungrouped -> TokenListUMData.TokenList(
tokensList = tokenList.toUngroupedItems(tokenConverter).toPersistentList(),
totalTokensCount = tokenList.flattenCurrencies().size,
)
}
): TokenListUMData = filterTokenList(tokenListParam, account).toUmData(tokenConverter)
private fun TokenList.toUmData(tokenConverter: TokenItemStateConverter): TokenListUMData = when (this) {
TokenList.Empty -> TokenListUMData.EmptyList
is TokenList.GroupedByNetwork -> TokenListUMData.TokenList(
tokensList = toGroupedItems(tokenConverter).toPersistentList(),
totalTokensCount = flattenCurrencies().size,
)
is TokenList.Ungrouped -> TokenListUMData.TokenList(
tokensList = toUngroupedItems(tokenConverter).toPersistentList(),
totalTokensCount = flattenCurrencies().size,
)
}
private fun List<CryptoCurrencyStatus>.filterCurrencies(account: AccountStatus): List<CryptoCurrencyStatus> =
filter { currency -> currency.filterByQuery() && tokenFilter(account, currency) }
private fun filterTokenList(tokenList: TokenList, account: AccountStatus.CryptoPortfolio): TokenList {
return when (tokenList) {
val filtered = when (tokenList) {
TokenList.Empty -> TokenList.Empty
is TokenList.Ungrouped -> {
val filtered = tokenList.currencies.filterCurrencies(account)
@ -166,6 +186,8 @@ internal class ChooseTokenListItemConverter(
if (filteredGroups.isEmpty()) TokenList.Empty else tokenList.copy(groups = filteredGroups)
}
}
return filtered.recalculateBalance()
}
private fun CryptoCurrencyStatus.filterByQuery(): Boolean {

View file

@ -7,6 +7,8 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.features.commonfeatures.api.R
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.choosetoken.*
@ -20,6 +22,7 @@ import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenFullUM
import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenInitialUM
import com.tangem.features.commonfeatures.impl.choosetoken.ui.state.ChooserBlockUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ -32,6 +35,7 @@ internal class ChooseTokenModel @Inject constructor(
marketBlockDelegateFactory: MarketBlockDelegate.Factory,
predefinedTokensBlockDelegateFactory: PredefinedTokensBlockDelegate.Factory,
addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
paramsContainer: ParamsContainer,
) : Model() {
@ -62,6 +66,13 @@ internal class ChooseTokenModel @Inject constructor(
)
}
/** Tokens the user already holds in the selected wallet — subtracted from the predefined "Other eligible" block. */
@OptIn(ExperimentalCoroutinesApi::class)
private val portfolioTokenKeysFlow: Flow<Set<Pair<String, String>>> = bridge.selectedWalletFlow
.flatMapLatest { wallet -> singleAccountStatusListSupplier(wallet.walletId) }
.map { accountStatusList -> accountStatusList.toTokenKeys() }
.onStart { emit(emptySet()) }
private val predefinedTokensBlockDelegate: PredefinedTokensBlockDelegate by lazy {
val block = bridge.settings.chooserBlock as ChooserBlock.Predefined
predefinedTokensBlockDelegateFactory.create(
@ -71,6 +82,7 @@ internal class ChooseTokenModel @Inject constructor(
addToPortfolioSlot = bottomSheetNavigation,
modelScope = modelScope,
tokenFilter = bridge.tokenFilter,
portfolioTokenKeys = portfolioTokenKeysFlow,
)
}
@ -104,7 +116,7 @@ internal class ChooseTokenModel @Inject constructor(
)
init {
if (bridge.settings.chooserBlock == ChooserBlock.Market) {
if (bridge.settings.chooserBlock is ChooserBlock.Market) {
modelScope.launch {
delay(MARKETS_INITIAL_LOAD_DELAY)
marketBlockDelegate.loadDefaultMarkets()
@ -124,6 +136,12 @@ internal class ChooseTokenModel @Inject constructor(
.launchIn(modelScope)
}
private fun AccountStatusList.toTokenKeys(): Set<Pair<String, String>> =
flattenCurrencies().mapNotNullTo(hashSetOf()) { status ->
val rawId = status.currency.id.rawCurrencyId?.value ?: return@mapNotNullTo null
rawId to status.currency.network.rawId
}
fun onBackClicked() {
bridge.onClose()
}

View file

@ -33,6 +33,7 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
@Assisted private val addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute>,
@Assisted private val modelScope: CoroutineScope,
@Assisted private val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>,
@Assisted private val portfolioTokenKeys: Flow<Set<Pair<String, String>>>,
) {
init {
@ -44,8 +45,13 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
val stateFlow: Flow<PredefinedTokensUM?> = combine(
predefinedTokens,
searchQueryState,
) { tokens, query ->
val filtered = tokens.filter { it.hasValidNetwork() && it.matchesQuery(query.value) }
portfolioTokenKeys,
) { tokens, query, portfolioKeys ->
val filtered = tokens.filter { token ->
token.hasValidNetwork() &&
token.matchesQuery(query.value) &&
!portfolioKeys.contains(token.toKey())
}
if (filtered.isEmpty()) {
null
} else {
@ -66,6 +72,9 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
}
}
/** Identity of a predefined token as `(rawCurrencyId, networkId)` — matches the portfolio token keys. */
private fun PredefinedTokenToAdd.toKey(): Pair<String, String> = token.id.value to network.networkId
private fun PredefinedTokenToAdd.hasValidNetwork(): Boolean =
network.networkId.isNotBlank() && network.decimalCount != null
@ -104,6 +113,7 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute>,
modelScope: CoroutineScope,
tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>,
portfolioTokenKeys: Flow<Set<Pair<String, String>>>,
): PredefinedTokensBlockDelegate
}
}

View file

@ -740,7 +740,7 @@ private fun LazyListScope.predefinedTokensListItems(state: PredefinedTokensUM) {
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = state.items.lastIndex,
backgroundColor = TangemTheme.colors2.surface.level1,
backgroundColor = TangemTheme.colors.background.primary,
)
.testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
.semantics { lazyListItemPosition = index },

View file

@ -125,6 +125,41 @@ internal class PredefinedTokensBlockDelegateTest {
assertThat(actual).isNull()
}
@Test
fun `GIVEN predefined token already in portfolio WHEN state emitted THEN it is excluded`() = runTest {
// Arrange
val tokens = listOf(
createPredefinedToken(id = "usd-coin", symbol = "USDC", networkId = ETHEREUM_NETWORK_ID),
createPredefinedToken(id = "tether", symbol = "USDT", networkId = ETHEREUM_NETWORK_ID),
)
val delegate = createDelegate(
predefinedTokens = MutableStateFlow(tokens),
portfolioTokenKeys = MutableStateFlow(setOf("usd-coin" to ETHEREUM_NETWORK_ID)),
)
// Act
val actual = lastState(delegate)
// Assert — usd-coin is already in the portfolio, so only tether stays in "Other eligible tokens"
assertThat(actual?.items?.map { it.id }).containsExactly("tether_$ETHEREUM_NETWORK_ID")
}
@Test
fun `GIVEN all predefined tokens already in portfolio WHEN state emitted THEN emits null`() = runTest {
// Arrange
val token = createPredefinedToken(id = "usd-coin", symbol = "USDC", networkId = ETHEREUM_NETWORK_ID)
val delegate = createDelegate(
predefinedTokens = MutableStateFlow(listOf(token)),
portfolioTokenKeys = MutableStateFlow(setOf("usd-coin" to ETHEREUM_NETWORK_ID)),
)
// Act
val actual = lastState(delegate)
// Assert
assertThat(actual).isNull()
}
@ParameterizedTest
@ProvideTestModels
fun filter(model: FilterModel) = runTest {
@ -234,6 +269,7 @@ internal class PredefinedTokensBlockDelegateTest {
searchQueryState: MutableStateFlow<SearchQuery> = MutableStateFlow(SearchQuery.Empty),
tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> =
MutableStateFlow({ _, _ -> true }),
portfolioTokenKeys: MutableStateFlow<Set<Pair<String, String>>> = MutableStateFlow(emptySet()),
): PredefinedTokensBlockDelegate = PredefinedTokensBlockDelegate(
predefinedTokens = predefinedTokens,
searchQueryState = searchQueryState,
@ -241,6 +277,7 @@ internal class PredefinedTokensBlockDelegateTest {
addToPortfolioSlot = addToPortfolioSlot,
modelScope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)),
tokenFilter = tokenFilter,
portfolioTokenKeys = portfolioTokenKeys,
)
private fun currency(rawId: String, networkId: String): CryptoCurrencyStatus =

View file

@ -17,6 +17,7 @@ dependencies {
api(projects.features.details.api)
api(projects.features.onboardingV2.api)
api(projects.features.wallet.api)
implementation(projects.features.virtualAccounts.details.api)
/* Project - Core */
api(projects.core.analytics)
@ -45,6 +46,7 @@ dependencies {
runtimeOnly(projects.domain.appCurrency)
runtimeOnly(projects.domain.balanceHiding)
runtimeOnly(projects.domain.tokens)
implementation(projects.domain.virtualAccount)
/* SDK */
// TODO: For TangemError model, should be removed after card domain scanning refactoring

View file

@ -39,6 +39,7 @@ import com.tangem.features.details.entity.SelectContactSupportTypeBS
import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS
import com.tangem.features.details.utils.ItemsBuilder
import com.tangem.features.details.utils.SocialsBuilder
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.logging.TangemLogger
@ -71,6 +72,7 @@ internal class DetailsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles,
private val tangemPayEligibilityManager: TangemPayEligibilityManager,
private val getVirtualAccountEligibilityUseCase: GetVirtualAccountEligibilityUseCase,
) : Model() {
@ -335,8 +337,9 @@ internal class DetailsModel @Inject constructor(
private fun addVirtualAccountItemIfEligible() {
modelScope.launch {
val isVirtualAccountEnabled = virtualAccountFeatureToggles.isVirtualAccountsEnabled
val eligibility = getVirtualAccountEligibilityUseCase(VirtualAccountEntryPoint.DETAILS)
if (eligibility is VirtualAccountEligibility.Available) {
if (eligibility is VirtualAccountEligibility.Available && isVirtualAccountEnabled) {
items.update { items ->
itemsBuilder.addVirtualAccountItem(
items = items,

View file

@ -23,6 +23,7 @@ import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.AddressBookFeatureToggles
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.details.entity.DetailsItemUM
import com.tangem.features.details.utils.ItemsBuilder
@ -64,6 +65,7 @@ internal abstract class DetailsModelTestBase {
protected val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true)
protected val tangemPayEligibilityManager: TangemPayEligibilityManager = mockk()
protected val getVirtualAccountEligibilityUseCase: GetVirtualAccountEligibilityUseCase = mockk()
protected val virtualAccountFeatureToggles: VirtualAccountFeatureToggles = mockk()
// Captured from itemsBuilder.buildAll(...) so the feature buttons can be driven.
protected val wcSlot = slot<Boolean>()
@ -88,6 +90,7 @@ internal abstract class DetailsModelTestBase {
every { appInfoProvider.appVersionCode } returns 456
coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns emptyList()
coEvery { getVirtualAccountEligibilityUseCase(any()) } returns VirtualAccountEligibility.NotAvailable
every { virtualAccountFeatureToggles.isVirtualAccountsEnabled } returns true
every {
itemsBuilder.buildAll(
@ -128,6 +131,7 @@ internal abstract class DetailsModelTestBase {
analyticsEventHandler = analyticsEventHandler,
tangemPayEligibilityManager = tangemPayEligibilityManager,
getVirtualAccountEligibilityUseCase = getVirtualAccountEligibilityUseCase,
virtualAccountFeatureToggles = virtualAccountFeatureToggles,
)
protected fun stubBuildAllReturns(list: ImmutableList<DetailsItemUM>) {

View file

@ -28,6 +28,7 @@ dependencies {
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.material3)
implementation(deps.compose.coil)
implementation(deps.lifecycle.compose)

View file

@ -125,9 +125,12 @@ internal class MarketingBannerModel @Inject constructor(
campaignId = id,
text = banner.text,
iconUrl = banner.iconUrl,
// When the backend omits iconAlign, follow the design default: a dismissible banner keeps the icon
// on the left (the close button occupies the right slot), a non-dismissible one moves it to the right.
iconAlign = when (banner.iconAlign) {
MarketingBanner.IconAlign.RIGHT -> MarketingBannerUM.IconAlign.RIGHT
MarketingBanner.IconAlign.LEFT, null -> MarketingBannerUM.IconAlign.LEFT
MarketingBanner.IconAlign.LEFT -> MarketingBannerUM.IconAlign.LEFT
null -> if (banner.isDismissible) MarketingBannerUM.IconAlign.LEFT else MarketingBannerUM.IconAlign.RIGHT
},
isDismissible = banner.isDismissible,
deeplink = banner.deeplink,

View file

@ -21,7 +21,6 @@ import coil.request.ImageRequest
import com.tangem.core.ui.R
import com.tangem.core.ui.ds2.messagebanner.CloseButton
import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -51,11 +50,10 @@ internal fun MarketingBanner(
TangemMessageBanner(
title = stringReference(banner.text.orEmpty()),
modifier = modifier.then(
if (hasDeeplink) Modifier.clickableSingle(onClick = onClick) else Modifier,
),
modifier = modifier,
variant = TangemMessageBanner.Variant.Default,
showGlowRing = false,
onClick = if (hasDeeplink) onClick else null,
slotStart = if (isIconAtStart) {
{ BannerIcon(banner.iconUrl, onLoadError = { isIconFailed = true }) }
} else {

View file

@ -16,6 +16,8 @@ import com.tangem.features.marketing.api.LinkedBannerRequest
import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.marketing.api.MarketingBannerRequest
import com.tangem.features.marketing.impl.ui.state.MarketingBannerListUM
import com.tangem.features.marketing.impl.ui.state.MarketingBannerUM
import com.tangem.test.core.ProvideTestModels
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import io.mockk.Runs
import io.mockk.clearMocks
@ -33,6 +35,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class MarketingBannerModelTest {
@ -280,4 +283,53 @@ internal class MarketingBannerModelTest {
// Assert
verify(exactly = 1) { deeplinkLauncher.launch("https://tangem.com/promo") }
}
@ParameterizedTest
@ProvideTestModels
fun `GIVEN iconAlign and dismissible WHEN mapped THEN align follows design default`(
model: IconAlignModel,
) = runTest {
// Arrange
coEvery { getMarketingBanner(onrampScreen, null) } returns listOf(
standaloneCampaign(id = 1, iconAlign = model.iconAlign, isDismissible = model.isDismissible),
).right()
val bannerModel = createModel(
MarketingBannerComponent.Params.Standalone(flowOf(MarketingBannerRequest(onrampScreen))),
)
// Act
advanceUntilIdle()
// Assert
val content = bannerModel.uiState.value as MarketingBannerListUM.Content
assertThat(content.banners.single().iconAlign).isEqualTo(model.expected)
}
private fun standaloneCampaign(id: Int, iconAlign: MarketingBanner.IconAlign?, isDismissible: Boolean) =
campaign(id, MarketingBanner.UiType.STANDALONE).let { base ->
base.copy(banner = base.banner.copy(iconAlign = iconAlign, isDismissible = isDismissible))
}
internal data class IconAlignModel(
val iconAlign: MarketingBanner.IconAlign?,
val isDismissible: Boolean,
val expected: MarketingBannerUM.IconAlign,
)
private fun provideTestModels() = listOf(
// Backend omits iconAlign -> derived from dismissible (design default)
IconAlignModel(iconAlign = null, isDismissible = false, expected = MarketingBannerUM.IconAlign.RIGHT),
IconAlignModel(iconAlign = null, isDismissible = true, expected = MarketingBannerUM.IconAlign.LEFT),
// Explicit backend value is always honored regardless of dismissible
IconAlignModel(
iconAlign = MarketingBanner.IconAlign.LEFT,
isDismissible = false,
expected = MarketingBannerUM.IconAlign.LEFT,
),
IconAlignModel(
iconAlign = MarketingBanner.IconAlign.RIGHT,
isDismissible = true,
expected = MarketingBannerUM.IconAlign.RIGHT,
),
)
}

View file

@ -17,8 +17,6 @@ dependencies {
api(projects.features.commonFeatures.api)
api(projects.features.onramp.api)
implementation(projects.features.marketing.api)
implementation(projects.domain.marketing.models)
implementation(projects.domain.quotes)
/** Project - Core */
api(projects.core.analytics)
@ -55,6 +53,8 @@ dependencies {
implementation(projects.domain.tokens.models)
implementation(projects.domain.transaction.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.marketing.models)
implementation(projects.domain.quotes)
runtimeOnly(projects.domain.card)
/** Data */

View file

@ -5,6 +5,7 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.features.marketing.api.MarketingBannerComponent
internal interface AllOffersComponent : ComposableBottomSheetComponent {
@ -14,6 +15,12 @@ internal interface AllOffersComponent : ComposableBottomSheetComponent {
val onDismiss: () -> Unit,
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
val amountCurrencyCode: String,
// Marketing banner components are created and owned by the parent onramp-main component and passed
// down so this sheet reuses their models (and their amount-gated request flows) instead of building
// its own: [marketingBannerComponent] renders the standalone banner, [linkedMarketingBannerComponent]
// renders the per-provider LINKED_TO_PROVIDER banner next to each offer.
val marketingBannerComponent: MarketingBannerComponent,
val linkedMarketingBannerComponent: MarketingBannerComponent,
)
interface Factory : ComponentFactory<Params, AllOffersComponent>

View file

@ -13,7 +13,7 @@ import dagger.assisted.AssistedInject
internal class DefaultAllOffersComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: AllOffersComponent.Params,
@Assisted private val params: AllOffersComponent.Params,
) : AllOffersComponent, AppComponentContext by context {
private val model: AllOffersModel = getOrCreateModel(params)
@ -27,6 +27,8 @@ internal class DefaultAllOffersComponent @AssistedInject constructor(
val state by model.state.collectAsState()
AllOffersContentSheet(
state = state,
marketingBannerComponent = params.marketingBannerComponent,
linkedMarketingBannerComponent = params.linkedMarketingBannerComponent,
onCloseClick = { dismiss() },
)
}

View file

@ -25,9 +25,11 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemeRedesign
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodStatus
import com.tangem.domain.onramp.model.PaymentMethodType
import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM
import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM
import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig
@ -35,13 +37,18 @@ import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.main.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.main.entity.OnrampOfferUM
import com.tangem.features.onramp.main.ui.Offer
import com.tangem.features.onramp.main.ui.OfferWithLinkedBanner
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@Composable
internal fun AllOffersContentSheet(state: AllOffersStateUM, onCloseClick: () -> Unit) {
internal fun AllOffersContentSheet(
state: AllOffersStateUM,
marketingBannerComponent: MarketingBannerComponent,
linkedMarketingBannerComponent: MarketingBannerComponent,
onCloseClick: () -> Unit,
) {
val onBack = remember(state) {
{
if (state is AllOffersStateUM.Content && state.currentMethod != null) {
@ -71,37 +78,64 @@ internal fun AllOffersContentSheet(state: AllOffersStateUM, onCloseClick: () ->
}
},
content = {
Box(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 8.dp)
.animateContentSize(),
) {
AnimatedContent(
targetState = state is AllOffersStateUM.Content && state.currentMethod != null,
transitionSpec = {
fadeIn(tween(durationMillis = 220)) togetherWith
fadeOut(tween(durationMillis = 220))
},
label = "Change offers and payment method state",
) { shouldShowOffersScreen ->
when (state) {
AllOffersStateUM.Loading -> AllOffersContentLoading()
is AllOffersStateUM.Error -> AllOffersError(state.errorNotification)
is AllOffersStateUM.Content -> {
if (shouldShowOffersScreen) {
state.currentMethod?.let {
OffersBasedOnPaymentMethodContent(offers = it.offers)
}
} else {
PaymentMethodsContent(methods = state.methods)
AllOffersSheetContent(
state = state,
marketingBannerComponent = marketingBannerComponent,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
)
},
)
}
@Composable
private fun AllOffersSheetContent(
state: AllOffersStateUM,
marketingBannerComponent: MarketingBannerComponent,
linkedMarketingBannerComponent: MarketingBannerComponent,
) {
Column(modifier = Modifier.fillMaxSize()) {
// Standalone marketing banner at the top of the sheet (DS3 -> wrap in the redesign theme).
// Renders nothing when no matching campaign, so it adds no space in the common case.
TangemThemeRedesign {
marketingBannerComponent.Content(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 8.dp)
.animateContentSize(),
) {
AnimatedContent(
targetState = state is AllOffersStateUM.Content && state.currentMethod != null,
transitionSpec = {
fadeIn(tween(durationMillis = 220)) togetherWith
fadeOut(tween(durationMillis = 220))
},
label = "Change offers and payment method state",
) { shouldShowOffersScreen ->
when (state) {
AllOffersStateUM.Loading -> AllOffersContentLoading()
is AllOffersStateUM.Error -> AllOffersError(state.errorNotification)
is AllOffersStateUM.Content -> {
if (shouldShowOffersScreen) {
state.currentMethod?.let { method ->
OffersBasedOnPaymentMethodContent(
offers = method.offers,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
)
}
} else {
PaymentMethodsContent(methods = state.methods)
}
}
}
}
},
)
}
}
}
@Composable
@ -127,7 +161,10 @@ private fun PaymentMethodTitle(onCloseClick: () -> Unit) {
}
@Composable
private fun OffersBasedOnPaymentMethodContent(offers: ImmutableList<OnrampOfferUM>) {
private fun OffersBasedOnPaymentMethodContent(
offers: ImmutableList<OnrampOfferUM>,
linkedMarketingBannerComponent: MarketingBannerComponent,
) {
Column(
modifier = Modifier
.fillMaxWidth()
@ -136,7 +173,7 @@ private fun OffersBasedOnPaymentMethodContent(offers: ImmutableList<OnrampOfferU
) {
offers.fastForEach { offer ->
key("${offer.paymentMethod.id} ${offer.providerName} ${offer.rate}") {
Offer(offer)
OfferWithLinkedBanner(offer, linkedMarketingBannerComponent)
SpacerH(8.dp)
}
}
@ -250,11 +287,18 @@ private fun AllOffersContentSheetPaymentPreview() {
currentMethod = method,
onBackClicked = {},
),
marketingBannerComponent = PreviewMarketingBannerComponent,
linkedMarketingBannerComponent = PreviewMarketingBannerComponent,
onCloseClick = {},
)
}
}
private val PreviewMarketingBannerComponent = object : MarketingBannerComponent {
@Composable
override fun Content(modifier: Modifier) = Unit
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
@ -319,6 +363,8 @@ private fun AllOffersContentSheetOffersPreview() {
currentMethod = null,
onBackClicked = {},
),
marketingBannerComponent = PreviewMarketingBannerComponent,
linkedMarketingBannerComponent = PreviewMarketingBannerComponent,
onCloseClick = {},
)
}

View file

@ -106,6 +106,8 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
onDismiss = model.bottomSheetNavigation::dismiss,
openRedirectPage = params.openRedirectPage,
amountCurrencyCode = config.amountCurrencyCode,
marketingBannerComponent = marketingBannerComponent,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
),
)
}

View file

@ -104,7 +104,7 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM, linkedMarketingBann
}
@Composable
private fun OfferWithLinkedBanner(offer: OnrampOfferUM, linkedMarketingBannerComponent: MarketingBannerComponent) {
internal fun OfferWithLinkedBanner(offer: OnrampOfferUM, linkedMarketingBannerComponent: MarketingBannerComponent) {
val hasBanner = linkedMarketingBannerComponent.hasLinkedBanner(offer.providerId)
// Square the offer's bottom corners so the bottom-rounded banner glues to it as one card.
Offer(offer, roundBottom = !hasBanner)

View file

@ -25,9 +25,11 @@ dependencies {
implementation(projects.domain.account.status)
implementation(projects.domain.promo)
implementation(projects.domain.promo.models)
implementation(projects.domain.markets.models)
/** Data */
implementation(projects.data.common)
implementation(tangemDeps.blockchain)
/** Core */
api(projects.core.configToggles)
@ -43,6 +45,7 @@ dependencies {
api(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.material3)
implementation(deps.lifecycle.compose)
/** Other */

View file

@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
@ -29,7 +28,6 @@ internal class ActivateCampaignBottomSheetComponent(
chooseTokenComponentFactory: ChooseTokenComponent.Factory,
private val params: Params,
val onDismiss: () -> Unit,
val onFooterExtraHeightReady: (Dp) -> Unit,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: ActivateCampaignsModel = getOrCreateModel(params)
@ -65,7 +63,6 @@ internal class ActivateCampaignBottomSheetComponent(
ActivateCampaignFooter(
footerUM = state.footerUM,
onFooterTextHeightReady = onFooterExtraHeightReady,
)
}

View file

@ -2,23 +2,31 @@ package com.tangem.features.promobanners.impl.campaigns.component
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.SpacerH32
import com.tangem.core.ui.components.bottomsheets.LocalBottomSheetContentScrollable
import com.tangem.core.ui.components.bottomsheets.LocalTangemBottomSheetContentBottomInset
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.DEFAULT_FOOTER_HEIGHT
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWithFooter
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.extensions.rememberLastNonNull
import com.tangem.core.ui.res.TangemTheme
@ -52,31 +60,44 @@ internal class DefaultCampaignsComponent @AssistedInject constructor(
val bottomSheet by bottomSheetSlot.subscribeAsState()
val activeChild = bottomSheet.child?.instance
val displayedChild = rememberLastNonNull(activeChild)
val footerExtraHeight by model.footerExtraHeightState.collectAsStateWithLifecycle()
TangemModalBottomSheetWithFooter<TangemBottomSheetConfigContent.Empty>(
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = activeChild != null,
onDismissRequest = model::onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
containerColor = TangemTheme.colors3.bg.primary,
footerHeight = DEFAULT_FOOTER_HEIGHT + footerExtraHeight,
containerColor = TangemTheme.colors3.bg.secondary,
type = TangemBottomSheetType.Modal,
onBack = model::onDismiss,
title = {
displayedChild?.Title()
},
content = {
Box(modifier = Modifier.animateContentSize()) {
displayedChild?.Content(modifier = Modifier)
val bottomInset = LocalTangemBottomSheetContentBottomInset.current
val bottomReserve = if (bottomInset > 0.dp) bottomInset else 16.dp
val scrollState = rememberScrollState()
val scrollableSignal = LocalBottomSheetContentScrollable.current
if (scrollableSignal != null) {
LaunchedEffect(scrollState) {
snapshotFlow { scrollState.canScrollForward || scrollState.canScrollBackward }
.collect { canScroll -> scrollableSignal.value = canScroll }
}
}
Column(modifier = Modifier.verticalScroll(state = scrollState)) {
Box(modifier = Modifier.animateContentSize()) {
displayedChild?.Content(modifier = Modifier)
}
if (scrollableSignal?.value != true) SpacerH32()
Spacer(modifier = Modifier.height(bottomReserve))
}
},
footer = {
Box(
modifier = Modifier
.navigationBarsPadding()
.padding(12.dp),
) {
Box(modifier = Modifier.padding(12.dp)) {
displayedChild?.Footer()
}
},
@ -102,7 +123,6 @@ internal class DefaultCampaignsComponent @AssistedInject constructor(
appComponentContext = context,
chooseTokenComponentFactory = chooseTokenComponentFactory,
onDismiss = model::onDismiss,
onFooterExtraHeightReady = model::onFooterExtraHeightReady,
params = ActivateCampaignBottomSheetComponent.Params(
campaignType = config.campaignType,
userWalletId = config.userWalletId,

View file

@ -11,11 +11,11 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
@ -46,6 +46,7 @@ import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
@ -60,7 +61,7 @@ internal class ActivateCampaignsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
chooseTokenBridgeFactory: ChooseTokenBridge.Factory,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val multiAccountListSupplier: MultiAccountListSupplier,
private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase,
private val urlOpener: UrlOpener,
@GlobalUiMessageSender private val messageSender: UiMessageSender,
@ -198,28 +199,46 @@ internal class ActivateCampaignsModel @Inject constructor(
urlOpener.openUrl(campaignContent.learnMoreUrl)
}
private suspend fun hasMultipleCryptoPortfolioAccounts(): Boolean {
return multiAccountListSupplier.invoke()
.first()
.any { accountList ->
accountList.accounts.filterIsInstance<Account.CryptoPortfolio>().size > 1
}
}
private fun onTokenChosen(result: ChooseTokenResult) {
val selectedToken = result.currency.currency as? CryptoCurrency.Token ?: return
val networkAddress = result.currency.value.networkAddress ?: return
modelScope.launch {
val selectedAccountUM = if (isAccountsModeEnabledUseCase.invokeSync()) {
val selectedAccountUM = if (hasMultipleCryptoPortfolioAccounts()) {
when (val account = result.account.account) {
is Account.CryptoPortfolio -> SelectedAccountUM(
iconState = accountIconConverter.convert(account),
name = account.accountName.toUM().value,
)
is Account.Payment -> SelectedAccountUM(
iconState = CurrencyIconState.PaymentAccount(size = AccountIconSize.ExtraSmall),
name = account.accountName.toUM().value,
)
is Account.Virtual -> null
// Payment accounts are hidden in the chooser and don't count towards accounts mode,
// so there is no account label to show for them.
is Account.Payment,
is Account.Virtual,
-> null
}
} else {
null
}
val tokenItem = TokenItemStateConverter(appCurrency = appCurrency).convert(result.currency)
val tokenItem = TokenItemStateConverter(
appCurrency = appCurrency,
subtitleStateProvider = { status ->
TokenItemState.SubtitleState.TextContent(
value = resourceReference(
R.string.domain_receive_assets_onboarding_network_name,
wrappedList(status.currency.network.name),
),
)
},
).convert(result.currency)
uiState.update { state ->
state.copy(

View file

@ -1,7 +1,5 @@
package com.tangem.features.promobanners.impl.campaigns.model
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
@ -24,8 +22,6 @@ import com.tangem.features.promobanners.impl.campaigns.entity.toPromoCampaignId
import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ -43,9 +39,6 @@ internal class CampaignsModel @Inject constructor(
val bottomSheetNavigation: SlotNavigation<CampaignsBottomSheetConfig> = SlotNavigation()
val footerExtraHeightState: StateFlow<Dp>
field = MutableStateFlow(0.dp)
init {
campaignsService.campaignFlow
.onEach { request ->
@ -89,22 +82,16 @@ internal class CampaignsModel @Inject constructor(
},
)
fun onFooterExtraHeightReady(height: Dp) {
footerExtraHeightState.value = height
}
fun onDismiss() {
bottomSheetNavigation.dismiss()
}
fun onActivated(campaignType: CampaignType) {
footerExtraHeightState.value = 0.dp
bottomSheetNavigation.activate(CampaignsBottomSheetConfig.Enrolled(campaignType))
}
fun onAlreadyActivated(campaignType: CampaignType) {
analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened())
footerExtraHeightState.value = 0.dp
bottomSheetNavigation.activate(CampaignsBottomSheetConfig.AlreadyActivated(campaignType = campaignType))
}
}

View file

@ -78,8 +78,6 @@ internal fun ActivateCampaignContent(um: ActivateCampaignUM, modifier: Modifier
selectedAccount = um.selectedAccount,
onChooseTokenClick = um.onChooseTokenClick,
)
SpacerH32()
}
}
@ -94,8 +92,8 @@ private fun SelectedTokenContent(
Text(
text = stringResourceSafe(R.string.promo_campaign_select_cashback_account),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.primary,
modifier = Modifier.fillMaxWidth(),
)

View file

@ -8,8 +8,6 @@ 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.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@ -18,7 +16,6 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SpacerH12
@ -29,28 +26,17 @@ import com.tangem.features.promobanners.impl.campaigns.entity.FooterUM
import com.tangem.features.promobanners.impl.campaigns.entity.TermsUM
@Composable
internal fun ActivateCampaignFooter(
footerUM: FooterUM,
onFooterTextHeightReady: (Dp) -> Unit,
modifier: Modifier = Modifier,
) {
internal fun ActivateCampaignFooter(footerUM: FooterUM, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
val terms = footerUM.terms
if (terms != null) {
val density = LocalDensity.current
Text(
text = termsAnnotatedString(terms),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.onSizeChanged {
val termsBlockHeight = with(density) { it.height.toDp() } + 12.dp
onFooterTextHeightReady.invoke(termsBlockHeight)
},
modifier = Modifier.fillMaxWidth(),
)
SpacerH12()
@ -99,7 +85,6 @@ private fun Preview_ActivateCampaignFooter_WithTerms() {
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
onFooterTextHeightReady = {},
)
}
}
@ -114,7 +99,6 @@ private fun Preview_ActivateCampaignFooter_NoTerms() {
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
onFooterTextHeightReady = {},
)
}
}

View file

@ -54,8 +54,6 @@ internal fun AlreadyActivatedCampaignContent(message: TextReference, modifier: M
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
SpacerH32()
}
}

View file

@ -58,8 +58,6 @@ fun CampaignEnrolledMessageContent(message: TextReference, modifier: Modifier =
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
SpacerH32()
}
}

View file

@ -65,7 +65,5 @@ fun NotActiveCampaignMessageContent(modifier: Modifier = Modifier) {
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
SpacerH32()
}
}

View file

@ -2,10 +2,15 @@ package com.tangem.features.promobanners.impl.model
import androidx.core.net.toUri
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.promobanners.impl.analytics.PromoBannerAnalyticsEvent
@ -26,6 +31,7 @@ import javax.inject.Inject
private typealias ShownBannerKey = Pair<String, Int>
@Suppress("LongParameterList")
@ModelScoped
internal class PromoBannersBlockModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
@ -34,6 +40,7 @@ internal class PromoBannersBlockModel @Inject constructor(
private val deeplinkLauncher: DeeplinkLauncher,
private val analyticsEventHandler: AnalyticsEventHandler,
private val userWalletsListRepository: UserWalletsListRepository,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) : Model() {
private val params = paramsContainer.require<PromoBannersBlockComponent.Params>()
@ -206,7 +213,12 @@ internal class PromoBannersBlockModel @Inject constructor(
private fun onButtonClick(displayId: Int, deeplink: String?) {
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholderName))
deeplink?.let { deeplinkLauncher.launch(appendSurveyDisplayId(it, displayId)) }
if (deeplink.isNullOrBlank()) {
uiMessageSender.send(ToastMessage(message = resourceReference(R.string.common_something_went_wrong)))
} else {
deeplinkLauncher.launch(appendSurveyDisplayId(deeplink, displayId))
}
}
private fun appendSurveyDisplayId(deeplink: String, displayId: Int): String {

View file

@ -9,7 +9,8 @@ import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
@ -53,7 +54,7 @@ internal class ActivateCampaignsModelTest {
private val chooseTokenBridgeFactory: ChooseTokenBridge.Factory = mockk(relaxed = true)
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk()
private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase = mockk()
private val urlOpener: UrlOpener = mockk(relaxed = true)
private val messageSender: UiMessageSender = mockk(relaxed = true)
@ -72,7 +73,7 @@ internal class ActivateCampaignsModelTest {
fun setup() {
clearMocks(
getSelectedAppCurrencyUseCase,
isAccountsModeEnabledUseCase,
multiAccountListSupplier,
enrollPromoCampaignUseCase,
getWalletsUseCase,
messageSender,
@ -258,7 +259,7 @@ internal class ActivateCampaignsModelTest {
}
every { chooseTokenBridgeFactory.create(any(), any(), any()) } returns bridge
every { getSelectedAppCurrencyUseCase.invokeOrDefault() } returns flowOf(AppCurrency.Default)
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList<AccountList>())
coEvery { getPromoCampaignStateUseCase(any(), any(), any()) } returns Either.Left(Throwable())
every { getWalletsUseCase.invokeSync() } returns allWalletIds.map { walletId ->
mockk<UserWallet> { every { this@mockk.walletId } returns walletId }
@ -274,7 +275,7 @@ internal class ActivateCampaignsModelTest {
dispatchers = createTestingCoroutineDispatcherProvider(),
chooseTokenBridgeFactory = chooseTokenBridgeFactory,
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase,
multiAccountListSupplier = multiAccountListSupplier,
enrollPromoCampaignUseCase = enrollPromoCampaignUseCase,
urlOpener = urlOpener,
messageSender = messageSender,

View file

@ -1,8 +1,6 @@
package com.tangem.features.promobanners.impl.campaigns.model
import androidx.compose.ui.unit.dp
import arrow.core.Either
import com.google.common.truth.Truth.assertThat
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.domain.models.wallet.UserWalletId
@ -108,11 +106,10 @@ internal class CampaignsModelTest {
}
@Test
fun `GIVEN footer height set WHEN onAlreadyActivated THEN analytics sent and height reset`() = runTest {
fun `WHEN onAlreadyActivated THEN analytics sent`() = runTest {
// Arrange
val model = createModel(campaignFlow = emptyFlow())
advanceUntilIdle()
model.onFooterExtraHeightReady(100.dp)
// Act
model.onAlreadyActivated(CampaignType.WhaleSwapCashback(campaignId = "1"))
@ -121,37 +118,20 @@ internal class CampaignsModelTest {
verify(exactly = 1) {
analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened())
}
assertThat(model.footerExtraHeightState.value).isEqualTo(0.dp)
model.onDestroy()
}
@Test
fun `GIVEN footer height set WHEN onActivated THEN no analytics and height reset`() = runTest {
fun `WHEN onActivated THEN no analytics sent`() = runTest {
// Arrange
val model = createModel(campaignFlow = emptyFlow())
advanceUntilIdle()
model.onFooterExtraHeightReady(100.dp)
// Act
model.onActivated(CampaignType.WhaleSwapCashback(campaignId = "1"))
// Assert
verify { analyticsEventHandler wasNot Called }
assertThat(model.footerExtraHeightState.value).isEqualTo(0.dp)
model.onDestroy()
}
@Test
fun `WHEN onFooterExtraHeightReady THEN height state is updated`() = runTest {
// Arrange
val model = createModel(campaignFlow = emptyFlow())
advanceUntilIdle()
// Act
model.onFooterExtraHeightReady(42.dp)
// Assert
assertThat(model.footerExtraHeightState.value).isEqualTo(42.dp)
model.onDestroy()
}

View file

@ -69,6 +69,7 @@ dependencies {
implementation(projects.domain.account)
implementation(projects.domain.account.status)
implementation(projects.domain.marketing.models)
implementation(projects.domain.onramp.models)
/** Common */
implementation(projects.common.ui)

View file

@ -167,6 +167,9 @@ private fun StakingScreenContent(
amountState = uiState.amountState,
clickIntents = uiState.clickIntents,
modifier = Modifier.background(TangemTheme.colors.background.secondary),
extraContent = {
marketingBannerComponent.Content(Modifier.fillMaxWidth())
},
)
StakingStep.Confirmation -> StakingConfirmationContent(
amountState = uiState.amountState,

View file

@ -110,6 +110,7 @@ internal class TangemPayCardPageScreenComponent(
paymentAccountAddress = navigation.paymentAccountAddress,
onDismiss = model.bottomSheetNavigation::dismiss,
onShowDetails = model::onShowVirtualAccountRequisites,
onShowBankingDetailsError = model::showVaBankingDetailsError,
onOrderCreated = model::onVirtualAccountOrderCreated,
),
)
@ -126,6 +127,15 @@ internal class TangemPayCardPageScreenComponent(
onFieldCopied = model::onVaFieldCopied,
),
)
is TangemPayCardNavigation.VaBankingDetailsError -> TangemPayVaBankingDetailsErrorComponent(
appComponentContext = context,
params = TangemPayVaBankingDetailsErrorComponent.Params(
userWalletId = navigation.userWalletId,
onDismiss = model.bottomSheetNavigation::dismiss,
onContactSupport = model::onContactSupportClicked,
onResolved = model::onVaBankingDetailsResolved,
),
)
is TangemPayCardNavigation.Receive -> tokenReceiveComponentFactory.create(
context = context,
params = TokenReceiveComponent.Params(

View file

@ -149,6 +149,7 @@ internal class TangemPayDetailsComponent(
paymentAccountAddress = navigation.paymentAccountAddress,
onDismiss = model.bottomSheetNavigation::dismiss,
onShowDetails = model::onShowVirtualAccountRequisites,
onShowBankingDetailsError = model::showVaBankingDetailsError,
onOrderCreated = model::onVirtualAccountOrderCreated,
),
)
@ -165,6 +166,15 @@ internal class TangemPayDetailsComponent(
onFieldCopied = model::onVaFieldCopied,
),
)
is TangemPayDetailsNavigation.VaBankingDetailsError -> TangemPayVaBankingDetailsErrorComponent(
appComponentContext = context,
params = TangemPayVaBankingDetailsErrorComponent.Params(
userWalletId = navigation.userWalletId,
onDismiss = model.bottomSheetNavigation::dismiss,
onContactSupport = model::onContactSupportClicked,
onResolved = model::onVaBankingDetailsResolved,
),
)
is TangemPayDetailsNavigation.IssueAdditionalCard -> TangemPayIssueAdditionalCardComponent(
appComponentContext = context,
params = TangemPayIssueAdditionalCardComponent.Params(

View file

@ -0,0 +1,44 @@
package com.tangem.features.tangempay.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.tangempay.model.TangemPayVaBankingDetailsErrorModel
import com.tangem.features.tangempay.ui.TangemPayVaBankingDetailsErrorBottomSheet
/**
* Error bottom sheet shown when VA bank credentials fail to load ([VirtualAccountOnramp.BankCredentialsError]).
*
* "Try again" re-fetches the payment account status while showing a loader on the button; on success the
* resolved on-ramp is handed back via [Params.onResolved] (the parent opens the bank-transfer sheet), otherwise
* the error stays visible with the loader cleared.
*/
internal class TangemPayVaBankingDetailsErrorComponent(
appComponentContext: AppComponentContext,
params: Params,
) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext {
private val model: TangemPayVaBankingDetailsErrorModel = getOrCreateModel(params = params)
override fun dismiss() {
model.onDismiss()
}
@Composable
override fun BottomSheet() {
val state by model.uiState.collectAsStateWithLifecycle()
TangemPayVaBankingDetailsErrorBottomSheet(state = state)
}
data class Params(
val userWalletId: UserWalletId,
val onDismiss: () -> Unit,
val onContactSupport: () -> Unit,
val onResolved: (VirtualAccountOnramp) -> Unit,
)
}

View file

@ -38,6 +38,7 @@ internal class TangemPayVirtualAccountDepositComponent(
val paymentAccountAddress: String,
val onDismiss: () -> Unit,
val onShowDetails: (VirtualAccountOnramp.Available) -> Unit,
val onShowBankingDetailsError: () -> Unit,
val onOrderCreated: () -> Unit,
)
}

View file

@ -53,6 +53,11 @@ internal interface TangemPayModelModule {
@ClassKey(TangemPayVirtualAccountDepositModel::class)
fun bindTangemPayVirtualAccountDepositModel(model: TangemPayVirtualAccountDepositModel): Model
@Binds
@IntoMap
@ClassKey(TangemPayVaBankingDetailsErrorModel::class)
fun bindTangemPayVaBankingDetailsErrorModel(model: TangemPayVaBankingDetailsErrorModel): Model
@Binds
@IntoMap
@ClassKey(TangemPayViewPinModel::class)

View file

@ -48,6 +48,11 @@ internal sealed class TangemPayCardNavigation {
val bankCredentials: BankCredentials,
) : TangemPayCardNavigation()
@Serializable
data class VaBankingDetailsError(
val userWalletId: UserWalletId,
) : TangemPayCardNavigation()
@Serializable
data class Receive(val config: TokenReceiveConfig) : TangemPayCardNavigation()
}

View file

@ -39,6 +39,11 @@ internal sealed class TangemPayDetailsNavigation {
val bankCredentials: BankCredentials,
) : TangemPayDetailsNavigation()
@Serializable
data class VaBankingDetailsError(
val userWalletId: UserWalletId,
) : TangemPayDetailsNavigation()
@Serializable
data class TransactionDetails(
val transaction: TangemPayTxHistoryItem,

View file

@ -0,0 +1,17 @@
package com.tangem.features.tangempay.entity
import androidx.compose.runtime.Immutable
/**
* UI state for the "couldn't load banking details" bottom sheet (VA MVP0, TWI-1638).
*
* @property isRetryLoading whether the "Try again" button shows a loader while the payment account status
* is being re-fetched. While `true` both actions are disabled.
*/
@Immutable
internal data class TangemPayVaBankingDetailsErrorUM(
val isRetryLoading: Boolean,
val onRetryClick: () -> Unit,
val onContactSupportClick: () -> Unit,
val onDismiss: () -> Unit,
)

View file

@ -7,6 +7,7 @@ import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -24,6 +25,9 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_arrow_refresh_20
import com.tangem.core.ui.test.TangemPayTestTags
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.feedback.models.WalletMetaInfo
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.account.AccountStatus
@ -67,16 +71,17 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
import com.tangem.core.ui.R as CoreUiR
@Suppress("LongParameterList", "LargeClass")
@Suppress("LongParameterList", "LargeClass", "TooManyFunctions")
@Stable
@ModelScoped
internal class TangemPayCardPageModel @Inject constructor(
paramsContainer: ParamsContainer,
paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val analytics: AnalyticsEventHandler,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val uiMessageSender: UiMessageSender,
private val changeCardFrozenStateUseCase: ChangeCardFrozenStateUseCase,
@ -446,7 +451,19 @@ internal class TangemPayCardPageModel @Inject constructor(
override fun onClickBankTransfer() {
val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return
val onramp = loaded.virtualAccount ?: return
when (val onramp = loaded.virtualAccount) {
null -> return
VirtualAccountOnramp.Processing -> showVaPreparing()
// BankCredentialsError opens the deposit intro first; the retryable error sheet is shown from
// its "Show details" action (see onShowDetailsClick).
is VirtualAccountOnramp.Available,
VirtualAccountOnramp.Eligible,
is VirtualAccountOnramp.BankCredentialsError,
-> openVirtualAccountDeposit(onramp, loaded)
}
}
private fun openVirtualAccountDeposit(onramp: VirtualAccountOnramp, loaded: PaymentAccountStatusValue.Loaded) {
analytics.send(TangemPayAnalyticsEvents.VaTopupButtonClicked())
bottomSheetNavigation.dismiss()
bottomSheetNavigation.activate(
@ -458,6 +475,43 @@ internal class TangemPayCardPageModel @Inject constructor(
)
}
fun showVaBankingDetailsError() {
bottomSheetNavigation.dismiss()
bottomSheetNavigation.activate(
TangemPayCardNavigation.VaBankingDetailsError(userWalletId = userWalletId),
)
}
private fun showVaPreparing() {
bottomSheetNavigation.dismiss()
uiMessageSender.send(message = TangemPayMessagesFactory.createVaPreparingMessage())
}
fun onVaBankingDetailsResolved(onramp: VirtualAccountOnramp) {
when (onramp) {
// Bank credentials just loaded on retry — show the requisites straight away ([REDACTED_TASK_KEY]),
// instead of the intro deposit sheet that would need another "Show details" tap.
is VirtualAccountOnramp.Available -> onShowVirtualAccountRequisites(onramp)
else -> {
val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return
openVirtualAccountDeposit(onramp, loaded)
}
}
}
fun onContactSupportClicked() {
analytics.send(Basic.ButtonSupport(source = AnalyticsParam.ScreensSources.TangemPay))
val customerId = currentStatus.value.ifLoadedOrNull { it.customerId } ?: return
modelScope.launch {
sendFeedbackEmailUseCase.invoke(
type = FeedbackEmailType.Visa.FeatureIsBeta(
walletMetaInfo = WalletMetaInfo(userWalletId = userWalletId),
customerId = customerId,
),
)
}
}
fun onVirtualAccountOrderCreated() {
analytics.send(TangemPayAnalyticsEvents.VaSuccessScreenActivation())
bottomSheetNavigation.dismiss()

View file

@ -64,7 +64,7 @@ import javax.inject.Inject
@ModelScoped
internal class TangemPayDetailsModel @Inject constructor(
paramsContainer: ParamsContainer,
paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
override val dispatchers: CoroutineDispatcherProvider,
private val analytics: AnalyticsEventHandler,
private val router: Router,
@ -355,7 +355,19 @@ internal class TangemPayDetailsModel @Inject constructor(
override fun onClickBankTransfer() {
val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return
val onramp = loaded.virtualAccount ?: return
when (val onramp = loaded.virtualAccount) {
null -> return
VirtualAccountOnramp.Processing -> showVaPreparing()
// BankCredentialsError opens the deposit intro first; the retryable error sheet is shown from
// its "Show details" action (see onShowDetailsClick).
is VirtualAccountOnramp.Available,
VirtualAccountOnramp.Eligible,
is VirtualAccountOnramp.BankCredentialsError,
-> openVirtualAccountDeposit(onramp, loaded)
}
}
private fun openVirtualAccountDeposit(onramp: VirtualAccountOnramp, loaded: PaymentAccountStatusValue.Loaded) {
analytics.send(TangemPayAnalyticsEvents.VaTopupButtonClicked())
bottomSheetNavigation.dismiss()
bottomSheetNavigation.activate(
@ -367,6 +379,30 @@ internal class TangemPayDetailsModel @Inject constructor(
)
}
fun showVaBankingDetailsError() {
bottomSheetNavigation.dismiss()
bottomSheetNavigation.activate(
TangemPayDetailsNavigation.VaBankingDetailsError(userWalletId = userWalletId),
)
}
private fun showVaPreparing() {
bottomSheetNavigation.dismiss()
uiMessageSender.send(message = TangemPayMessagesFactory.createVaPreparingMessage())
}
fun onVaBankingDetailsResolved(onramp: VirtualAccountOnramp) {
when (onramp) {
// Bank credentials just loaded on retry — show the requisites straight away ([REDACTED_TASK_KEY]),
// instead of the intro deposit sheet that would need another "Show details" tap.
is VirtualAccountOnramp.Available -> onShowVirtualAccountRequisites(onramp)
else -> {
val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return
openVirtualAccountDeposit(onramp, loaded)
}
}
}
fun onVirtualAccountOrderCreated() {
analytics.send(TangemPayAnalyticsEvents.VaSuccessScreenActivation())
bottomSheetNavigation.dismiss()

View file

@ -0,0 +1,63 @@
package com.tangem.features.tangempay.model
import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.features.tangempay.components.TangemPayVaBankingDetailsErrorComponent
import com.tangem.features.tangempay.entity.TangemPayVaBankingDetailsErrorUM
import com.tangem.features.tangempay.utils.ifLoadedOrNull
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ModelScoped
internal class TangemPayVaBankingDetailsErrorModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
) : Model() {
private val params = paramsContainer.require<TangemPayVaBankingDetailsErrorComponent.Params>()
val uiState: StateFlow<TangemPayVaBankingDetailsErrorUM>
field = MutableStateFlow(
TangemPayVaBankingDetailsErrorUM(
isRetryLoading = false,
onRetryClick = ::onRetryClick,
onContactSupportClick = params.onContactSupport,
onDismiss = ::onDismiss,
),
)
fun onDismiss() {
params.onDismiss()
}
private fun onRetryClick() {
if (uiState.value.isRetryLoading) return
uiState.update { it.copy(isRetryLoading = true) }
modelScope.launch {
paymentAccountStatusFetcher.invoke(params.userWalletId)
val onramp = paymentAccountStatusSupplier.invoke(params.userWalletId)
.first()
.ifLoadedOrNull { it.virtualAccount }
when (onramp) {
is VirtualAccountOnramp.Available,
VirtualAccountOnramp.Eligible,
-> params.onResolved(onramp)
// Still failing (BankCredentialsError) or unavailable — keep the sheet, clear the loader.
else -> uiState.update { it.copy(isRetryLoading = false) }
}
}
}
}

View file

@ -81,6 +81,9 @@ internal class TangemPayVirtualAccountDepositModel @Inject constructor(
analytics.send(TangemPayAnalyticsEvents.VaShowDetailsFirstTimeClicked())
createVirtualAccountOrder()
}
VirtualAccountOnramp.BankCredentialsError -> params.onShowBankingDetailsError()
// Processing never reaches this sheet (the Preparing message is shown instead); defensive.
VirtualAccountOnramp.Processing -> onDismiss()
}
}

View file

@ -0,0 +1,171 @@
package com.tangem.features.tangempay.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.ds2.button.Close
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_error_28
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayVaBankingDetailsErrorUM
@Composable
internal fun TangemPayVaBankingDetailsErrorBottomSheet(state: TangemPayVaBankingDetailsErrorUM) {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = state.onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
type = TangemBottomSheetType.Modal,
containerColor = TangemTheme.colors3.bg.secondary,
title = {
TangemTopBar(
type = TangemTopBarType.BottomSheet,
title = null,
endContent = { TangemButton.Close(onClick = state.onDismiss) },
)
},
content = { _ -> Content(state) },
)
}
@Composable
private fun Content(state: TangemPayVaBankingDetailsErrorUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = TangemTheme.dimens2.x4)
.padding(bottom = TangemTheme.dimens2.x4),
horizontalAlignment = Alignment.CenterHorizontally,
) {
WarningIcon(modifier = Modifier.padding(top = TangemTheme.dimens2.x4))
TitleText(
text = resourceReference(R.string.tangempay_va_banking_details_error_title),
modifier = Modifier.padding(top = TangemTheme.dimens2.x8),
)
SubtitleText(
text = resourceReference(R.string.tangempay_va_banking_details_error_description),
modifier = Modifier.padding(top = TangemTheme.dimens2.x2),
)
TangemButton(
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens2.x8),
text = resourceReference(R.string.common_contact_support),
variant = TangemButton.Variant.Secondary,
size = TangemButton.Size.X12,
isEnabled = !state.isRetryLoading,
onClick = state.onContactSupportClick,
)
TangemButton(
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens2.x2),
text = resourceReference(R.string.common_retry),
variant = TangemButton.Variant.Primary,
size = TangemButton.Size.X12,
isLoading = state.isRetryLoading,
isEnabled = !state.isRetryLoading,
onClick = state.onRetryClick,
)
}
}
@Composable
private fun WarningIcon(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(TangemTheme.dimens2.x20)
.clip(CircleShape)
.background(TangemTheme.colors3.bg.status.warningSubtle),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens2.x7),
imageVector = Icons.ic_error_28,
contentDescription = null,
tint = TangemTheme.colors3.icon.status.warning,
)
}
}
@Composable
private fun TitleText(text: TextReference, modifier: Modifier = Modifier) {
Text(
modifier = modifier.fillMaxWidth(),
text = text.resolveReference(),
style = TangemTheme.typography3.heading.small,
color = TangemTheme.colors3.text.primary,
textAlign = TextAlign.Center,
)
}
@Composable
private fun SubtitleText(text: TextReference, modifier: Modifier = Modifier) {
Text(
modifier = modifier.fillMaxWidth(),
text = text.resolveReference(),
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.secondary,
textAlign = TextAlign.Center,
)
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TangemPayVaBankingDetailsErrorPreview(
@PreviewParameter(VaBankingDetailsErrorPreviewProvider::class) state: TangemPayVaBankingDetailsErrorUM,
) {
TangemThemePreviewRedesign {
Content(
state = state,
modifier = Modifier.background(TangemTheme.colors3.bg.secondary),
)
}
}
private class VaBankingDetailsErrorPreviewProvider :
CollectionPreviewParameterProvider<TangemPayVaBankingDetailsErrorUM>(
collection = listOf(
TangemPayVaBankingDetailsErrorUM(
isRetryLoading = false,
onRetryClick = {},
onContactSupportClick = {},
onDismiss = {},
),
TangemPayVaBankingDetailsErrorUM(
isRetryLoading = true,
onRetryClick = {},
onContactSupportClick = {},
onDismiss = {},
),
),
)

View file

@ -5,8 +5,10 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -73,6 +75,7 @@ private fun DepositContent(state: TangemPayVirtualAccountDepositUM, modifier: Mo
Column(
modifier = modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = TangemTheme.dimens2.x4)
.padding(bottom = TangemTheme.dimens2.x4),
horizontalAlignment = Alignment.CenterHorizontally,
@ -255,10 +258,10 @@ private fun UsdcIcon(modifier: Modifier = Modifier) {
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens2.x4),
modifier = Modifier.size(TangemTheme.dimens2.x6),
painter = painterResource(CoreUiR.drawable.ic_polygon_22),
contentDescription = null,
tint = TangemTheme.colors3.icon.inverse,
tint = TangemTheme.colors3.icon.staticDark,
)
}
}

View file

@ -5,7 +5,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
@ -16,6 +15,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerHMax
import com.tangem.core.ui.components.haze.hazeForegroundEffectTangem
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
@ -25,6 +25,7 @@ import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_success_24
import com.tangem.features.tangempay.details.impl.R
import dev.chrisbanes.haze.HazeStyle
private const val DEFAULT_FADE_COLOR = 0xFF9FC824
private val BlurRadius = 192.dp
@ -46,7 +47,7 @@ internal fun TangemPaySuccessScreenWrapper(
Box(
modifier = Modifier
.matchParentSize()
.blur(BlurRadius)
.hazeForegroundEffectTangem(style = HazeStyle(blurRadius = BlurRadius, tint = null))
.drawBehind {
val w = size.width
drawRect(

View file

@ -169,6 +169,23 @@ internal object TangemPayMessagesFactory {
)
}
fun createVaPreparingMessage(): BottomSheetMessage {
return bottomSheetMessage {
infoBlock {
icon(R.drawable.ic_clock_24) {
type = MessageBottomSheetUM.Icon.Type.Informative
backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Informative
}
title = TextReference.Res(R.string.tangempay_bank_transfer_success_title)
body = TextReference.Res(R.string.tangempay_bank_transfer_success_subtitle)
}
secondaryButton {
text = resourceReference(R.string.common_got_it)
onClick { closeBs() }
}
}
}
fun createFutureFeature(onGotItClick: () -> Unit): BottomSheetMessage {
return bottomSheetMessage {
infoBlock {

View file

@ -1,6 +1,8 @@
package com.tangem.features.tangempay.utils
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.account.BankCredentials
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent.RequisitesRow
/**
@ -16,22 +18,32 @@ internal const val VA_DAILY_DEPOSIT_LIMIT_PLACEHOLDER = "$10,000"
*/
internal fun BankCredentials.toRequisitesRows(): List<RequisitesRow> = listOf(
RequisitesRow(
title = "Beneficiary name and address",
titleForShare = "Beneficiary name and address",
value = "$beneficiaryName\n$beneficiaryAddress",
title = resourceReference(R.string.virtual_account_requisites_beneficiary_name),
titleForShare = "Beneficiary name",
value = beneficiaryName,
),
RequisitesRow(
title = "Bank name and address",
titleForShare = "Bank name and address",
value = "$beneficiaryBankName\n$beneficiaryBankAddress",
title = resourceReference(R.string.virtual_account_requisites_beneficiary_address),
titleForShare = "Beneficiary address",
value = beneficiaryAddress,
),
RequisitesRow(
title = "Account number",
title = resourceReference(R.string.virtual_account_requisites_bank_name),
titleForShare = "Bank name",
value = beneficiaryBankName,
),
RequisitesRow(
title = resourceReference(R.string.virtual_account_requisites_bank_address),
titleForShare = "Bank address",
value = beneficiaryBankAddress,
),
RequisitesRow(
title = resourceReference(R.string.virtual_account_requisites_account_number),
titleForShare = "Account number",
value = accountNumber,
),
RequisitesRow(
title = "Routing number",
title = resourceReference(R.string.virtual_account_requisites_routing_number),
titleForShare = "Routing number",
value = routingNumber,
),

View file

@ -0,0 +1,132 @@
package com.tangem.features.tangempay.model
import arrow.core.Either
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.features.tangempay.components.TangemPayVaBankingDetailsErrorComponent
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.Called
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
internal class TangemPayVaBankingDetailsErrorModelTest {
private val userWalletId = UserWalletId("1234567890ABCDEF")
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk()
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk()
private val onDismiss: () -> Unit = mockk(relaxed = true)
private val onContactSupport: () -> Unit = mockk(relaxed = true)
private val onResolved: (VirtualAccountOnramp) -> Unit = mockk(relaxed = true)
@BeforeEach
fun resetMocks() {
clearMocks(paymentAccountStatusFetcher, paymentAccountStatusSupplier, onDismiss, onContactSupport, onResolved)
}
@Test
fun `GIVEN refetch resolves to available WHEN retry THEN onResolved called`() = runTest {
// Arrange
val onramp = VirtualAccountOnramp.Available(productInstanceId = "pi_1", bankCredentials = mockk())
coEvery { paymentAccountStatusFetcher.invoke(userWalletId) } returns Unit.right()
stubSupplier(onramp)
val model = createModel()
// Act
model.uiState.value.onRetryClick()
advanceUntilIdle()
// Assert
verify(exactly = 1) { onResolved(onramp) }
}
@Test
fun `GIVEN refetch still fails WHEN retry THEN onResolved not called and loading reset`() = runTest {
// Arrange
coEvery { paymentAccountStatusFetcher.invoke(userWalletId) } returns Unit.right()
stubSupplier(VirtualAccountOnramp.BankCredentialsError)
val model = createModel()
// Act
model.uiState.value.onRetryClick()
advanceUntilIdle()
// Assert
verify { onResolved wasNot Called }
assertThat(model.uiState.value.isRetryLoading).isFalse()
}
@Test
fun `GIVEN refetch in progress WHEN retry twice THEN fetch invoked once and loading shown`() = runTest {
// Arrange
val pending = CompletableDeferred<Either<Throwable, Unit>>()
coEvery { paymentAccountStatusFetcher.invoke(userWalletId) } coAnswers { pending.await() }
stubSupplier(VirtualAccountOnramp.BankCredentialsError)
val model = createModel()
// Act
model.uiState.value.onRetryClick() // starts loading, fetch suspends
advanceUntilIdle()
model.uiState.value.onRetryClick() // gated by isRetryLoading — must be ignored
advanceUntilIdle()
// Assert
assertThat(model.uiState.value.isRetryLoading).isTrue()
coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(userWalletId) }
pending.complete(Unit.right()) // let the in-flight call finish cleanly
advanceUntilIdle()
}
private fun stubSupplier(onramp: VirtualAccountOnramp) {
val loaded = mockk<PaymentAccountStatusValue.Loaded>()
every { loaded.virtualAccount } returns onramp
val status = mockk<AccountStatus.Payment>()
every { status.value } returns loaded
every { paymentAccountStatusSupplier.invoke(userWalletId) } returns flowOf(status)
}
private fun TestScope.createModel() = TangemPayVaBankingDetailsErrorModel(
paramsContainer = MutableParamsContainer(
TangemPayVaBankingDetailsErrorComponent.Params(
userWalletId = userWalletId,
onDismiss = onDismiss,
onContactSupport = onContactSupport,
onResolved = onResolved,
),
),
dispatchers = createTestingCoroutineDispatcherProvider(),
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
paymentAccountStatusSupplier = paymentAccountStatusSupplier,
)
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}

View file

@ -40,12 +40,20 @@ internal class TangemPayVirtualAccountDepositModelTest {
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
private val createVirtualAccountOrderUseCase: CreateVirtualAccountOrderUseCase = mockk()
private val onShowDetails: (VirtualAccountOnramp.Available) -> Unit = mockk(relaxed = true)
private val onShowBankingDetailsError: () -> Unit = mockk(relaxed = true)
private val onOrderCreated: () -> Unit = mockk(relaxed = true)
private val analytics: AnalyticsEventHandler = mockk(relaxed = true)
@BeforeEach
fun resetMocks() {
clearMocks(createVirtualAccountOrderUseCase, onShowDetails, onOrderCreated, uiMessageSender, analytics)
clearMocks(
createVirtualAccountOrderUseCase,
onShowDetails,
onShowBankingDetailsError,
onOrderCreated,
uiMessageSender,
analytics,
)
}
@Test
@ -65,6 +73,21 @@ internal class TangemPayVirtualAccountDepositModelTest {
verify(exactly = 1) { analytics.send(ofType<TangemPayAnalyticsEvents.VaShowDetailsClicked>()) }
}
@Test
fun `GIVEN bank credentials error WHEN show details THEN shows banking details error sheet`() = runTest {
// Arrange
val model = createModel(VirtualAccountOnramp.BankCredentialsError)
// Act
model.uiState.value.onShowDetailsClick()
advanceUntilIdle()
// Assert
verify(exactly = 1) { onShowBankingDetailsError() }
verify(exactly = 0) { onShowDetails(any()) }
coVerify(exactly = 0) { createVirtualAccountOrderUseCase(any(), any()) }
}
@Test
fun `GIVEN eligible and create succeeds WHEN show details THEN order created and loading reset`() = runTest {
// Arrange
@ -130,6 +153,7 @@ internal class TangemPayVirtualAccountDepositModelTest {
paymentAccountAddress = paymentAccountAddress,
onDismiss = {},
onShowDetails = onShowDetails,
onShowBankingDetailsError = onShowBankingDetailsError,
onOrderCreated = onOrderCreated,
),
),

View file

@ -376,59 +376,6 @@ internal data class TangemTokenIconStory(
enum class UiStateVariant { Token, Shimmer, Error }
}
internal data class TangemGlowRingStory(
val variant: TangemGlowRing.Variant,
val quality: TangemGlowRing.Quality,
val background: Background,
val isAnimated: Boolean,
val onVariantChange: (TangemGlowRing.Variant) -> Unit,
val onQualityChange: (TangemGlowRing.Quality) -> Unit,
val onBackgroundChange: (Background) -> Unit,
val onAnimatedToggle: () -> Unit,
) : DsStoryBookPage {
/** Backdrop the glow-ring preview is rendered on top of. */
enum class Background(val label: String) {
BgPrimary("bg.primary"),
BgSecondary("bg.secondary"),
BgInverse("bg.inverse"),
}
}
@Suppress("BooleanPropertyNaming")
internal data class TangemMessageBannerStory(
val variant: TangemMessageBanner.Variant,
val contentAlign: TangemMessageBanner.ContentAlign,
val hasGlowRing: Boolean,
val hasDescription: Boolean,
val hasSecondaryButton: Boolean,
val hasPrimaryButton: Boolean,
val hasCloseButton: Boolean,
val hasSlotStart: Boolean,
val hasSlotEnd: Boolean,
val hasExtraContent: Boolean,
val background: Background,
val onVariantChange: (TangemMessageBanner.Variant) -> Unit,
val onContentAlignChange: (TangemMessageBanner.ContentAlign) -> Unit,
val onGlowRingToggle: () -> Unit,
val onDescriptionToggle: () -> Unit,
val onSecondaryButtonToggle: () -> Unit,
val onPrimaryButtonToggle: () -> Unit,
val onCloseButtonToggle: () -> Unit,
val onSlotStartToggle: () -> Unit,
val onSlotEndToggle: () -> Unit,
val onExtraContentToggle: () -> Unit,
val onBackgroundChange: (Background) -> Unit,
) : DsStoryBookPage {
/** Backdrop the banner preview is rendered on top of. */
enum class Background(val label: String) {
BgPrimary("bg.primary"),
BgSecondary("bg.secondary"),
BgInverse("bg.inverse"),
}
}
internal data class TextStyleStory(
val style: Style,
val textScale: Float,
@ -505,6 +452,61 @@ internal data class TangemTokenRowMarketStory(
val onLongTitleToggle: () -> Unit,
) : DsStoryBookPage
internal data class TangemGlowRingStory(
val variant: TangemGlowRing.Variant,
val quality: TangemGlowRing.Quality,
val background: Background,
val isAnimated: Boolean,
val onVariantChange: (TangemGlowRing.Variant) -> Unit,
val onQualityChange: (TangemGlowRing.Quality) -> Unit,
val onBackgroundChange: (Background) -> Unit,
val onAnimatedToggle: () -> Unit,
) : DsStoryBookPage {
/** Backdrop the glow-ring preview is rendered on top of. */
enum class Background(val label: String) {
BgPrimary("bg.primary"),
BgSecondary("bg.secondary"),
BgInverse("bg.inverse"),
}
}
@Suppress("BooleanPropertyNaming")
internal data class TangemMessageBannerStory(
val variant: TangemMessageBanner.Variant,
val contentAlign: TangemMessageBanner.ContentAlign,
val hasGlowRing: Boolean,
val hasDescription: Boolean,
val hasSecondaryButton: Boolean,
val hasPrimaryButton: Boolean,
val hasCloseButton: Boolean,
val hasSlotStart: Boolean,
val hasSlotEnd: Boolean,
val hasExtraContent: Boolean,
val isClickable: Boolean,
val background: Background,
val onVariantChange: (TangemMessageBanner.Variant) -> Unit,
val onContentAlignChange: (TangemMessageBanner.ContentAlign) -> Unit,
val onGlowRingToggle: () -> Unit,
val onDescriptionToggle: () -> Unit,
val onSecondaryButtonToggle: () -> Unit,
val onPrimaryButtonToggle: () -> Unit,
val onCloseButtonToggle: () -> Unit,
val onSlotStartToggle: () -> Unit,
val onSlotEndToggle: () -> Unit,
val onExtraContentToggle: () -> Unit,
val onClickableToggle: () -> Unit,
val onBackgroundChange: (Background) -> Unit,
) : DsStoryBookPage {
/** Backdrop the banner preview is rendered on top of. */
enum class Background(val label: String) {
BgPrimary("bg.primary"),
BgSecondary("bg.secondary"),
BgInverse("bg.inverse"),
}
}
internal data class TangemBadgeV2Story(
val variant: TangemBadge.Variant,
val status: TangemBadge.Status,

View file

@ -18,6 +18,7 @@ internal fun StateUpdater<TangemMessageBannerStory>.build(): TangemMessageBanner
hasSlotStart = true,
hasSlotEnd = true,
hasExtraContent = true,
isClickable = false,
background = Background.BgSecondary,
onVariantChange = { variant -> updateStory { it.copy(variant = variant) } },
onContentAlignChange = { align -> updateStory { it.copy(contentAlign = align) } },
@ -29,6 +30,7 @@ internal fun StateUpdater<TangemMessageBannerStory>.build(): TangemMessageBanner
onSlotStartToggle = { updateStory { it.copy(hasSlotStart = !it.hasSlotStart) } },
onSlotEndToggle = { updateStory { it.copy(hasSlotEnd = !it.hasSlotEnd) } },
onExtraContentToggle = { updateStory { it.copy(hasExtraContent = !it.hasExtraContent) } },
onClickableToggle = { updateStory { it.copy(isClickable = !it.isClickable) } },
onBackgroundChange = { background -> updateStory { it.copy(background = background) } },
)
}

View file

@ -95,6 +95,11 @@ private fun PreviewBanner(state: TangemMessageBannerStory) {
variant = state.variant,
contentAlign = state.contentAlign,
showGlowRing = state.hasGlowRing,
onClick = if (state.isClickable) {
{}
} else {
null
},
title = stringReference("Would you predict?"),
description = if (state.hasDescription) {
stringReference("France will win FIFA 2026")
@ -233,6 +238,11 @@ private fun Toggles(state: TangemMessageBannerStory) {
ToggleRow(label = "slotStart", checked = state.hasSlotStart, onToggle = state.onSlotStartToggle)
ToggleRow(label = "slotEnd", checked = state.hasSlotEnd, onToggle = state.onSlotEndToggle)
ToggleRow(label = "extraContent", checked = state.hasExtraContent, onToggle = state.onExtraContentToggle)
ToggleRow(
label = "clickable (no buttons only)",
checked = state.isClickable,
onToggle = state.onClickableToggle,
)
}
}
}

View file

@ -15,4 +15,7 @@ dependencies {
/** Domain */
api(projects.domain.models)
/** Compose */
implementation(deps.compose.runtime)
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.virtualaccount.details.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.wallet.UserWalletId
/**
@ -25,7 +26,7 @@ interface VirtualAccountAddFundsBottomSheetComponent : ComposableBottomSheetComp
)
data class RequisitesRow(
val title: String,
val title: TextReference,
val titleForShare: String,
val value: String,
)

View file

@ -57,22 +57,17 @@ internal class VirtualAccountMainModel @Inject constructor(
private fun buildRequisites(details: VirtualAccountDepositDetails) = listOf(
VirtualAccountAddFundsBottomSheetComponent.RequisitesRow(
title = "Beneficiary name and address",
titleForShare = "Beneficiary name and address",
value = "${details.beneficiaryName}\n${details.beneficiaryAddress}",
title = resourceReference(R.string.virtual_account_requisites_beneficiary_name),
titleForShare = "Beneficiary name",
value = details.beneficiaryName,
),
VirtualAccountAddFundsBottomSheetComponent.RequisitesRow(
title = "Bank name and address",
titleForShare = "Bank name and address",
value = "${details.bankName}\n${details.bankAddress}",
),
VirtualAccountAddFundsBottomSheetComponent.RequisitesRow(
title = "Account number",
title = resourceReference(R.string.virtual_account_requisites_account_number),
titleForShare = "Account number",
value = details.accountNumber,
),
VirtualAccountAddFundsBottomSheetComponent.RequisitesRow(
title = "Routing number",
title = resourceReference(R.string.virtual_account_requisites_routing_number),
titleForShare = "Routing number",
value = details.routingNumber,
),

View file

@ -5,8 +5,10 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -110,6 +112,7 @@ private fun DetailsContent(content: VirtualAccountAddFundsUM.Content.Details, mo
Column(
modifier = modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = TangemTheme.dimens2.x4),
) {
content.items.forEachIndexed { index, item ->

View file

@ -7,7 +7,6 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
@ -55,25 +54,31 @@ internal class VirtualAccountAddFundsModel @Inject constructor(
uiState.update { state -> state.copy(content = buildDetailsContent()) }
}
private fun buildDetailsContent() = VirtualAccountAddFundsUM.Content.Details(
items = params.requisites
.map { detailItem(label = it.title, value = it.value) }
.toImmutableList(),
dailyLimit = params.dailyDepositLimit,
onShareClick = {
params.onShareClicked()
shareManager.shareText(buildShareText())
},
)
private fun buildDetailsContent(): VirtualAccountAddFundsUM.Content.Details {
return VirtualAccountAddFundsUM.Content.Details(
items = params.requisites
.map(::detailItem)
.toImmutableList(),
dailyLimit = params.dailyDepositLimit,
onShareClick = {
params.onShareClicked()
shareManager.shareText(buildShareText())
},
)
}
private fun detailItem(label: String, value: String) = VirtualAccountAddFundsUM.DetailItem(
label = stringReference(label),
value = value,
onCopyClick = {
params.onFieldCopied(label)
clipboardManager.setText(text = value, isSensitive = true)
},
)
private fun detailItem(
requisitesRow: VirtualAccountAddFundsBottomSheetComponent.RequisitesRow,
): VirtualAccountAddFundsUM.DetailItem {
return VirtualAccountAddFundsUM.DetailItem(
label = requisitesRow.title,
value = requisitesRow.value,
onCopyClick = {
params.onFieldCopied(requisitesRow.titleForShare)
clipboardManager.setText(text = requisitesRow.value, isSensitive = true)
},
)
}
private fun buildShareText(): String {
return buildString {

View file

@ -61,6 +61,7 @@ dependencies {
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.marketing.models)
implementation(projects.domain.onramp.models)
implementation(projects.libs.crypto)
/** Compose */