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