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.UserWalletsListRepository.LockMethod
import com.tangem.domain.common.wallets.error.* import com.tangem.domain.common.wallets.error.*
import com.tangem.domain.hotwallet.repository.HotWalletRepository 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.models.wallet.*
import com.tangem.domain.wallets.R import com.tangem.domain.wallets.R
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
@ -126,6 +128,8 @@ internal class DefaultUserWalletsListRepository(
canOverride: Boolean, canOverride: Boolean,
): Either<SaveWalletError, UserWallet> = either { ): Either<SaveWalletError, UserWallet> = either {
if (canOverride.not() && userWallets.value?.any { it.walletId == userWallet.walletId } == true) { 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)) raise(SaveWalletError.WalletAlreadySaved(messageId = R.string.user_wallet_list_error_wallet_already_saved))
} }
@ -321,6 +325,8 @@ internal class DefaultUserWalletsListRepository(
raise(UnlockWalletError.ScannedCardWalletNotMatched) raise(UnlockWalletError.ScannedCardWalletNotMatched)
} }
refreshStoredCardState(scanResponse)
val encryptionKey = UserWalletEncryptionKey( val encryptionKey = UserWalletEncryptionKey(
walletId = userWallet.walletId, walletId = userWallet.walletId,
encryptionKey = scanResponse.encryptionKey ?: raise(UnlockWalletError.UnableToUnlock.Empty), 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( private suspend fun checkForUpgradeAndDeleteHotWalletIfNeeded(
newUserWallet: UserWallet, newUserWallet: UserWallet,
oldUserWallet: 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.google.common.truth.Truth.assertThat
import com.tangem.common.CompletionResult import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemError 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.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase 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.UserWalletSelectedHandler
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.hotwallet.repository.HotWalletRepository 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.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
@ -144,4 +149,162 @@ internal class DefaultUserWalletsListRepositoryTest {
assertThat(result.isLeft()).isTrue() assertThat(result.isLeft()).isTrue()
verify(exactly = 0) { trackingContextProxy.eraseContext() } 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", "name": "AND_16204_POLYMARKET_ENABLED",
"version": "undefined" "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() .fillMaxWidth()
.align(Alignment.BottomCenter), .align(Alignment.BottomCenter),
) { ) {
if (gradientHeight > 0.dp) { val isGradientDisplayed = gradientHeight > 0.dp
if (isGradientDisplayed) {
Fade( Fade(
backgroundColor = fadeMax, backgroundColor = fadeMax,
height = gradientHeight, height = gradientHeight,
@ -333,7 +335,7 @@ fun BoxScope.FooterOverlay(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(measuredFooterHeight ?: 0.dp) .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.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.RippleAlpha
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalRippleConfiguration
import androidx.compose.material3.RippleConfiguration
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Alignment 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.ds2.surface.TangemSurface
import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.clickableSingle 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.resolveAnnotatedReference
import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme 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 * 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. * 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) * [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 variant Visual appearance background color + glow ring.
* @param showGlowRing Whether the glow ring is drawn around the banner. `false` shows only the * @param showGlowRing Whether the glow ring is drawn around the banner. `false` shows only the
* background. * 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 secondaryButton Start action. `null` hides it.
* @param primaryButton End action. `null` hides it. * @param primaryButton End action. `null` hides it.
* @param content The banner body above the buttons. * @param content The banner body above the buttons.
@ -54,26 +63,33 @@ fun TangemMessageBanner(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
variant: TangemMessageBanner.Variant = TangemMessageBanner.Variant.Default, variant: TangemMessageBanner.Variant = TangemMessageBanner.Variant.Default,
showGlowRing: Boolean = true, showGlowRing: Boolean = true,
onClick: (() -> Unit)? = null,
secondaryButton: TangemMessageBanner.Button? = null, secondaryButton: TangemMessageBanner.Button? = null,
primaryButton: TangemMessageBanner.Button? = null, primaryButton: TangemMessageBanner.Button? = null,
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
val tokens = variant.tokens() val tokens = variant.tokens()
val isClickable = onClick != null && secondaryButton == null && primaryButton == null
Box(modifier = modifier) { Box(modifier = modifier) {
TangemSurface( WithMessageBannerRipple(enabled = isClickable) {
modifier = Modifier.fillMaxWidth(), TangemSurface(
color = tokens.background, modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(28.dp), color = tokens.background,
) { shape = RoundedCornerShape(28.dp),
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) { ) {
content() Column(
MessageBannerButtons(secondaryButton = secondaryButton, primaryButton = primaryButton) 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) { if (showGlowRing) {
@ -90,6 +106,8 @@ fun TangemMessageBanner(
* Design-system v2 (DS3) **Message Banner** title/description header with optional slots and an * Design-system v2 (DS3) **Message Banner** title/description header with optional slots and an
* action-button row. * 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) * [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5475-7680&m=dev)
* *
* @param title Banner headline. * @param title Banner headline.
@ -97,6 +115,8 @@ fun TangemMessageBanner(
* @param contentAlign Horizontal alignment of the text block. * @param contentAlign Horizontal alignment of the text block.
* @param showGlowRing Whether the glow ring is drawn around the banner. `false` shows only the * @param showGlowRing Whether the glow ring is drawn around the banner. `false` shows only the
* background. * 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 description Secondary line under the [title]. `null` hides it.
* @param secondaryButton Start action. `null` hides it. * @param secondaryButton Start action. `null` hides it.
* @param primaryButton End action. `null` hides it. * @param primaryButton End action. `null` hides it.
@ -112,6 +132,7 @@ fun TangemMessageBanner(
variant: TangemMessageBanner.Variant = TangemMessageBanner.Variant.Default, variant: TangemMessageBanner.Variant = TangemMessageBanner.Variant.Default,
contentAlign: TangemMessageBanner.ContentAlign = TangemMessageBanner.ContentAlign.Start, contentAlign: TangemMessageBanner.ContentAlign = TangemMessageBanner.ContentAlign.Start,
showGlowRing: Boolean = true, showGlowRing: Boolean = true,
onClick: (() -> Unit)? = null,
description: TextReference? = null, description: TextReference? = null,
secondaryButton: TangemMessageBanner.Button? = null, secondaryButton: TangemMessageBanner.Button? = null,
primaryButton: TangemMessageBanner.Button? = null, primaryButton: TangemMessageBanner.Button? = null,
@ -123,6 +144,7 @@ fun TangemMessageBanner(
modifier = modifier, modifier = modifier,
variant = variant, variant = variant,
showGlowRing = showGlowRing, showGlowRing = showGlowRing,
onClick = onClick,
secondaryButton = secondaryButton, secondaryButton = secondaryButton,
primaryButton = primaryButton, primaryButton = primaryButton,
) { ) {
@ -252,7 +274,7 @@ private fun MessageBannerTextWrapper(
) )
} }
extraBottomSlot?.let { slot -> 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]. */ /** Resolved appearance tokens for a [TangemMessageBanner.Variant]. */
private data class MessageBannerTokens(val background: Color, val glowRing: TangemGlowRing.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 = {}), 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 formattedAmount = formatter.format(value)
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator 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( combinedReference(
stringReference(formattedAmount.take(separatorIndex)), stringReference(formattedAmount.take(separatorIndex)),
@ -167,8 +168,9 @@ fun BigDecimalCryptoFormatStyled.defaultAmount(spanStyleReference: SpanStyleRefe
cryptoCurrencySymbol = symbol, cryptoCurrencySymbol = symbol,
) )
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.monetaryDecimalSeparator
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it) } ?: formattedAmount.length val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it).takeIf { i -> i >= 0 } }
?: formattedAmount.length
combinedReference( combinedReference(
stringReference(formattedAmount.take(separatorIndex)), stringReference(formattedAmount.take(separatorIndex)),

View file

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

View file

@ -1,8 +1,13 @@
package com.tangem.core.ui.format.bigdecimal package com.tangem.core.ui.format.bigdecimal
import androidx.compose.ui.text.SpanStyle
import com.google.common.truth.Truth 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.api.Test
import java.math.BigDecimal import java.math.BigDecimal
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Locale import java.util.Locale
internal class BigDecimalCryptoFormatTest { internal class BigDecimalCryptoFormatTest {
@ -10,6 +15,7 @@ internal class BigDecimalCryptoFormatTest {
private val testLocale = Locale.US private val testLocale = Locale.US
private val testLocale2 = Locale.GERMANY private val testLocale2 = Locale.GERMANY
private val symbol = "BTC" private val symbol = "BTC"
private val spanStyleStub = SpanStyleReference { SpanStyle() }
// === defaultAmount() === // === defaultAmount() ===
@ -125,6 +131,39 @@ internal class BigDecimalCryptoFormatTest {
.isEqualTo("12,345,678.11".addSymbolWithSpaceLeft(symbol)) .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() === // === shorted() ===
@Test @Test

View file

@ -1,11 +1,16 @@
package com.tangem.core.ui.format.bigdecimal package com.tangem.core.ui.format.bigdecimal
import androidx.compose.ui.text.SpanStyle
import com.google.common.truth.Truth 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.api.Test
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource import org.junit.jupiter.params.provider.MethodSource
import java.math.BigDecimal import java.math.BigDecimal
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Locale import java.util.Locale
internal class BigDecimalFiatFormatTest { internal class BigDecimalFiatFormatTest {
@ -16,6 +21,8 @@ internal class BigDecimalFiatFormatTest {
val usdCurrencyCode = "USD" val usdCurrencyCode = "USD"
val usdSymbol = "$" val usdSymbol = "$"
private val spanStyleStub = SpanStyleReference { SpanStyle() }
private fun String.addUsdSymbolLeft() = usdSymbol + this private fun String.addUsdSymbolLeft() = usdSymbol + this
// === defaultAmount() === // === defaultAmount() ===
@ -132,6 +139,40 @@ internal class BigDecimalFiatFormatTest {
.isEqualTo("-" + "0.01".addUsdSymbolLeft()) .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() === // === approximateAmount() ===
@Test @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. 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 ## Updating tokens
> **Note:** You only need `git submodule update --remote` when you want to pull **new** design 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 > 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, > 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. > 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. > 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: 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( fun provideCreateVirtualAccountOrderUseCase(
onboardingRepository: OnboardingRepository, onboardingRepository: OnboardingRepository,
pollingUseCase: StartTangemPayOrderPollingUseCase, pollingUseCase: StartTangemPayOrderPollingUseCase,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
appCoroutineScope: AppCoroutineScope,
): CreateVirtualAccountOrderUseCase { ): CreateVirtualAccountOrderUseCase {
return CreateVirtualAccountOrderUseCase( return CreateVirtualAccountOrderUseCase(
onboardingRepository = onboardingRepository, onboardingRepository = onboardingRepository,
pollingUseCase = pollingUseCase, 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()}") logger.i("invoke() end ${params.userWalletId}: isRight=${result.isRight()}")
} }
override suspend fun markVirtualAccountProcessing(userWalletId: UserWalletId) {
paymentAccountStatusesStore.markVirtualAccountProcessing(userWalletId)
}
private suspend fun proceedHasTangemPayResult( private suspend fun proceedHasTangemPayResult(
account: Account.Payment, account: Account.Payment,
hasTangemPay: Boolean, 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. * 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 * Resolution order:
* the `VISA_VIRTUAL_ACCOUNT` eligibility channel (fetched fresh via the user token), else `null`. * 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? { private suspend fun CustomerInfo.resolveVirtualAccountOnramp(userWalletId: UserWalletId): VirtualAccountOnramp? {
if (!virtualAccountFeatureToggles.isVaMvp0Enabled) return null if (!virtualAccountFeatureToggles.isVaMvp0Enabled) return null
@ -471,10 +482,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
it.specificationDataType == SpecificationDataType.ACCOUNT it.specificationDataType == SpecificationDataType.ACCOUNT
} }
if (accountInstance != null) { 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( return onboardingRepository.getBankCredentials(userWalletId, accountInstance.id).fold(
ifLeft = { error -> ifLeft = { error ->
logger.e("getBankCredentials failed for ${accountInstance.id}: $error") logger.e("getBankCredentials failed for ${accountInstance.id}: $error")
null VirtualAccountOnramp.BankCredentialsError
}, },
ifRight = { credentials -> ifRight = { credentials ->
VirtualAccountOnramp.Available( 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( return onboardingRepository.fetchCustomerEligibility(userWalletId).fold(
ifLeft = { error -> ifLeft = { error ->
logger.e("fetchCustomerEligibility failed for $userWalletId: $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 { private fun getUserWallet(userWalletId: UserWalletId): UserWallet {
return userWalletsListRepository.userWallets.value?.firstOrNull { it.walletId == userWalletId } return userWalletsListRepository.userWallets.value?.firstOrNull { it.walletId == userWalletId }
?: error("no userWallet found") ?: 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.Account
import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue 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.models.wallet.UserWalletId
import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.runSuspendCatching 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) { suspend fun store(userWalletId: UserWalletId, status: AccountStatus.Payment) {
logger.i("store($userWalletId): valueType=${status.value::class.simpleName}") logger.i("store($userWalletId): valueType=${status.value::class.simpleName}")
coroutineScope { coroutineScope {

View file

@ -113,6 +113,14 @@ internal class MockAwareOnboardingRepository @Inject constructor(
real.storeVirtualAccountOrderId(userWalletId, vaOrderId) 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 — // 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 // 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; // 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.Either
import arrow.core.left import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat 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.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.AccountStatus
import com.tangem.domain.models.account.BankCredentials import com.tangem.domain.models.account.BankCredentials
import com.tangem.domain.models.account.PaymentAccountStatusValue 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.TangemPayEligibilityManager
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.CustomerInfo 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.repository.*
import com.tangem.domain.pay.usecase.GetTangemPayTariffPlanStateUseCase import com.tangem.domain.pay.usecase.GetTangemPayTariffPlanStateUseCase
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier 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.tangempay.TangemPayFeatureToggles
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.security.DeviceSecurityInfoProvider 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 com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.* import io.mockk.*
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -226,7 +237,39 @@ internal class DefaultPaymentAccountStatusFetcherTest {
.map { it.value } .map { it.value }
.filterIsInstance<PaymentAccountStatusValue.Loaded>() .filterIsInstance<PaymentAccountStatusValue.Loaded>()
.lastOrNull() .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 @Nested
@ -258,6 +301,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
) )
stubHappyPath(customerInfo) stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.clearVirtualAccountOrderId(userWalletId) } just Runs
coEvery { coEvery {
onboardingRepository.getBankCredentials(userWalletId, "pi_account") onboardingRepository.getBankCredentials(userWalletId, "pi_account")
} returns Either.Right(bankCredentialsFixture) } returns Either.Right(bankCredentialsFixture)
@ -274,10 +318,11 @@ internal class DefaultPaymentAccountStatusFetcherTest {
bankCredentials = bankCredentialsFixture, bankCredentials = bankCredentialsFixture,
), ),
) )
coVerify(exactly = 1) { onboardingRepository.clearVirtualAccountOrderId(userWalletId) }
} }
@Test @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 { runTest {
// Arrange // Arrange
val customerInfo = buildCustomerInfo( val customerInfo = buildCustomerInfo(
@ -285,6 +330,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
) )
stubHappyPath(customerInfo) stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.clearVirtualAccountOrderId(userWalletId) } just Runs
coEvery { coEvery {
onboardingRepository.getBankCredentials(userWalletId, "pi_account") onboardingRepository.getBankCredentials(userWalletId, "pi_account")
} returns VisaApiError.UnknownWithoutCode.left() } returns VisaApiError.UnknownWithoutCode.left()
@ -295,7 +341,8 @@ internal class DefaultPaymentAccountStatusFetcherTest {
// Assert // Assert
val loaded = storedStatuses.lastLoaded() val loaded = storedStatuses.lastLoaded()
assertThat(loaded.virtualAccount).isNull() assertThat(loaded.virtualAccount).isEqualTo(VirtualAccountOnramp.BankCredentialsError)
coVerify(exactly = 1) { onboardingRepository.clearVirtualAccountOrderId(userWalletId) }
} }
@Test @Test
@ -305,6 +352,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance)) val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
stubHappyPath(customerInfo) stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns null
coEvery { coEvery {
onboardingRepository.fetchCustomerEligibility(userWalletId) onboardingRepository.fetchCustomerEligibility(userWalletId)
} returns Either.Right(listOf(TangemPayEligibilityType.VISA_VIRTUAL_ACCOUNT)) } returns Either.Right(listOf(TangemPayEligibilityType.VISA_VIRTUAL_ACCOUNT))
@ -325,6 +373,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance)) val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
stubHappyPath(customerInfo) stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns null
coEvery { coEvery {
onboardingRepository.fetchCustomerEligibility(userWalletId) onboardingRepository.fetchCustomerEligibility(userWalletId)
} returns VisaApiError.UnknownWithoutCode.left() } returns VisaApiError.UnknownWithoutCode.left()
@ -337,6 +386,168 @@ internal class DefaultPaymentAccountStatusFetcherTest {
val loaded = storedStatuses.lastLoaded() val loaded = storedStatuses.lastLoaded()
assertThat(loaded.virtualAccount).isNull() 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 @Nested

View file

@ -21,4 +21,21 @@ sealed interface VirtualAccountOnramp {
val productInstanceId: String, val productInstanceId: String,
val bankCredentials: BankCredentials, val bankCredentials: BankCredentials,
) : VirtualAccountOnramp ) : 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 arrow.core.Either
import com.tangem.domain.core.flow.FlowFetcher 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 import com.tangem.domain.models.wallet.UserWalletId
interface PaymentAccountStatusFetcher : FlowFetcher<PaymentAccountStatusFetcher.Params> { interface PaymentAccountStatusFetcher : FlowFetcher<PaymentAccountStatusFetcher.Params> {
@ -10,5 +12,12 @@ interface PaymentAccountStatusFetcher : FlowFetcher<PaymentAccountStatusFetcher.
return invoke(Params(userWalletId)) 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) data class Params(val userWalletId: UserWalletId)
} }

View file

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

View file

@ -3,10 +3,13 @@ package com.tangem.domain.pay.usecase
import arrow.core.Either import arrow.core.Either
import arrow.core.raise.either import arrow.core.raise.either
import com.tangem.domain.models.wallet.UserWalletId 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.OrderStatus
import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.coroutines.AppCoroutineScope
import kotlinx.coroutines.launch
import java.util.UUID import java.util.UUID
/** /**
@ -20,6 +23,8 @@ import java.util.UUID
class CreateVirtualAccountOrderUseCase( class CreateVirtualAccountOrderUseCase(
private val onboardingRepository: OnboardingRepository, private val onboardingRepository: OnboardingRepository,
private val pollingUseCase: StartTangemPayOrderPollingUseCase, private val pollingUseCase: StartTangemPayOrderPollingUseCase,
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
private val appCoroutineScope: AppCoroutineScope,
) { ) {
suspend operator fun invoke( suspend operator fun invoke(
userWalletId: UserWalletId, userWalletId: UserWalletId,
@ -33,10 +38,15 @@ class CreateVirtualAccountOrderUseCase(
idempotencyKey = UUID.randomUUID().toString(), idempotencyKey = UUID.randomUUID().toString(),
).bind() ).bind()
onboardingRepository.storeVirtualAccountOrderId(userWalletId = userWalletId, vaOrderId = vaOrderId) onboardingRepository.storeVirtualAccountOrderId(userWalletId = userWalletId, vaOrderId = vaOrderId)
pollingUseCase.invoke( // Optimistically flip the cached on-ramp to Processing so the UI shows "Preparing" immediately
order = TangemPayOrderInfo(orderId = vaOrderId, orderStatus = OrderStatus.NEW), // (no wait for the poll/refetch to confirm).
userWalletId = userWalletId, 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 arrow.core.right
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.wallet.UserWalletId 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.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.error.VisaApiError
import com.tangem.test.core.TestAppCoroutineScope
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.mockk import io.mockk.mockk
@ -16,7 +18,14 @@ internal class CreateVirtualAccountOrderUseCaseTest {
private val onboardingRepository: OnboardingRepository = mockk(relaxUnitFun = true) private val onboardingRepository: OnboardingRepository = mockk(relaxUnitFun = true)
private val pollingUseCase: StartTangemPayOrderPollingUseCase = mockk(relaxed = 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 userWalletId = UserWalletId("1234567890ABCDEF")
private val paymentAccountAddress = "0xcollateral" private val paymentAccountAddress = "0xcollateral"
@ -31,6 +40,7 @@ internal class CreateVirtualAccountOrderUseCaseTest {
coVerify(exactly = 0) { onboardingRepository.createVirtualAccountOrder(any(), any(), any()) } coVerify(exactly = 0) { onboardingRepository.createVirtualAccountOrder(any(), any(), any()) }
coVerify(exactly = 0) { onboardingRepository.storeVirtualAccountOrderId(any(), any()) } coVerify(exactly = 0) { onboardingRepository.storeVirtualAccountOrderId(any(), any()) }
coVerify(exactly = 0) { pollingUseCase.invoke(any(), any()) } coVerify(exactly = 0) { pollingUseCase.invoke(any(), any()) }
coVerify(exactly = 0) { paymentAccountStatusFetcher.markVirtualAccountProcessing(any()) }
} }
@Test @Test
@ -45,6 +55,7 @@ internal class CreateVirtualAccountOrderUseCaseTest {
assertThat(result.isRight()).isTrue() assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { onboardingRepository.storeVirtualAccountOrderId(userWalletId, "new-id") } coVerify(exactly = 1) { onboardingRepository.storeVirtualAccountOrderId(userWalletId, "new-id") }
coVerify(exactly = 1) { pollingUseCase.invoke(any(), userWalletId) } coVerify(exactly = 1) { pollingUseCase.invoke(any(), userWalletId) }
coVerify(exactly = 1) { paymentAccountStatusFetcher.markVirtualAccountProcessing(userWalletId) }
} }
@Test @Test
@ -59,5 +70,6 @@ internal class CreateVirtualAccountOrderUseCaseTest {
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified) assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
coVerify(exactly = 0) { onboardingRepository.storeVirtualAccountOrderId(any(), any()) } coVerify(exactly = 0) { onboardingRepository.storeVirtualAccountOrderId(any(), any()) }
coVerify(exactly = 0) { pollingUseCase.invoke(any(), any()) } coVerify(exactly = 0) { pollingUseCase.invoke(any(), any()) }
coVerify(exactly = 0) { paymentAccountStatusFetcher.markVirtualAccountProcessing(any()) }
} }
} }

View file

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

View file

@ -1,5 +1,6 @@
package com.tangem.features.commonfeatures.impl.choosetoken.converter 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.AccountCryptoPortfolioItemStateConverter
import com.tangem.common.ui.account.TokensListPortfolioItemConverter import com.tangem.common.ui.account.TokensListPortfolioItemConverter
import com.tangem.common.ui.account.toUM 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.account.PaymentAccountStatusValue
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.tokenlist.TokenList 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
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData
@ -101,8 +103,9 @@ internal class ChooseTokenListItemConverter(
private fun AccountStatus.CryptoPortfolio.toPortfolioItem( private fun AccountStatus.CryptoPortfolio.toPortfolioItem(
params: TokenConverterParams.Account, params: TokenConverterParams.Account,
): TokensListItemUM.Portfolio { ): TokensListItemUM.Portfolio {
val tokenList: TokenList = this.tokenList val displayedStatus = filterForDisplay()
val account: Account.CryptoPortfolio = this.account val account: Account.CryptoPortfolio = displayedStatus.account
val displayedTokenList: TokenList = displayedStatus.tokenList
val isExpanded = isSearchingState || params.expandedAccounts.contains(account.accountId) val isExpanded = isSearchingState || params.expandedAccounts.contains(account.accountId)
val onItemClick: (Account.CryptoPortfolio) -> Unit = { clickedAccount -> val onItemClick: (Account.CryptoPortfolio) -> Unit = { clickedAccount ->
onAccountItemClick(clickedAccount, isExpanded) onAccountItemClick(clickedAccount, isExpanded)
@ -116,10 +119,9 @@ internal class ChooseTokenListItemConverter(
fiatAmountStateProvider = { fiatBalance -> fiatAmountStateProvider(fiatBalance, isExpanded) }, fiatAmountStateProvider = { fiatBalance -> fiatAmountStateProvider(fiatBalance, isExpanded) },
subtitle2StateProvider = { _ -> null }, subtitle2StateProvider = { _ -> null },
) )
val accountItem = converter.convert(tokenList.totalFiatBalance) val accountItem = converter.convert(displayedTokenList.totalFiatBalance)
val tokenConverter = tokenStatusConverter(this) val items = displayedTokenList.toUmData(tokenStatusConverter(this)).tokensList
val tokensListState = convertTokenList(tokenConverter, tokenList, this)
val items = tokensListState.tokensList
return TokensListPortfolioItemConverter( return TokensListPortfolioItemConverter(
tokenItemUM = accountItem, tokenItemUM = accountItem,
isExpanded = isExpanded, isExpanded = isExpanded,
@ -128,29 +130,47 @@ internal class ChooseTokenListItemConverter(
).convert(Unit) ).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( private fun convertTokenList(
tokenConverter: TokenItemStateConverter, tokenConverter: TokenItemStateConverter,
tokenListParam: TokenList, tokenListParam: TokenList,
account: AccountStatus.CryptoPortfolio, account: AccountStatus.CryptoPortfolio,
): TokenListUMData { ): TokenListUMData = filterTokenList(tokenListParam, account).toUmData(tokenConverter)
return when (val tokenList = filterTokenList(tokenListParam, account)) {
is TokenList.Empty -> TokenListUMData.EmptyList private fun TokenList.toUmData(tokenConverter: TokenItemStateConverter): TokenListUMData = when (this) {
is TokenList.GroupedByNetwork -> TokenListUMData.TokenList( TokenList.Empty -> TokenListUMData.EmptyList
tokensList = tokenList.toGroupedItems(tokenConverter).toPersistentList(), is TokenList.GroupedByNetwork -> TokenListUMData.TokenList(
totalTokensCount = tokenList.flattenCurrencies().size, tokensList = toGroupedItems(tokenConverter).toPersistentList(),
) totalTokensCount = flattenCurrencies().size,
is TokenList.Ungrouped -> TokenListUMData.TokenList( )
tokensList = tokenList.toUngroupedItems(tokenConverter).toPersistentList(), is TokenList.Ungrouped -> TokenListUMData.TokenList(
totalTokensCount = tokenList.flattenCurrencies().size, tokensList = toUngroupedItems(tokenConverter).toPersistentList(),
) totalTokensCount = flattenCurrencies().size,
} )
} }
private fun List<CryptoCurrencyStatus>.filterCurrencies(account: AccountStatus): List<CryptoCurrencyStatus> = private fun List<CryptoCurrencyStatus>.filterCurrencies(account: AccountStatus): List<CryptoCurrencyStatus> =
filter { currency -> currency.filterByQuery() && tokenFilter(account, currency) } filter { currency -> currency.filterByQuery() && tokenFilter(account, currency) }
private fun filterTokenList(tokenList: TokenList, account: AccountStatus.CryptoPortfolio): TokenList { private fun filterTokenList(tokenList: TokenList, account: AccountStatus.CryptoPortfolio): TokenList {
return when (tokenList) { val filtered = when (tokenList) {
TokenList.Empty -> TokenList.Empty TokenList.Empty -> TokenList.Empty
is TokenList.Ungrouped -> { is TokenList.Ungrouped -> {
val filtered = tokenList.currencies.filterCurrencies(account) val filtered = tokenList.currencies.filterCurrencies(account)
@ -166,6 +186,8 @@ internal class ChooseTokenListItemConverter(
if (filteredGroups.isEmpty()) TokenList.Empty else tokenList.copy(groups = filteredGroups) if (filteredGroups.isEmpty()) TokenList.Empty else tokenList.copy(groups = filteredGroups)
} }
} }
return filtered.recalculateBalance()
} }
private fun CryptoCurrencyStatus.filterByQuery(): Boolean { 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.decompose.model.ParamsContainer
import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference 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.R
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.choosetoken.* 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.ChooseTokenInitialUM
import com.tangem.features.commonfeatures.impl.choosetoken.ui.state.ChooserBlockUM import com.tangem.features.commonfeatures.impl.choosetoken.ui.state.ChooserBlockUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -32,6 +35,7 @@ internal class ChooseTokenModel @Inject constructor(
marketBlockDelegateFactory: MarketBlockDelegate.Factory, marketBlockDelegateFactory: MarketBlockDelegate.Factory,
predefinedTokensBlockDelegateFactory: PredefinedTokensBlockDelegate.Factory, predefinedTokensBlockDelegateFactory: PredefinedTokensBlockDelegate.Factory,
addToPortfolioManagerFactory: AddToPortfolioManager.Factory, addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
) : Model() { ) : 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 { private val predefinedTokensBlockDelegate: PredefinedTokensBlockDelegate by lazy {
val block = bridge.settings.chooserBlock as ChooserBlock.Predefined val block = bridge.settings.chooserBlock as ChooserBlock.Predefined
predefinedTokensBlockDelegateFactory.create( predefinedTokensBlockDelegateFactory.create(
@ -71,6 +82,7 @@ internal class ChooseTokenModel @Inject constructor(
addToPortfolioSlot = bottomSheetNavigation, addToPortfolioSlot = bottomSheetNavigation,
modelScope = modelScope, modelScope = modelScope,
tokenFilter = bridge.tokenFilter, tokenFilter = bridge.tokenFilter,
portfolioTokenKeys = portfolioTokenKeysFlow,
) )
} }
@ -104,7 +116,7 @@ internal class ChooseTokenModel @Inject constructor(
) )
init { init {
if (bridge.settings.chooserBlock == ChooserBlock.Market) { if (bridge.settings.chooserBlock is ChooserBlock.Market) {
modelScope.launch { modelScope.launch {
delay(MARKETS_INITIAL_LOAD_DELAY) delay(MARKETS_INITIAL_LOAD_DELAY)
marketBlockDelegate.loadDefaultMarkets() marketBlockDelegate.loadDefaultMarkets()
@ -124,6 +136,12 @@ internal class ChooseTokenModel @Inject constructor(
.launchIn(modelScope) .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() { fun onBackClicked() {
bridge.onClose() bridge.onClose()
} }

View file

@ -33,6 +33,7 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
@Assisted private val addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute>, @Assisted private val addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute>,
@Assisted private val modelScope: CoroutineScope, @Assisted private val modelScope: CoroutineScope,
@Assisted private val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>, @Assisted private val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>,
@Assisted private val portfolioTokenKeys: Flow<Set<Pair<String, String>>>,
) { ) {
init { init {
@ -44,8 +45,13 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
val stateFlow: Flow<PredefinedTokensUM?> = combine( val stateFlow: Flow<PredefinedTokensUM?> = combine(
predefinedTokens, predefinedTokens,
searchQueryState, searchQueryState,
) { tokens, query -> portfolioTokenKeys,
val filtered = tokens.filter { it.hasValidNetwork() && it.matchesQuery(query.value) } ) { tokens, query, portfolioKeys ->
val filtered = tokens.filter { token ->
token.hasValidNetwork() &&
token.matchesQuery(query.value) &&
!portfolioKeys.contains(token.toKey())
}
if (filtered.isEmpty()) { if (filtered.isEmpty()) {
null null
} else { } 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 = private fun PredefinedTokenToAdd.hasValidNetwork(): Boolean =
network.networkId.isNotBlank() && network.decimalCount != null network.networkId.isNotBlank() && network.decimalCount != null
@ -104,6 +113,7 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute>, addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute>,
modelScope: CoroutineScope, modelScope: CoroutineScope,
tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>, tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>,
portfolioTokenKeys: Flow<Set<Pair<String, String>>>,
): PredefinedTokensBlockDelegate ): PredefinedTokensBlockDelegate
} }
} }

View file

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

View file

@ -125,6 +125,41 @@ internal class PredefinedTokensBlockDelegateTest {
assertThat(actual).isNull() 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 @ParameterizedTest
@ProvideTestModels @ProvideTestModels
fun filter(model: FilterModel) = runTest { fun filter(model: FilterModel) = runTest {
@ -234,6 +269,7 @@ internal class PredefinedTokensBlockDelegateTest {
searchQueryState: MutableStateFlow<SearchQuery> = MutableStateFlow(SearchQuery.Empty), searchQueryState: MutableStateFlow<SearchQuery> = MutableStateFlow(SearchQuery.Empty),
tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> = tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> =
MutableStateFlow({ _, _ -> true }), MutableStateFlow({ _, _ -> true }),
portfolioTokenKeys: MutableStateFlow<Set<Pair<String, String>>> = MutableStateFlow(emptySet()),
): PredefinedTokensBlockDelegate = PredefinedTokensBlockDelegate( ): PredefinedTokensBlockDelegate = PredefinedTokensBlockDelegate(
predefinedTokens = predefinedTokens, predefinedTokens = predefinedTokens,
searchQueryState = searchQueryState, searchQueryState = searchQueryState,
@ -241,6 +277,7 @@ internal class PredefinedTokensBlockDelegateTest {
addToPortfolioSlot = addToPortfolioSlot, addToPortfolioSlot = addToPortfolioSlot,
modelScope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)), modelScope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)),
tokenFilter = tokenFilter, tokenFilter = tokenFilter,
portfolioTokenKeys = portfolioTokenKeys,
) )
private fun currency(rawId: String, networkId: String): CryptoCurrencyStatus = private fun currency(rawId: String, networkId: String): CryptoCurrencyStatus =

View file

@ -17,6 +17,7 @@ dependencies {
api(projects.features.details.api) api(projects.features.details.api)
api(projects.features.onboardingV2.api) api(projects.features.onboardingV2.api)
api(projects.features.wallet.api) api(projects.features.wallet.api)
implementation(projects.features.virtualAccounts.details.api)
/* Project - Core */ /* Project - Core */
api(projects.core.analytics) api(projects.core.analytics)
@ -45,6 +46,7 @@ dependencies {
runtimeOnly(projects.domain.appCurrency) runtimeOnly(projects.domain.appCurrency)
runtimeOnly(projects.domain.balanceHiding) runtimeOnly(projects.domain.balanceHiding)
runtimeOnly(projects.domain.tokens) runtimeOnly(projects.domain.tokens)
implementation(projects.domain.virtualAccount)
/* SDK */ /* SDK */
// TODO: For TangemError model, should be removed after card domain scanning refactoring // 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.entity.SelectEmailFeedbackTypeBS
import com.tangem.features.details.utils.ItemsBuilder import com.tangem.features.details.utils.ItemsBuilder
import com.tangem.features.details.utils.SocialsBuilder import com.tangem.features.details.utils.SocialsBuilder
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.info.AppInfoProvider import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
@ -71,6 +72,7 @@ internal class DetailsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsEventHandler: AnalyticsEventHandler,
private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles,
private val tangemPayEligibilityManager: TangemPayEligibilityManager, private val tangemPayEligibilityManager: TangemPayEligibilityManager,
private val getVirtualAccountEligibilityUseCase: GetVirtualAccountEligibilityUseCase, private val getVirtualAccountEligibilityUseCase: GetVirtualAccountEligibilityUseCase,
) : Model() { ) : Model() {
@ -335,8 +337,9 @@ internal class DetailsModel @Inject constructor(
private fun addVirtualAccountItemIfEligible() { private fun addVirtualAccountItemIfEligible() {
modelScope.launch { modelScope.launch {
val isVirtualAccountEnabled = virtualAccountFeatureToggles.isVirtualAccountsEnabled
val eligibility = getVirtualAccountEligibilityUseCase(VirtualAccountEntryPoint.DETAILS) val eligibility = getVirtualAccountEligibilityUseCase(VirtualAccountEntryPoint.DETAILS)
if (eligibility is VirtualAccountEligibility.Available) { if (eligibility is VirtualAccountEligibility.Available && isVirtualAccountEnabled) {
items.update { items -> items.update { items ->
itemsBuilder.addVirtualAccountItem( itemsBuilder.addVirtualAccountItem(
items = items, 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.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.AddressBookFeatureToggles import com.tangem.features.addressbook.AddressBookFeatureToggles
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.features.details.component.DetailsComponent import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.details.entity.DetailsItemUM import com.tangem.features.details.entity.DetailsItemUM
import com.tangem.features.details.utils.ItemsBuilder import com.tangem.features.details.utils.ItemsBuilder
@ -64,6 +65,7 @@ internal abstract class DetailsModelTestBase {
protected val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true) protected val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true)
protected val tangemPayEligibilityManager: TangemPayEligibilityManager = mockk() protected val tangemPayEligibilityManager: TangemPayEligibilityManager = mockk()
protected val getVirtualAccountEligibilityUseCase: GetVirtualAccountEligibilityUseCase = mockk() protected val getVirtualAccountEligibilityUseCase: GetVirtualAccountEligibilityUseCase = mockk()
protected val virtualAccountFeatureToggles: VirtualAccountFeatureToggles = mockk()
// Captured from itemsBuilder.buildAll(...) so the feature buttons can be driven. // Captured from itemsBuilder.buildAll(...) so the feature buttons can be driven.
protected val wcSlot = slot<Boolean>() protected val wcSlot = slot<Boolean>()
@ -88,6 +90,7 @@ internal abstract class DetailsModelTestBase {
every { appInfoProvider.appVersionCode } returns 456 every { appInfoProvider.appVersionCode } returns 456
coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns emptyList() coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns emptyList()
coEvery { getVirtualAccountEligibilityUseCase(any()) } returns VirtualAccountEligibility.NotAvailable coEvery { getVirtualAccountEligibilityUseCase(any()) } returns VirtualAccountEligibility.NotAvailable
every { virtualAccountFeatureToggles.isVirtualAccountsEnabled } returns true
every { every {
itemsBuilder.buildAll( itemsBuilder.buildAll(
@ -128,6 +131,7 @@ internal abstract class DetailsModelTestBase {
analyticsEventHandler = analyticsEventHandler, analyticsEventHandler = analyticsEventHandler,
tangemPayEligibilityManager = tangemPayEligibilityManager, tangemPayEligibilityManager = tangemPayEligibilityManager,
getVirtualAccountEligibilityUseCase = getVirtualAccountEligibilityUseCase, getVirtualAccountEligibilityUseCase = getVirtualAccountEligibilityUseCase,
virtualAccountFeatureToggles = virtualAccountFeatureToggles,
) )
protected fun stubBuildAllReturns(list: ImmutableList<DetailsItemUM>) { protected fun stubBuildAllReturns(list: ImmutableList<DetailsItemUM>) {

View file

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

View file

@ -125,9 +125,12 @@ internal class MarketingBannerModel @Inject constructor(
campaignId = id, campaignId = id,
text = banner.text, text = banner.text,
iconUrl = banner.iconUrl, 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) { iconAlign = when (banner.iconAlign) {
MarketingBanner.IconAlign.RIGHT -> MarketingBannerUM.IconAlign.RIGHT 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, isDismissible = banner.isDismissible,
deeplink = banner.deeplink, deeplink = banner.deeplink,

View file

@ -21,7 +21,6 @@ import coil.request.ImageRequest
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.ds2.messagebanner.CloseButton import com.tangem.core.ui.ds2.messagebanner.CloseButton
import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner 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.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -51,11 +50,10 @@ internal fun MarketingBanner(
TangemMessageBanner( TangemMessageBanner(
title = stringReference(banner.text.orEmpty()), title = stringReference(banner.text.orEmpty()),
modifier = modifier.then( modifier = modifier,
if (hasDeeplink) Modifier.clickableSingle(onClick = onClick) else Modifier,
),
variant = TangemMessageBanner.Variant.Default, variant = TangemMessageBanner.Variant.Default,
showGlowRing = false, showGlowRing = false,
onClick = if (hasDeeplink) onClick else null,
slotStart = if (isIconAtStart) { slotStart = if (isIconAtStart) {
{ BannerIcon(banner.iconUrl, onLoadError = { isIconFailed = true }) } { BannerIcon(banner.iconUrl, onLoadError = { isIconFailed = true }) }
} else { } 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.MarketingBannerComponent
import com.tangem.features.marketing.api.MarketingBannerRequest import com.tangem.features.marketing.api.MarketingBannerRequest
import com.tangem.features.marketing.impl.ui.state.MarketingBannerListUM 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 com.tangem.utils.coroutines.CoroutineDispatcherProvider
import io.mockk.Runs import io.mockk.Runs
import io.mockk.clearMocks import io.mockk.clearMocks
@ -33,6 +35,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class MarketingBannerModelTest { internal class MarketingBannerModelTest {
@ -280,4 +283,53 @@ internal class MarketingBannerModelTest {
// Assert // Assert
verify(exactly = 1) { deeplinkLauncher.launch("https://tangem.com/promo") } 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.commonFeatures.api)
api(projects.features.onramp.api) api(projects.features.onramp.api)
implementation(projects.features.marketing.api) implementation(projects.features.marketing.api)
implementation(projects.domain.marketing.models)
implementation(projects.domain.quotes)
/** Project - Core */ /** Project - Core */
api(projects.core.analytics) api(projects.core.analytics)
@ -55,6 +53,8 @@ dependencies {
implementation(projects.domain.tokens.models) implementation(projects.domain.tokens.models)
implementation(projects.domain.transaction.models) implementation(projects.domain.transaction.models)
implementation(projects.domain.wallets.models) implementation(projects.domain.wallets.models)
implementation(projects.domain.marketing.models)
implementation(projects.domain.quotes)
runtimeOnly(projects.domain.card) runtimeOnly(projects.domain.card)
/** Data */ /** 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.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.onramp.model.OnrampProviderWithQuote import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.features.marketing.api.MarketingBannerComponent
internal interface AllOffersComponent : ComposableBottomSheetComponent { internal interface AllOffersComponent : ComposableBottomSheetComponent {
@ -14,6 +15,12 @@ internal interface AllOffersComponent : ComposableBottomSheetComponent {
val onDismiss: () -> Unit, val onDismiss: () -> Unit,
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit, val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
val amountCurrencyCode: String, 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> interface Factory : ComponentFactory<Params, AllOffersComponent>

View file

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

View file

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

View file

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

View file

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

View file

@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.child
@ -29,7 +28,6 @@ internal class ActivateCampaignBottomSheetComponent(
chooseTokenComponentFactory: ChooseTokenComponent.Factory, chooseTokenComponentFactory: ChooseTokenComponent.Factory,
private val params: Params, private val params: Params,
val onDismiss: () -> Unit, val onDismiss: () -> Unit,
val onFooterExtraHeightReady: (Dp) -> Unit,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext { ) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: ActivateCampaignsModel = getOrCreateModel(params) private val model: ActivateCampaignsModel = getOrCreateModel(params)
@ -65,7 +63,6 @@ internal class ActivateCampaignBottomSheetComponent(
ActivateCampaignFooter( ActivateCampaignFooter(
footerUM = state.footerUM, 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.animation.animateContentSize
import androidx.compose.foundation.layout.Box 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.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.childSlot
import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel 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.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent 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.TangemBottomSheetType
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWithFooter
import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.extensions.rememberLastNonNull import com.tangem.core.ui.extensions.rememberLastNonNull
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
@ -52,31 +60,44 @@ internal class DefaultCampaignsComponent @AssistedInject constructor(
val bottomSheet by bottomSheetSlot.subscribeAsState() val bottomSheet by bottomSheetSlot.subscribeAsState()
val activeChild = bottomSheet.child?.instance val activeChild = bottomSheet.child?.instance
val displayedChild = rememberLastNonNull(activeChild) val displayedChild = rememberLastNonNull(activeChild)
val footerExtraHeight by model.footerExtraHeightState.collectAsStateWithLifecycle()
TangemModalBottomSheetWithFooter<TangemBottomSheetConfigContent.Empty>( TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig( config = TangemBottomSheetConfig(
isShown = activeChild != null, isShown = activeChild != null,
onDismissRequest = model::onDismiss, onDismissRequest = model::onDismiss,
content = TangemBottomSheetConfigContent.Empty, content = TangemBottomSheetConfigContent.Empty,
), ),
containerColor = TangemTheme.colors3.bg.primary, containerColor = TangemTheme.colors3.bg.secondary,
footerHeight = DEFAULT_FOOTER_HEIGHT + footerExtraHeight, type = TangemBottomSheetType.Modal,
onBack = model::onDismiss, onBack = model::onDismiss,
title = { title = {
displayedChild?.Title() displayedChild?.Title()
}, },
content = { content = {
Box(modifier = Modifier.animateContentSize()) { val bottomInset = LocalTangemBottomSheetContentBottomInset.current
displayedChild?.Content(modifier = Modifier) 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 = { footer = {
Box( Box(modifier = Modifier.padding(12.dp)) {
modifier = Modifier
.navigationBarsPadding()
.padding(12.dp),
) {
displayedChild?.Footer() displayedChild?.Footer()
} }
}, },
@ -102,7 +123,6 @@ internal class DefaultCampaignsComponent @AssistedInject constructor(
appComponentContext = context, appComponentContext = context,
chooseTokenComponentFactory = chooseTokenComponentFactory, chooseTokenComponentFactory = chooseTokenComponentFactory,
onDismiss = model::onDismiss, onDismiss = model::onDismiss,
onFooterExtraHeightReady = model::onFooterExtraHeightReady,
params = ActivateCampaignBottomSheetComponent.Params( params = ActivateCampaignBottomSheetComponent.Params(
campaignType = config.campaignType, campaignType = config.campaignType,
userWalletId = config.userWalletId, 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.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.components.account.AccountIconSize 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.resourceReference
import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.ToastMessage 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.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.Account
@ -46,6 +46,7 @@ import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
@ -60,7 +61,7 @@ internal class ActivateCampaignsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
chooseTokenBridgeFactory: ChooseTokenBridge.Factory, chooseTokenBridgeFactory: ChooseTokenBridge.Factory,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val multiAccountListSupplier: MultiAccountListSupplier,
private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase, private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase,
private val urlOpener: UrlOpener, private val urlOpener: UrlOpener,
@GlobalUiMessageSender private val messageSender: UiMessageSender, @GlobalUiMessageSender private val messageSender: UiMessageSender,
@ -198,28 +199,46 @@ internal class ActivateCampaignsModel @Inject constructor(
urlOpener.openUrl(campaignContent.learnMoreUrl) 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) { private fun onTokenChosen(result: ChooseTokenResult) {
val selectedToken = result.currency.currency as? CryptoCurrency.Token ?: return val selectedToken = result.currency.currency as? CryptoCurrency.Token ?: return
val networkAddress = result.currency.value.networkAddress ?: return val networkAddress = result.currency.value.networkAddress ?: return
modelScope.launch { modelScope.launch {
val selectedAccountUM = if (isAccountsModeEnabledUseCase.invokeSync()) { val selectedAccountUM = if (hasMultipleCryptoPortfolioAccounts()) {
when (val account = result.account.account) { when (val account = result.account.account) {
is Account.CryptoPortfolio -> SelectedAccountUM( is Account.CryptoPortfolio -> SelectedAccountUM(
iconState = accountIconConverter.convert(account), iconState = accountIconConverter.convert(account),
name = account.accountName.toUM().value, name = account.accountName.toUM().value,
) )
is Account.Payment -> SelectedAccountUM( // Payment accounts are hidden in the chooser and don't count towards accounts mode,
iconState = CurrencyIconState.PaymentAccount(size = AccountIconSize.ExtraSmall), // so there is no account label to show for them.
name = account.accountName.toUM().value, is Account.Payment,
) is Account.Virtual,
is Account.Virtual -> null -> null
} }
} else { } else {
null 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 -> uiState.update { state ->
state.copy( state.copy(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,10 +2,15 @@ package com.tangem.features.promobanners.impl.model
import androidx.core.net.toUri import androidx.core.net.toUri
import com.tangem.core.analytics.api.AnalyticsEventHandler 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.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.deeplink.DeeplinkLauncher 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.domain.common.wallets.UserWalletsListRepository
import com.tangem.features.promobanners.api.PromoBannersBlockComponent import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.promobanners.impl.analytics.PromoBannerAnalyticsEvent import com.tangem.features.promobanners.impl.analytics.PromoBannerAnalyticsEvent
@ -26,6 +31,7 @@ import javax.inject.Inject
private typealias ShownBannerKey = Pair<String, Int> private typealias ShownBannerKey = Pair<String, Int>
@Suppress("LongParameterList")
@ModelScoped @ModelScoped
internal class PromoBannersBlockModel @Inject constructor( internal class PromoBannersBlockModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
@ -34,6 +40,7 @@ internal class PromoBannersBlockModel @Inject constructor(
private val deeplinkLauncher: DeeplinkLauncher, private val deeplinkLauncher: DeeplinkLauncher,
private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsEventHandler: AnalyticsEventHandler,
private val userWalletsListRepository: UserWalletsListRepository, private val userWalletsListRepository: UserWalletsListRepository,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) : Model() { ) : Model() {
private val params = paramsContainer.require<PromoBannersBlockComponent.Params>() private val params = paramsContainer.require<PromoBannersBlockComponent.Params>()
@ -206,7 +213,12 @@ internal class PromoBannersBlockModel @Inject constructor(
private fun onButtonClick(displayId: Int, deeplink: String?) { private fun onButtonClick(displayId: Int, deeplink: String?) {
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholderName)) 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 { 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.model.MutableParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener 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.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
@ -53,7 +54,7 @@ internal class ActivateCampaignsModelTest {
private val chooseTokenBridgeFactory: ChooseTokenBridge.Factory = mockk(relaxed = true) private val chooseTokenBridgeFactory: ChooseTokenBridge.Factory = mockk(relaxed = true)
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk() private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk() private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase = mockk() private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase = mockk()
private val urlOpener: UrlOpener = mockk(relaxed = true) private val urlOpener: UrlOpener = mockk(relaxed = true)
private val messageSender: UiMessageSender = mockk(relaxed = true) private val messageSender: UiMessageSender = mockk(relaxed = true)
@ -72,7 +73,7 @@ internal class ActivateCampaignsModelTest {
fun setup() { fun setup() {
clearMocks( clearMocks(
getSelectedAppCurrencyUseCase, getSelectedAppCurrencyUseCase,
isAccountsModeEnabledUseCase, multiAccountListSupplier,
enrollPromoCampaignUseCase, enrollPromoCampaignUseCase,
getWalletsUseCase, getWalletsUseCase,
messageSender, messageSender,
@ -258,7 +259,7 @@ internal class ActivateCampaignsModelTest {
} }
every { chooseTokenBridgeFactory.create(any(), any(), any()) } returns bridge every { chooseTokenBridgeFactory.create(any(), any(), any()) } returns bridge
every { getSelectedAppCurrencyUseCase.invokeOrDefault() } returns flowOf(AppCurrency.Default) 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()) coEvery { getPromoCampaignStateUseCase(any(), any(), any()) } returns Either.Left(Throwable())
every { getWalletsUseCase.invokeSync() } returns allWalletIds.map { walletId -> every { getWalletsUseCase.invokeSync() } returns allWalletIds.map { walletId ->
mockk<UserWallet> { every { this@mockk.walletId } returns walletId } mockk<UserWallet> { every { this@mockk.walletId } returns walletId }
@ -274,7 +275,7 @@ internal class ActivateCampaignsModelTest {
dispatchers = createTestingCoroutineDispatcherProvider(), dispatchers = createTestingCoroutineDispatcherProvider(),
chooseTokenBridgeFactory = chooseTokenBridgeFactory, chooseTokenBridgeFactory = chooseTokenBridgeFactory,
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase, multiAccountListSupplier = multiAccountListSupplier,
enrollPromoCampaignUseCase = enrollPromoCampaignUseCase, enrollPromoCampaignUseCase = enrollPromoCampaignUseCase,
urlOpener = urlOpener, urlOpener = urlOpener,
messageSender = messageSender, messageSender = messageSender,

View file

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

View file

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

View file

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

View file

@ -110,6 +110,7 @@ internal class TangemPayCardPageScreenComponent(
paymentAccountAddress = navigation.paymentAccountAddress, paymentAccountAddress = navigation.paymentAccountAddress,
onDismiss = model.bottomSheetNavigation::dismiss, onDismiss = model.bottomSheetNavigation::dismiss,
onShowDetails = model::onShowVirtualAccountRequisites, onShowDetails = model::onShowVirtualAccountRequisites,
onShowBankingDetailsError = model::showVaBankingDetailsError,
onOrderCreated = model::onVirtualAccountOrderCreated, onOrderCreated = model::onVirtualAccountOrderCreated,
), ),
) )
@ -126,6 +127,15 @@ internal class TangemPayCardPageScreenComponent(
onFieldCopied = model::onVaFieldCopied, 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( is TangemPayCardNavigation.Receive -> tokenReceiveComponentFactory.create(
context = context, context = context,
params = TokenReceiveComponent.Params( params = TokenReceiveComponent.Params(

View file

@ -149,6 +149,7 @@ internal class TangemPayDetailsComponent(
paymentAccountAddress = navigation.paymentAccountAddress, paymentAccountAddress = navigation.paymentAccountAddress,
onDismiss = model.bottomSheetNavigation::dismiss, onDismiss = model.bottomSheetNavigation::dismiss,
onShowDetails = model::onShowVirtualAccountRequisites, onShowDetails = model::onShowVirtualAccountRequisites,
onShowBankingDetailsError = model::showVaBankingDetailsError,
onOrderCreated = model::onVirtualAccountOrderCreated, onOrderCreated = model::onVirtualAccountOrderCreated,
), ),
) )
@ -165,6 +166,15 @@ internal class TangemPayDetailsComponent(
onFieldCopied = model::onVaFieldCopied, 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( is TangemPayDetailsNavigation.IssueAdditionalCard -> TangemPayIssueAdditionalCardComponent(
appComponentContext = context, appComponentContext = context,
params = TangemPayIssueAdditionalCardComponent.Params( 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 paymentAccountAddress: String,
val onDismiss: () -> Unit, val onDismiss: () -> Unit,
val onShowDetails: (VirtualAccountOnramp.Available) -> Unit, val onShowDetails: (VirtualAccountOnramp.Available) -> Unit,
val onShowBankingDetailsError: () -> Unit,
val onOrderCreated: () -> Unit, val onOrderCreated: () -> Unit,
) )
} }

View file

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

View file

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

View file

@ -39,6 +39,11 @@ internal sealed class TangemPayDetailsNavigation {
val bankCredentials: BankCredentials, val bankCredentials: BankCredentials,
) : TangemPayDetailsNavigation() ) : TangemPayDetailsNavigation()
@Serializable
data class VaBankingDetailsError(
val userWalletId: UserWalletId,
) : TangemPayDetailsNavigation()
@Serializable @Serializable
data class TransactionDetails( data class TransactionDetails(
val transaction: TangemPayTxHistoryItem, 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.common.routing.AppRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam 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.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
@ -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.Icons
import com.tangem.core.ui.res.generated.icons.ic_arrow_refresh_20 import com.tangem.core.ui.res.generated.icons.ic_arrow_refresh_20
import com.tangem.core.ui.test.TangemPayTestTags 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.StatusSource
import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.AccountStatus
@ -67,16 +71,17 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import com.tangem.core.ui.R as CoreUiR import com.tangem.core.ui.R as CoreUiR
@Suppress("LongParameterList", "LargeClass") @Suppress("LongParameterList", "LargeClass", "TooManyFunctions")
@Stable @Stable
@ModelScoped @ModelScoped
internal class TangemPayCardPageModel @Inject constructor( internal class TangemPayCardPageModel @Inject constructor(
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
paymentAccountStatusSupplier: PaymentAccountStatusSupplier, private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
private val router: Router, private val router: Router,
private val analytics: AnalyticsEventHandler, private val analytics: AnalyticsEventHandler,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val cardDetailsRepository: TangemPayCardDetailsRepository, private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val uiMessageSender: UiMessageSender, private val uiMessageSender: UiMessageSender,
private val changeCardFrozenStateUseCase: ChangeCardFrozenStateUseCase, private val changeCardFrozenStateUseCase: ChangeCardFrozenStateUseCase,
@ -446,7 +451,19 @@ internal class TangemPayCardPageModel @Inject constructor(
override fun onClickBankTransfer() { override fun onClickBankTransfer() {
val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return 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()) analytics.send(TangemPayAnalyticsEvents.VaTopupButtonClicked())
bottomSheetNavigation.dismiss() bottomSheetNavigation.dismiss()
bottomSheetNavigation.activate( 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() { fun onVirtualAccountOrderCreated() {
analytics.send(TangemPayAnalyticsEvents.VaSuccessScreenActivation()) analytics.send(TangemPayAnalyticsEvents.VaSuccessScreenActivation())
bottomSheetNavigation.dismiss() bottomSheetNavigation.dismiss()

View file

@ -64,7 +64,7 @@ import javax.inject.Inject
@ModelScoped @ModelScoped
internal class TangemPayDetailsModel @Inject constructor( internal class TangemPayDetailsModel @Inject constructor(
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
paymentAccountStatusSupplier: PaymentAccountStatusSupplier, private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
private val analytics: AnalyticsEventHandler, private val analytics: AnalyticsEventHandler,
private val router: Router, private val router: Router,
@ -355,7 +355,19 @@ internal class TangemPayDetailsModel @Inject constructor(
override fun onClickBankTransfer() { override fun onClickBankTransfer() {
val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return 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()) analytics.send(TangemPayAnalyticsEvents.VaTopupButtonClicked())
bottomSheetNavigation.dismiss() bottomSheetNavigation.dismiss()
bottomSheetNavigation.activate( 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() { fun onVirtualAccountOrderCreated() {
analytics.send(TangemPayAnalyticsEvents.VaSuccessScreenActivation()) analytics.send(TangemPayAnalyticsEvents.VaSuccessScreenActivation())
bottomSheetNavigation.dismiss() 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()) analytics.send(TangemPayAnalyticsEvents.VaShowDetailsFirstTimeClicked())
createVirtualAccountOrder() 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.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -73,6 +75,7 @@ private fun DepositContent(state: TangemPayVirtualAccountDepositUM, modifier: Mo
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = TangemTheme.dimens2.x4) .padding(horizontal = TangemTheme.dimens2.x4)
.padding(bottom = TangemTheme.dimens2.x4), .padding(bottom = TangemTheme.dimens2.x4),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@ -255,10 +258,10 @@ private fun UsdcIcon(modifier: Modifier = Modifier) {
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
modifier = Modifier.size(TangemTheme.dimens2.x4), modifier = Modifier.size(TangemTheme.dimens2.x6),
painter = painterResource(CoreUiR.drawable.ic_polygon_22), painter = painterResource(CoreUiR.drawable.ic_polygon_22),
contentDescription = null, 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
@ -16,6 +15,7 @@ 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.SpacerH import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerHMax 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.ds2.button.TangemButton
import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference 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.Icons
import com.tangem.core.ui.res.generated.icons.ic_success_24 import com.tangem.core.ui.res.generated.icons.ic_success_24
import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.details.impl.R
import dev.chrisbanes.haze.HazeStyle
private const val DEFAULT_FADE_COLOR = 0xFF9FC824 private const val DEFAULT_FADE_COLOR = 0xFF9FC824
private val BlurRadius = 192.dp private val BlurRadius = 192.dp
@ -46,7 +47,7 @@ internal fun TangemPaySuccessScreenWrapper(
Box( Box(
modifier = Modifier modifier = Modifier
.matchParentSize() .matchParentSize()
.blur(BlurRadius) .hazeForegroundEffectTangem(style = HazeStyle(blurRadius = BlurRadius, tint = null))
.drawBehind { .drawBehind {
val w = size.width val w = size.width
drawRect( 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 { fun createFutureFeature(onGotItClick: () -> Unit): BottomSheetMessage {
return bottomSheetMessage { return bottomSheetMessage {
infoBlock { infoBlock {

View file

@ -1,6 +1,8 @@
package com.tangem.features.tangempay.utils package com.tangem.features.tangempay.utils
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.account.BankCredentials import com.tangem.domain.models.account.BankCredentials
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent.RequisitesRow 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( internal fun BankCredentials.toRequisitesRows(): List<RequisitesRow> = listOf(
RequisitesRow( RequisitesRow(
title = "Beneficiary name and address", title = resourceReference(R.string.virtual_account_requisites_beneficiary_name),
titleForShare = "Beneficiary name and address", titleForShare = "Beneficiary name",
value = "$beneficiaryName\n$beneficiaryAddress", value = beneficiaryName,
), ),
RequisitesRow( RequisitesRow(
title = "Bank name and address", title = resourceReference(R.string.virtual_account_requisites_beneficiary_address),
titleForShare = "Bank name and address", titleForShare = "Beneficiary address",
value = "$beneficiaryBankName\n$beneficiaryBankAddress", value = beneficiaryAddress,
), ),
RequisitesRow( 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", titleForShare = "Account number",
value = accountNumber, value = accountNumber,
), ),
RequisitesRow( RequisitesRow(
title = "Routing number", title = resourceReference(R.string.virtual_account_requisites_routing_number),
titleForShare = "Routing number", titleForShare = "Routing number",
value = routingNumber, 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 uiMessageSender: UiMessageSender = mockk(relaxed = true)
private val createVirtualAccountOrderUseCase: CreateVirtualAccountOrderUseCase = mockk() private val createVirtualAccountOrderUseCase: CreateVirtualAccountOrderUseCase = mockk()
private val onShowDetails: (VirtualAccountOnramp.Available) -> Unit = mockk(relaxed = true) 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 onOrderCreated: () -> Unit = mockk(relaxed = true)
private val analytics: AnalyticsEventHandler = mockk(relaxed = true) private val analytics: AnalyticsEventHandler = mockk(relaxed = true)
@BeforeEach @BeforeEach
fun resetMocks() { fun resetMocks() {
clearMocks(createVirtualAccountOrderUseCase, onShowDetails, onOrderCreated, uiMessageSender, analytics) clearMocks(
createVirtualAccountOrderUseCase,
onShowDetails,
onShowBankingDetailsError,
onOrderCreated,
uiMessageSender,
analytics,
)
} }
@Test @Test
@ -65,6 +73,21 @@ internal class TangemPayVirtualAccountDepositModelTest {
verify(exactly = 1) { analytics.send(ofType<TangemPayAnalyticsEvents.VaShowDetailsClicked>()) } 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 @Test
fun `GIVEN eligible and create succeeds WHEN show details THEN order created and loading reset`() = runTest { fun `GIVEN eligible and create succeeds WHEN show details THEN order created and loading reset`() = runTest {
// Arrange // Arrange
@ -130,6 +153,7 @@ internal class TangemPayVirtualAccountDepositModelTest {
paymentAccountAddress = paymentAccountAddress, paymentAccountAddress = paymentAccountAddress,
onDismiss = {}, onDismiss = {},
onShowDetails = onShowDetails, onShowDetails = onShowDetails,
onShowBankingDetailsError = onShowBankingDetailsError,
onOrderCreated = onOrderCreated, onOrderCreated = onOrderCreated,
), ),
), ),

View file

@ -376,59 +376,6 @@ internal data class TangemTokenIconStory(
enum class UiStateVariant { Token, Shimmer, Error } 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( internal data class TextStyleStory(
val style: Style, val style: Style,
val textScale: Float, val textScale: Float,
@ -505,6 +452,61 @@ internal data class TangemTokenRowMarketStory(
val onLongTitleToggle: () -> Unit, val onLongTitleToggle: () -> Unit,
) : DsStoryBookPage ) : 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( internal data class TangemBadgeV2Story(
val variant: TangemBadge.Variant, val variant: TangemBadge.Variant,
val status: TangemBadge.Status, val status: TangemBadge.Status,

View file

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

View file

@ -95,6 +95,11 @@ private fun PreviewBanner(state: TangemMessageBannerStory) {
variant = state.variant, variant = state.variant,
contentAlign = state.contentAlign, contentAlign = state.contentAlign,
showGlowRing = state.hasGlowRing, showGlowRing = state.hasGlowRing,
onClick = if (state.isClickable) {
{}
} else {
null
},
title = stringReference("Would you predict?"), title = stringReference("Would you predict?"),
description = if (state.hasDescription) { description = if (state.hasDescription) {
stringReference("France will win FIFA 2026") 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 = "slotStart", checked = state.hasSlotStart, onToggle = state.onSlotStartToggle)
ToggleRow(label = "slotEnd", checked = state.hasSlotEnd, onToggle = state.onSlotEndToggle) ToggleRow(label = "slotEnd", checked = state.hasSlotEnd, onToggle = state.onSlotEndToggle)
ToggleRow(label = "extraContent", checked = state.hasExtraContent, onToggle = state.onExtraContentToggle) 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 */ /** Domain */
api(projects.domain.models) 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.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
/** /**
@ -25,7 +26,7 @@ interface VirtualAccountAddFundsBottomSheetComponent : ComposableBottomSheetComp
) )
data class RequisitesRow( data class RequisitesRow(
val title: String, val title: TextReference,
val titleForShare: String, val titleForShare: String,
val value: String, val value: String,
) )

View file

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

View file

@ -5,8 +5,10 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -110,6 +112,7 @@ private fun DetailsContent(content: VirtualAccountAddFundsUM.Content.Details, mo
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = TangemTheme.dimens2.x4), .padding(bottom = TangemTheme.dimens2.x4),
) { ) {
content.items.forEachIndexed { index, item -> 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.decompose.model.ParamsContainer
import com.tangem.core.navigation.share.ShareManager import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.ui.clipboard.ClipboardManager 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.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -55,25 +54,31 @@ internal class VirtualAccountAddFundsModel @Inject constructor(
uiState.update { state -> state.copy(content = buildDetailsContent()) } uiState.update { state -> state.copy(content = buildDetailsContent()) }
} }
private fun buildDetailsContent() = VirtualAccountAddFundsUM.Content.Details( private fun buildDetailsContent(): VirtualAccountAddFundsUM.Content.Details {
items = params.requisites return VirtualAccountAddFundsUM.Content.Details(
.map { detailItem(label = it.title, value = it.value) } items = params.requisites
.toImmutableList(), .map(::detailItem)
dailyLimit = params.dailyDepositLimit, .toImmutableList(),
onShareClick = { dailyLimit = params.dailyDepositLimit,
params.onShareClicked() onShareClick = {
shareManager.shareText(buildShareText()) params.onShareClicked()
}, shareManager.shareText(buildShareText())
) },
)
}
private fun detailItem(label: String, value: String) = VirtualAccountAddFundsUM.DetailItem( private fun detailItem(
label = stringReference(label), requisitesRow: VirtualAccountAddFundsBottomSheetComponent.RequisitesRow,
value = value, ): VirtualAccountAddFundsUM.DetailItem {
onCopyClick = { return VirtualAccountAddFundsUM.DetailItem(
params.onFieldCopied(label) label = requisitesRow.title,
clipboardManager.setText(text = value, isSensitive = true) value = requisitesRow.value,
}, onCopyClick = {
) params.onFieldCopied(requisitesRow.titleForShare)
clipboardManager.setText(text = requisitesRow.value, isSensitive = true)
},
)
}
private fun buildShareText(): String { private fun buildShareText(): String {
return buildString { return buildString {

View file

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