Updated on 2026-08-14
This commit is contained in:
commit
e6ec52e35a
83 changed files with 1846 additions and 301 deletions
|
|
@ -21,6 +21,8 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository
|
|||
import com.tangem.domain.common.wallets.UserWalletsListRepository.LockMethod
|
||||
import com.tangem.domain.common.wallets.error.*
|
||||
import com.tangem.domain.hotwallet.repository.HotWalletRepository
|
||||
import com.tangem.domain.models.scan.CardDTO
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.domain.models.wallet.*
|
||||
import com.tangem.domain.wallets.R
|
||||
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
|
||||
|
|
@ -126,6 +128,8 @@ internal class DefaultUserWalletsListRepository(
|
|||
canOverride: Boolean,
|
||||
): Either<SaveWalletError, UserWallet> = either {
|
||||
if (canOverride.not() && userWallets.value?.any { it.walletId == userWallet.walletId } == true) {
|
||||
// the wallet was rebuilt from a fresh scan — reconcile the stored card state before rejecting
|
||||
(userWallet as? UserWallet.Cold)?.let { refreshStoredCardState(scanResponse = it.scanResponse) }
|
||||
raise(SaveWalletError.WalletAlreadySaved(messageId = R.string.user_wallet_list_error_wallet_already_saved))
|
||||
}
|
||||
|
||||
|
|
@ -321,6 +325,8 @@ internal class DefaultUserWalletsListRepository(
|
|||
raise(UnlockWalletError.ScannedCardWalletNotMatched)
|
||||
}
|
||||
|
||||
refreshStoredCardState(scanResponse)
|
||||
|
||||
val encryptionKey = UserWalletEncryptionKey(
|
||||
walletId = userWallet.walletId,
|
||||
encryptionKey = scanResponse.encryptionKey ?: raise(UnlockWalletError.UnableToUnlock.Empty),
|
||||
|
|
@ -448,6 +454,49 @@ internal class DefaultUserWalletsListRepository(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the persisted card state of an already saved wallet from a freshly scanned card.
|
||||
*
|
||||
* Heals a stale backup status — e.g. when backup was finalized on another device or the app was
|
||||
* terminated before the post-backup update was persisted. A scan of the same physical card is
|
||||
* the ground truth and is applied as is. A scan of another card of the same wallet refreshes the
|
||||
* state too, except when the stored card is [CardDTO.BackupStatus.CardLinked] — its backup is in
|
||||
* progress, so the status is preserved until the same card is scanned again.
|
||||
*/
|
||||
private suspend fun refreshStoredCardState(scanResponse: ScanResponse) {
|
||||
val walletId = UserWalletIdBuilder.scanResponse(scanResponse).build() ?: return
|
||||
val storedWallet = userWallets.value?.find { it.walletId == walletId } as? UserWallet.Cold ?: return
|
||||
|
||||
val storedCard = storedWallet.scanResponse.card
|
||||
val scannedCard = scanResponse.card
|
||||
|
||||
val isUpToDate = storedCard.backupStatus == scannedCard.backupStatus &&
|
||||
storedCard.isAccessCodeSet == scannedCard.isAccessCodeSet
|
||||
if (isUpToDate) return
|
||||
|
||||
// another card of the wallet must not override the stored card's in-progress backup state
|
||||
val isAnotherCard = storedCard.cardId != scannedCard.cardId
|
||||
val isBackupInProgress = storedCard.backupStatus is CardDTO.BackupStatus.CardLinked
|
||||
if (isAnotherCard && isBackupInProgress) return
|
||||
|
||||
val updatedWallet = storedWallet.copy(
|
||||
scanResponse = storedWallet.scanResponse.copy(
|
||||
card = storedCard.copy(
|
||||
backupStatus = scannedCard.backupStatus,
|
||||
isAccessCodeSet = scannedCard.isAccessCodeSet,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
if (savePersistentInformation()) {
|
||||
publicInformationRepository.save(updatedWallet, canOverride = true)
|
||||
}
|
||||
|
||||
updateWallets { wallets ->
|
||||
wallets?.addOrReplace(updatedWallet) { it.walletId == walletId }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkForUpgradeAndDeleteHotWalletIfNeeded(
|
||||
newUserWallet: UserWallet,
|
||||
oldUserWallet: UserWallet,
|
||||
|
|
|
|||
|
|
@ -3,13 +3,18 @@ package com.tangem.tap.domain.userWalletList.repository
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.CompletionResult
|
||||
import com.tangem.common.core.TangemError
|
||||
import com.tangem.common.test.domain.card.MockScanResponseFactory
|
||||
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.utils.TrackingContextProxy
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
|
||||
import com.tangem.domain.card.configs.GenericCardConfig
|
||||
import com.tangem.domain.common.wallets.UserWalletSelectedHandler
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.hotwallet.repository.HotWalletRepository
|
||||
import com.tangem.domain.models.scan.CardDTO
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
|
||||
|
|
@ -144,4 +149,162 @@ internal class DefaultUserWalletsListRepositoryTest {
|
|||
assertThat(result.isLeft()).isTrue()
|
||||
verify(exactly = 0) { trackingContextProxy.eraseContext() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stale backup status WHEN duplicate save rejected THEN stored card state refreshed`() = runTest {
|
||||
// Arrange
|
||||
val storedWallet = MockUserWalletFactory.create(staleScanResponse)
|
||||
val freshWallet = MockUserWalletFactory.create(freshScanResponse)
|
||||
repository.userWallets.value = listOf(storedWallet)
|
||||
coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit)
|
||||
|
||||
// Act
|
||||
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold
|
||||
assertThat(updatedWallet.scanResponse.card.backupStatus)
|
||||
.isEqualTo(CardDTO.BackupStatus.Active(cardCount = 1))
|
||||
assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isTrue()
|
||||
coVerify(exactly = 1) { publicInformationRepository.save(any(), true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN locked wallet with stale backup status WHEN unlock with scanned card THEN stored card state refreshed`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val storedWallet = MockUserWalletFactory.create(staleScanResponse).let { wallet ->
|
||||
wallet.copy(
|
||||
scanResponse = wallet.scanResponse.copy(
|
||||
card = wallet.scanResponse.card.copy(wallets = emptyList()),
|
||||
),
|
||||
)
|
||||
}
|
||||
repository.userWallets.value = listOf(storedWallet)
|
||||
coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit)
|
||||
coEvery { sensitiveInformationRepository.getAll(any()) } returns CompletionResult.Success(emptyMap())
|
||||
|
||||
// Act
|
||||
val result = repository.unlock(
|
||||
userWalletId = storedWallet.walletId,
|
||||
unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(
|
||||
scanResponse = freshScanResponse,
|
||||
source = AnalyticsParam.ScreensSources.SignIn,
|
||||
),
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isRight()).isTrue()
|
||||
val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold
|
||||
assertThat(updatedWallet.scanResponse.card.backupStatus)
|
||||
.isEqualTo(CardDTO.BackupStatus.Active(cardCount = 1))
|
||||
assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isTrue()
|
||||
coVerify(exactly = 1) { publicInformationRepository.save(any(), true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active card of backup set scanned WHEN duplicate save rejected THEN stored card state refreshed`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val storedWallet = MockUserWalletFactory.create(staleScanResponse)
|
||||
val otherCardScanResponse = freshScanResponse.copy(
|
||||
card = freshScanResponse.card.copy(cardId = "OTHER-CARD"),
|
||||
)
|
||||
val freshWallet = MockUserWalletFactory.create(otherCardScanResponse)
|
||||
repository.userWallets.value = listOf(storedWallet)
|
||||
coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit)
|
||||
|
||||
// Act
|
||||
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold
|
||||
assertThat(updatedWallet.scanResponse.card.cardId).isEqualTo(storedWallet.scanResponse.card.cardId)
|
||||
assertThat(updatedWallet.scanResponse.card.backupStatus)
|
||||
.isEqualTo(CardDTO.BackupStatus.Active(cardCount = 1))
|
||||
assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isTrue()
|
||||
coVerify(exactly = 1) { publicInformationRepository.save(any(), true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no backup card of same wallet scanned WHEN duplicate save rejected THEN stored status downgraded`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val storedWallet = MockUserWalletFactory.create(freshScanResponse)
|
||||
val newCardScanResponse = staleScanResponse.copy(
|
||||
card = staleScanResponse.card.copy(cardId = "SAME-SEED-NEW-CARD"),
|
||||
)
|
||||
val freshWallet = MockUserWalletFactory.create(newCardScanResponse)
|
||||
repository.userWallets.value = listOf(storedWallet)
|
||||
coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit)
|
||||
|
||||
// Act
|
||||
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold
|
||||
assertThat(updatedWallet.scanResponse.card.cardId).isEqualTo(storedWallet.scanResponse.card.cardId)
|
||||
assertThat(updatedWallet.scanResponse.card.backupStatus).isEqualTo(CardDTO.BackupStatus.NoBackup)
|
||||
assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isFalse()
|
||||
coVerify(exactly = 1) { publicInformationRepository.save(any(), true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored card linked status WHEN duplicate save with another card rejected THEN status preserved`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val cardLinkedScanResponse = staleScanResponse.copy(
|
||||
card = staleScanResponse.card.copy(backupStatus = CardDTO.BackupStatus.CardLinked(cardCount = 1)),
|
||||
)
|
||||
val storedWallet = MockUserWalletFactory.create(cardLinkedScanResponse)
|
||||
val otherCardScanResponse = freshScanResponse.copy(
|
||||
card = freshScanResponse.card.copy(cardId = "OTHER-CARD"),
|
||||
)
|
||||
val freshWallet = MockUserWalletFactory.create(otherCardScanResponse)
|
||||
repository.userWallets.value = listOf(storedWallet)
|
||||
|
||||
// Act
|
||||
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
assertThat(repository.userWallets.value).containsExactly(storedWallet)
|
||||
coVerify(exactly = 0) { publicInformationRepository.save(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored card state is actual WHEN duplicate save rejected THEN nothing persisted`() = runTest {
|
||||
// Arrange
|
||||
val storedWallet = MockUserWalletFactory.create(freshScanResponse)
|
||||
val freshWallet = MockUserWalletFactory.create(freshScanResponse)
|
||||
repository.userWallets.value = listOf(storedWallet)
|
||||
|
||||
// Act
|
||||
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
assertThat(repository.userWallets.value).containsExactly(storedWallet)
|
||||
coVerify(exactly = 0) { publicInformationRepository.save(any(), any()) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
val staleScanResponse = MockScanResponseFactory.create(
|
||||
cardConfig = GenericCardConfig(maxWalletCount = 2),
|
||||
derivedKeys = emptyMap(),
|
||||
).let { scanResponse ->
|
||||
scanResponse.copy(card = scanResponse.card.copy(backupStatus = CardDTO.BackupStatus.NoBackup))
|
||||
}
|
||||
|
||||
val freshScanResponse = staleScanResponse.copy(
|
||||
card = staleScanResponse.card.copy(
|
||||
backupStatus = CardDTO.BackupStatus.Active(cardCount = 1),
|
||||
isAccessCodeSet = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -110,5 +110,17 @@
|
|||
{
|
||||
"name": "AND_16204_POLYMARKET_ENABLED",
|
||||
"version": "undefined"
|
||||
},
|
||||
{
|
||||
"name": "TWI_1638_VA_MVP0_ENABLED",
|
||||
"version": "6.0.1"
|
||||
},
|
||||
{
|
||||
"name": "TWI_1637_CASHBACK_AND_REACTIVATION_CAMPAIGNS_ENABLED",
|
||||
"version": "6.0.1"
|
||||
},
|
||||
{
|
||||
"name": "TWI_1522_MARKETING_BANNERS_ENABLED",
|
||||
"version": "6.0.1"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -323,7 +323,9 @@ fun BoxScope.FooterOverlay(
|
|||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter),
|
||||
) {
|
||||
if (gradientHeight > 0.dp) {
|
||||
val isGradientDisplayed = gradientHeight > 0.dp
|
||||
|
||||
if (isGradientDisplayed) {
|
||||
Fade(
|
||||
backgroundColor = fadeMax,
|
||||
height = gradientHeight,
|
||||
|
|
@ -333,7 +335,7 @@ fun BoxScope.FooterOverlay(
|
|||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(measuredFooterHeight ?: 0.dp)
|
||||
.background(fadeMax),
|
||||
.background(if (isGradientDisplayed) fadeMax else Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,13 @@ package com.tangem.core.ui.ds2.messagebanner
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ripple.RippleAlpha
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalRippleConfiguration
|
||||
import androidx.compose.material3.RippleConfiguration
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -29,6 +33,7 @@ import com.tangem.core.ui.ds2.glowring.TangemGlowRing
|
|||
import com.tangem.core.ui.ds2.surface.TangemSurface
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.clickableSingle
|
||||
import com.tangem.core.ui.extensions.conditionalCompose
|
||||
import com.tangem.core.ui.extensions.resolveAnnotatedReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
|
@ -40,11 +45,15 @@ import com.tangem.core.ui.res.generated.icons.ic_cross_circle_20_filled
|
|||
* Design-system v2 (DS3) **Message Banner** — low-level slot API: a [content] block above an
|
||||
* optional action-button row. For the common title/description layout, prefer the `title` overload.
|
||||
*
|
||||
* Version: 1.2
|
||||
*
|
||||
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5475-7680&m=dev)
|
||||
*
|
||||
* @param variant Visual appearance — background color + glow ring.
|
||||
* @param showGlowRing Whether the glow ring is drawn around the banner. `false` shows only the
|
||||
* background.
|
||||
* @param onClick Makes the whole banner clickable. Honored only when no action buttons are present — ignored while
|
||||
* [secondaryButton] or [primaryButton] is set.
|
||||
* @param secondaryButton Start action. `null` hides it.
|
||||
* @param primaryButton End action. `null` hides it.
|
||||
* @param content The banner body above the buttons.
|
||||
|
|
@ -54,26 +63,33 @@ fun TangemMessageBanner(
|
|||
modifier: Modifier = Modifier,
|
||||
variant: TangemMessageBanner.Variant = TangemMessageBanner.Variant.Default,
|
||||
showGlowRing: Boolean = true,
|
||||
onClick: (() -> Unit)? = null,
|
||||
secondaryButton: TangemMessageBanner.Button? = null,
|
||||
primaryButton: TangemMessageBanner.Button? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val tokens = variant.tokens()
|
||||
val isClickable = onClick != null && secondaryButton == null && primaryButton == null
|
||||
|
||||
Box(modifier = modifier) {
|
||||
TangemSurface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = tokens.background,
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
WithMessageBannerRipple(enabled = isClickable) {
|
||||
TangemSurface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = tokens.background,
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
) {
|
||||
content()
|
||||
MessageBannerButtons(secondaryButton = secondaryButton, primaryButton = primaryButton)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.conditionalCompose(isClickable) {
|
||||
clickableSingle(role = Role.Button) { onClick?.invoke() }
|
||||
}
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
content()
|
||||
MessageBannerButtons(secondaryButton = secondaryButton, primaryButton = primaryButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showGlowRing) {
|
||||
|
|
@ -90,6 +106,8 @@ fun TangemMessageBanner(
|
|||
* Design-system v2 (DS3) **Message Banner** — title/description header with optional slots and an
|
||||
* action-button row.
|
||||
*
|
||||
* Version: 1.2
|
||||
*
|
||||
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5475-7680&m=dev)
|
||||
*
|
||||
* @param title Banner headline.
|
||||
|
|
@ -97,6 +115,8 @@ fun TangemMessageBanner(
|
|||
* @param contentAlign Horizontal alignment of the text block.
|
||||
* @param showGlowRing Whether the glow ring is drawn around the banner. `false` shows only the
|
||||
* background.
|
||||
* @param onClick Makes the whole banner clickable. Honored only when no action buttons are present — ignored while
|
||||
* [secondaryButton] or [primaryButton] is set.
|
||||
* @param description Secondary line under the [title]. `null` hides it.
|
||||
* @param secondaryButton Start action. `null` hides it.
|
||||
* @param primaryButton End action. `null` hides it.
|
||||
|
|
@ -112,6 +132,7 @@ fun TangemMessageBanner(
|
|||
variant: TangemMessageBanner.Variant = TangemMessageBanner.Variant.Default,
|
||||
contentAlign: TangemMessageBanner.ContentAlign = TangemMessageBanner.ContentAlign.Start,
|
||||
showGlowRing: Boolean = true,
|
||||
onClick: (() -> Unit)? = null,
|
||||
description: TextReference? = null,
|
||||
secondaryButton: TangemMessageBanner.Button? = null,
|
||||
primaryButton: TangemMessageBanner.Button? = null,
|
||||
|
|
@ -123,6 +144,7 @@ fun TangemMessageBanner(
|
|||
modifier = modifier,
|
||||
variant = variant,
|
||||
showGlowRing = showGlowRing,
|
||||
onClick = onClick,
|
||||
secondaryButton = secondaryButton,
|
||||
primaryButton = primaryButton,
|
||||
) {
|
||||
|
|
@ -252,7 +274,7 @@ private fun MessageBannerTextWrapper(
|
|||
)
|
||||
}
|
||||
extraBottomSlot?.let { slot ->
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) { slot() }
|
||||
Column(modifier = Modifier.padding(top = 8.dp)) { slot() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -388,6 +410,29 @@ fun TangemMessageBanner.CloseButton(
|
|||
)
|
||||
}
|
||||
|
||||
/** Overrides the ripple for a clickable banner; pass-through when [enabled] is `false`. */
|
||||
@Composable
|
||||
private fun WithMessageBannerRipple(enabled: Boolean, content: @Composable () -> Unit) {
|
||||
if (enabled) {
|
||||
CompositionLocalProvider(LocalRippleConfiguration provides messageBannerRipple(), content = content)
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/** Press ripple of a clickable banner — the `color/interaction/press/static-light` token. */
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
private fun messageBannerRipple(): RippleConfiguration = RippleConfiguration(
|
||||
color = TangemTheme.colors3.interaction.press.staticLight,
|
||||
rippleAlpha = RippleAlpha(
|
||||
draggedAlpha = 0f,
|
||||
focusedAlpha = 0f,
|
||||
hoveredAlpha = 0.05f,
|
||||
pressedAlpha = 0.1f,
|
||||
),
|
||||
)
|
||||
|
||||
/** Resolved appearance tokens for a [TangemMessageBanner.Variant]. */
|
||||
private data class MessageBannerTokens(val background: Color, val glowRing: TangemGlowRing.Variant)
|
||||
|
||||
|
|
@ -450,6 +495,12 @@ private fun TangemMessageBannerPreview() {
|
|||
},
|
||||
primaryButton = TangemMessageBanner.Button(text = stringReference("Invite friends"), onClick = {}),
|
||||
)
|
||||
TangemMessageBanner(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
title = stringReference("Clickable banner"),
|
||||
description = stringReference("Whole banner is tappable when no buttons are set."),
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -145,7 +145,8 @@ fun BigDecimalCryptoFormatStyled.defaultAmount(spanStyleReference: SpanStyleRefe
|
|||
val formattedAmount = formatter.format(value)
|
||||
|
||||
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
|
||||
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it) } ?: formattedAmount.length
|
||||
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it).takeIf { i -> i >= 0 } }
|
||||
?: formattedAmount.length
|
||||
|
||||
combinedReference(
|
||||
stringReference(formattedAmount.take(separatorIndex)),
|
||||
|
|
@ -167,8 +168,9 @@ fun BigDecimalCryptoFormatStyled.defaultAmount(spanStyleReference: SpanStyleRefe
|
|||
cryptoCurrencySymbol = symbol,
|
||||
)
|
||||
|
||||
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
|
||||
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it) } ?: formattedAmount.length
|
||||
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.monetaryDecimalSeparator
|
||||
val separatorIndex = decimalSeparator?.let { formattedAmount.indexOf(it).takeIf { i -> i >= 0 } }
|
||||
?: formattedAmount.length
|
||||
|
||||
combinedReference(
|
||||
stringReference(formattedAmount.take(separatorIndex)),
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ fun BigDecimalFiatFormatStyled.defaultAmount(spanStyleReference: SpanStyleRefere
|
|||
value.zeroIfRoundsToZero(FIAT_MARKET_DEFAULT_DIGITS)
|
||||
}
|
||||
|
||||
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
|
||||
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.monetaryDecimalSeparator
|
||||
val currencySymbol = formatterCurrency.getSymbol(locale)
|
||||
val rawFormatted = formatter.format(formattingAmount)
|
||||
|
||||
|
|
@ -200,7 +200,7 @@ private fun BigDecimalFiatFormatStyled.price(spanStyleReference: SpanStyleRefere
|
|||
roundingMode = RoundingMode.HALF_UP
|
||||
}
|
||||
|
||||
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
|
||||
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.monetaryDecimalSeparator
|
||||
val currencySymbol = formatterCurrency.getSymbol(locale)
|
||||
val rawFormatted = formatter.format(priceAmount)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
package com.tangem.core.ui.format.bigdecimal
|
||||
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.core.ui.extensions.SpanStyleReference
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
import java.text.DecimalFormat
|
||||
import java.text.NumberFormat
|
||||
import java.util.Locale
|
||||
|
||||
internal class BigDecimalCryptoFormatTest {
|
||||
|
|
@ -10,6 +15,7 @@ internal class BigDecimalCryptoFormatTest {
|
|||
private val testLocale = Locale.US
|
||||
private val testLocale2 = Locale.GERMANY
|
||||
private val symbol = "BTC"
|
||||
private val spanStyleStub = SpanStyleReference { SpanStyle() }
|
||||
|
||||
// === defaultAmount() ===
|
||||
|
||||
|
|
@ -125,6 +131,39 @@ internal class BigDecimalCryptoFormatTest {
|
|||
.isEqualTo("12,345,678.11".addSymbolWithSpaceLeft(symbol))
|
||||
}
|
||||
|
||||
// === defaultAmount() styled ===
|
||||
|
||||
@Test
|
||||
fun `GIVEN locale with distinct monetary separator WHEN styled defaultAmount THEN fraction split without crash`() {
|
||||
// Arrange
|
||||
// Regression: fr_CH plain separator is ',' but currency output uses '.' — indexOf(',') returned -1,
|
||||
// and formattedAmount.take(-1) threw IllegalArgumentException
|
||||
val swissLocale = Locale("fr", "CH")
|
||||
val symbols = (NumberFormat.getCurrencyInstance(swissLocale) as DecimalFormat).decimalFormatSymbols
|
||||
Truth.assertThat(symbols.monetaryDecimalSeparator).isNotEqualTo(symbols.decimalSeparator)
|
||||
|
||||
val testValue = BigDecimal("12.34")
|
||||
|
||||
// Act
|
||||
val formatted = testValue.formatStyled {
|
||||
cryptoStyled(
|
||||
symbol = symbol,
|
||||
decimals = 8,
|
||||
spanStyleReference = spanStyleStub,
|
||||
locale = swissLocale,
|
||||
)
|
||||
}
|
||||
|
||||
// Assert
|
||||
val refs = (formatted as TextReference.Combined).refs.data
|
||||
Truth.assertThat(refs).hasSize(2)
|
||||
Truth.assertThat((refs[0] as TextReference.Str).value).isEqualTo("12")
|
||||
|
||||
val fraction = refs[1] as TextReference.StyledStr
|
||||
Truth.assertThat(fraction.value).startsWith("${symbols.monetaryDecimalSeparator}34")
|
||||
Truth.assertThat(fraction.value).endsWith(symbol)
|
||||
}
|
||||
|
||||
// === shorted() ===
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
package com.tangem.core.ui.format.bigdecimal
|
||||
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.core.ui.extensions.SpanStyleReference
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.Arguments
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
import java.math.BigDecimal
|
||||
import java.text.DecimalFormat
|
||||
import java.text.NumberFormat
|
||||
import java.util.Locale
|
||||
|
||||
internal class BigDecimalFiatFormatTest {
|
||||
|
|
@ -16,6 +21,8 @@ internal class BigDecimalFiatFormatTest {
|
|||
val usdCurrencyCode = "USD"
|
||||
val usdSymbol = "$"
|
||||
|
||||
private val spanStyleStub = SpanStyleReference { SpanStyle() }
|
||||
|
||||
private fun String.addUsdSymbolLeft() = usdSymbol + this
|
||||
|
||||
// === defaultAmount() ===
|
||||
|
|
@ -132,6 +139,40 @@ internal class BigDecimalFiatFormatTest {
|
|||
.isEqualTo("-" + "0.01".addUsdSymbolLeft())
|
||||
}
|
||||
|
||||
// === defaultAmount() styled ===
|
||||
|
||||
@Test
|
||||
fun `GIVEN locale with distinct monetary separator WHEN styled defaultAmount THEN fraction split at monetary separator`() {
|
||||
// Arrange
|
||||
// fr_CH plain separator is ',' but currency output uses '.' — searching for the plain one
|
||||
// failed to split the amount into whole and styled fractional parts
|
||||
val swissLocale = Locale("fr", "CH")
|
||||
val symbols = (NumberFormat.getCurrencyInstance(swissLocale) as DecimalFormat).decimalFormatSymbols
|
||||
Truth.assertThat(symbols.monetaryDecimalSeparator).isNotEqualTo(symbols.decimalSeparator)
|
||||
|
||||
val testValue = BigDecimal("12.34")
|
||||
|
||||
// Act
|
||||
val formatted = testValue.formatStyled {
|
||||
fiat(
|
||||
fiatCurrencyCode = usdCurrencyCode,
|
||||
fiatCurrencySymbol = usdSymbol,
|
||||
spanStyleReference = spanStyleStub,
|
||||
locale = swissLocale,
|
||||
)
|
||||
}
|
||||
|
||||
// Assert
|
||||
val refs = (formatted as TextReference.Combined).refs.data
|
||||
Truth.assertThat(refs).hasSize(3)
|
||||
Truth.assertThat(refs[0]).isEqualTo(TextReference.EMPTY)
|
||||
Truth.assertThat((refs[1] as TextReference.Str).value).isEqualTo("12")
|
||||
|
||||
val fraction = refs[2] as TextReference.StyledStr
|
||||
Truth.assertThat(fraction.value).startsWith("${symbols.monetaryDecimalSeparator}34")
|
||||
Truth.assertThat(fraction.value).endsWith(usdSymbol)
|
||||
}
|
||||
|
||||
// === approximateAmount() ===
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -2,17 +2,19 @@
|
|||
|
||||
Generates Kotlin (Jetpack Compose) source files from design tokens and icons defined in the `ds-tokens` git submodule.
|
||||
|
||||
## Making sure submodule is at the pinned commit
|
||||
|
||||
***For the most cases*** (a fresh checkout, or making sure the submodule is at the pinned commit), use:
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
## Updating tokens
|
||||
|
||||
> **Note:** You only need `git submodule update --remote` when you want to pull **new** design tokens
|
||||
> from the remote `ds-tokens` repository. If you're just regenerating Kotlin from the tokens already
|
||||
> checked out (e.g. changing the generation script), **skip step 1** — don't run it without the need,
|
||||
> as it moves the submodule pointer to the latest remote commit and pulls in unrelated token changes.
|
||||
>
|
||||
> For all other cases (a fresh checkout, or making sure the submodule is at the pinned commit), use:
|
||||
> ```bash
|
||||
> git submodule update --init --recursive
|
||||
> ```
|
||||
> This checks out the submodule at the commit already recorded in the repo, without pulling anything new.
|
||||
|
||||
1. *(Only if you need newer tokens)* Update the `ds-tokens` submodule to the latest commit:
|
||||
|
|
|
|||
|
|
@ -371,10 +371,14 @@ internal interface TangemPayDataModule {
|
|||
fun provideCreateVirtualAccountOrderUseCase(
|
||||
onboardingRepository: OnboardingRepository,
|
||||
pollingUseCase: StartTangemPayOrderPollingUseCase,
|
||||
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
|
||||
appCoroutineScope: AppCoroutineScope,
|
||||
): CreateVirtualAccountOrderUseCase {
|
||||
return CreateVirtualAccountOrderUseCase(
|
||||
onboardingRepository = onboardingRepository,
|
||||
pollingUseCase = pollingUseCase,
|
||||
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
|
||||
appCoroutineScope = appCoroutineScope,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -115,6 +115,10 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
logger.i("invoke() end ${params.userWalletId}: isRight=${result.isRight()}")
|
||||
}
|
||||
|
||||
override suspend fun markVirtualAccountProcessing(userWalletId: UserWalletId) {
|
||||
paymentAccountStatusesStore.markVirtualAccountProcessing(userWalletId)
|
||||
}
|
||||
|
||||
private suspend fun proceedHasTangemPayResult(
|
||||
account: Account.Payment,
|
||||
hasTangemPay: Boolean,
|
||||
|
|
@ -460,9 +464,16 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
|
||||
/**
|
||||
* Resolves the Virtual Account on-ramp dimension (VA MVP0, TWI-1638). Gated by the feature toggle.
|
||||
* If a product instance with [SpecificationDataType.ACCOUNT] exists, eagerly fetches its bank credentials
|
||||
* ([VirtualAccountOnramp.Available]); otherwise surfaces [VirtualAccountOnramp.Eligible] when the wallet has
|
||||
* the `VISA_VIRTUAL_ACCOUNT` eligibility channel (fetched fresh via the user token), else `null`.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. A product instance with [SpecificationDataType.ACCOUNT] exists — clears any stale persisted VA order id
|
||||
* (idempotent) and eagerly fetches its bank credentials ([VirtualAccountOnramp.Available], or
|
||||
* [VirtualAccountOnramp.BankCredentialsError] on failure).
|
||||
* 2. Otherwise, a VA order id is persisted locally — checks its status via `getOrderData`:
|
||||
* NEW/PROCESSING/COMPLETED (or a lookup failure) surface [VirtualAccountOnramp.Processing]; CANCELED
|
||||
* clears the persisted id and falls through to eligibility.
|
||||
* 3. Otherwise (or after a CANCELED order) — surfaces [VirtualAccountOnramp.Eligible] when the wallet has
|
||||
* the `VISA_VIRTUAL_ACCOUNT` eligibility channel (fetched fresh via the user token), else `null`.
|
||||
*/
|
||||
private suspend fun CustomerInfo.resolveVirtualAccountOnramp(userWalletId: UserWalletId): VirtualAccountOnramp? {
|
||||
if (!virtualAccountFeatureToggles.isVaMvp0Enabled) return null
|
||||
|
|
@ -471,10 +482,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
it.specificationDataType == SpecificationDataType.ACCOUNT
|
||||
}
|
||||
if (accountInstance != null) {
|
||||
// Order provisioned into an ACCOUNT product instance — drop the in-flight order hint (idempotent).
|
||||
onboardingRepository.clearVirtualAccountOrderId(userWalletId)
|
||||
return onboardingRepository.getBankCredentials(userWalletId, accountInstance.id).fold(
|
||||
ifLeft = { error ->
|
||||
logger.e("getBankCredentials failed for ${accountInstance.id}: $error")
|
||||
null
|
||||
VirtualAccountOnramp.BankCredentialsError
|
||||
},
|
||||
ifRight = { credentials ->
|
||||
VirtualAccountOnramp.Available(
|
||||
|
|
@ -485,6 +498,32 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
val vaOrderId = onboardingRepository.getVirtualAccountOrderId(userWalletId)
|
||||
if (vaOrderId != null) {
|
||||
return customerOrderRepository.getOrderData(userWalletId = userWalletId, orderId = vaOrderId).fold(
|
||||
ifLeft = { error ->
|
||||
logger.e("getOrderData(va) failed for $vaOrderId: $error")
|
||||
VirtualAccountOnramp.Processing
|
||||
},
|
||||
ifRight = { orderData ->
|
||||
when (orderData.status) {
|
||||
OrderStatus.CANCELED -> {
|
||||
onboardingRepository.clearVirtualAccountOrderId(userWalletId)
|
||||
resolveEligibility(userWalletId)
|
||||
}
|
||||
OrderStatus.NEW,
|
||||
OrderStatus.PROCESSING,
|
||||
OrderStatus.COMPLETED,
|
||||
-> VirtualAccountOnramp.Processing
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return resolveEligibility(userWalletId)
|
||||
}
|
||||
|
||||
private suspend fun resolveEligibility(userWalletId: UserWalletId): VirtualAccountOnramp? {
|
||||
return onboardingRepository.fetchCustomerEligibility(userWalletId).fold(
|
||||
ifLeft = { error ->
|
||||
logger.e("fetchCustomerEligibility failed for $userWalletId: $error")
|
||||
|
|
|
|||
|
|
@ -207,6 +207,13 @@ internal class DefaultOnboardingRepository @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun clearVirtualAccountOrderId(userWalletId: UserWalletId) {
|
||||
withContext(dispatcherProvider.io) {
|
||||
val customerWalletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
|
||||
tangemPayStorage.clearVirtualAccountOrderId(customerWalletAddress)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getUserWallet(userWalletId: UserWalletId): UserWallet {
|
||||
return userWalletsListRepository.userWallets.value?.firstOrNull { it.walletId == userWalletId }
|
||||
?: error("no userWallet found")
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.tangem.domain.models.StatusSource
|
|||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||
import com.tangem.domain.models.account.VirtualAccountOnramp
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
|
|
@ -89,6 +90,28 @@ internal class PaymentAccountStatusesStore(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically marks the cached VA on-ramp as [VirtualAccountOnramp.Processing] so the UI reflects a
|
||||
|
||||
* read-modify-write atomically inside [RuntimeSharedStore.update] to avoid a lost update racing with a
|
||||
* concurrent [store]/[updateStatusSource] call. No-op (no write) when there is no cached entry for
|
||||
* [userWalletId], or when its value isn't [PaymentAccountStatusValue.Loaded]. Not persisted, mirroring
|
||||
* [updateStatusSource].
|
||||
*/
|
||||
suspend fun markVirtualAccountProcessing(userWalletId: UserWalletId) {
|
||||
logger.i("markVirtualAccountProcessing($userWalletId)")
|
||||
runtimeStore.update(emptyMap()) { stored ->
|
||||
stored.toMutableMap().apply {
|
||||
val paymentAccountStatus = this[userWalletId.stringValue] ?: return@update stored
|
||||
val loaded = paymentAccountStatus.value as? PaymentAccountStatusValue.Loaded ?: return@update stored
|
||||
val newValue = paymentAccountStatus.copy(
|
||||
value = loaded.copy(virtualAccount = VirtualAccountOnramp.Processing),
|
||||
)
|
||||
put(key = userWalletId.stringValue, value = newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun store(userWalletId: UserWalletId, status: AccountStatus.Payment) {
|
||||
logger.i("store($userWalletId): valueType=${status.value::class.simpleName}")
|
||||
coroutineScope {
|
||||
|
|
|
|||
|
|
@ -113,6 +113,14 @@ internal class MockAwareOnboardingRepository @Inject constructor(
|
|||
real.storeVirtualAccountOrderId(userWalletId, vaOrderId)
|
||||
}
|
||||
|
||||
override suspend fun clearVirtualAccountOrderId(userWalletId: UserWalletId) {
|
||||
if (isMockMode) {
|
||||
mockVaOrderIds.remove(userWalletId)
|
||||
return
|
||||
}
|
||||
real.clearVirtualAccountOrderId(userWalletId)
|
||||
}
|
||||
|
||||
// The "existing Tangem Pay customer" gate (decides whether an active Payment account — and accounts mode —
|
||||
// appears). Delegates to WireMock's checkCustomerWalletId via the real repo (static token, no signing), so it
|
||||
// is driven by the `tangem_pay_eligibility` scenario: `Started` (default) → 404/NotPaeraCustomer → no account;
|
||||
|
|
|
|||
|
|
@ -2,8 +2,15 @@ package com.tangem.data.pay.flow
|
|||
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter
|
||||
import com.tangem.data.pay.store.PaymentAccountStatusesStore
|
||||
import com.tangem.data.pay.store.WalletIdWithPaymentStatus
|
||||
import com.tangem.data.pay.store.WalletIdWithPaymentStatusDM
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.account.BankCredentials
|
||||
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||
|
|
@ -22,6 +29,8 @@ import com.tangem.domain.pay.TangemPayCurrencyFactory
|
|||
import com.tangem.domain.pay.TangemPayEligibilityManager
|
||||
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
|
||||
import com.tangem.domain.pay.model.CustomerInfo
|
||||
import com.tangem.domain.pay.model.OrderData
|
||||
import com.tangem.domain.pay.model.OrderStatus
|
||||
import com.tangem.domain.pay.repository.*
|
||||
import com.tangem.domain.pay.usecase.GetTangemPayTariffPlanStateUseCase
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
|
||||
|
|
@ -29,6 +38,8 @@ import com.tangem.domain.visa.error.VisaApiError
|
|||
import com.tangem.features.tangempay.TangemPayFeatureToggles
|
||||
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
|
||||
import com.tangem.security.DeviceSecurityInfoProvider
|
||||
import com.tangem.test.core.TestAppCoroutineScope
|
||||
import com.tangem.test.core.datastore.MockStateDataStore
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -226,7 +237,39 @@ internal class DefaultPaymentAccountStatusFetcherTest {
|
|||
.map { it.value }
|
||||
.filterIsInstance<PaymentAccountStatusValue.Loaded>()
|
||||
.lastOrNull()
|
||||
return requireNotNull(loaded) { "Expected at least one Loaded status to be stored; stored: ${map { it.value::class.simpleName }}" }
|
||||
return requireNotNull(
|
||||
loaded,
|
||||
) { "Expected at least one Loaded status to be stored; stored: ${map { it.value::class.simpleName }}" }
|
||||
}
|
||||
|
||||
/** Builds a [PaymentAccountStatusValue.Loaded] fixture with every field defaulted except [virtualAccount]. */
|
||||
private fun loadedFixture(virtualAccount: VirtualAccountOnramp? = null): PaymentAccountStatusValue.Loaded {
|
||||
val token: CryptoCurrency.Token = mockk(relaxed = true)
|
||||
return PaymentAccountStatusValue.Loaded(
|
||||
source = StatusSource.ACTUAL,
|
||||
customerId = "cust_1",
|
||||
depositAddress = "0xdeposit",
|
||||
balance = PaymentAccountStatusValue.Balance(
|
||||
fiatBalance = PaymentAccountStatusValue.FiatBalance(
|
||||
availableBalance = BigDecimal.TEN,
|
||||
currency = "USD",
|
||||
),
|
||||
cryptoBalance = PaymentAccountStatusValue.CryptoBalance(
|
||||
id = "usdc",
|
||||
chainId = 137L,
|
||||
depositAddress = "0xdeposit",
|
||||
tokenContractAddress = "0xcontract",
|
||||
balance = BigDecimal.TEN,
|
||||
),
|
||||
availableForWithdrawal = BigDecimal.TEN,
|
||||
),
|
||||
cryptoCurrency = token,
|
||||
cards = emptyList(),
|
||||
fiatRate = null,
|
||||
error = null,
|
||||
virtualAccount = virtualAccount,
|
||||
tariffPlan = null
|
||||
)
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
|
@ -258,6 +301,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
|
|||
)
|
||||
stubHappyPath(customerInfo)
|
||||
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
|
||||
coEvery { onboardingRepository.clearVirtualAccountOrderId(userWalletId) } just Runs
|
||||
coEvery {
|
||||
onboardingRepository.getBankCredentials(userWalletId, "pi_account")
|
||||
} returns Either.Right(bankCredentialsFixture)
|
||||
|
|
@ -274,10 +318,11 @@ internal class DefaultPaymentAccountStatusFetcherTest {
|
|||
bankCredentials = bankCredentialsFixture,
|
||||
),
|
||||
)
|
||||
coVerify(exactly = 1) { onboardingRepository.clearVirtualAccountOrderId(userWalletId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN toggle on and ACCOUNT instance but bank credentials fetch fails WHEN invoke THEN virtualAccount is null`() =
|
||||
fun `GIVEN toggle on and ACCOUNT instance but bank credentials fetch fails WHEN invoke THEN virtualAccount is Error`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val customerInfo = buildCustomerInfo(
|
||||
|
|
@ -285,6 +330,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
|
|||
)
|
||||
stubHappyPath(customerInfo)
|
||||
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
|
||||
coEvery { onboardingRepository.clearVirtualAccountOrderId(userWalletId) } just Runs
|
||||
coEvery {
|
||||
onboardingRepository.getBankCredentials(userWalletId, "pi_account")
|
||||
} returns VisaApiError.UnknownWithoutCode.left()
|
||||
|
|
@ -295,7 +341,8 @@ internal class DefaultPaymentAccountStatusFetcherTest {
|
|||
|
||||
// Assert
|
||||
val loaded = storedStatuses.lastLoaded()
|
||||
assertThat(loaded.virtualAccount).isNull()
|
||||
assertThat(loaded.virtualAccount).isEqualTo(VirtualAccountOnramp.BankCredentialsError)
|
||||
coVerify(exactly = 1) { onboardingRepository.clearVirtualAccountOrderId(userWalletId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -305,6 +352,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
|
|||
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
|
||||
stubHappyPath(customerInfo)
|
||||
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
|
||||
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns null
|
||||
coEvery {
|
||||
onboardingRepository.fetchCustomerEligibility(userWalletId)
|
||||
} returns Either.Right(listOf(TangemPayEligibilityType.VISA_VIRTUAL_ACCOUNT))
|
||||
|
|
@ -325,6 +373,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
|
|||
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
|
||||
stubHappyPath(customerInfo)
|
||||
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
|
||||
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns null
|
||||
coEvery {
|
||||
onboardingRepository.fetchCustomerEligibility(userWalletId)
|
||||
} returns VisaApiError.UnknownWithoutCode.left()
|
||||
|
|
@ -337,6 +386,168 @@ internal class DefaultPaymentAccountStatusFetcherTest {
|
|||
val loaded = storedStatuses.lastLoaded()
|
||||
assertThat(loaded.virtualAccount).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no instance and va order PROCESSING WHEN invoke THEN virtualAccount is Processing`() = runTest {
|
||||
// Arrange
|
||||
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
|
||||
stubHappyPath(customerInfo)
|
||||
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
|
||||
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns "va-1"
|
||||
coEvery {
|
||||
customerOrderRepository.getOrderData(userWalletId, "va-1")
|
||||
} returns OrderData(customerId = "c1", status = OrderStatus.PROCESSING, withdrawTxHash = null).right()
|
||||
val storedStatuses = captureStoredStatuses()
|
||||
|
||||
// Act
|
||||
fetcher.invoke(params)
|
||||
|
||||
// Assert
|
||||
assertThat(storedStatuses.lastLoaded().virtualAccount).isEqualTo(VirtualAccountOnramp.Processing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no instance and va order COMPLETED but instance absent WHEN invoke THEN virtualAccount is Processing`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
|
||||
stubHappyPath(customerInfo)
|
||||
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
|
||||
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns "va-1"
|
||||
coEvery {
|
||||
customerOrderRepository.getOrderData(userWalletId, "va-1")
|
||||
} returns OrderData(customerId = "c1", status = OrderStatus.COMPLETED, withdrawTxHash = null).right()
|
||||
val storedStatuses = captureStoredStatuses()
|
||||
|
||||
// Act
|
||||
fetcher.invoke(params)
|
||||
|
||||
// Assert
|
||||
assertThat(storedStatuses.lastLoaded().virtualAccount).isEqualTo(VirtualAccountOnramp.Processing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no instance and va getOrderData fails WHEN invoke THEN virtualAccount is Processing`() = runTest {
|
||||
// Arrange
|
||||
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
|
||||
stubHappyPath(customerInfo)
|
||||
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
|
||||
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns "va-1"
|
||||
coEvery {
|
||||
customerOrderRepository.getOrderData(userWalletId, "va-1")
|
||||
} returns VisaApiError.UnknownWithoutCode.left()
|
||||
val storedStatuses = captureStoredStatuses()
|
||||
|
||||
// Act
|
||||
fetcher.invoke(params)
|
||||
|
||||
// Assert
|
||||
assertThat(storedStatuses.lastLoaded().virtualAccount).isEqualTo(VirtualAccountOnramp.Processing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no instance and va order CANCELED WHEN invoke THEN id cleared and falls back to eligibility`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
|
||||
stubHappyPath(customerInfo)
|
||||
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
|
||||
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns "va-1"
|
||||
coEvery {
|
||||
customerOrderRepository.getOrderData(userWalletId, "va-1")
|
||||
} returns OrderData(customerId = "c1", status = OrderStatus.CANCELED, withdrawTxHash = null).right()
|
||||
coEvery { onboardingRepository.clearVirtualAccountOrderId(userWalletId) } just Runs
|
||||
coEvery {
|
||||
onboardingRepository.fetchCustomerEligibility(userWalletId)
|
||||
} returns Either.Right(listOf(TangemPayEligibilityType.VISA_VIRTUAL_ACCOUNT))
|
||||
val storedStatuses = captureStoredStatuses()
|
||||
|
||||
// Act
|
||||
fetcher.invoke(params)
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { onboardingRepository.clearVirtualAccountOrderId(userWalletId) }
|
||||
assertThat(storedStatuses.lastLoaded().virtualAccount).isEqualTo(VirtualAccountOnramp.Eligible)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [markVirtualAccountProcessing] now delegates entirely to the atomic
|
||||
* [PaymentAccountStatusesStore.markVirtualAccountProcessing] (read-modify-write happens inside the store's
|
||||
* `runtimeStore.update` lambda, see [REDACTED_TASK_KEY] review). A mocked store can't exercise that internal branching,
|
||||
* so these tests wire the fetcher to a real [PaymentAccountStatusesStore] (real [RuntimeSharedStore] +
|
||||
* in-memory persistence fake) and assert on its resulting state — exercising the delegate wiring and the
|
||||
* store's atomic logic together.
|
||||
*/
|
||||
@Nested
|
||||
inner class MarkVirtualAccountProcessing {
|
||||
|
||||
private val runtimeStore = RuntimeSharedStore<WalletIdWithPaymentStatus>()
|
||||
private val persistenceStore = MockStateDataStore<WalletIdWithPaymentStatusDM>(default = emptyMap())
|
||||
private val converter: PaymentAccountStatusValueDMConverter = mockk(relaxed = true)
|
||||
|
||||
private val realStore = PaymentAccountStatusesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
converter = converter,
|
||||
scope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
private val realFetcher = DefaultPaymentAccountStatusFetcher(
|
||||
paymentAccountStatusesStore = realStore,
|
||||
onboardingRepository = onboardingRepository,
|
||||
customerOrderRepository = customerOrderRepository,
|
||||
deviceSecurity = deviceSecurity,
|
||||
dispatchers = dispatchers,
|
||||
tangemPayCurrencyFactory = tangemPayCurrencyFactory,
|
||||
eligibilityManager = eligibilityManager,
|
||||
reissueCardRepository = reissueCardRepository,
|
||||
singleQuoteSupplier = singleQuoteSupplier,
|
||||
closeCardRepository = closeCardRepository,
|
||||
cardDetailsRepository = cardDetailsRepository,
|
||||
issueCardRepository = issueCardRepository,
|
||||
virtualAccountFeatureToggles = virtualAccountFeatureToggles,
|
||||
tangemPayFeatureToggles = tangemPayFeatureToggles,
|
||||
getTangemPayTariffPlanStateUseCase = getTangemPayTariffPlanStateUseCase,
|
||||
)
|
||||
|
||||
private val account = Account.Payment(userWalletId = userWalletId)
|
||||
|
||||
@Test
|
||||
fun `GIVEN cached Loaded with eligible onramp WHEN mark THEN virtualAccount becomes Processing`() = runTest {
|
||||
// Arrange
|
||||
val loaded = loadedFixture(virtualAccount = VirtualAccountOnramp.Eligible)
|
||||
realStore.store(userWalletId, AccountStatus.Payment(account = account, value = loaded))
|
||||
|
||||
// Act
|
||||
realFetcher.markVirtualAccountProcessing(userWalletId)
|
||||
|
||||
// Assert
|
||||
val updated = realStore.getSyncOrNull(userWalletId)?.value
|
||||
assertThat(updated).isEqualTo(loaded.copy(virtualAccount = VirtualAccountOnramp.Processing))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no cached value WHEN mark THEN store stays empty`() = runTest {
|
||||
// Act
|
||||
realFetcher.markVirtualAccountProcessing(userWalletId)
|
||||
|
||||
// Assert
|
||||
assertThat(realStore.getSyncOrNull(userWalletId)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cached non-Loaded value WHEN mark THEN value stays unchanged`() = runTest {
|
||||
// Arrange
|
||||
val issuingCard = PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
|
||||
realStore.store(userWalletId, AccountStatus.Payment(account = account, value = issuingCard))
|
||||
|
||||
// Act
|
||||
realFetcher.markVirtualAccountProcessing(userWalletId)
|
||||
|
||||
// Assert
|
||||
assertThat(realStore.getSyncOrNull(userWalletId)?.value).isEqualTo(issuingCard)
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
|
|
|||
|
|
@ -21,4 +21,21 @@ sealed interface VirtualAccountOnramp {
|
|||
val productInstanceId: String,
|
||||
val bankCredentials: BankCredentials,
|
||||
) : VirtualAccountOnramp
|
||||
|
||||
/**
|
||||
* A VA on-ramp order has been submitted and is being provisioned (order status NEW/PROCESSING, or
|
||||
* COMPLETED before the ACCOUNT product instance appears). The bank-transfer entry point stays visible;
|
||||
* tapping it shows the "Preparing your banking details" bottom sheet. Transient — never persisted,
|
||||
* re-resolved on the next status fetch, cleared once the ACCOUNT instance appears or the order is canceled.
|
||||
*/
|
||||
@Serializable
|
||||
data object Processing : VirtualAccountOnramp
|
||||
|
||||
/**
|
||||
* VA product instance exists, but its bank credentials failed to load. The bank-transfer entry point
|
||||
* stays visible; tapping it surfaces a retryable "couldn't load banking details" error instead of the
|
||||
* requisites. Transient — never persisted, re-resolved on the next status fetch.
|
||||
*/
|
||||
@Serializable
|
||||
data object BankCredentialsError : VirtualAccountOnramp
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ package com.tangem.domain.pay.flow
|
|||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.core.flow.FlowFetcher
|
||||
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||
import com.tangem.domain.models.account.VirtualAccountOnramp
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
interface PaymentAccountStatusFetcher : FlowFetcher<PaymentAccountStatusFetcher.Params> {
|
||||
|
|
@ -10,5 +12,12 @@ interface PaymentAccountStatusFetcher : FlowFetcher<PaymentAccountStatusFetcher.
|
|||
return invoke(Params(userWalletId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically marks the cached VA on-ramp as [VirtualAccountOnramp.Processing] so the UI reflects a
|
||||
|
||||
* cached [PaymentAccountStatusValue.Loaded].
|
||||
*/
|
||||
suspend fun markVirtualAccountProcessing(userWalletId: UserWalletId)
|
||||
|
||||
data class Params(val userWalletId: UserWalletId)
|
||||
}
|
||||
|
|
@ -42,6 +42,8 @@ interface OnboardingRepository {
|
|||
|
||||
suspend fun storeVirtualAccountOrderId(userWalletId: UserWalletId, vaOrderId: String)
|
||||
|
||||
suspend fun clearVirtualAccountOrderId(userWalletId: UserWalletId)
|
||||
|
||||
suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean>
|
||||
|
||||
suspend fun checkCustomerEligibility(): List<TangemPayEligibilityType>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@ package com.tangem.domain.pay.usecase
|
|||
import arrow.core.Either
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
|
||||
import com.tangem.domain.pay.model.OrderStatus
|
||||
import com.tangem.domain.pay.model.TangemPayOrderInfo
|
||||
import com.tangem.domain.pay.repository.OnboardingRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
|
|
@ -20,6 +23,8 @@ import java.util.UUID
|
|||
class CreateVirtualAccountOrderUseCase(
|
||||
private val onboardingRepository: OnboardingRepository,
|
||||
private val pollingUseCase: StartTangemPayOrderPollingUseCase,
|
||||
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
|
||||
private val appCoroutineScope: AppCoroutineScope,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
userWalletId: UserWalletId,
|
||||
|
|
@ -33,10 +38,15 @@ class CreateVirtualAccountOrderUseCase(
|
|||
idempotencyKey = UUID.randomUUID().toString(),
|
||||
).bind()
|
||||
onboardingRepository.storeVirtualAccountOrderId(userWalletId = userWalletId, vaOrderId = vaOrderId)
|
||||
pollingUseCase.invoke(
|
||||
order = TangemPayOrderInfo(orderId = vaOrderId, orderStatus = OrderStatus.NEW),
|
||||
userWalletId = userWalletId,
|
||||
)
|
||||
// Optimistically flip the cached on-ramp to Processing so the UI shows "Preparing" immediately
|
||||
// (no wait for the poll/refetch to confirm).
|
||||
paymentAccountStatusFetcher.markVirtualAccountProcessing(userWalletId)
|
||||
appCoroutineScope.launch {
|
||||
pollingUseCase.invoke(
|
||||
order = TangemPayOrderInfo(orderId = vaOrderId, orderStatus = OrderStatus.NEW),
|
||||
userWalletId = userWalletId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,10 @@ import arrow.core.left
|
|||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
|
||||
import com.tangem.domain.pay.repository.OnboardingRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import com.tangem.test.core.TestAppCoroutineScope
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
|
|
@ -16,7 +18,14 @@ internal class CreateVirtualAccountOrderUseCaseTest {
|
|||
|
||||
private val onboardingRepository: OnboardingRepository = mockk(relaxUnitFun = true)
|
||||
private val pollingUseCase: StartTangemPayOrderPollingUseCase = mockk(relaxed = true)
|
||||
private val useCase = CreateVirtualAccountOrderUseCase(onboardingRepository, pollingUseCase)
|
||||
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk(relaxUnitFun = true)
|
||||
|
||||
private val useCase = CreateVirtualAccountOrderUseCase(
|
||||
onboardingRepository = onboardingRepository,
|
||||
pollingUseCase = pollingUseCase,
|
||||
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
|
||||
appCoroutineScope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
private val userWalletId = UserWalletId("1234567890ABCDEF")
|
||||
private val paymentAccountAddress = "0xcollateral"
|
||||
|
|
@ -31,6 +40,7 @@ internal class CreateVirtualAccountOrderUseCaseTest {
|
|||
coVerify(exactly = 0) { onboardingRepository.createVirtualAccountOrder(any(), any(), any()) }
|
||||
coVerify(exactly = 0) { onboardingRepository.storeVirtualAccountOrderId(any(), any()) }
|
||||
coVerify(exactly = 0) { pollingUseCase.invoke(any(), any()) }
|
||||
coVerify(exactly = 0) { paymentAccountStatusFetcher.markVirtualAccountProcessing(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -45,6 +55,7 @@ internal class CreateVirtualAccountOrderUseCaseTest {
|
|||
assertThat(result.isRight()).isTrue()
|
||||
coVerify(exactly = 1) { onboardingRepository.storeVirtualAccountOrderId(userWalletId, "new-id") }
|
||||
coVerify(exactly = 1) { pollingUseCase.invoke(any(), userWalletId) }
|
||||
coVerify(exactly = 1) { paymentAccountStatusFetcher.markVirtualAccountProcessing(userWalletId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -59,5 +70,6 @@ internal class CreateVirtualAccountOrderUseCaseTest {
|
|||
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
|
||||
coVerify(exactly = 0) { onboardingRepository.storeVirtualAccountOrderId(any(), any()) }
|
||||
coVerify(exactly = 0) { pollingUseCase.invoke(any(), any()) }
|
||||
coVerify(exactly = 0) { paymentAccountStatusFetcher.markVirtualAccountProcessing(any()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,7 @@ dependencies {
|
|||
|
||||
/** Tangem libraries */
|
||||
implementation(tangemDeps.card.core)
|
||||
implementation(tangemDeps.blockchain)
|
||||
|
||||
/** Common */
|
||||
api(projects.common.routing)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.features.commonfeatures.impl.choosetoken.converter
|
||||
|
||||
import arrow.core.toNonEmptyListOrNull
|
||||
import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter
|
||||
import com.tangem.common.ui.account.TokensListPortfolioItemConverter
|
||||
import com.tangem.common.ui.account.toUM
|
||||
|
|
@ -22,6 +23,7 @@ import com.tangem.domain.models.account.AccountStatus
|
|||
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.tokenlist.TokenList
|
||||
import com.tangem.domain.tokens.operations.TotalFiatBalanceCalculator
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData
|
||||
|
|
@ -101,8 +103,9 @@ internal class ChooseTokenListItemConverter(
|
|||
private fun AccountStatus.CryptoPortfolio.toPortfolioItem(
|
||||
params: TokenConverterParams.Account,
|
||||
): TokensListItemUM.Portfolio {
|
||||
val tokenList: TokenList = this.tokenList
|
||||
val account: Account.CryptoPortfolio = this.account
|
||||
val displayedStatus = filterForDisplay()
|
||||
val account: Account.CryptoPortfolio = displayedStatus.account
|
||||
val displayedTokenList: TokenList = displayedStatus.tokenList
|
||||
val isExpanded = isSearchingState || params.expandedAccounts.contains(account.accountId)
|
||||
val onItemClick: (Account.CryptoPortfolio) -> Unit = { clickedAccount ->
|
||||
onAccountItemClick(clickedAccount, isExpanded)
|
||||
|
|
@ -116,10 +119,9 @@ internal class ChooseTokenListItemConverter(
|
|||
fiatAmountStateProvider = { fiatBalance -> fiatAmountStateProvider(fiatBalance, isExpanded) },
|
||||
subtitle2StateProvider = { _ -> null },
|
||||
)
|
||||
val accountItem = converter.convert(tokenList.totalFiatBalance)
|
||||
val tokenConverter = tokenStatusConverter(this)
|
||||
val tokensListState = convertTokenList(tokenConverter, tokenList, this)
|
||||
val items = tokensListState.tokensList
|
||||
val accountItem = converter.convert(displayedTokenList.totalFiatBalance)
|
||||
val items = displayedTokenList.toUmData(tokenStatusConverter(this)).tokensList
|
||||
|
||||
return TokensListPortfolioItemConverter(
|
||||
tokenItemUM = accountItem,
|
||||
isExpanded = isExpanded,
|
||||
|
|
@ -128,29 +130,47 @@ internal class ChooseTokenListItemConverter(
|
|||
).convert(Unit)
|
||||
}
|
||||
|
||||
private fun AccountStatus.CryptoPortfolio.filterForDisplay(): AccountStatus.CryptoPortfolio {
|
||||
val filteredTokenList = filterTokenList(tokenList, this)
|
||||
return copy(
|
||||
account = account.copy(cryptoCurrencies = filteredTokenList.flattenCurrencies().map { it.currency }),
|
||||
tokenList = filteredTokenList,
|
||||
)
|
||||
}
|
||||
|
||||
private fun TokenList.recalculateBalance(): TokenList {
|
||||
val statuses = flattenCurrencies().toNonEmptyListOrNull() ?: return this
|
||||
val total = TotalFiatBalanceCalculator.calculate(statuses)
|
||||
return when (this) {
|
||||
TokenList.Empty -> this
|
||||
is TokenList.Ungrouped -> copy(totalFiatBalance = total)
|
||||
is TokenList.GroupedByNetwork -> copy(totalFiatBalance = total)
|
||||
}
|
||||
}
|
||||
|
||||
private fun convertTokenList(
|
||||
tokenConverter: TokenItemStateConverter,
|
||||
tokenListParam: TokenList,
|
||||
account: AccountStatus.CryptoPortfolio,
|
||||
): TokenListUMData {
|
||||
return when (val tokenList = filterTokenList(tokenListParam, account)) {
|
||||
is TokenList.Empty -> TokenListUMData.EmptyList
|
||||
is TokenList.GroupedByNetwork -> TokenListUMData.TokenList(
|
||||
tokensList = tokenList.toGroupedItems(tokenConverter).toPersistentList(),
|
||||
totalTokensCount = tokenList.flattenCurrencies().size,
|
||||
)
|
||||
is TokenList.Ungrouped -> TokenListUMData.TokenList(
|
||||
tokensList = tokenList.toUngroupedItems(tokenConverter).toPersistentList(),
|
||||
totalTokensCount = tokenList.flattenCurrencies().size,
|
||||
)
|
||||
}
|
||||
): TokenListUMData = filterTokenList(tokenListParam, account).toUmData(tokenConverter)
|
||||
|
||||
private fun TokenList.toUmData(tokenConverter: TokenItemStateConverter): TokenListUMData = when (this) {
|
||||
TokenList.Empty -> TokenListUMData.EmptyList
|
||||
is TokenList.GroupedByNetwork -> TokenListUMData.TokenList(
|
||||
tokensList = toGroupedItems(tokenConverter).toPersistentList(),
|
||||
totalTokensCount = flattenCurrencies().size,
|
||||
)
|
||||
is TokenList.Ungrouped -> TokenListUMData.TokenList(
|
||||
tokensList = toUngroupedItems(tokenConverter).toPersistentList(),
|
||||
totalTokensCount = flattenCurrencies().size,
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<CryptoCurrencyStatus>.filterCurrencies(account: AccountStatus): List<CryptoCurrencyStatus> =
|
||||
filter { currency -> currency.filterByQuery() && tokenFilter(account, currency) }
|
||||
|
||||
private fun filterTokenList(tokenList: TokenList, account: AccountStatus.CryptoPortfolio): TokenList {
|
||||
return when (tokenList) {
|
||||
val filtered = when (tokenList) {
|
||||
TokenList.Empty -> TokenList.Empty
|
||||
is TokenList.Ungrouped -> {
|
||||
val filtered = tokenList.currencies.filterCurrencies(account)
|
||||
|
|
@ -166,6 +186,8 @@ internal class ChooseTokenListItemConverter(
|
|||
if (filteredGroups.isEmpty()) TokenList.Empty else tokenList.copy(groups = filteredGroups)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered.recalculateBalance()
|
||||
}
|
||||
|
||||
private fun CryptoCurrencyStatus.filterByQuery(): Boolean {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import com.tangem.core.decompose.model.Model
|
|||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.ui.components.fields.entity.SearchBarUM
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.account.models.AccountStatusList
|
||||
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
|
||||
import com.tangem.features.commonfeatures.api.R
|
||||
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.*
|
||||
|
|
@ -20,6 +22,7 @@ import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenFullUM
|
|||
import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenInitialUM
|
||||
import com.tangem.features.commonfeatures.impl.choosetoken.ui.state.ChooserBlockUM
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -32,6 +35,7 @@ internal class ChooseTokenModel @Inject constructor(
|
|||
marketBlockDelegateFactory: MarketBlockDelegate.Factory,
|
||||
predefinedTokensBlockDelegateFactory: PredefinedTokensBlockDelegate.Factory,
|
||||
addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
|
||||
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
||||
paramsContainer: ParamsContainer,
|
||||
) : Model() {
|
||||
|
||||
|
|
@ -62,6 +66,13 @@ internal class ChooseTokenModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
/** Tokens the user already holds in the selected wallet — subtracted from the predefined "Other eligible" block. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val portfolioTokenKeysFlow: Flow<Set<Pair<String, String>>> = bridge.selectedWalletFlow
|
||||
.flatMapLatest { wallet -> singleAccountStatusListSupplier(wallet.walletId) }
|
||||
.map { accountStatusList -> accountStatusList.toTokenKeys() }
|
||||
.onStart { emit(emptySet()) }
|
||||
|
||||
private val predefinedTokensBlockDelegate: PredefinedTokensBlockDelegate by lazy {
|
||||
val block = bridge.settings.chooserBlock as ChooserBlock.Predefined
|
||||
predefinedTokensBlockDelegateFactory.create(
|
||||
|
|
@ -71,6 +82,7 @@ internal class ChooseTokenModel @Inject constructor(
|
|||
addToPortfolioSlot = bottomSheetNavigation,
|
||||
modelScope = modelScope,
|
||||
tokenFilter = bridge.tokenFilter,
|
||||
portfolioTokenKeys = portfolioTokenKeysFlow,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -104,7 +116,7 @@ internal class ChooseTokenModel @Inject constructor(
|
|||
)
|
||||
|
||||
init {
|
||||
if (bridge.settings.chooserBlock == ChooserBlock.Market) {
|
||||
if (bridge.settings.chooserBlock is ChooserBlock.Market) {
|
||||
modelScope.launch {
|
||||
delay(MARKETS_INITIAL_LOAD_DELAY)
|
||||
marketBlockDelegate.loadDefaultMarkets()
|
||||
|
|
@ -124,6 +136,12 @@ internal class ChooseTokenModel @Inject constructor(
|
|||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun AccountStatusList.toTokenKeys(): Set<Pair<String, String>> =
|
||||
flattenCurrencies().mapNotNullTo(hashSetOf()) { status ->
|
||||
val rawId = status.currency.id.rawCurrencyId?.value ?: return@mapNotNullTo null
|
||||
rawId to status.currency.network.rawId
|
||||
}
|
||||
|
||||
fun onBackClicked() {
|
||||
bridge.onClose()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
|
|||
@Assisted private val addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute>,
|
||||
@Assisted private val modelScope: CoroutineScope,
|
||||
@Assisted private val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>,
|
||||
@Assisted private val portfolioTokenKeys: Flow<Set<Pair<String, String>>>,
|
||||
) {
|
||||
|
||||
init {
|
||||
|
|
@ -44,8 +45,13 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
|
|||
val stateFlow: Flow<PredefinedTokensUM?> = combine(
|
||||
predefinedTokens,
|
||||
searchQueryState,
|
||||
) { tokens, query ->
|
||||
val filtered = tokens.filter { it.hasValidNetwork() && it.matchesQuery(query.value) }
|
||||
portfolioTokenKeys,
|
||||
) { tokens, query, portfolioKeys ->
|
||||
val filtered = tokens.filter { token ->
|
||||
token.hasValidNetwork() &&
|
||||
token.matchesQuery(query.value) &&
|
||||
!portfolioKeys.contains(token.toKey())
|
||||
}
|
||||
if (filtered.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
|
|
@ -66,6 +72,9 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/** Identity of a predefined token as `(rawCurrencyId, networkId)` — matches the portfolio token keys. */
|
||||
private fun PredefinedTokenToAdd.toKey(): Pair<String, String> = token.id.value to network.networkId
|
||||
|
||||
private fun PredefinedTokenToAdd.hasValidNetwork(): Boolean =
|
||||
network.networkId.isNotBlank() && network.decimalCount != null
|
||||
|
||||
|
|
@ -104,6 +113,7 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
|
|||
addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute>,
|
||||
modelScope: CoroutineScope,
|
||||
tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>,
|
||||
portfolioTokenKeys: Flow<Set<Pair<String, String>>>,
|
||||
): PredefinedTokensBlockDelegate
|
||||
}
|
||||
}
|
||||
|
|
@ -740,7 +740,7 @@ private fun LazyListScope.predefinedTokensListItems(state: PredefinedTokensUM) {
|
|||
.roundedShapeItemDecoration(
|
||||
currentIndex = index,
|
||||
lastIndex = state.items.lastIndex,
|
||||
backgroundColor = TangemTheme.colors2.surface.level1,
|
||||
backgroundColor = TangemTheme.colors.background.primary,
|
||||
)
|
||||
.testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
|
||||
.semantics { lazyListItemPosition = index },
|
||||
|
|
|
|||
|
|
@ -125,6 +125,41 @@ internal class PredefinedTokensBlockDelegateTest {
|
|||
assertThat(actual).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN predefined token already in portfolio WHEN state emitted THEN it is excluded`() = runTest {
|
||||
// Arrange
|
||||
val tokens = listOf(
|
||||
createPredefinedToken(id = "usd-coin", symbol = "USDC", networkId = ETHEREUM_NETWORK_ID),
|
||||
createPredefinedToken(id = "tether", symbol = "USDT", networkId = ETHEREUM_NETWORK_ID),
|
||||
)
|
||||
val delegate = createDelegate(
|
||||
predefinedTokens = MutableStateFlow(tokens),
|
||||
portfolioTokenKeys = MutableStateFlow(setOf("usd-coin" to ETHEREUM_NETWORK_ID)),
|
||||
)
|
||||
|
||||
// Act
|
||||
val actual = lastState(delegate)
|
||||
|
||||
// Assert — usd-coin is already in the portfolio, so only tether stays in "Other eligible tokens"
|
||||
assertThat(actual?.items?.map { it.id }).containsExactly("tether_$ETHEREUM_NETWORK_ID")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN all predefined tokens already in portfolio WHEN state emitted THEN emits null`() = runTest {
|
||||
// Arrange
|
||||
val token = createPredefinedToken(id = "usd-coin", symbol = "USDC", networkId = ETHEREUM_NETWORK_ID)
|
||||
val delegate = createDelegate(
|
||||
predefinedTokens = MutableStateFlow(listOf(token)),
|
||||
portfolioTokenKeys = MutableStateFlow(setOf("usd-coin" to ETHEREUM_NETWORK_ID)),
|
||||
)
|
||||
|
||||
// Act
|
||||
val actual = lastState(delegate)
|
||||
|
||||
// Assert
|
||||
assertThat(actual).isNull()
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun filter(model: FilterModel) = runTest {
|
||||
|
|
@ -234,6 +269,7 @@ internal class PredefinedTokensBlockDelegateTest {
|
|||
searchQueryState: MutableStateFlow<SearchQuery> = MutableStateFlow(SearchQuery.Empty),
|
||||
tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> =
|
||||
MutableStateFlow({ _, _ -> true }),
|
||||
portfolioTokenKeys: MutableStateFlow<Set<Pair<String, String>>> = MutableStateFlow(emptySet()),
|
||||
): PredefinedTokensBlockDelegate = PredefinedTokensBlockDelegate(
|
||||
predefinedTokens = predefinedTokens,
|
||||
searchQueryState = searchQueryState,
|
||||
|
|
@ -241,6 +277,7 @@ internal class PredefinedTokensBlockDelegateTest {
|
|||
addToPortfolioSlot = addToPortfolioSlot,
|
||||
modelScope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)),
|
||||
tokenFilter = tokenFilter,
|
||||
portfolioTokenKeys = portfolioTokenKeys,
|
||||
)
|
||||
|
||||
private fun currency(rawId: String, networkId: String): CryptoCurrencyStatus =
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ dependencies {
|
|||
api(projects.features.details.api)
|
||||
api(projects.features.onboardingV2.api)
|
||||
api(projects.features.wallet.api)
|
||||
implementation(projects.features.virtualAccounts.details.api)
|
||||
|
||||
/* Project - Core */
|
||||
api(projects.core.analytics)
|
||||
|
|
@ -45,6 +46,7 @@ dependencies {
|
|||
runtimeOnly(projects.domain.appCurrency)
|
||||
runtimeOnly(projects.domain.balanceHiding)
|
||||
runtimeOnly(projects.domain.tokens)
|
||||
implementation(projects.domain.virtualAccount)
|
||||
|
||||
/* SDK */
|
||||
// TODO: For TangemError model, should be removed after card domain scanning refactoring
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import com.tangem.features.details.entity.SelectContactSupportTypeBS
|
|||
import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS
|
||||
import com.tangem.features.details.utils.ItemsBuilder
|
||||
import com.tangem.features.details.utils.SocialsBuilder
|
||||
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.info.AppInfoProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
|
|
@ -71,6 +72,7 @@ internal class DetailsModel @Inject constructor(
|
|||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
|
||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||
private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles,
|
||||
private val tangemPayEligibilityManager: TangemPayEligibilityManager,
|
||||
private val getVirtualAccountEligibilityUseCase: GetVirtualAccountEligibilityUseCase,
|
||||
) : Model() {
|
||||
|
|
@ -335,8 +337,9 @@ internal class DetailsModel @Inject constructor(
|
|||
|
||||
private fun addVirtualAccountItemIfEligible() {
|
||||
modelScope.launch {
|
||||
val isVirtualAccountEnabled = virtualAccountFeatureToggles.isVirtualAccountsEnabled
|
||||
val eligibility = getVirtualAccountEligibilityUseCase(VirtualAccountEntryPoint.DETAILS)
|
||||
if (eligibility is VirtualAccountEligibility.Available) {
|
||||
if (eligibility is VirtualAccountEligibility.Available && isVirtualAccountEnabled) {
|
||||
items.update { items ->
|
||||
itemsBuilder.addVirtualAccountItem(
|
||||
items = items,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
|
|||
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.features.addressbook.AddressBookFeatureToggles
|
||||
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
|
||||
import com.tangem.features.details.component.DetailsComponent
|
||||
import com.tangem.features.details.entity.DetailsItemUM
|
||||
import com.tangem.features.details.utils.ItemsBuilder
|
||||
|
|
@ -64,6 +65,7 @@ internal abstract class DetailsModelTestBase {
|
|||
protected val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true)
|
||||
protected val tangemPayEligibilityManager: TangemPayEligibilityManager = mockk()
|
||||
protected val getVirtualAccountEligibilityUseCase: GetVirtualAccountEligibilityUseCase = mockk()
|
||||
protected val virtualAccountFeatureToggles: VirtualAccountFeatureToggles = mockk()
|
||||
|
||||
// Captured from itemsBuilder.buildAll(...) so the feature buttons can be driven.
|
||||
protected val wcSlot = slot<Boolean>()
|
||||
|
|
@ -88,6 +90,7 @@ internal abstract class DetailsModelTestBase {
|
|||
every { appInfoProvider.appVersionCode } returns 456
|
||||
coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns emptyList()
|
||||
coEvery { getVirtualAccountEligibilityUseCase(any()) } returns VirtualAccountEligibility.NotAvailable
|
||||
every { virtualAccountFeatureToggles.isVirtualAccountsEnabled } returns true
|
||||
|
||||
every {
|
||||
itemsBuilder.buildAll(
|
||||
|
|
@ -128,6 +131,7 @@ internal abstract class DetailsModelTestBase {
|
|||
analyticsEventHandler = analyticsEventHandler,
|
||||
tangemPayEligibilityManager = tangemPayEligibilityManager,
|
||||
getVirtualAccountEligibilityUseCase = getVirtualAccountEligibilityUseCase,
|
||||
virtualAccountFeatureToggles = virtualAccountFeatureToggles,
|
||||
)
|
||||
|
||||
protected fun stubBuildAllReturns(list: ImmutableList<DetailsItemUM>) {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ dependencies {
|
|||
implementation(deps.compose.foundation)
|
||||
implementation(deps.compose.ui)
|
||||
implementation(deps.compose.ui.tooling)
|
||||
implementation(deps.compose.material3)
|
||||
implementation(deps.compose.coil)
|
||||
implementation(deps.lifecycle.compose)
|
||||
|
||||
|
|
|
|||
|
|
@ -125,9 +125,12 @@ internal class MarketingBannerModel @Inject constructor(
|
|||
campaignId = id,
|
||||
text = banner.text,
|
||||
iconUrl = banner.iconUrl,
|
||||
// When the backend omits iconAlign, follow the design default: a dismissible banner keeps the icon
|
||||
// on the left (the close button occupies the right slot), a non-dismissible one moves it to the right.
|
||||
iconAlign = when (banner.iconAlign) {
|
||||
MarketingBanner.IconAlign.RIGHT -> MarketingBannerUM.IconAlign.RIGHT
|
||||
MarketingBanner.IconAlign.LEFT, null -> MarketingBannerUM.IconAlign.LEFT
|
||||
MarketingBanner.IconAlign.LEFT -> MarketingBannerUM.IconAlign.LEFT
|
||||
null -> if (banner.isDismissible) MarketingBannerUM.IconAlign.LEFT else MarketingBannerUM.IconAlign.RIGHT
|
||||
},
|
||||
isDismissible = banner.isDismissible,
|
||||
deeplink = banner.deeplink,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import coil.request.ImageRequest
|
|||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.ds2.messagebanner.CloseButton
|
||||
import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner
|
||||
import com.tangem.core.ui.extensions.clickableSingle
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
|
|
@ -51,11 +50,10 @@ internal fun MarketingBanner(
|
|||
|
||||
TangemMessageBanner(
|
||||
title = stringReference(banner.text.orEmpty()),
|
||||
modifier = modifier.then(
|
||||
if (hasDeeplink) Modifier.clickableSingle(onClick = onClick) else Modifier,
|
||||
),
|
||||
modifier = modifier,
|
||||
variant = TangemMessageBanner.Variant.Default,
|
||||
showGlowRing = false,
|
||||
onClick = if (hasDeeplink) onClick else null,
|
||||
slotStart = if (isIconAtStart) {
|
||||
{ BannerIcon(banner.iconUrl, onLoadError = { isIconFailed = true }) }
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import com.tangem.features.marketing.api.LinkedBannerRequest
|
|||
import com.tangem.features.marketing.api.MarketingBannerComponent
|
||||
import com.tangem.features.marketing.api.MarketingBannerRequest
|
||||
import com.tangem.features.marketing.impl.ui.state.MarketingBannerListUM
|
||||
import com.tangem.features.marketing.impl.ui.state.MarketingBannerUM
|
||||
import com.tangem.test.core.ProvideTestModels
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import io.mockk.Runs
|
||||
import io.mockk.clearMocks
|
||||
|
|
@ -33,6 +35,7 @@ import kotlinx.coroutines.test.runTest
|
|||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class MarketingBannerModelTest {
|
||||
|
|
@ -280,4 +283,53 @@ internal class MarketingBannerModelTest {
|
|||
// Assert
|
||||
verify(exactly = 1) { deeplinkLauncher.launch("https://tangem.com/promo") }
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun `GIVEN iconAlign and dismissible WHEN mapped THEN align follows design default`(
|
||||
model: IconAlignModel,
|
||||
) = runTest {
|
||||
// Arrange
|
||||
coEvery { getMarketingBanner(onrampScreen, null) } returns listOf(
|
||||
standaloneCampaign(id = 1, iconAlign = model.iconAlign, isDismissible = model.isDismissible),
|
||||
).right()
|
||||
val bannerModel = createModel(
|
||||
MarketingBannerComponent.Params.Standalone(flowOf(MarketingBannerRequest(onrampScreen))),
|
||||
)
|
||||
|
||||
// Act
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val content = bannerModel.uiState.value as MarketingBannerListUM.Content
|
||||
assertThat(content.banners.single().iconAlign).isEqualTo(model.expected)
|
||||
}
|
||||
|
||||
private fun standaloneCampaign(id: Int, iconAlign: MarketingBanner.IconAlign?, isDismissible: Boolean) =
|
||||
campaign(id, MarketingBanner.UiType.STANDALONE).let { base ->
|
||||
base.copy(banner = base.banner.copy(iconAlign = iconAlign, isDismissible = isDismissible))
|
||||
}
|
||||
|
||||
internal data class IconAlignModel(
|
||||
val iconAlign: MarketingBanner.IconAlign?,
|
||||
val isDismissible: Boolean,
|
||||
val expected: MarketingBannerUM.IconAlign,
|
||||
)
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
// Backend omits iconAlign -> derived from dismissible (design default)
|
||||
IconAlignModel(iconAlign = null, isDismissible = false, expected = MarketingBannerUM.IconAlign.RIGHT),
|
||||
IconAlignModel(iconAlign = null, isDismissible = true, expected = MarketingBannerUM.IconAlign.LEFT),
|
||||
// Explicit backend value is always honored regardless of dismissible
|
||||
IconAlignModel(
|
||||
iconAlign = MarketingBanner.IconAlign.LEFT,
|
||||
isDismissible = false,
|
||||
expected = MarketingBannerUM.IconAlign.LEFT,
|
||||
),
|
||||
IconAlignModel(
|
||||
iconAlign = MarketingBanner.IconAlign.RIGHT,
|
||||
isDismissible = true,
|
||||
expected = MarketingBannerUM.IconAlign.RIGHT,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -17,8 +17,6 @@ dependencies {
|
|||
api(projects.features.commonFeatures.api)
|
||||
api(projects.features.onramp.api)
|
||||
implementation(projects.features.marketing.api)
|
||||
implementation(projects.domain.marketing.models)
|
||||
implementation(projects.domain.quotes)
|
||||
|
||||
/** Project - Core */
|
||||
api(projects.core.analytics)
|
||||
|
|
@ -55,6 +53,8 @@ dependencies {
|
|||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.transaction.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.marketing.models)
|
||||
implementation(projects.domain.quotes)
|
||||
runtimeOnly(projects.domain.card)
|
||||
|
||||
/** Data */
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
|||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
|
||||
import com.tangem.features.marketing.api.MarketingBannerComponent
|
||||
|
||||
internal interface AllOffersComponent : ComposableBottomSheetComponent {
|
||||
|
||||
|
|
@ -14,6 +15,12 @@ internal interface AllOffersComponent : ComposableBottomSheetComponent {
|
|||
val onDismiss: () -> Unit,
|
||||
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
|
||||
val amountCurrencyCode: String,
|
||||
// Marketing banner components are created and owned by the parent onramp-main component and passed
|
||||
// down so this sheet reuses their models (and their amount-gated request flows) instead of building
|
||||
// its own: [marketingBannerComponent] renders the standalone banner, [linkedMarketingBannerComponent]
|
||||
// renders the per-provider LINKED_TO_PROVIDER banner next to each offer.
|
||||
val marketingBannerComponent: MarketingBannerComponent,
|
||||
val linkedMarketingBannerComponent: MarketingBannerComponent,
|
||||
)
|
||||
|
||||
interface Factory : ComponentFactory<Params, AllOffersComponent>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import dagger.assisted.AssistedInject
|
|||
|
||||
internal class DefaultAllOffersComponent @AssistedInject constructor(
|
||||
@Assisted context: AppComponentContext,
|
||||
@Assisted params: AllOffersComponent.Params,
|
||||
@Assisted private val params: AllOffersComponent.Params,
|
||||
) : AllOffersComponent, AppComponentContext by context {
|
||||
|
||||
private val model: AllOffersModel = getOrCreateModel(params)
|
||||
|
|
@ -27,6 +27,8 @@ internal class DefaultAllOffersComponent @AssistedInject constructor(
|
|||
val state by model.state.collectAsState()
|
||||
AllOffersContentSheet(
|
||||
state = state,
|
||||
marketingBannerComponent = params.marketingBannerComponent,
|
||||
linkedMarketingBannerComponent = params.linkedMarketingBannerComponent,
|
||||
onCloseClick = { dismiss() },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,9 +25,11 @@ import com.tangem.core.ui.extensions.TextReference
|
|||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.res.TangemThemeRedesign
|
||||
import com.tangem.domain.onramp.model.OnrampPaymentMethod
|
||||
import com.tangem.domain.onramp.model.PaymentMethodStatus
|
||||
import com.tangem.domain.onramp.model.PaymentMethodType
|
||||
import com.tangem.features.marketing.api.MarketingBannerComponent
|
||||
import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM
|
||||
import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM
|
||||
import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig
|
||||
|
|
@ -35,13 +37,18 @@ import com.tangem.features.onramp.impl.R
|
|||
import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM
|
||||
import com.tangem.features.onramp.main.entity.OnrampOfferCategoryUM
|
||||
import com.tangem.features.onramp.main.entity.OnrampOfferUM
|
||||
import com.tangem.features.onramp.main.ui.Offer
|
||||
import com.tangem.features.onramp.main.ui.OfferWithLinkedBanner
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
@Composable
|
||||
internal fun AllOffersContentSheet(state: AllOffersStateUM, onCloseClick: () -> Unit) {
|
||||
internal fun AllOffersContentSheet(
|
||||
state: AllOffersStateUM,
|
||||
marketingBannerComponent: MarketingBannerComponent,
|
||||
linkedMarketingBannerComponent: MarketingBannerComponent,
|
||||
onCloseClick: () -> Unit,
|
||||
) {
|
||||
val onBack = remember(state) {
|
||||
{
|
||||
if (state is AllOffersStateUM.Content && state.currentMethod != null) {
|
||||
|
|
@ -71,37 +78,64 @@ internal fun AllOffersContentSheet(state: AllOffersStateUM, onCloseClick: () ->
|
|||
}
|
||||
},
|
||||
content = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(vertical = 8.dp)
|
||||
.animateContentSize(),
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = state is AllOffersStateUM.Content && state.currentMethod != null,
|
||||
transitionSpec = {
|
||||
fadeIn(tween(durationMillis = 220)) togetherWith
|
||||
fadeOut(tween(durationMillis = 220))
|
||||
},
|
||||
label = "Change offers and payment method state",
|
||||
) { shouldShowOffersScreen ->
|
||||
when (state) {
|
||||
AllOffersStateUM.Loading -> AllOffersContentLoading()
|
||||
is AllOffersStateUM.Error -> AllOffersError(state.errorNotification)
|
||||
is AllOffersStateUM.Content -> {
|
||||
if (shouldShowOffersScreen) {
|
||||
state.currentMethod?.let {
|
||||
OffersBasedOnPaymentMethodContent(offers = it.offers)
|
||||
}
|
||||
} else {
|
||||
PaymentMethodsContent(methods = state.methods)
|
||||
AllOffersSheetContent(
|
||||
state = state,
|
||||
marketingBannerComponent = marketingBannerComponent,
|
||||
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AllOffersSheetContent(
|
||||
state: AllOffersStateUM,
|
||||
marketingBannerComponent: MarketingBannerComponent,
|
||||
linkedMarketingBannerComponent: MarketingBannerComponent,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Standalone marketing banner at the top of the sheet (DS3 -> wrap in the redesign theme).
|
||||
// Renders nothing when no matching campaign, so it adds no space in the common case.
|
||||
TangemThemeRedesign {
|
||||
marketingBannerComponent.Content(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(vertical = 8.dp)
|
||||
.animateContentSize(),
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = state is AllOffersStateUM.Content && state.currentMethod != null,
|
||||
transitionSpec = {
|
||||
fadeIn(tween(durationMillis = 220)) togetherWith
|
||||
fadeOut(tween(durationMillis = 220))
|
||||
},
|
||||
label = "Change offers and payment method state",
|
||||
) { shouldShowOffersScreen ->
|
||||
when (state) {
|
||||
AllOffersStateUM.Loading -> AllOffersContentLoading()
|
||||
is AllOffersStateUM.Error -> AllOffersError(state.errorNotification)
|
||||
is AllOffersStateUM.Content -> {
|
||||
if (shouldShowOffersScreen) {
|
||||
state.currentMethod?.let { method ->
|
||||
OffersBasedOnPaymentMethodContent(
|
||||
offers = method.offers,
|
||||
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
PaymentMethodsContent(methods = state.methods)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -127,7 +161,10 @@ private fun PaymentMethodTitle(onCloseClick: () -> Unit) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun OffersBasedOnPaymentMethodContent(offers: ImmutableList<OnrampOfferUM>) {
|
||||
private fun OffersBasedOnPaymentMethodContent(
|
||||
offers: ImmutableList<OnrampOfferUM>,
|
||||
linkedMarketingBannerComponent: MarketingBannerComponent,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -136,7 +173,7 @@ private fun OffersBasedOnPaymentMethodContent(offers: ImmutableList<OnrampOfferU
|
|||
) {
|
||||
offers.fastForEach { offer ->
|
||||
key("${offer.paymentMethod.id} ${offer.providerName} ${offer.rate}") {
|
||||
Offer(offer)
|
||||
OfferWithLinkedBanner(offer, linkedMarketingBannerComponent)
|
||||
SpacerH(8.dp)
|
||||
}
|
||||
}
|
||||
|
|
@ -250,11 +287,18 @@ private fun AllOffersContentSheetPaymentPreview() {
|
|||
currentMethod = method,
|
||||
onBackClicked = {},
|
||||
),
|
||||
marketingBannerComponent = PreviewMarketingBannerComponent,
|
||||
linkedMarketingBannerComponent = PreviewMarketingBannerComponent,
|
||||
onCloseClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val PreviewMarketingBannerComponent = object : MarketingBannerComponent {
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) = Unit
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
|
|
@ -319,6 +363,8 @@ private fun AllOffersContentSheetOffersPreview() {
|
|||
currentMethod = null,
|
||||
onBackClicked = {},
|
||||
),
|
||||
marketingBannerComponent = PreviewMarketingBannerComponent,
|
||||
linkedMarketingBannerComponent = PreviewMarketingBannerComponent,
|
||||
onCloseClick = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
|
|||
onDismiss = model.bottomSheetNavigation::dismiss,
|
||||
openRedirectPage = params.openRedirectPage,
|
||||
amountCurrencyCode = config.amountCurrencyCode,
|
||||
marketingBannerComponent = marketingBannerComponent,
|
||||
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM, linkedMarketingBann
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun OfferWithLinkedBanner(offer: OnrampOfferUM, linkedMarketingBannerComponent: MarketingBannerComponent) {
|
||||
internal fun OfferWithLinkedBanner(offer: OnrampOfferUM, linkedMarketingBannerComponent: MarketingBannerComponent) {
|
||||
val hasBanner = linkedMarketingBannerComponent.hasLinkedBanner(offer.providerId)
|
||||
// Square the offer's bottom corners so the bottom-rounded banner glues to it as one card.
|
||||
Offer(offer, roundBottom = !hasBanner)
|
||||
|
|
|
|||
|
|
@ -25,9 +25,11 @@ dependencies {
|
|||
implementation(projects.domain.account.status)
|
||||
implementation(projects.domain.promo)
|
||||
implementation(projects.domain.promo.models)
|
||||
implementation(projects.domain.markets.models)
|
||||
|
||||
/** Data */
|
||||
implementation(projects.data.common)
|
||||
implementation(tangemDeps.blockchain)
|
||||
|
||||
/** Core */
|
||||
api(projects.core.configToggles)
|
||||
|
|
@ -43,6 +45,7 @@ dependencies {
|
|||
api(deps.compose.foundation)
|
||||
implementation(deps.compose.ui)
|
||||
implementation(deps.compose.ui.tooling)
|
||||
implementation(deps.compose.material3)
|
||||
implementation(deps.lifecycle.compose)
|
||||
|
||||
/** Other */
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.child
|
||||
|
|
@ -29,7 +28,6 @@ internal class ActivateCampaignBottomSheetComponent(
|
|||
chooseTokenComponentFactory: ChooseTokenComponent.Factory,
|
||||
private val params: Params,
|
||||
val onDismiss: () -> Unit,
|
||||
val onFooterExtraHeightReady: (Dp) -> Unit,
|
||||
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: ActivateCampaignsModel = getOrCreateModel(params)
|
||||
|
|
@ -65,7 +63,6 @@ internal class ActivateCampaignBottomSheetComponent(
|
|||
|
||||
ActivateCampaignFooter(
|
||||
footerUM = state.footerUM,
|
||||
onFooterTextHeightReady = onFooterExtraHeightReady,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,23 +2,31 @@ package com.tangem.features.promobanners.impl.campaigns.component
|
|||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||
import com.arkivanov.decompose.router.slot.childSlot
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.childByContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.components.SpacerH32
|
||||
import com.tangem.core.ui.components.bottomsheets.LocalBottomSheetContentScrollable
|
||||
import com.tangem.core.ui.components.bottomsheets.LocalTangemBottomSheetContentBottomInset
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
||||
import com.tangem.core.ui.components.bottomsheets.modal.DEFAULT_FOOTER_HEIGHT
|
||||
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWithFooter
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
|
||||
import com.tangem.core.ui.decompose.ComposableModularContentComponent
|
||||
import com.tangem.core.ui.extensions.rememberLastNonNull
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
|
@ -52,31 +60,44 @@ internal class DefaultCampaignsComponent @AssistedInject constructor(
|
|||
val bottomSheet by bottomSheetSlot.subscribeAsState()
|
||||
val activeChild = bottomSheet.child?.instance
|
||||
val displayedChild = rememberLastNonNull(activeChild)
|
||||
val footerExtraHeight by model.footerExtraHeightState.collectAsStateWithLifecycle()
|
||||
|
||||
TangemModalBottomSheetWithFooter<TangemBottomSheetConfigContent.Empty>(
|
||||
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
|
||||
config = TangemBottomSheetConfig(
|
||||
isShown = activeChild != null,
|
||||
onDismissRequest = model::onDismiss,
|
||||
content = TangemBottomSheetConfigContent.Empty,
|
||||
),
|
||||
containerColor = TangemTheme.colors3.bg.primary,
|
||||
footerHeight = DEFAULT_FOOTER_HEIGHT + footerExtraHeight,
|
||||
containerColor = TangemTheme.colors3.bg.secondary,
|
||||
type = TangemBottomSheetType.Modal,
|
||||
onBack = model::onDismiss,
|
||||
title = {
|
||||
displayedChild?.Title()
|
||||
},
|
||||
content = {
|
||||
Box(modifier = Modifier.animateContentSize()) {
|
||||
displayedChild?.Content(modifier = Modifier)
|
||||
val bottomInset = LocalTangemBottomSheetContentBottomInset.current
|
||||
val bottomReserve = if (bottomInset > 0.dp) bottomInset else 16.dp
|
||||
val scrollState = rememberScrollState()
|
||||
val scrollableSignal = LocalBottomSheetContentScrollable.current
|
||||
|
||||
if (scrollableSignal != null) {
|
||||
LaunchedEffect(scrollState) {
|
||||
snapshotFlow { scrollState.canScrollForward || scrollState.canScrollBackward }
|
||||
.collect { canScroll -> scrollableSignal.value = canScroll }
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.verticalScroll(state = scrollState)) {
|
||||
Box(modifier = Modifier.animateContentSize()) {
|
||||
displayedChild?.Content(modifier = Modifier)
|
||||
}
|
||||
|
||||
if (scrollableSignal?.value != true) SpacerH32()
|
||||
|
||||
Spacer(modifier = Modifier.height(bottomReserve))
|
||||
}
|
||||
},
|
||||
footer = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.padding(12.dp)) {
|
||||
displayedChild?.Footer()
|
||||
}
|
||||
},
|
||||
|
|
@ -102,7 +123,6 @@ internal class DefaultCampaignsComponent @AssistedInject constructor(
|
|||
appComponentContext = context,
|
||||
chooseTokenComponentFactory = chooseTokenComponentFactory,
|
||||
onDismiss = model::onDismiss,
|
||||
onFooterExtraHeightReady = model::onFooterExtraHeightReady,
|
||||
params = ActivateCampaignBottomSheetComponent.Params(
|
||||
campaignType = config.campaignType,
|
||||
userWalletId = config.userWalletId,
|
||||
|
|
|
|||
|
|
@ -11,11 +11,11 @@ import com.tangem.core.decompose.model.ParamsContainer
|
|||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.navigation.url.UrlOpener
|
||||
import com.tangem.core.ui.components.account.AccountIconSize
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.token.state.TokenItemState
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.core.ui.message.ToastMessage
|
||||
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
|
||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
||||
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.account.Account
|
||||
|
|
@ -46,6 +46,7 @@ import com.tangem.utils.logging.TangemLogger
|
|||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
|
|
@ -60,7 +61,7 @@ internal class ActivateCampaignsModel @Inject constructor(
|
|||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
chooseTokenBridgeFactory: ChooseTokenBridge.Factory,
|
||||
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
|
||||
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
|
||||
private val multiAccountListSupplier: MultiAccountListSupplier,
|
||||
private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase,
|
||||
private val urlOpener: UrlOpener,
|
||||
@GlobalUiMessageSender private val messageSender: UiMessageSender,
|
||||
|
|
@ -198,28 +199,46 @@ internal class ActivateCampaignsModel @Inject constructor(
|
|||
urlOpener.openUrl(campaignContent.learnMoreUrl)
|
||||
}
|
||||
|
||||
private suspend fun hasMultipleCryptoPortfolioAccounts(): Boolean {
|
||||
return multiAccountListSupplier.invoke()
|
||||
.first()
|
||||
.any { accountList ->
|
||||
accountList.accounts.filterIsInstance<Account.CryptoPortfolio>().size > 1
|
||||
}
|
||||
}
|
||||
|
||||
private fun onTokenChosen(result: ChooseTokenResult) {
|
||||
val selectedToken = result.currency.currency as? CryptoCurrency.Token ?: return
|
||||
val networkAddress = result.currency.value.networkAddress ?: return
|
||||
|
||||
modelScope.launch {
|
||||
val selectedAccountUM = if (isAccountsModeEnabledUseCase.invokeSync()) {
|
||||
val selectedAccountUM = if (hasMultipleCryptoPortfolioAccounts()) {
|
||||
when (val account = result.account.account) {
|
||||
is Account.CryptoPortfolio -> SelectedAccountUM(
|
||||
iconState = accountIconConverter.convert(account),
|
||||
name = account.accountName.toUM().value,
|
||||
)
|
||||
is Account.Payment -> SelectedAccountUM(
|
||||
iconState = CurrencyIconState.PaymentAccount(size = AccountIconSize.ExtraSmall),
|
||||
name = account.accountName.toUM().value,
|
||||
)
|
||||
is Account.Virtual -> null
|
||||
// Payment accounts are hidden in the chooser and don't count towards accounts mode,
|
||||
// so there is no account label to show for them.
|
||||
is Account.Payment,
|
||||
is Account.Virtual,
|
||||
-> null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val tokenItem = TokenItemStateConverter(appCurrency = appCurrency).convert(result.currency)
|
||||
val tokenItem = TokenItemStateConverter(
|
||||
appCurrency = appCurrency,
|
||||
subtitleStateProvider = { status ->
|
||||
TokenItemState.SubtitleState.TextContent(
|
||||
value = resourceReference(
|
||||
R.string.domain_receive_assets_onboarding_network_name,
|
||||
wrappedList(status.currency.network.name),
|
||||
),
|
||||
)
|
||||
},
|
||||
).convert(result.currency)
|
||||
|
||||
uiState.update { state ->
|
||||
state.copy(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.model
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||
import com.arkivanov.decompose.router.slot.activate
|
||||
import com.arkivanov.decompose.router.slot.dismiss
|
||||
|
|
@ -24,8 +22,6 @@ import com.tangem.features.promobanners.impl.campaigns.entity.toPromoCampaignId
|
|||
import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -43,9 +39,6 @@ internal class CampaignsModel @Inject constructor(
|
|||
|
||||
val bottomSheetNavigation: SlotNavigation<CampaignsBottomSheetConfig> = SlotNavigation()
|
||||
|
||||
val footerExtraHeightState: StateFlow<Dp>
|
||||
field = MutableStateFlow(0.dp)
|
||||
|
||||
init {
|
||||
campaignsService.campaignFlow
|
||||
.onEach { request ->
|
||||
|
|
@ -89,22 +82,16 @@ internal class CampaignsModel @Inject constructor(
|
|||
},
|
||||
)
|
||||
|
||||
fun onFooterExtraHeightReady(height: Dp) {
|
||||
footerExtraHeightState.value = height
|
||||
}
|
||||
|
||||
fun onDismiss() {
|
||||
bottomSheetNavigation.dismiss()
|
||||
}
|
||||
|
||||
fun onActivated(campaignType: CampaignType) {
|
||||
footerExtraHeightState.value = 0.dp
|
||||
bottomSheetNavigation.activate(CampaignsBottomSheetConfig.Enrolled(campaignType))
|
||||
}
|
||||
|
||||
fun onAlreadyActivated(campaignType: CampaignType) {
|
||||
analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened())
|
||||
footerExtraHeightState.value = 0.dp
|
||||
bottomSheetNavigation.activate(CampaignsBottomSheetConfig.AlreadyActivated(campaignType = campaignType))
|
||||
}
|
||||
}
|
||||
|
|
@ -78,8 +78,6 @@ internal fun ActivateCampaignContent(um: ActivateCampaignUM, modifier: Modifier
|
|||
selectedAccount = um.selectedAccount,
|
||||
onChooseTokenClick = um.onChooseTokenClick,
|
||||
)
|
||||
|
||||
SpacerH32()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,8 +92,8 @@ private fun SelectedTokenContent(
|
|||
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.promo_campaign_select_cashback_account),
|
||||
style = TangemTheme.typography.subtitle1,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
|
|
@ -18,7 +16,6 @@ import androidx.compose.ui.text.style.TextDecoration
|
|||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.PrimaryButton
|
||||
import com.tangem.core.ui.components.SpacerH12
|
||||
|
|
@ -29,28 +26,17 @@ import com.tangem.features.promobanners.impl.campaigns.entity.FooterUM
|
|||
import com.tangem.features.promobanners.impl.campaigns.entity.TermsUM
|
||||
|
||||
@Composable
|
||||
internal fun ActivateCampaignFooter(
|
||||
footerUM: FooterUM,
|
||||
onFooterTextHeightReady: (Dp) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
internal fun ActivateCampaignFooter(footerUM: FooterUM, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier) {
|
||||
val terms = footerUM.terms
|
||||
|
||||
if (terms != null) {
|
||||
val density = LocalDensity.current
|
||||
|
||||
Text(
|
||||
text = termsAnnotatedString(terms),
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.secondary,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged {
|
||||
val termsBlockHeight = with(density) { it.height.toDp() } + 12.dp
|
||||
onFooterTextHeightReady.invoke(termsBlockHeight)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
SpacerH12()
|
||||
|
|
@ -99,7 +85,6 @@ private fun Preview_ActivateCampaignFooter_WithTerms() {
|
|||
modifier = Modifier
|
||||
.background(TangemTheme.colors3.bg.primary)
|
||||
.padding(16.dp),
|
||||
onFooterTextHeightReady = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -114,7 +99,6 @@ private fun Preview_ActivateCampaignFooter_NoTerms() {
|
|||
modifier = Modifier
|
||||
.background(TangemTheme.colors3.bg.primary)
|
||||
.padding(16.dp),
|
||||
onFooterTextHeightReady = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,8 +54,6 @@ internal fun AlreadyActivatedCampaignContent(message: TextReference, modifier: M
|
|||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
SpacerH32()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,8 +58,6 @@ fun CampaignEnrolledMessageContent(message: TextReference, modifier: Modifier =
|
|||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
SpacerH32()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,5 @@ fun NotActiveCampaignMessageContent(modifier: Modifier = Modifier) {
|
|||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
SpacerH32()
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,15 @@ package com.tangem.features.promobanners.impl.model
|
|||
|
||||
import androidx.core.net.toUri
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.decompose.di.GlobalUiMessageSender
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.navigation.deeplink.DeeplinkLauncher
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.message.ToastMessage
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
|
||||
import com.tangem.features.promobanners.impl.analytics.PromoBannerAnalyticsEvent
|
||||
|
|
@ -26,6 +31,7 @@ import javax.inject.Inject
|
|||
|
||||
private typealias ShownBannerKey = Pair<String, Int>
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@ModelScoped
|
||||
internal class PromoBannersBlockModel @Inject constructor(
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
|
|
@ -34,6 +40,7 @@ internal class PromoBannersBlockModel @Inject constructor(
|
|||
private val deeplinkLauncher: DeeplinkLauncher,
|
||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
|
||||
) : Model() {
|
||||
|
||||
private val params = paramsContainer.require<PromoBannersBlockComponent.Params>()
|
||||
|
|
@ -206,7 +213,12 @@ internal class PromoBannersBlockModel @Inject constructor(
|
|||
|
||||
private fun onButtonClick(displayId: Int, deeplink: String?) {
|
||||
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholderName))
|
||||
deeplink?.let { deeplinkLauncher.launch(appendSurveyDisplayId(it, displayId)) }
|
||||
|
||||
if (deeplink.isNullOrBlank()) {
|
||||
uiMessageSender.send(ToastMessage(message = resourceReference(R.string.common_something_went_wrong)))
|
||||
} else {
|
||||
deeplinkLauncher.launch(appendSurveyDisplayId(deeplink, displayId))
|
||||
}
|
||||
}
|
||||
|
||||
private fun appendSurveyDisplayId(deeplink: String, displayId: Int): String {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import com.tangem.core.analytics.models.AnalyticsEvent
|
|||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.navigation.url.UrlOpener
|
||||
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
|
||||
import com.tangem.domain.account.models.AccountList
|
||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
||||
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
|
|
@ -53,7 +54,7 @@ internal class ActivateCampaignsModelTest {
|
|||
|
||||
private val chooseTokenBridgeFactory: ChooseTokenBridge.Factory = mockk(relaxed = true)
|
||||
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
|
||||
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk()
|
||||
private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
|
||||
private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase = mockk()
|
||||
private val urlOpener: UrlOpener = mockk(relaxed = true)
|
||||
private val messageSender: UiMessageSender = mockk(relaxed = true)
|
||||
|
|
@ -72,7 +73,7 @@ internal class ActivateCampaignsModelTest {
|
|||
fun setup() {
|
||||
clearMocks(
|
||||
getSelectedAppCurrencyUseCase,
|
||||
isAccountsModeEnabledUseCase,
|
||||
multiAccountListSupplier,
|
||||
enrollPromoCampaignUseCase,
|
||||
getWalletsUseCase,
|
||||
messageSender,
|
||||
|
|
@ -258,7 +259,7 @@ internal class ActivateCampaignsModelTest {
|
|||
}
|
||||
every { chooseTokenBridgeFactory.create(any(), any(), any()) } returns bridge
|
||||
every { getSelectedAppCurrencyUseCase.invokeOrDefault() } returns flowOf(AppCurrency.Default)
|
||||
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
|
||||
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList<AccountList>())
|
||||
coEvery { getPromoCampaignStateUseCase(any(), any(), any()) } returns Either.Left(Throwable())
|
||||
every { getWalletsUseCase.invokeSync() } returns allWalletIds.map { walletId ->
|
||||
mockk<UserWallet> { every { this@mockk.walletId } returns walletId }
|
||||
|
|
@ -274,7 +275,7 @@ internal class ActivateCampaignsModelTest {
|
|||
dispatchers = createTestingCoroutineDispatcherProvider(),
|
||||
chooseTokenBridgeFactory = chooseTokenBridgeFactory,
|
||||
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
|
||||
isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase,
|
||||
multiAccountListSupplier = multiAccountListSupplier,
|
||||
enrollPromoCampaignUseCase = enrollPromoCampaignUseCase,
|
||||
urlOpener = urlOpener,
|
||||
messageSender = messageSender,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.model
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import arrow.core.Either
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
|
@ -108,11 +106,10 @@ internal class CampaignsModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN footer height set WHEN onAlreadyActivated THEN analytics sent and height reset`() = runTest {
|
||||
fun `WHEN onAlreadyActivated THEN analytics sent`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel(campaignFlow = emptyFlow())
|
||||
advanceUntilIdle()
|
||||
model.onFooterExtraHeightReady(100.dp)
|
||||
|
||||
// Act
|
||||
model.onAlreadyActivated(CampaignType.WhaleSwapCashback(campaignId = "1"))
|
||||
|
|
@ -121,37 +118,20 @@ internal class CampaignsModelTest {
|
|||
verify(exactly = 1) {
|
||||
analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened())
|
||||
}
|
||||
assertThat(model.footerExtraHeightState.value).isEqualTo(0.dp)
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN footer height set WHEN onActivated THEN no analytics and height reset`() = runTest {
|
||||
fun `WHEN onActivated THEN no analytics sent`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel(campaignFlow = emptyFlow())
|
||||
advanceUntilIdle()
|
||||
model.onFooterExtraHeightReady(100.dp)
|
||||
|
||||
// Act
|
||||
model.onActivated(CampaignType.WhaleSwapCashback(campaignId = "1"))
|
||||
|
||||
// Assert
|
||||
verify { analyticsEventHandler wasNot Called }
|
||||
assertThat(model.footerExtraHeightState.value).isEqualTo(0.dp)
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN onFooterExtraHeightReady THEN height state is updated`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel(campaignFlow = emptyFlow())
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onFooterExtraHeightReady(42.dp)
|
||||
|
||||
// Assert
|
||||
assertThat(model.footerExtraHeightState.value).isEqualTo(42.dp)
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ dependencies {
|
|||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.account.status)
|
||||
implementation(projects.domain.marketing.models)
|
||||
implementation(projects.domain.onramp.models)
|
||||
|
||||
/** Common */
|
||||
implementation(projects.common.ui)
|
||||
|
|
|
|||
|
|
@ -167,6 +167,9 @@ private fun StakingScreenContent(
|
|||
amountState = uiState.amountState,
|
||||
clickIntents = uiState.clickIntents,
|
||||
modifier = Modifier.background(TangemTheme.colors.background.secondary),
|
||||
extraContent = {
|
||||
marketingBannerComponent.Content(Modifier.fillMaxWidth())
|
||||
},
|
||||
)
|
||||
StakingStep.Confirmation -> StakingConfirmationContent(
|
||||
amountState = uiState.amountState,
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ internal class TangemPayCardPageScreenComponent(
|
|||
paymentAccountAddress = navigation.paymentAccountAddress,
|
||||
onDismiss = model.bottomSheetNavigation::dismiss,
|
||||
onShowDetails = model::onShowVirtualAccountRequisites,
|
||||
onShowBankingDetailsError = model::showVaBankingDetailsError,
|
||||
onOrderCreated = model::onVirtualAccountOrderCreated,
|
||||
),
|
||||
)
|
||||
|
|
@ -126,6 +127,15 @@ internal class TangemPayCardPageScreenComponent(
|
|||
onFieldCopied = model::onVaFieldCopied,
|
||||
),
|
||||
)
|
||||
is TangemPayCardNavigation.VaBankingDetailsError -> TangemPayVaBankingDetailsErrorComponent(
|
||||
appComponentContext = context,
|
||||
params = TangemPayVaBankingDetailsErrorComponent.Params(
|
||||
userWalletId = navigation.userWalletId,
|
||||
onDismiss = model.bottomSheetNavigation::dismiss,
|
||||
onContactSupport = model::onContactSupportClicked,
|
||||
onResolved = model::onVaBankingDetailsResolved,
|
||||
),
|
||||
)
|
||||
is TangemPayCardNavigation.Receive -> tokenReceiveComponentFactory.create(
|
||||
context = context,
|
||||
params = TokenReceiveComponent.Params(
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ internal class TangemPayDetailsComponent(
|
|||
paymentAccountAddress = navigation.paymentAccountAddress,
|
||||
onDismiss = model.bottomSheetNavigation::dismiss,
|
||||
onShowDetails = model::onShowVirtualAccountRequisites,
|
||||
onShowBankingDetailsError = model::showVaBankingDetailsError,
|
||||
onOrderCreated = model::onVirtualAccountOrderCreated,
|
||||
),
|
||||
)
|
||||
|
|
@ -165,6 +166,15 @@ internal class TangemPayDetailsComponent(
|
|||
onFieldCopied = model::onVaFieldCopied,
|
||||
),
|
||||
)
|
||||
is TangemPayDetailsNavigation.VaBankingDetailsError -> TangemPayVaBankingDetailsErrorComponent(
|
||||
appComponentContext = context,
|
||||
params = TangemPayVaBankingDetailsErrorComponent.Params(
|
||||
userWalletId = navigation.userWalletId,
|
||||
onDismiss = model.bottomSheetNavigation::dismiss,
|
||||
onContactSupport = model::onContactSupportClicked,
|
||||
onResolved = model::onVaBankingDetailsResolved,
|
||||
),
|
||||
)
|
||||
is TangemPayDetailsNavigation.IssueAdditionalCard -> TangemPayIssueAdditionalCardComponent(
|
||||
appComponentContext = context,
|
||||
params = TangemPayIssueAdditionalCardComponent.Params(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ internal class TangemPayVirtualAccountDepositComponent(
|
|||
val paymentAccountAddress: String,
|
||||
val onDismiss: () -> Unit,
|
||||
val onShowDetails: (VirtualAccountOnramp.Available) -> Unit,
|
||||
val onShowBankingDetailsError: () -> Unit,
|
||||
val onOrderCreated: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -53,6 +53,11 @@ internal interface TangemPayModelModule {
|
|||
@ClassKey(TangemPayVirtualAccountDepositModel::class)
|
||||
fun bindTangemPayVirtualAccountDepositModel(model: TangemPayVirtualAccountDepositModel): Model
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(TangemPayVaBankingDetailsErrorModel::class)
|
||||
fun bindTangemPayVaBankingDetailsErrorModel(model: TangemPayVaBankingDetailsErrorModel): Model
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(TangemPayViewPinModel::class)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,11 @@ internal sealed class TangemPayCardNavigation {
|
|||
val bankCredentials: BankCredentials,
|
||||
) : TangemPayCardNavigation()
|
||||
|
||||
@Serializable
|
||||
data class VaBankingDetailsError(
|
||||
val userWalletId: UserWalletId,
|
||||
) : TangemPayCardNavigation()
|
||||
|
||||
@Serializable
|
||||
data class Receive(val config: TokenReceiveConfig) : TangemPayCardNavigation()
|
||||
}
|
||||
|
|
@ -39,6 +39,11 @@ internal sealed class TangemPayDetailsNavigation {
|
|||
val bankCredentials: BankCredentials,
|
||||
) : TangemPayDetailsNavigation()
|
||||
|
||||
@Serializable
|
||||
data class VaBankingDetailsError(
|
||||
val userWalletId: UserWalletId,
|
||||
) : TangemPayDetailsNavigation()
|
||||
|
||||
@Serializable
|
||||
data class TransactionDetails(
|
||||
val transaction: TangemPayTxHistoryItem,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -7,6 +7,7 @@ import com.arkivanov.decompose.router.slot.dismiss
|
|||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.models.Basic
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
|
|
@ -24,6 +25,9 @@ import com.tangem.core.ui.res.TangemTheme
|
|||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_arrow_refresh_20
|
||||
import com.tangem.core.ui.test.TangemPayTestTags
|
||||
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
|
||||
import com.tangem.domain.feedback.models.FeedbackEmailType
|
||||
import com.tangem.domain.feedback.models.WalletMetaInfo
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.TokenReceiveConfig
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
|
|
@ -67,16 +71,17 @@ import kotlinx.coroutines.launch
|
|||
import javax.inject.Inject
|
||||
import com.tangem.core.ui.R as CoreUiR
|
||||
|
||||
@Suppress("LongParameterList", "LargeClass")
|
||||
@Suppress("LongParameterList", "LargeClass", "TooManyFunctions")
|
||||
@Stable
|
||||
@ModelScoped
|
||||
internal class TangemPayCardPageModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
|
||||
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
|
||||
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val router: Router,
|
||||
private val analytics: AnalyticsEventHandler,
|
||||
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
|
||||
private val cardDetailsRepository: TangemPayCardDetailsRepository,
|
||||
private val uiMessageSender: UiMessageSender,
|
||||
private val changeCardFrozenStateUseCase: ChangeCardFrozenStateUseCase,
|
||||
|
|
@ -446,7 +451,19 @@ internal class TangemPayCardPageModel @Inject constructor(
|
|||
|
||||
override fun onClickBankTransfer() {
|
||||
val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return
|
||||
val onramp = loaded.virtualAccount ?: return
|
||||
when (val onramp = loaded.virtualAccount) {
|
||||
null -> return
|
||||
VirtualAccountOnramp.Processing -> showVaPreparing()
|
||||
// BankCredentialsError opens the deposit intro first; the retryable error sheet is shown from
|
||||
// its "Show details" action (see onShowDetailsClick).
|
||||
is VirtualAccountOnramp.Available,
|
||||
VirtualAccountOnramp.Eligible,
|
||||
is VirtualAccountOnramp.BankCredentialsError,
|
||||
-> openVirtualAccountDeposit(onramp, loaded)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openVirtualAccountDeposit(onramp: VirtualAccountOnramp, loaded: PaymentAccountStatusValue.Loaded) {
|
||||
analytics.send(TangemPayAnalyticsEvents.VaTopupButtonClicked())
|
||||
bottomSheetNavigation.dismiss()
|
||||
bottomSheetNavigation.activate(
|
||||
|
|
@ -458,6 +475,43 @@ internal class TangemPayCardPageModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
fun showVaBankingDetailsError() {
|
||||
bottomSheetNavigation.dismiss()
|
||||
bottomSheetNavigation.activate(
|
||||
TangemPayCardNavigation.VaBankingDetailsError(userWalletId = userWalletId),
|
||||
)
|
||||
}
|
||||
|
||||
private fun showVaPreparing() {
|
||||
bottomSheetNavigation.dismiss()
|
||||
uiMessageSender.send(message = TangemPayMessagesFactory.createVaPreparingMessage())
|
||||
}
|
||||
|
||||
fun onVaBankingDetailsResolved(onramp: VirtualAccountOnramp) {
|
||||
when (onramp) {
|
||||
// Bank credentials just loaded on retry — show the requisites straight away ([REDACTED_TASK_KEY]),
|
||||
// instead of the intro deposit sheet that would need another "Show details" tap.
|
||||
is VirtualAccountOnramp.Available -> onShowVirtualAccountRequisites(onramp)
|
||||
else -> {
|
||||
val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return
|
||||
openVirtualAccountDeposit(onramp, loaded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onContactSupportClicked() {
|
||||
analytics.send(Basic.ButtonSupport(source = AnalyticsParam.ScreensSources.TangemPay))
|
||||
val customerId = currentStatus.value.ifLoadedOrNull { it.customerId } ?: return
|
||||
modelScope.launch {
|
||||
sendFeedbackEmailUseCase.invoke(
|
||||
type = FeedbackEmailType.Visa.FeatureIsBeta(
|
||||
walletMetaInfo = WalletMetaInfo(userWalletId = userWalletId),
|
||||
customerId = customerId,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onVirtualAccountOrderCreated() {
|
||||
analytics.send(TangemPayAnalyticsEvents.VaSuccessScreenActivation())
|
||||
bottomSheetNavigation.dismiss()
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ import javax.inject.Inject
|
|||
@ModelScoped
|
||||
internal class TangemPayDetailsModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
|
||||
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val analytics: AnalyticsEventHandler,
|
||||
private val router: Router,
|
||||
|
|
@ -355,7 +355,19 @@ internal class TangemPayDetailsModel @Inject constructor(
|
|||
|
||||
override fun onClickBankTransfer() {
|
||||
val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return
|
||||
val onramp = loaded.virtualAccount ?: return
|
||||
when (val onramp = loaded.virtualAccount) {
|
||||
null -> return
|
||||
VirtualAccountOnramp.Processing -> showVaPreparing()
|
||||
// BankCredentialsError opens the deposit intro first; the retryable error sheet is shown from
|
||||
// its "Show details" action (see onShowDetailsClick).
|
||||
is VirtualAccountOnramp.Available,
|
||||
VirtualAccountOnramp.Eligible,
|
||||
is VirtualAccountOnramp.BankCredentialsError,
|
||||
-> openVirtualAccountDeposit(onramp, loaded)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openVirtualAccountDeposit(onramp: VirtualAccountOnramp, loaded: PaymentAccountStatusValue.Loaded) {
|
||||
analytics.send(TangemPayAnalyticsEvents.VaTopupButtonClicked())
|
||||
bottomSheetNavigation.dismiss()
|
||||
bottomSheetNavigation.activate(
|
||||
|
|
@ -367,6 +379,30 @@ internal class TangemPayDetailsModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
fun showVaBankingDetailsError() {
|
||||
bottomSheetNavigation.dismiss()
|
||||
bottomSheetNavigation.activate(
|
||||
TangemPayDetailsNavigation.VaBankingDetailsError(userWalletId = userWalletId),
|
||||
)
|
||||
}
|
||||
|
||||
private fun showVaPreparing() {
|
||||
bottomSheetNavigation.dismiss()
|
||||
uiMessageSender.send(message = TangemPayMessagesFactory.createVaPreparingMessage())
|
||||
}
|
||||
|
||||
fun onVaBankingDetailsResolved(onramp: VirtualAccountOnramp) {
|
||||
when (onramp) {
|
||||
// Bank credentials just loaded on retry — show the requisites straight away ([REDACTED_TASK_KEY]),
|
||||
// instead of the intro deposit sheet that would need another "Show details" tap.
|
||||
is VirtualAccountOnramp.Available -> onShowVirtualAccountRequisites(onramp)
|
||||
else -> {
|
||||
val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return
|
||||
openVirtualAccountDeposit(onramp, loaded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onVirtualAccountOrderCreated() {
|
||||
analytics.send(TangemPayAnalyticsEvents.VaSuccessScreenActivation())
|
||||
bottomSheetNavigation.dismiss()
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -81,6 +81,9 @@ internal class TangemPayVirtualAccountDepositModel @Inject constructor(
|
|||
analytics.send(TangemPayAnalyticsEvents.VaShowDetailsFirstTimeClicked())
|
||||
createVirtualAccountOrder()
|
||||
}
|
||||
VirtualAccountOnramp.BankCredentialsError -> params.onShowBankingDetailsError()
|
||||
// Processing never reaches this sheet (the Preparing message is shown instead); defensive.
|
||||
VirtualAccountOnramp.Processing -> onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -5,8 +5,10 @@ import androidx.compose.foundation.Image
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -73,6 +75,7 @@ private fun DepositContent(state: TangemPayVirtualAccountDepositUM, modifier: Mo
|
|||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = TangemTheme.dimens2.x4)
|
||||
.padding(bottom = TangemTheme.dimens2.x4),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
|
|
@ -255,10 +258,10 @@ private fun UsdcIcon(modifier: Modifier = Modifier) {
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(TangemTheme.dimens2.x4),
|
||||
modifier = Modifier.size(TangemTheme.dimens2.x6),
|
||||
painter = painterResource(CoreUiR.drawable.ic_polygon_22),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.inverse,
|
||||
tint = TangemTheme.colors3.icon.staticDark,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import androidx.compose.material3.Icon
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
|
|
@ -16,6 +15,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.components.SpacerHMax
|
||||
import com.tangem.core.ui.components.haze.hazeForegroundEffectTangem
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
|
|
@ -25,6 +25,7 @@ import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
|||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_success_24
|
||||
import com.tangem.features.tangempay.details.impl.R
|
||||
import dev.chrisbanes.haze.HazeStyle
|
||||
|
||||
private const val DEFAULT_FADE_COLOR = 0xFF9FC824
|
||||
private val BlurRadius = 192.dp
|
||||
|
|
@ -46,7 +47,7 @@ internal fun TangemPaySuccessScreenWrapper(
|
|||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.blur(BlurRadius)
|
||||
.hazeForegroundEffectTangem(style = HazeStyle(blurRadius = BlurRadius, tint = null))
|
||||
.drawBehind {
|
||||
val w = size.width
|
||||
drawRect(
|
||||
|
|
|
|||
|
|
@ -169,6 +169,23 @@ internal object TangemPayMessagesFactory {
|
|||
)
|
||||
}
|
||||
|
||||
fun createVaPreparingMessage(): BottomSheetMessage {
|
||||
return bottomSheetMessage {
|
||||
infoBlock {
|
||||
icon(R.drawable.ic_clock_24) {
|
||||
type = MessageBottomSheetUM.Icon.Type.Informative
|
||||
backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Informative
|
||||
}
|
||||
title = TextReference.Res(R.string.tangempay_bank_transfer_success_title)
|
||||
body = TextReference.Res(R.string.tangempay_bank_transfer_success_subtitle)
|
||||
}
|
||||
secondaryButton {
|
||||
text = resourceReference(R.string.common_got_it)
|
||||
onClick { closeBs() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createFutureFeature(onGotItClick: () -> Unit): BottomSheetMessage {
|
||||
return bottomSheetMessage {
|
||||
infoBlock {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.tangem.features.tangempay.utils
|
||||
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.models.account.BankCredentials
|
||||
import com.tangem.features.tangempay.details.impl.R
|
||||
import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent.RequisitesRow
|
||||
|
||||
/**
|
||||
|
|
@ -16,22 +18,32 @@ internal const val VA_DAILY_DEPOSIT_LIMIT_PLACEHOLDER = "$10,000"
|
|||
*/
|
||||
internal fun BankCredentials.toRequisitesRows(): List<RequisitesRow> = listOf(
|
||||
RequisitesRow(
|
||||
title = "Beneficiary name and address",
|
||||
titleForShare = "Beneficiary name and address",
|
||||
value = "$beneficiaryName\n$beneficiaryAddress",
|
||||
title = resourceReference(R.string.virtual_account_requisites_beneficiary_name),
|
||||
titleForShare = "Beneficiary name",
|
||||
value = beneficiaryName,
|
||||
),
|
||||
RequisitesRow(
|
||||
title = "Bank name and address",
|
||||
titleForShare = "Bank name and address",
|
||||
value = "$beneficiaryBankName\n$beneficiaryBankAddress",
|
||||
title = resourceReference(R.string.virtual_account_requisites_beneficiary_address),
|
||||
titleForShare = "Beneficiary address",
|
||||
value = beneficiaryAddress,
|
||||
),
|
||||
RequisitesRow(
|
||||
title = "Account number",
|
||||
title = resourceReference(R.string.virtual_account_requisites_bank_name),
|
||||
titleForShare = "Bank name",
|
||||
value = beneficiaryBankName,
|
||||
),
|
||||
RequisitesRow(
|
||||
title = resourceReference(R.string.virtual_account_requisites_bank_address),
|
||||
titleForShare = "Bank address",
|
||||
value = beneficiaryBankAddress,
|
||||
),
|
||||
RequisitesRow(
|
||||
title = resourceReference(R.string.virtual_account_requisites_account_number),
|
||||
titleForShare = "Account number",
|
||||
value = accountNumber,
|
||||
),
|
||||
RequisitesRow(
|
||||
title = "Routing number",
|
||||
title = resourceReference(R.string.virtual_account_requisites_routing_number),
|
||||
titleForShare = "Routing number",
|
||||
value = routingNumber,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -40,12 +40,20 @@ internal class TangemPayVirtualAccountDepositModelTest {
|
|||
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
|
||||
private val createVirtualAccountOrderUseCase: CreateVirtualAccountOrderUseCase = mockk()
|
||||
private val onShowDetails: (VirtualAccountOnramp.Available) -> Unit = mockk(relaxed = true)
|
||||
private val onShowBankingDetailsError: () -> Unit = mockk(relaxed = true)
|
||||
private val onOrderCreated: () -> Unit = mockk(relaxed = true)
|
||||
private val analytics: AnalyticsEventHandler = mockk(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(createVirtualAccountOrderUseCase, onShowDetails, onOrderCreated, uiMessageSender, analytics)
|
||||
clearMocks(
|
||||
createVirtualAccountOrderUseCase,
|
||||
onShowDetails,
|
||||
onShowBankingDetailsError,
|
||||
onOrderCreated,
|
||||
uiMessageSender,
|
||||
analytics,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -65,6 +73,21 @@ internal class TangemPayVirtualAccountDepositModelTest {
|
|||
verify(exactly = 1) { analytics.send(ofType<TangemPayAnalyticsEvents.VaShowDetailsClicked>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN bank credentials error WHEN show details THEN shows banking details error sheet`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel(VirtualAccountOnramp.BankCredentialsError)
|
||||
|
||||
// Act
|
||||
model.uiState.value.onShowDetailsClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) { onShowBankingDetailsError() }
|
||||
verify(exactly = 0) { onShowDetails(any()) }
|
||||
coVerify(exactly = 0) { createVirtualAccountOrderUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN eligible and create succeeds WHEN show details THEN order created and loading reset`() = runTest {
|
||||
// Arrange
|
||||
|
|
@ -130,6 +153,7 @@ internal class TangemPayVirtualAccountDepositModelTest {
|
|||
paymentAccountAddress = paymentAccountAddress,
|
||||
onDismiss = {},
|
||||
onShowDetails = onShowDetails,
|
||||
onShowBankingDetailsError = onShowBankingDetailsError,
|
||||
onOrderCreated = onOrderCreated,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -376,59 +376,6 @@ internal data class TangemTokenIconStory(
|
|||
enum class UiStateVariant { Token, Shimmer, Error }
|
||||
}
|
||||
|
||||
internal data class TangemGlowRingStory(
|
||||
val variant: TangemGlowRing.Variant,
|
||||
val quality: TangemGlowRing.Quality,
|
||||
val background: Background,
|
||||
val isAnimated: Boolean,
|
||||
val onVariantChange: (TangemGlowRing.Variant) -> Unit,
|
||||
val onQualityChange: (TangemGlowRing.Quality) -> Unit,
|
||||
val onBackgroundChange: (Background) -> Unit,
|
||||
val onAnimatedToggle: () -> Unit,
|
||||
) : DsStoryBookPage {
|
||||
|
||||
/** Backdrop the glow-ring preview is rendered on top of. */
|
||||
enum class Background(val label: String) {
|
||||
BgPrimary("bg.primary"),
|
||||
BgSecondary("bg.secondary"),
|
||||
BgInverse("bg.inverse"),
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("BooleanPropertyNaming")
|
||||
internal data class TangemMessageBannerStory(
|
||||
val variant: TangemMessageBanner.Variant,
|
||||
val contentAlign: TangemMessageBanner.ContentAlign,
|
||||
val hasGlowRing: Boolean,
|
||||
val hasDescription: Boolean,
|
||||
val hasSecondaryButton: Boolean,
|
||||
val hasPrimaryButton: Boolean,
|
||||
val hasCloseButton: Boolean,
|
||||
val hasSlotStart: Boolean,
|
||||
val hasSlotEnd: Boolean,
|
||||
val hasExtraContent: Boolean,
|
||||
val background: Background,
|
||||
val onVariantChange: (TangemMessageBanner.Variant) -> Unit,
|
||||
val onContentAlignChange: (TangemMessageBanner.ContentAlign) -> Unit,
|
||||
val onGlowRingToggle: () -> Unit,
|
||||
val onDescriptionToggle: () -> Unit,
|
||||
val onSecondaryButtonToggle: () -> Unit,
|
||||
val onPrimaryButtonToggle: () -> Unit,
|
||||
val onCloseButtonToggle: () -> Unit,
|
||||
val onSlotStartToggle: () -> Unit,
|
||||
val onSlotEndToggle: () -> Unit,
|
||||
val onExtraContentToggle: () -> Unit,
|
||||
val onBackgroundChange: (Background) -> Unit,
|
||||
) : DsStoryBookPage {
|
||||
|
||||
/** Backdrop the banner preview is rendered on top of. */
|
||||
enum class Background(val label: String) {
|
||||
BgPrimary("bg.primary"),
|
||||
BgSecondary("bg.secondary"),
|
||||
BgInverse("bg.inverse"),
|
||||
}
|
||||
}
|
||||
|
||||
internal data class TextStyleStory(
|
||||
val style: Style,
|
||||
val textScale: Float,
|
||||
|
|
@ -505,6 +452,61 @@ internal data class TangemTokenRowMarketStory(
|
|||
val onLongTitleToggle: () -> Unit,
|
||||
) : DsStoryBookPage
|
||||
|
||||
internal data class TangemGlowRingStory(
|
||||
val variant: TangemGlowRing.Variant,
|
||||
val quality: TangemGlowRing.Quality,
|
||||
val background: Background,
|
||||
val isAnimated: Boolean,
|
||||
val onVariantChange: (TangemGlowRing.Variant) -> Unit,
|
||||
val onQualityChange: (TangemGlowRing.Quality) -> Unit,
|
||||
val onBackgroundChange: (Background) -> Unit,
|
||||
val onAnimatedToggle: () -> Unit,
|
||||
) : DsStoryBookPage {
|
||||
|
||||
/** Backdrop the glow-ring preview is rendered on top of. */
|
||||
enum class Background(val label: String) {
|
||||
BgPrimary("bg.primary"),
|
||||
BgSecondary("bg.secondary"),
|
||||
BgInverse("bg.inverse"),
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("BooleanPropertyNaming")
|
||||
internal data class TangemMessageBannerStory(
|
||||
val variant: TangemMessageBanner.Variant,
|
||||
val contentAlign: TangemMessageBanner.ContentAlign,
|
||||
val hasGlowRing: Boolean,
|
||||
val hasDescription: Boolean,
|
||||
val hasSecondaryButton: Boolean,
|
||||
val hasPrimaryButton: Boolean,
|
||||
val hasCloseButton: Boolean,
|
||||
val hasSlotStart: Boolean,
|
||||
val hasSlotEnd: Boolean,
|
||||
val hasExtraContent: Boolean,
|
||||
val isClickable: Boolean,
|
||||
val background: Background,
|
||||
val onVariantChange: (TangemMessageBanner.Variant) -> Unit,
|
||||
val onContentAlignChange: (TangemMessageBanner.ContentAlign) -> Unit,
|
||||
val onGlowRingToggle: () -> Unit,
|
||||
val onDescriptionToggle: () -> Unit,
|
||||
val onSecondaryButtonToggle: () -> Unit,
|
||||
val onPrimaryButtonToggle: () -> Unit,
|
||||
val onCloseButtonToggle: () -> Unit,
|
||||
val onSlotStartToggle: () -> Unit,
|
||||
val onSlotEndToggle: () -> Unit,
|
||||
val onExtraContentToggle: () -> Unit,
|
||||
val onClickableToggle: () -> Unit,
|
||||
val onBackgroundChange: (Background) -> Unit,
|
||||
) : DsStoryBookPage {
|
||||
|
||||
/** Backdrop the banner preview is rendered on top of. */
|
||||
enum class Background(val label: String) {
|
||||
BgPrimary("bg.primary"),
|
||||
BgSecondary("bg.secondary"),
|
||||
BgInverse("bg.inverse"),
|
||||
}
|
||||
}
|
||||
|
||||
internal data class TangemBadgeV2Story(
|
||||
val variant: TangemBadge.Variant,
|
||||
val status: TangemBadge.Status,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ internal fun StateUpdater<TangemMessageBannerStory>.build(): TangemMessageBanner
|
|||
hasSlotStart = true,
|
||||
hasSlotEnd = true,
|
||||
hasExtraContent = true,
|
||||
isClickable = false,
|
||||
background = Background.BgSecondary,
|
||||
onVariantChange = { variant -> updateStory { it.copy(variant = variant) } },
|
||||
onContentAlignChange = { align -> updateStory { it.copy(contentAlign = align) } },
|
||||
|
|
@ -29,6 +30,7 @@ internal fun StateUpdater<TangemMessageBannerStory>.build(): TangemMessageBanner
|
|||
onSlotStartToggle = { updateStory { it.copy(hasSlotStart = !it.hasSlotStart) } },
|
||||
onSlotEndToggle = { updateStory { it.copy(hasSlotEnd = !it.hasSlotEnd) } },
|
||||
onExtraContentToggle = { updateStory { it.copy(hasExtraContent = !it.hasExtraContent) } },
|
||||
onClickableToggle = { updateStory { it.copy(isClickable = !it.isClickable) } },
|
||||
onBackgroundChange = { background -> updateStory { it.copy(background = background) } },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,6 +95,11 @@ private fun PreviewBanner(state: TangemMessageBannerStory) {
|
|||
variant = state.variant,
|
||||
contentAlign = state.contentAlign,
|
||||
showGlowRing = state.hasGlowRing,
|
||||
onClick = if (state.isClickable) {
|
||||
{}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
title = stringReference("Would you predict?"),
|
||||
description = if (state.hasDescription) {
|
||||
stringReference("France will win FIFA 2026")
|
||||
|
|
@ -233,6 +238,11 @@ private fun Toggles(state: TangemMessageBannerStory) {
|
|||
ToggleRow(label = "slotStart", checked = state.hasSlotStart, onToggle = state.onSlotStartToggle)
|
||||
ToggleRow(label = "slotEnd", checked = state.hasSlotEnd, onToggle = state.onSlotEndToggle)
|
||||
ToggleRow(label = "extraContent", checked = state.hasExtraContent, onToggle = state.onExtraContentToggle)
|
||||
ToggleRow(
|
||||
label = "clickable (no buttons only)",
|
||||
checked = state.isClickable,
|
||||
onToggle = state.onClickableToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,7 @@ dependencies {
|
|||
|
||||
/** Domain */
|
||||
api(projects.domain.models)
|
||||
|
||||
/** Compose */
|
||||
implementation(deps.compose.runtime)
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package com.tangem.features.virtualaccount.details.component
|
|||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
|
|
@ -25,7 +26,7 @@ interface VirtualAccountAddFundsBottomSheetComponent : ComposableBottomSheetComp
|
|||
)
|
||||
|
||||
data class RequisitesRow(
|
||||
val title: String,
|
||||
val title: TextReference,
|
||||
val titleForShare: String,
|
||||
val value: String,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -57,22 +57,17 @@ internal class VirtualAccountMainModel @Inject constructor(
|
|||
|
||||
private fun buildRequisites(details: VirtualAccountDepositDetails) = listOf(
|
||||
VirtualAccountAddFundsBottomSheetComponent.RequisitesRow(
|
||||
title = "Beneficiary name and address",
|
||||
titleForShare = "Beneficiary name and address",
|
||||
value = "${details.beneficiaryName}\n${details.beneficiaryAddress}",
|
||||
title = resourceReference(R.string.virtual_account_requisites_beneficiary_name),
|
||||
titleForShare = "Beneficiary name",
|
||||
value = details.beneficiaryName,
|
||||
),
|
||||
VirtualAccountAddFundsBottomSheetComponent.RequisitesRow(
|
||||
title = "Bank name and address",
|
||||
titleForShare = "Bank name and address",
|
||||
value = "${details.bankName}\n${details.bankAddress}",
|
||||
),
|
||||
VirtualAccountAddFundsBottomSheetComponent.RequisitesRow(
|
||||
title = "Account number",
|
||||
title = resourceReference(R.string.virtual_account_requisites_account_number),
|
||||
titleForShare = "Account number",
|
||||
value = details.accountNumber,
|
||||
),
|
||||
VirtualAccountAddFundsBottomSheetComponent.RequisitesRow(
|
||||
title = "Routing number",
|
||||
title = resourceReference(R.string.virtual_account_requisites_routing_number),
|
||||
titleForShare = "Routing number",
|
||||
value = details.routingNumber,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ import androidx.compose.foundation.Image
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -110,6 +112,7 @@ private fun DetailsContent(content: VirtualAccountAddFundsUM.Content.Details, mo
|
|||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = TangemTheme.dimens2.x4),
|
||||
) {
|
||||
content.items.forEachIndexed { index, item ->
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import com.tangem.core.decompose.model.Model
|
|||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.navigation.share.ShareManager
|
||||
import com.tangem.core.ui.clipboard.ClipboardManager
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
|
@ -55,25 +54,31 @@ internal class VirtualAccountAddFundsModel @Inject constructor(
|
|||
uiState.update { state -> state.copy(content = buildDetailsContent()) }
|
||||
}
|
||||
|
||||
private fun buildDetailsContent() = VirtualAccountAddFundsUM.Content.Details(
|
||||
items = params.requisites
|
||||
.map { detailItem(label = it.title, value = it.value) }
|
||||
.toImmutableList(),
|
||||
dailyLimit = params.dailyDepositLimit,
|
||||
onShareClick = {
|
||||
params.onShareClicked()
|
||||
shareManager.shareText(buildShareText())
|
||||
},
|
||||
)
|
||||
private fun buildDetailsContent(): VirtualAccountAddFundsUM.Content.Details {
|
||||
return VirtualAccountAddFundsUM.Content.Details(
|
||||
items = params.requisites
|
||||
.map(::detailItem)
|
||||
.toImmutableList(),
|
||||
dailyLimit = params.dailyDepositLimit,
|
||||
onShareClick = {
|
||||
params.onShareClicked()
|
||||
shareManager.shareText(buildShareText())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun detailItem(label: String, value: String) = VirtualAccountAddFundsUM.DetailItem(
|
||||
label = stringReference(label),
|
||||
value = value,
|
||||
onCopyClick = {
|
||||
params.onFieldCopied(label)
|
||||
clipboardManager.setText(text = value, isSensitive = true)
|
||||
},
|
||||
)
|
||||
private fun detailItem(
|
||||
requisitesRow: VirtualAccountAddFundsBottomSheetComponent.RequisitesRow,
|
||||
): VirtualAccountAddFundsUM.DetailItem {
|
||||
return VirtualAccountAddFundsUM.DetailItem(
|
||||
label = requisitesRow.title,
|
||||
value = requisitesRow.value,
|
||||
onCopyClick = {
|
||||
params.onFieldCopied(requisitesRow.titleForShare)
|
||||
clipboardManager.setText(text = requisitesRow.value, isSensitive = true)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildShareText(): String {
|
||||
return buildString {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ dependencies {
|
|||
implementation(projects.domain.balanceHiding.models)
|
||||
implementation(projects.domain.balanceHiding)
|
||||
implementation(projects.domain.marketing.models)
|
||||
implementation(projects.domain.onramp.models)
|
||||
implementation(projects.libs.crypto)
|
||||
|
||||
/** Compose */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue