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