From fdbd09cccb71abb1f119512cf9911b0edfd2907a Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Jun 2026 21:18:17 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../PaymentAccountStatusValueDMConverter.kt | 3 +- .../DefaultPaymentAccountStatusFetcher.kt | 13 +- .../DefaultPaymentAccountStatusFetcherTest.kt | 303 ++++++++++++++++++ .../account/PaymentAccountStatusValue.kt | 5 +- .../models/account/VirtualAccountOnramp.kt | 4 - .../tangempay/details/impl/build.gradle.kts | 1 + .../components/TangemPayAddFundsComponent.kt | 3 + .../TangemPayCardPageScreenComponent.kt | 8 + .../components/TangemPayDetailsComponent.kt | 8 + ...TangemPayVirtualAccountDepositComponent.kt | 35 ++ .../tangempay/di/TangemPayModelModule.kt | 5 + .../entity/TangemPayCardNavigation.kt | 7 + .../entity/TangemPayDetailsNavigation.kt | 7 + .../TangemPayVirtualAccountDepositUM.kt | 27 ++ .../tangempay/model/TangemPayAddFundsModel.kt | 3 + .../tangempay/model/TangemPayCardPageModel.kt | 7 + .../tangempay/model/TangemPayDetailsModel.kt | 7 + .../TangemPayVirtualAccountDepositModel.kt | 50 +++ .../TangemPayAddFundsUMConverter.kt | 131 ++++---- ...ngemPayVirtualAccountDepositBottomSheet.kt | 302 +++++++++++++++++ 20 files changed, 852 insertions(+), 77 deletions(-) create mode 100644 data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVirtualAccountDepositComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayVirtualAccountDepositUM.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayVirtualAccountDepositModel.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayVirtualAccountDepositBottomSheet.kt diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt index 95ee815e71..77beec76bc 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt @@ -5,7 +5,6 @@ import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.account.PaymentAccountStatusValue -import com.tangem.domain.models.account.VirtualAccountOnramp import com.tangem.domain.models.pay.* import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayCurrencyFactory @@ -119,7 +118,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( ) }, error = null, - virtualAccount = VirtualAccountOnramp.None, + virtualAccount = null, tariffPlan = null, ) is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview( 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 83c1acd7c6..f2741234f6 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 @@ -403,11 +403,10 @@ 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 - * [VirtualAccountOnramp.None]. + * 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 VirtualAccountOnramp.None + private suspend fun CustomerInfo.resolveVirtualAccountOnramp(userWalletId: UserWalletId): VirtualAccountOnramp? { + if (!virtualAccountFeatureToggles.isVaMvp0Enabled) return null val accountInstance = productInstances.firstOrNull { it.specificationDataType == SpecificationDataType.ACCOUNT @@ -416,7 +415,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( return onboardingRepository.getBankCredentials(userWalletId, accountInstance.id).fold( ifLeft = { error -> logger.e("getBankCredentials failed for ${accountInstance.id}: $error") - VirtualAccountOnramp.None + null }, ifRight = { credentials -> VirtualAccountOnramp.Available( @@ -430,13 +429,13 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( return onboardingRepository.fetchCustomerEligibility(userWalletId).fold( ifLeft = { error -> logger.e("fetchCustomerEligibility failed for $userWalletId: $error") - VirtualAccountOnramp.None + null }, ifRight = { channels -> if (channels.contains(TangemPayEligibilityType.VISA_VIRTUAL_ACCOUNT)) { VirtualAccountOnramp.Eligible } else { - VirtualAccountOnramp.None + null } }, ) 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 new file mode 100644 index 0000000000..e0c4bcc677 --- /dev/null +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt @@ -0,0 +1,303 @@ +package com.tangem.data.pay.flow + +import arrow.core.Either +import arrow.core.left +import com.google.common.truth.Truth.assertThat +import com.tangem.data.pay.store.PaymentAccountStatusesStore +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.BankCredentials +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.account.VirtualAccountOnramp +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.kyc.KycStatus +import com.tangem.domain.models.pay.TangemPayCard +import com.tangem.domain.models.pay.TangemPayCardFrozenState +import com.tangem.domain.models.pay.TangemPayEligibilityType +import com.tangem.domain.models.wallet.UserWalletId +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.repository.* +import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles +import com.tangem.security.DeviceSecurityInfoProvider +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultPaymentAccountStatusFetcherTest { + + private val paymentAccountStatusesStore: PaymentAccountStatusesStore = mockk(relaxed = true) + private val onboardingRepository: OnboardingRepository = mockk() + private val customerOrderRepository: CustomerOrderRepository = mockk() + private val deviceSecurity: DeviceSecurityInfoProvider = mockk(relaxed = true) + private val dispatchers = TestingCoroutineDispatcherProvider() + private val tangemPayCurrencyFactory: TangemPayCurrencyFactory = mockk() + private val eligibilityManager: TangemPayEligibilityManager = mockk(relaxed = true) + private val reissueCardRepository: TangemPayReissueCardRepository = mockk() + private val singleQuoteSupplier: SingleQuoteStatusSupplier = mockk() + private val closeCardRepository: TangemPayCloseCardRepository = mockk() + private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk() + private val issueCardRepository: TangemPayIssueCardRepository = mockk() + private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles = mockk() + + private val fetcher = DefaultPaymentAccountStatusFetcher( + paymentAccountStatusesStore = paymentAccountStatusesStore, + onboardingRepository = onboardingRepository, + customerOrderRepository = customerOrderRepository, + deviceSecurity = deviceSecurity, + dispatchers = dispatchers, + tangemPayCurrencyFactory = tangemPayCurrencyFactory, + eligibilityManager = eligibilityManager, + reissueCardRepository = reissueCardRepository, + singleQuoteSupplier = singleQuoteSupplier, + closeCardRepository = closeCardRepository, + cardDetailsRepository = cardDetailsRepository, + issueCardRepository = issueCardRepository, + virtualAccountFeatureToggles = virtualAccountFeatureToggles, + ) + + private val userWalletId = UserWalletId("011") + private val params = PaymentAccountStatusFetcher.Params(userWalletId) + + private val bankCredentialsFixture = BankCredentials( + type = "ACH", + beneficiaryName = "Test Beneficiary", + beneficiaryAddress = "123 Main St", + beneficiaryBankName = "Test Bank", + beneficiaryBankAddress = "456 Bank Ave", + accountNumber = "1234567890", + routingNumber = "021000021", + ) + + private val cardProductInstance = CustomerInfo.ProductInstance( + id = "pi_card", + cardId = "card_1", + frozenState = TangemPayCardFrozenState.Unfrozen, + displayName = null, + actualCardLimit = null, + adminCardLimit = null, + status = CustomerInfo.ProductInstance.Status.ACTIVE, + specificationDataType = CustomerInfo.ProductInstance.SpecificationDataType.CARD, + ) + + private val accountProductInstance = CustomerInfo.ProductInstance( + id = "pi_account", + cardId = "", + frozenState = TangemPayCardFrozenState.Unfrozen, + displayName = null, + actualCardLimit = null, + adminCardLimit = null, + status = CustomerInfo.ProductInstance.Status.ACTIVE, + specificationDataType = CustomerInfo.ProductInstance.SpecificationDataType.ACCOUNT, + ) + + private val cardInfo = CustomerInfo.CardInfo( + cardId = "card_1", + cardStatus = TangemPayCard.Status.ACTIVE, + lastFourDigits = "1234", + isPinSet = true, + ) + + private fun buildCustomerInfo( + productInstances: List = listOf(cardProductInstance), + ) = CustomerInfo( + customerId = "cust_1", + kycStatus = KycStatus.APPROVED, + state = CustomerInfo.State.ACTIVE, + 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, + cards = listOf(cardInfo), + productInstances = productInstances, + tariffPlan = null, + ) + + @BeforeEach + fun setUp() { + clearMocks( + onboardingRepository, + customerOrderRepository, + tangemPayCurrencyFactory, + reissueCardRepository, + singleQuoteSupplier, + closeCardRepository, + cardDetailsRepository, + issueCardRepository, + virtualAccountFeatureToggles, + ) + // Relaxed mocks don't need clearing — deviceSecurity, eligibilityManager, paymentAccountStatusesStore + // are relaxed and consistent with their relaxed defaults (false, empty, etc.) + clearMocks(paymentAccountStatusesStore, answers = false) + } + + /** + * Stubs the full happy-path chain up to [CustomerInfo.convertToContentState] so the fetcher + * can produce a [PaymentAccountStatusValue.Loaded] result. Only the [customerInfo] parameter is + * varied per test to exercise different VA on-ramp branches. + */ + private suspend fun stubHappyPath(customerInfo: CustomerInfo) { + val token: CryptoCurrency.Token = mockk(relaxed = true) + + coEvery { onboardingRepository.hasTangemPayInWallet(userWalletId) } returns Either.Right(true) + coEvery { onboardingRepository.isTangemPayInitialDataProduced(userWalletId) } returns true + coEvery { onboardingRepository.getOrderId(userWalletId) } returns null + coEvery { onboardingRepository.getCustomerInfo(userWalletId) } returns Either.Right(customerInfo) + + coEvery { paymentAccountStatusesStore.getSyncOrNull(userWalletId) } returns null + coEvery { paymentAccountStatusesStore.store(any(), any()) } just Runs + + every { tangemPayCurrencyFactory.create(userWalletId) } returns token + + coEvery { singleQuoteSupplier.getSyncOrNull(any()) } returns null + + coEvery { cardDetailsRepository.cardFrozenStateSync(any()) } returns TangemPayCardFrozenState.Unfrozen + + coEvery { closeCardRepository.getCloseOrderId(any(), any()) } returns Either.Right(null) + coEvery { reissueCardRepository.getReissueOrderId(any(), any()) } returns Either.Right(null) + + coEvery { issueCardRepository.getIssueOrderIds(any()) } returns emptyList() + } + + /** Collects all [AccountStatus.Payment] values stored via [PaymentAccountStatusesStore.store]. */ + private fun captureStoredStatuses(): MutableList { + val captured = mutableListOf() + coEvery { paymentAccountStatusesStore.store(any(), capture(captured)) } just Runs + return captured + } + + private fun MutableList.lastLoaded(): PaymentAccountStatusValue.Loaded { + val loaded = filterIsInstance() + .map { it.value } + .filterIsInstance() + .lastOrNull() + return requireNotNull(loaded) { "Expected at least one Loaded status to be stored; stored: ${map { it.value::class.simpleName }}" } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class `resolveVirtualAccountOnramp` { + + @Test + fun `GIVEN feature toggle is off WHEN invoke THEN virtualAccount is null`() = runTest { + // Arrange + val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance)) + stubHappyPath(customerInfo) + every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns false + val storedStatuses = captureStoredStatuses() + + // Act + fetcher.invoke(params) + + // Assert + val loaded = storedStatuses.lastLoaded() + assertThat(loaded.virtualAccount).isNull() + } + + @Test + fun `GIVEN toggle on and ACCOUNT instance with bank credentials WHEN invoke THEN virtualAccount is Available`() = + runTest { + // Arrange + val customerInfo = buildCustomerInfo( + productInstances = listOf(cardProductInstance, accountProductInstance), + ) + stubHappyPath(customerInfo) + every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true + coEvery { + onboardingRepository.getBankCredentials(userWalletId, "pi_account") + } returns Either.Right(bankCredentialsFixture) + val storedStatuses = captureStoredStatuses() + + // Act + fetcher.invoke(params) + + // Assert + val loaded = storedStatuses.lastLoaded() + assertThat(loaded.virtualAccount).isEqualTo( + VirtualAccountOnramp.Available( + productInstanceId = "pi_account", + bankCredentials = bankCredentialsFixture, + ), + ) + } + + @Test + fun `GIVEN toggle on and ACCOUNT instance but bank credentials fetch fails WHEN invoke THEN virtualAccount is null`() = + runTest { + // Arrange + val customerInfo = buildCustomerInfo( + productInstances = listOf(cardProductInstance, accountProductInstance), + ) + stubHappyPath(customerInfo) + every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true + coEvery { + onboardingRepository.getBankCredentials(userWalletId, "pi_account") + } returns VisaApiError.UnknownWithoutCode.left() + val storedStatuses = captureStoredStatuses() + + // Act + fetcher.invoke(params) + + // Assert + val loaded = storedStatuses.lastLoaded() + assertThat(loaded.virtualAccount).isNull() + } + + @Test + fun `GIVEN toggle on and no ACCOUNT instance and customer is eligible WHEN invoke THEN virtualAccount is Eligible`() = + runTest { + // Arrange + val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance)) + stubHappyPath(customerInfo) + every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true + coEvery { + onboardingRepository.fetchCustomerEligibility(userWalletId) + } returns Either.Right(listOf(TangemPayEligibilityType.VISA_VIRTUAL_ACCOUNT)) + val storedStatuses = captureStoredStatuses() + + // Act + fetcher.invoke(params) + + // Assert + val loaded = storedStatuses.lastLoaded() + assertThat(loaded.virtualAccount).isEqualTo(VirtualAccountOnramp.Eligible) + } + + @Test + fun `GIVEN toggle on and no ACCOUNT instance and eligibility fetch fails WHEN invoke THEN virtualAccount is null`() = + runTest { + // Arrange + val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance)) + stubHappyPath(customerInfo) + every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true + coEvery { + onboardingRepository.fetchCustomerEligibility(userWalletId) + } returns VisaApiError.UnknownWithoutCode.left() + val storedStatuses = captureStoredStatuses() + + // Act + fetcher.invoke(params) + + // Assert + val loaded = storedStatuses.lastLoaded() + assertThat(loaded.virtualAccount).isNull() + } + } +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt index 83267d850c..aebdca0714 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt @@ -149,7 +149,8 @@ sealed class PaymentAccountStatusValue { * [totalFiatBalance] resolves to [TotalFiatBalance.Failed]. * @property error Transient error overlaid on top of cached data when a refresh fails * (see [copySealed]), or `null` when the status is up to date. Not persisted. - * @property virtualAccount Virtual Account (Visa on-ramp) availability — VA MVP0 (TWI-1638). + * @property virtualAccount Virtual Account (Visa on-ramp) availability — VA MVP0 (TWI-1638), or `null` + * when not applicable (feature toggle off / wallet not eligible). * Transient: not persisted in the local cache. * @property tariffPlan Current tariff plan with subscription data (Tiers). * Transient: not persisted in the local cache. @@ -164,7 +165,7 @@ sealed class PaymentAccountStatusValue { val cards: List, val fiatRate: SerializedBigDecimal?, val error: Error?, - val virtualAccount: VirtualAccountOnramp, + val virtualAccount: VirtualAccountOnramp?, val tariffPlan: TangemPayCustomerTariffPlan?, ) : PaymentAccountStatusValue() { val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( 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 2b504b4c0f..46b95dd157 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 @@ -11,10 +11,6 @@ import kotlinx.serialization.Serializable @Serializable sealed interface VirtualAccountOnramp { - /** On-ramp not applicable: feature toggle off, or wallet not eligible. */ - @Serializable - data object None : VirtualAccountOnramp - /** No VA product instance yet, but the wallet is eligible to add funds (channel `VISA_VIRTUAL_ACCOUNT`). */ @Serializable data object Eligible : VirtualAccountOnramp diff --git a/features/tangempay/details/impl/build.gradle.kts b/features/tangempay/details/impl/build.gradle.kts index 0f0485f42f..124401babb 100644 --- a/features/tangempay/details/impl/build.gradle.kts +++ b/features/tangempay/details/impl/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { implementation(projects.features.txhistory.api) implementation(projects.features.tokendetails.api) implementation(projects.features.promoBanners.api) + implementation(projects.features.virtualAccounts.details.api) // TWI_1638_VA_MVP0_ENABLED /** Domain */ implementation(projects.domain.balanceHiding) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddFundsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddFundsComponent.kt index e89cd44a45..3e672b76d3 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddFundsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddFundsComponent.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable 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.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.TangemPayTopUpData @@ -39,11 +40,13 @@ internal class TangemPayAddFundsComponent( val fiatBalance: BigDecimal, val depositAddress: String, val cryptoCurrency: CryptoCurrency, + val virtualAccountOnramp: VirtualAccountOnramp?, ) } internal interface AddFundsListener { fun onClickReceive(data: TangemPayTopUpData) fun onClickSwap(data: TangemPayTopUpData) + fun onClickBankTransfer() fun onDismissAddFunds() } \ No newline at end of file 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 c32727f8ce..7a193232f2 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 @@ -97,6 +97,14 @@ internal class TangemPayCardPageScreenComponent( fiatBalance = navigation.fiatBalance, depositAddress = navigation.depositAddress, cryptoCurrency = navigation.cryptoCurrency, + virtualAccountOnramp = navigation.virtualAccountOnramp, + ), + ) + is TangemPayCardNavigation.VirtualAccountDeposit -> TangemPayVirtualAccountDepositComponent( + appComponentContext = context, + params = TangemPayVirtualAccountDepositComponent.Params( + virtualAccountOnramp = navigation.virtualAccountOnramp, + onDismiss = model.bottomSheetNavigation::dismiss, ), ) is TangemPayCardNavigation.Receive -> tokenReceiveComponentFactory.create( 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 9d5070cf82..d118e2351b 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 @@ -143,6 +143,14 @@ internal class TangemPayDetailsComponent( depositAddress = navigation.depositAddress, cryptoCurrency = navigation.cryptoCurrency, listener = model, + virtualAccountOnramp = navigation.virtualAccountOnramp, + ), + ) + is TangemPayDetailsNavigation.VirtualAccountDeposit -> TangemPayVirtualAccountDepositComponent( + appComponentContext = context, + params = TangemPayVirtualAccountDepositComponent.Params( + virtualAccountOnramp = navigation.virtualAccountOnramp, + onDismiss = model.bottomSheetNavigation::dismiss, ), ) is TangemPayDetailsNavigation.IssueAdditionalCard -> TangemPayIssueAdditionalCardComponent( 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 new file mode 100644 index 0000000000..6aed0c69e9 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVirtualAccountDepositComponent.kt @@ -0,0 +1,35 @@ +package com.tangem.features.tangempay.components + +import androidx.compose.runtime.Composable +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.features.tangempay.model.TangemPayVirtualAccountDepositModel +import com.tangem.features.tangempay.ui.TangemPayVirtualAccountDepositBottomSheet + +/** + * Bank-transfer deposit bottom sheet (VA MVP0, TWI-1638). Opened from the add-funds "Bank transfer" option. + * Renders the on-ramp intro; the [VirtualAccountOnramp.Eligible] state additionally shows a T&C consent footer. + */ +internal class TangemPayVirtualAccountDepositComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: TangemPayVirtualAccountDepositModel = getOrCreateModel(params = params) + + override fun dismiss() { + model.onDismiss() + } + + @Composable + override fun BottomSheet() { + TangemPayVirtualAccountDepositBottomSheet(state = model.uiState) + } + + data class Params( + val virtualAccountOnramp: VirtualAccountOnramp, + val onDismiss: () -> 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 0815d4c5ca..23e1df1634 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 @@ -46,6 +46,11 @@ internal interface TangemPayModelModule { @ClassKey(TangemPayAddFundsModel::class) fun bindTangemPayAddFundsModel(model: TangemPayAddFundsModel): Model + @Binds + @IntoMap + @ClassKey(TangemPayVirtualAccountDepositModel::class) + fun bindTangemPayVirtualAccountDepositModel(model: TangemPayVirtualAccountDepositModel): 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 425d6813f8..98b7a9ecc5 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 @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.entity import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.account.VirtualAccountOnramp import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.serialization.SerializedBigDecimal import com.tangem.domain.models.wallet.UserWalletId @@ -30,6 +31,12 @@ internal sealed class TangemPayCardNavigation { val fiatBalance: SerializedBigDecimal, val depositAddress: String, val cryptoCurrency: CryptoCurrency, + val virtualAccountOnramp: VirtualAccountOnramp?, + ) : TangemPayCardNavigation() + + @Serializable + data class VirtualAccountDeposit( + val virtualAccountOnramp: VirtualAccountOnramp, ) : TangemPayCardNavigation() @Serializable 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 b3426e9094..67ba299b4a 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 @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.entity import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.account.VirtualAccountOnramp import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.serialization.SerializedBigDecimal import com.tangem.domain.models.serialization.SerializedCurrency @@ -21,6 +22,12 @@ internal sealed class TangemPayDetailsNavigation { val fiatBalance: SerializedBigDecimal, val depositAddress: String, val cryptoCurrency: CryptoCurrency, + val virtualAccountOnramp: VirtualAccountOnramp?, + ) : TangemPayDetailsNavigation() + + @Serializable + data class VirtualAccountDeposit( + val virtualAccountOnramp: VirtualAccountOnramp, ) : TangemPayDetailsNavigation() @Serializable diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayVirtualAccountDepositUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayVirtualAccountDepositUM.kt new file mode 100644 index 0000000000..e6c98fa99d --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayVirtualAccountDepositUM.kt @@ -0,0 +1,27 @@ +package com.tangem.features.tangempay.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +/** + * UI state of the bank-transfer deposit bottom sheet (VA MVP0, TWI-1638). + * + * @property shouldShowTermsAndConditions `true` for the `Eligible` state — shows the provider T&C consent footer. + */ +@Immutable +internal data class TangemPayVirtualAccountDepositUM( + val fees: ImmutableList, + val shouldShowTermsAndConditions: Boolean, + val onShowDetailsClick: () -> Unit, + val onDismiss: () -> Unit, + val onTermsClick: () -> Unit, + val onPrivacyClick: () -> Unit, +) { + + @Immutable + data class FeeRow( + val title: TextReference, + val value: String, + ) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt index 76a6cc91b1..f8e50e9509 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt @@ -11,6 +11,7 @@ import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.features.tangempay.components.TangemPayAddFundsComponent import com.tangem.features.tangempay.entity.TangemPayAddFundsUM import com.tangem.features.tangempay.model.transformers.TangemPayAddFundsUMConverter +import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import javax.inject.Inject @@ -20,6 +21,7 @@ internal class TangemPayAddFundsModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val tangemPayFeatureToggles: TangemPayFeatureToggles, + private val virtualAccountToggles: VirtualAccountFeatureToggles, ) : Model() { private val params = paramsContainer.require() @@ -43,6 +45,7 @@ internal class TangemPayAddFundsModel @Inject constructor( return TangemPayAddFundsUMConverter( listener = params.listener, isRedesignEnabled = tangemPayFeatureToggles.isRedesignEnabled, + shouldShowBankTransfer = virtualAccountToggles.isVaMvp0Enabled && params.virtualAccountOnramp != null, ).convert(data) } 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 f8ee0c6d02..6f57b78a10 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 @@ -449,6 +449,7 @@ internal class TangemPayCardPageModel @Inject constructor( cryptoBalance = balance.cryptoBalance.balance, depositAddress = balance.cryptoBalance.depositAddress, cryptoCurrency = cryptoCurrency, + virtualAccountOnramp = currentStatus.value.ifLoadedOrNull { it.virtualAccount }, ), ) } @@ -484,6 +485,12 @@ internal class TangemPayCardPageModel @Inject constructor( ) } + override fun onClickBankTransfer() { + val onramp = currentStatus.value.ifLoadedOrNull { it.virtualAccount } ?: return + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate(TangemPayCardNavigation.VirtualAccountDeposit(onramp)) + } + override fun onDismissAddFunds() { 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 5c22c9c463..b6c400b7bc 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 @@ -210,6 +210,7 @@ internal class TangemPayDetailsModel @Inject constructor( cryptoBalance = balance.availableForWithdrawal, depositAddress = balance.cryptoBalance.depositAddress, cryptoCurrency = cryptoCurrency, + virtualAccountOnramp = currentStatus.value.ifLoadedOrNull { it.virtualAccount }, ), ) } @@ -344,6 +345,12 @@ internal class TangemPayDetailsModel @Inject constructor( ) } + override fun onClickBankTransfer() { + val onramp = currentStatus.value.ifLoadedOrNull { it.virtualAccount } ?: return + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate(TangemPayDetailsNavigation.VirtualAccountDeposit(onramp)) + } + override fun onClickReceive(data: TangemPayTopUpData) { analytics.send(TangemPayAnalyticsEvents.ReceiveFundsClicked()) bottomSheetNavigation.dismiss() 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 new file mode 100644 index 0000000000..06d81c0b50 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayVirtualAccountDepositModel.kt @@ -0,0 +1,50 @@ +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.core.navigation.url.UrlOpener +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.VirtualAccountOnramp +import com.tangem.features.tangempay.components.TangemPayVirtualAccountDepositComponent +import com.tangem.features.tangempay.entity.TangemPayVirtualAccountDepositUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.persistentListOf +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayVirtualAccountDepositModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val urlOpener: UrlOpener, +) : Model() { + + private val params = paramsContainer.require() + + val uiState: TangemPayVirtualAccountDepositUM = TangemPayVirtualAccountDepositUM( + fees = persistentListOf( + TangemPayVirtualAccountDepositUM.FeeRow(title = stringReference("ACH"), value = "$1"), + TangemPayVirtualAccountDepositUM.FeeRow(title = stringReference("FedWire"), value = "$11"), + ), + shouldShowTermsAndConditions = params.virtualAccountOnramp is VirtualAccountOnramp.Eligible, + onShowDetailsClick = ::onShowDetailsClick, + onDismiss = ::onDismiss, + onTermsClick = { urlOpener.openUrl(TERMS_OF_USE_URL) }, + onPrivacyClick = { urlOpener.openUrl(PRIVACY_POLICY_URL) }, + ) + + fun onDismiss() { + params.onDismiss() + } + + private fun onShowDetailsClick() { + // TODO([REDACTED_TASK_KEY]): VA MVP0 — open the bank-transfer requisites screen (separate PR). + } + + private companion object { + const val TERMS_OF_USE_URL = "https://tangem.com/docs/en/virtual-account-terms.pdf" + const val PRIVACY_POLICY_URL = "https://tangem.com/docs/en/pay-privacy-policy.pdf" + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayAddFundsUMConverter.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayAddFundsUMConverter.kt index f8d441ad8d..4a6b08f099 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayAddFundsUMConverter.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayAddFundsUMConverter.kt @@ -1,82 +1,89 @@ package com.tangem.features.tangempay.model.transformers import com.tangem.core.ui.ds.image.TangemIconUM -import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference 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_card_20 import com.tangem.core.ui.res.generated.icons.ic_logo_tangem_20 +import com.tangem.core.ui.res.generated.icons.ic_sign_usd_20 import com.tangem.domain.pay.model.TangemPayTopUpData import com.tangem.features.tangempay.components.AddFundsListener import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayAddFundsItemUM import com.tangem.features.tangempay.entity.TangemPayAddFundsUM -import com.tangem.features.tangempay.entity.TangemPayDetailsErrorType -import com.tangem.features.tangempay.utils.TangemPayMessagesFactory import com.tangem.utils.converter.Converter -import kotlinx.collections.immutable.persistentListOf +import com.tangem.utils.extensions.addIf +import kotlinx.collections.immutable.toPersistentList internal class TangemPayAddFundsUMConverter( val listener: AddFundsListener, val isRedesignEnabled: Boolean, -) : Converter { + val shouldShowBankTransfer: Boolean, +) : Converter { - override fun convert(value: TangemPayTopUpData?): TangemPayAddFundsUM { - return if (value == null) { - TangemPayAddFundsUM( - items = persistentListOf(), - dismiss = listener::onDismissAddFunds, - errorMessage = TangemPayMessagesFactory.createErrorMessage( - errorType = TangemPayDetailsErrorType.Receive, - ).messageBottomSheetUM, - ) - } else { - TangemPayAddFundsUM( - items = persistentListOf( - TangemPayAddFundsItemUM( - icon = if (isRedesignEnabled) { - TangemIconUM.Icon( - imageVector = Icons.ic_logo_tangem_20, - tintReference = { - TangemTheme.colors3.icon.brand - }, - ) - } else { - TangemIconUM.Icon( - iconRes = R.drawable.ic_exchange_vertical_24, - tintReference = { - TangemTheme.colors.icon.accent - }, - ) - }, - title = TextReference.Res(R.string.tangempay_topup_swap_title), - description = TextReference.Res(R.string.tangempay_topup_swap_body), - onClick = { listener.onClickSwap(value) }, - ), - TangemPayAddFundsItemUM( - icon = if (isRedesignEnabled) { - TangemIconUM.Icon( - imageVector = Icons.ic_card_20, - tintReference = { - TangemTheme.colors3.icon.brand - }, - ) - } else { - TangemIconUM.Icon( - iconRes = R.drawable.ic_arrow_down_24, - tintReference = { - TangemTheme.colors.icon.accent - }, - ) - }, - title = TextReference.Res(R.string.tangempay_topup_receive_title), - description = TextReference.Res(R.string.tangempay_topup_receive_body), - onClick = { listener.onClickReceive(value) }, - ), - ), - dismiss = listener::onDismissAddFunds, - errorMessage = null, - ) - } + @Suppress("UnnecessaryLet") + override fun convert(value: TangemPayTopUpData): TangemPayAddFundsUM { + return TangemPayAddFundsUM( + items = buildList { + TangemPayAddFundsItemUM( + icon = if (isRedesignEnabled) { + TangemIconUM.Icon( + imageVector = Icons.ic_logo_tangem_20, + tintReference = { + TangemTheme.colors3.icon.brand + }, + ) + } else { + TangemIconUM.Icon( + iconRes = R.drawable.ic_exchange_vertical_24, + tintReference = { + TangemTheme.colors.icon.accent + }, + ) + }, + title = resourceReference(R.string.tangempay_topup_swap_title), + description = resourceReference(R.string.tangempay_topup_swap_body), + onClick = { listener.onClickSwap(value) }, + ).let(::add) + TangemPayAddFundsItemUM( + icon = if (isRedesignEnabled) { + TangemIconUM.Icon( + imageVector = Icons.ic_card_20, + tintReference = { + TangemTheme.colors3.icon.brand + }, + ) + } else { + TangemIconUM.Icon( + iconRes = R.drawable.ic_arrow_down_24, + tintReference = { + TangemTheme.colors.icon.accent + }, + ) + }, + title = resourceReference(R.string.tangempay_topup_receive_title), + description = resourceReference(R.string.tangempay_topup_receive_body), + onClick = { listener.onClickReceive(value) }, + ).let(::add) + addIf( + condition = shouldShowBankTransfer, + create = { + TangemPayAddFundsItemUM( + icon = TangemIconUM.Icon( + imageVector = Icons.ic_sign_usd_20, + tintReference = { TangemTheme.colors3.icon.brand }, + ), + title = stringReference("Bank transfer"), + description = stringReference("Receive fiat USD via ACH/FedWire"), + onClick = listener::onClickBankTransfer, + ) + }, + ) + }.toPersistentList(), + dismiss = listener::onDismissAddFunds, + errorMessage = null, + ) } } \ 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 new file mode 100644 index 0000000000..583004082d --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayVirtualAccountDepositBottomSheet.kt @@ -0,0 +1,302 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +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.res.painterResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +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 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.ds2.row.* +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +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_info_24 +import com.tangem.core.ui.res.generated.icons.ic_sign_usd_32 +import com.tangem.features.tangempay.entity.TangemPayVirtualAccountDepositUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import com.tangem.core.ui.R as CoreUiR + +@Composable +internal fun TangemPayVirtualAccountDepositBottomSheet(state: TangemPayVirtualAccountDepositUM) { + 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 = { _ -> DepositContent(state) }, + ) +} + +@Composable +private fun DepositContent(state: TangemPayVirtualAccountDepositUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(bottom = TangemTheme.dimens2.x4), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + IntroIcons(modifier = Modifier.padding(top = TangemTheme.dimens2.x4)) + TitleText( + text = stringReference("Bank transfer might take 1-2 business days"), + modifier = Modifier.padding(top = TangemTheme.dimens2.x8), + ) + SubtitleText( + text = stringReference("Received USD will be converted to USDC by 1:1 rate"), + modifier = Modifier.padding(top = TangemTheme.dimens2.x2), + ) + FeesBlock( + fees = state.fees, + modifier = Modifier.padding(top = TangemTheme.dimens2.x6), + ) + InfoNotification( + text = stringReference("Deposit via ACH or FedWire only. SWIFT transfers will be returned."), + modifier = Modifier.padding(top = TangemTheme.dimens2.x4), + ) + TangemButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens2.x4), + text = stringReference("Show details"), + variant = TangemButton.Variant.Primary, + size = TangemButton.Size.X12, + onClick = state.onShowDetailsClick, + ) + if (state.shouldShowTermsAndConditions) { + TermsFooter( + onTermsClick = state.onTermsClick, + onPrivacyClick = state.onPrivacyClick, + modifier = Modifier.padding(top = TangemTheme.dimens2.x3), + ) + } + } +} + +@Composable +private fun FeesBlock(fees: ImmutableList, modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + modifier = Modifier.padding( + start = TangemTheme.dimens2.x4, + bottom = TangemTheme.dimens2.x2, + ), + text = stringReference("Fee for onramp").resolveReference(), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, + ) + fees.forEachIndexed { index, fee -> + TangemRow( + contentLead = TangemRowContentLead.Equal, + verticalAlignment = TangemRowVerticalAlignment.Center, + divider = index != fees.lastIndex, + titleSlot = { TangemRowText(text = fee.title, role = TangemRowTextRole.Title) }, + valueSlot = { TangemRowText(text = stringReference(fee.value), role = TangemRowTextRole.Value) }, + ) + } + } +} + +@Composable +private fun InfoNotification(text: TextReference, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(TangemTheme.dimens2.x4)) + .background(TangemTheme.colors3.bg.status.infoSubtle) + .padding(TangemTheme.dimens2.x4), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x5), + imageVector = Icons.ic_info_24, + contentDescription = null, + tint = TangemTheme.colors3.icon.status.info, + ) + Text( + text = text.resolveReference(), + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.primary, + ) + } +} + +@Composable +private fun TermsFooter(onTermsClick: () -> Unit, onPrivacyClick: () -> Unit, modifier: Modifier = Modifier) { + val linkStyle = SpanStyle(color = TangemTheme.colors3.text.primary) + val text = buildAnnotatedString { + append("By using service, you agree with provider ") + withLink(LinkAnnotation.Clickable(tag = "terms", linkInteractionListener = { onTermsClick() })) { + withStyle(linkStyle) { append("Terms of Use") } + } + append(" and ") + withLink(LinkAnnotation.Clickable(tag = "privacy", linkInteractionListener = { onPrivacyClick() })) { + withStyle(linkStyle) { append("Privacy Policy") } + } + } + Text( + modifier = modifier.fillMaxWidth(), + text = text, + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun IntroIcons(modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(-TangemTheme.dimens2.x4), + ) { + UsdIcon() + UsdcIcon() + } +} + +@Composable +private fun UsdIcon(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(TangemTheme.dimens2.x20) + .clip(CircleShape) + .background(TangemTheme.colors3.bg.opaque.primary) + .border(width = 1.dp, color = TangemTheme.colors3.border.secondary, shape = CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x8), + imageVector = Icons.ic_sign_usd_32, + contentDescription = null, + tint = TangemTheme.colors3.icon.primary, + ) + } +} + +@Composable +private fun UsdcIcon(modifier: Modifier = Modifier) { + Box(modifier = modifier.size(TangemTheme.dimens2.x20)) { + Image( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + .border(width = 1.dp, color = TangemTheme.colors3.border.secondary, shape = CircleShape), + painter = painterResource(CoreUiR.drawable.img_usdc_16), + contentDescription = null, + ) + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .size(TangemTheme.dimens2.x6) + .background(color = TangemTheme.colors3.bg.accent.violet, shape = CircleShape) + .border( + width = TangemTheme.dimens2.x0_5, + color = TangemTheme.colors3.bg.secondary, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x4), + painter = painterResource(CoreUiR.drawable.ic_polygon_22), + contentDescription = null, + tint = TangemTheme.colors3.icon.inverse, + ) + } + } +} + +@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, + ) +} + +private fun previewState(shouldShowTermsAndConditions: Boolean) = TangemPayVirtualAccountDepositUM( + fees = persistentListOf( + TangemPayVirtualAccountDepositUM.FeeRow(title = stringReference("ACH"), value = "$1"), + TangemPayVirtualAccountDepositUM.FeeRow(title = stringReference("FedWire"), value = "$11"), + ), + shouldShowTermsAndConditions = shouldShowTermsAndConditions, + onShowDetailsClick = {}, + onDismiss = {}, + onTermsClick = {}, + onPrivacyClick = {}, +) + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun DepositEligiblePreview() { + TangemThemePreviewRedesign { + DepositContent( + state = previewState(shouldShowTermsAndConditions = true), + modifier = Modifier.background(TangemTheme.colors3.bg.secondary), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun DepositAvailablePreview() { + TangemThemePreviewRedesign { + DepositContent( + state = previewState(shouldShowTermsAndConditions = false), + modifier = Modifier.background(TangemTheme.colors3.bg.secondary), + ) + } +} \ No newline at end of file