From b28711ef4303f6c4c2ecbb833cef1da4a22db834 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 12:17:31 +0500 Subject: [PATCH 01/59] Updated on 2026-08-14 --- .../src/main/assets/configs/feature_toggles_config.json | 4 ++++ .../features/virtualaccount/VirtualAccountFeatureToggles.kt | 1 + .../virtualaccount/DefaultVirtualAccountFeatureToggles.kt | 3 +++ 3 files changed, 8 insertions(+) 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 a5de61882f..a3a71db20e 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 @@ -170,5 +170,9 @@ { "name": "AND_16148_SOLANA_UNSTAKE_VALIDATION_ENABLED", "version": "6.0" + }, + { + "name": "TWI_1638_VA_MVP0_ENABLED", + "version": "6.0.1" } ] diff --git a/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/VirtualAccountFeatureToggles.kt b/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/VirtualAccountFeatureToggles.kt index d01bb74ff3..e7a97bafaf 100644 --- a/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/VirtualAccountFeatureToggles.kt +++ b/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/VirtualAccountFeatureToggles.kt @@ -2,4 +2,5 @@ package com.tangem.features.virtualaccount interface VirtualAccountFeatureToggles { val isVirtualAccountsEnabled: Boolean + val isVaMvp0Enabled: Boolean } \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/DefaultVirtualAccountFeatureToggles.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/DefaultVirtualAccountFeatureToggles.kt index 5bab2f0a5d..19d37dfa0c 100644 --- a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/DefaultVirtualAccountFeatureToggles.kt +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/DefaultVirtualAccountFeatureToggles.kt @@ -9,4 +9,7 @@ internal class DefaultVirtualAccountFeatureToggles @Inject constructor( ) : VirtualAccountFeatureToggles { override val isVirtualAccountsEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.VIRTUAL_ACCOUNTS_ENABLED) + + override val isVaMvp0Enabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.TWI_1638_VA_MVP0_ENABLED) } \ No newline at end of file From ed26d23a78164c6c185f146bea517f4e64b0a7b2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Jun 2026 16:14:23 +0500 Subject: [PATCH 02/59] Updated on 2026-08-14 --- .../multi/DefaultMultiNetworkStatusFetcher.kt | 2 +- .../DefaultSingleNetworkStatusFetcher.kt | 1 + .../entity/DefaultTangemPayCurrencyFactory.kt | 24 ++++- .../DefaultVirtualAccountStatusFetcher.kt | 74 +++++++++++++ .../DefaultVirtualAccountStatusFetcherTest.kt | 100 ++++++++++++++++++ .../domain/card/common/visa/VisaUtilities.kt | 1 + .../multi/MultiNetworkStatusFetcher.kt | 14 ++- .../single/SingleNetworkStatusFetcher.kt | 10 +- .../domain/pay/TangemPayCurrencyFactory.kt | 1 + 9 files changed, 219 insertions(+), 8 deletions(-) create mode 100644 data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt diff --git a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt index 1f3e0860a0..7db5617464 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt @@ -67,7 +67,7 @@ internal class DefaultMultiNetworkStatusFetcher @Inject constructor( commonNetworkStatusFetcher.fetch( userWalletId = params.userWalletId, network = network, - networkCurrencies = networksCurrencies[network].orEmpty().toSet(), + networkCurrencies = networksCurrencies[network].orEmpty().toSet() + params.extraTokens, xpub = xpubByNetwork[network], ) } diff --git a/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcher.kt b/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcher.kt index c89ba87fe5..0ace80ea40 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcher.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcher.kt @@ -21,6 +21,7 @@ internal class DefaultSingleNetworkStatusFetcher @Inject constructor( params = MultiNetworkStatusFetcher.Params( userWalletId = params.userWalletId, networks = setOf(params.network), + extraTokens = params.extraTokens, ), ) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/entity/DefaultTangemPayCurrencyFactory.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/DefaultTangemPayCurrencyFactory.kt index 711c82bc5f..bb95aa5384 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/entity/DefaultTangemPayCurrencyFactory.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/DefaultTangemPayCurrencyFactory.kt @@ -5,8 +5,9 @@ import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.data.common.network.NetworkFactory import com.tangem.domain.card.common.visa.VisaUtilities import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.common.wallets.requireUserWalletsSync +import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayCurrencyFactory import javax.inject.Inject @@ -23,9 +24,7 @@ internal class DefaultTangemPayCurrencyFactory @Inject constructor( } override fun create(userWalletId: UserWalletId): CryptoCurrency.Token { - val userWallet = userWalletsListRepository.requireUserWalletsSync() - .firstOrNull { it.walletId == userWalletId } - ?: error("User wallet with id $userWalletId not found") + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) val network = networkFactory.create( blockchain = VisaUtilities.visaBlockchain, userWallet = userWallet, @@ -40,4 +39,21 @@ internal class DefaultTangemPayCurrencyFactory @Inject constructor( decimals = TangemPayCurrencyFactory.TOKEN_DECIMALS, ) } + + override fun createVirtualAccountToken(userWalletId: UserWalletId): CryptoCurrency.Token { + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) + val network = networkFactory.create( + blockchain = VisaUtilities.visaBlockchain, + derivationPath = Network.DerivationPath.Custom(VisaUtilities.virtualAccountDerivationPath.rawPath), + userWallet = userWallet, + ) + return cryptoCurrencyFactory.createToken( + network = requireNotNull(network), + rawId = TangemPayCurrencyFactory.TOKEN_ID, + name = TangemPayCurrencyFactory.TOKEN_NAME, + symbol = TangemPayCurrencyFactory.TOKEN_NAME, + contractAddress = TangemPayCurrencyFactory.TOKEN_CONTRACT_ADDRESS, + decimals = TangemPayCurrencyFactory.TOKEN_DECIMALS, + ) + } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt index 8a1643d193..6c0249af86 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt @@ -1,19 +1,41 @@ package com.tangem.data.virtualaccount.flow import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.data.common.network.NetworkFactory import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore +import com.tangem.domain.card.common.visa.VisaUtilities +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.core.utils.catchOn 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.VirtualAccountStatusValue +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import com.tangem.domain.networks.single.SingleNetworkStatusProducer +import com.tangem.domain.networks.single.SingleNetworkStatusSupplier +import com.tangem.domain.pay.TangemPayCurrencyFactory import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher +import com.tangem.domain.wallets.extension.hasDerivation import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import java.math.BigDecimal import javax.inject.Inject +@Suppress("LongParameterList") internal class DefaultVirtualAccountStatusFetcher @Inject constructor( private val virtualAccountStatusesStore: VirtualAccountStatusesStore, private val dispatchers: CoroutineDispatcherProvider, + private val networkFactory: NetworkFactory, + private val tangemPayCurrencyFactory: TangemPayCurrencyFactory, + private val userWalletsListRepository: UserWalletsListRepository, + private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, + private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, ) : VirtualAccountStatusFetcher { override suspend fun invoke(params: VirtualAccountStatusFetcher.Params) = Either.catchOn(dispatchers.default) { @@ -21,6 +43,7 @@ internal class DefaultVirtualAccountStatusFetcher @Inject constructor( // TODO([REDACTED_TASK_KEY]): Replace with the real VA status fetch (provisioning state, balance and banking // details) from the backend once Virtual Account status endpoints are available. Until then the // account is surfaced as NotCreated so the entity flows through the app end-to-end. + getBalance(params.userWalletId) virtualAccountStatusesStore.store( userWalletId = params.userWalletId, status = AccountStatus.Virtual(account = account, value = VirtualAccountStatusValue.NotCreated), @@ -31,4 +54,55 @@ internal class DefaultVirtualAccountStatusFetcher @Inject constructor( source = StatusSource.ONLY_CACHE, ) } + + private suspend fun getBalance(userWalletId: UserWalletId): Either { + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) + + val hasVirtualAccountDerivation = userWallet.hasDerivation( + blockchain = VisaUtilities.visaBlockchain, + derivationPath = VisaUtilities.virtualAccountDerivationPath.rawPath, + ) + if (!hasVirtualAccountDerivation) { + // TODO: Doston(VA) Derive will be implemented in [REDACTED_TASK_KEY] + TangemLogger.withTag(TAG).d("Virtual account is not derived") + return VirtualAccountStatusValue.Error.NotSynced.left() + } + + val network = networkFactory.create( + blockchain = VisaUtilities.visaBlockchain, + derivationPath = + Network.DerivationPath.Custom(VisaUtilities.virtualAccountDerivationPath.rawPath), + userWallet = userWallet, + ) + if (network == null) { + TangemLogger.withTag(TAG).d("Can not create network for Virtual account") + return VirtualAccountStatusValue.Error.Unavailable.left() + } + val token = tangemPayCurrencyFactory.createVirtualAccountToken(userWalletId) + + singleNetworkStatusFetcher( + SingleNetworkStatusFetcher.Params( + userWalletId = userWalletId, + network = network, + extraTokens = setOf(token), + ), + ) + + val verifiedStatus = singleNetworkStatusSupplier + .getSyncOrNull(SingleNetworkStatusProducer.Params(userWalletId, network)) + ?.value as? NetworkStatus.Verified + val balance = (verifiedStatus?.amounts?.get(token.id) as? NetworkStatus.Amount.Loaded)?.value + + return if (balance != null) { + TangemLogger.withTag(TAG).d("VA on-chain balance = $balance") + balance.right() + } else { + TangemLogger.withTag(TAG).d("Can not get VA balance") + VirtualAccountStatusValue.Error.Unavailable.left() + } + } + + private companion object { + private const val TAG = "VirtualAccountStatusFetcher" + } } \ No newline at end of file diff --git a/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt b/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt new file mode 100644 index 0000000000..2960eaa74b --- /dev/null +++ b/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt @@ -0,0 +1,100 @@ +package com.tangem.data.virtualaccount.flow + +import arrow.core.right +import com.tangem.blockchain.common.Blockchain +import com.tangem.data.common.network.NetworkFactory +import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import com.tangem.domain.networks.single.SingleNetworkStatusSupplier +import com.tangem.domain.pay.TangemPayCurrencyFactory +import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher +import com.tangem.domain.wallets.extension.hasDerivation +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +private const val USER_WALLET_EXTENSIONS = "com.tangem.domain.wallets.extension.UserWalletExtensionsKt" + +@OptIn(ExperimentalCoroutinesApi::class) +internal class DefaultVirtualAccountStatusFetcherTest { + + private val virtualAccountStatusesStore: VirtualAccountStatusesStore = mockk(relaxed = true) + private val dispatchers = TestingCoroutineDispatcherProvider() + private val networkFactory: NetworkFactory = mockk() + private val tangemPayCurrencyFactory: TangemPayCurrencyFactory = mockk() + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher = mockk() + private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier = mockk(relaxed = true) + + private val fetcher = DefaultVirtualAccountStatusFetcher( + virtualAccountStatusesStore = virtualAccountStatusesStore, + dispatchers = dispatchers, + networkFactory = networkFactory, + tangemPayCurrencyFactory = tangemPayCurrencyFactory, + userWalletsListRepository = userWalletsListRepository, + singleNetworkStatusFetcher = singleNetworkStatusFetcher, + singleNetworkStatusSupplier = singleNetworkStatusSupplier, + ) + + private val userWalletId = UserWalletId("011") + private val userWallet: UserWallet = mockk { every { walletId } returns userWalletId } + private val network: Network = mockk() + private val token: CryptoCurrency.Token = mockk() + + @BeforeEach + fun setUp() { + mockkStatic(USER_WALLET_EXTENSIONS) + clearMocks(networkFactory, tangemPayCurrencyFactory, singleNetworkStatusFetcher, userWalletsListRepository) + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) + every { + networkFactory.create(any(), any(), any()) + } returns network + every { tangemPayCurrencyFactory.createVirtualAccountToken(userWalletId) } returns token + coEvery { singleNetworkStatusFetcher(any()) } returns Unit.right() + } + + @AfterEach + fun tearDown() { + unmockkStatic(USER_WALLET_EXTENSIONS) + } + + @Test + fun `GIVEN VA derivation missing WHEN invoke THEN on-chain fetch skipped`() = runTest { + // Arrange + every { userWallet.hasDerivation(any(), any()) } returns false + + // Act + fetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId)) + + // Assert + coVerify(exactly = 0) { singleNetworkStatusFetcher(any()) } + } + + @Test + fun `GIVEN VA derivation present WHEN invoke THEN on-chain fetch performed`() = runTest { + // Arrange + every { userWallet.hasDerivation(any(), any()) } returns true + + // Act + fetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId)) + + // Assert + coVerify(exactly = 1) { singleNetworkStatusFetcher(any()) } + } +} \ No newline at end of file diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/common/visa/VisaUtilities.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/common/visa/VisaUtilities.kt index ef0f7ffdf6..6b0800e209 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/common/visa/VisaUtilities.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/common/visa/VisaUtilities.kt @@ -22,6 +22,7 @@ object VisaUtilities { val visaDefaultDerivationPath get() = visaBlockchain.derivationPath(DerivationStyle.V3) val customDerivationPath = DerivationPath("m/44'/60'/999999'/0/0") + val virtualAccountDerivationPath = DerivationPath("m/44'/60'/999998'/0/0") val curve = EllipticCurve.Secp256k1 fun signWithNonceMessage(nonce: String): String { diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusFetcher.kt b/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusFetcher.kt index dcd68ceb1b..7eeee62750 100644 --- a/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusFetcher.kt +++ b/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusFetcher.kt @@ -1,6 +1,7 @@ package com.tangem.domain.networks.multi import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId @@ -11,5 +12,16 @@ import com.tangem.domain.models.wallet.UserWalletId */ interface MultiNetworkStatusFetcher : FlowFetcher { - data class Params(val userWalletId: UserWalletId, val networks: Set) + /** + * Params + * + * @property userWalletId user wallet id + * @property networks networks whose statuses are fetched + * @property extraTokens additional tokens to fetch balances for, beyond the wallet's added currencies + */ + data class Params( + val userWalletId: UserWalletId, + val networks: Set, + val extraTokens: Set = emptySet(), + ) } \ No newline at end of file diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusFetcher.kt b/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusFetcher.kt index 7c638d12f4..5923c9d977 100644 --- a/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusFetcher.kt +++ b/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusFetcher.kt @@ -1,6 +1,7 @@ package com.tangem.domain.networks.single import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId @@ -15,7 +16,12 @@ interface SingleNetworkStatusFetcher : FlowFetcher = emptySet(), + ) } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCurrencyFactory.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCurrencyFactory.kt index 39bdae4191..fa37a30d68 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCurrencyFactory.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCurrencyFactory.kt @@ -17,6 +17,7 @@ interface TangemPayCurrencyFactory { * @throws IllegalStateException if no wallet with [userWalletId] is currently loaded. */ fun create(userWalletId: UserWalletId): CryptoCurrency.Token + fun createVirtualAccountToken(userWalletId: UserWalletId): CryptoCurrency.Token /** Hardcoded token metadata for the Tangem Pay currency (USDC on Polygon). */ companion object { From f3192b6d2712dba0c0f7edcaef9a6ce1a9e13392 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Jun 2026 20:31:03 +0500 Subject: [PATCH 03/59] Updated on 2026-08-14 --- .../tangem/tap/di/TangemSdkManagerModule.kt | 3 + .../sdk/impl/DefaultTangemSdkManager.kt | 20 +++++ .../domain/sdk/impl/MockTangemSdkManager.kt | 7 ++ ...gemPayGenerateVirtualAccountAddressTask.kt | 80 +++++++++++++++++++ .../DefaultTangemPayAuthDataSource.kt | 13 +++ .../pay/datasource/TangemPayHotSdkManager.kt | 31 +++++++ .../di/VirtualAccountDataModule.kt | 17 ++++ .../DefaultVirtualAccountStatusFetcher.kt | 15 +--- ...faultVirtualAccountActivationRepository.kt | 38 +++++++++ .../DefaultVirtualAccountStatusFetcherTest.kt | 57 ++++++------- ...tVirtualAccountActivationRepositoryTest.kt | 79 ++++++++++++++++++ .../DefaultColdMapDerivationsRepository.kt | 5 ++ .../DefaultDerivationsRepository.kt | 18 +++++ .../hot/DefaultHotMapDerivationsRepository.kt | 5 ++ domain/visa/models/build.gradle.kts | 3 + .../model/VirtualAccountActivationData.kt | 16 ++++ .../pay/datasource/TangemPayAuthDataSource.kt | 3 + .../VirtualAccountActivationRepository.kt | 13 +++ .../usecase/ActivateVirtualAccountUseCase.kt | 17 ++++ .../ColdMapDerivationsRepository.kt | 3 + .../derivations/DerivationsRepository.kt | 8 ++ .../HotMapDerivationsRepository.kt | 3 + .../com/tangem/sdk/api/TangemSdkManager.kt | 5 ++ 23 files changed, 413 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/domain/tasks/visa/TangemPayGenerateVirtualAccountAddressTask.kt create mode 100644 data/visa/src/main/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepository.kt create mode 100644 data/visa/src/test/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepositoryTest.kt create mode 100644 domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VirtualAccountActivationData.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/repository/VirtualAccountActivationRepository.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/ActivateVirtualAccountUseCase.kt diff --git a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt index 37929d7d54..e24a130c14 100644 --- a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt +++ b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt @@ -10,6 +10,7 @@ import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask +import com.tangem.tap.domain.tasks.visa.TangemPayGenerateVirtualAccountAddressTask import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask import com.tangem.tap.domain.visa.VisaCardScanHandler import dagger.Module @@ -31,6 +32,7 @@ internal class TangemSdkManagerModule { visaCardScanHandler: VisaCardScanHandler, visaCardActivationTaskFactory: VisaCardActivationTask.Factory, tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, + tangemPayVirtualAccountTaskFactory: TangemPayGenerateVirtualAccountAddressTask.Factory, onboardingV2FeatureToggles: OnboardingV2FeatureToggles, analyticsErrorHandler: AnalyticsErrorHandler, cardRepository: CardRepository, @@ -44,6 +46,7 @@ internal class TangemSdkManagerModule { visaCardScanHandler = visaCardScanHandler, visaCardActivationTaskFactory = visaCardActivationTaskFactory, tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory, + tangemPayVirtualAccountTaskFactory = tangemPayVirtualAccountTaskFactory, onboardingV2FeatureToggles = onboardingV2FeatureToggles, analyticsErrorHandler = analyticsErrorHandler, cardRepository = cardRepository, diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt index 4ed0eef964..314f5eba49 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt @@ -50,6 +50,7 @@ import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent import com.tangem.tap.common.analytics.paramsInterceptor.CardContextInterceptor import com.tangem.tap.domain.tasks.product.* import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask +import com.tangem.tap.domain.tasks.visa.TangemPayGenerateVirtualAccountAddressTask import com.tangem.tap.domain.tasks.visa.TangemPaySignWithdrawalHashTask import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask import com.tangem.tap.domain.tasks.visa.VisaCustomerWalletApproveTask @@ -72,6 +73,7 @@ internal class DefaultTangemSdkManager( private val visaCardScanHandler: VisaCardScanHandler, private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory, private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, + private val tangemPayVirtualAccountTaskFactory: TangemPayGenerateVirtualAccountAddressTask.Factory, private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles, private val analyticsErrorHandler: AnalyticsErrorHandler, private val cardRepository: CardRepository, @@ -531,6 +533,24 @@ internal class DefaultTangemSdkManager( } } + override suspend fun tangemPayProduceVirtualAccountData( + preflightReadFilter: PreflightReadFilter, + ): Either { + return coroutineScope { + val result = runTaskAsyncReturnOnMain( + runnable = tangemPayVirtualAccountTaskFactory.create(coroutineScope = this), + cardId = null, + initialMessage = Message(resources.getStringSafe(R.string.initial_message_tap_header)), + preflightReadFilter = preflightReadFilter, + ) + + return@coroutineScope when (result) { + is CompletionResult.Failure<*> -> result.error.left() + is CompletionResult.Success -> result.data.right() + } + } + } + override suspend fun getWithdrawalSignature( hash: String, preflightReadFilter: PreflightReadFilter, diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt index 0ed38d78c4..1debe0f426 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt @@ -23,6 +23,7 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.WithdrawalSignatureResult import com.tangem.domain.visa.model.TangemPayInitialCredentials +import com.tangem.domain.visa.model.VirtualAccountActivationData import com.tangem.domain.visa.model.VisaActivationInput import com.tangem.domain.visa.model.VisaDataForApprove import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet @@ -241,6 +242,12 @@ class MockTangemSdkManager( error("Not implemented") } + override suspend fun tangemPayProduceVirtualAccountData( + preflightReadFilter: PreflightReadFilter, + ): Either { + error("Not implemented") + } + override suspend fun getWithdrawalSignature( hash: String, preflightReadFilter: PreflightReadFilter, diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/visa/TangemPayGenerateVirtualAccountAddressTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/visa/TangemPayGenerateVirtualAccountAddressTask.kt new file mode 100644 index 0000000000..cbc6dce5f6 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/tasks/visa/TangemPayGenerateVirtualAccountAddressTask.kt @@ -0,0 +1,80 @@ +package com.tangem.tap.domain.tasks.visa + +import com.tangem.common.CompletionResult +import com.tangem.common.card.CardWallet +import com.tangem.common.core.CardSession +import com.tangem.common.core.CardSessionRunnable +import com.tangem.common.core.CompletionCallback +import com.tangem.common.core.TangemSdkError +import com.tangem.common.extensions.toMapKey +import com.tangem.core.error.ext.tangemError +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.card.common.visa.VisaUtilities +import com.tangem.domain.visa.error.VisaActivationError +import com.tangem.domain.visa.model.VirtualAccountActivationData +import com.tangem.operations.derivation.DeriveWalletPublicKeyTask +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Derives the Virtual Account key ([VisaUtilities.virtualAccountDerivationPath]) on the card and + * generates its deposit address. The derived key is returned (keyed by the seed wallet public key) + * so the caller can persist it via `DerivationsRepository.storeDerivedKeys` — no second tap needed. + */ +class TangemPayGenerateVirtualAccountAddressTask @AssistedInject constructor( + @Assisted private val coroutineScope: CoroutineScope, +) : CardSessionRunnable { + + override fun run(session: CardSession, callback: CompletionCallback) { + coroutineScope.launch { + callback(runSuspend(session = session)) + } + } + + private suspend fun runSuspend(session: CardSession): CompletionResult { + val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead()) + val wallet = card.wallets.firstOrNull { it.curve == VisaUtilities.curve } + ?: return CompletionResult.Failure(VisaActivationError.MissingWallet.tangemError) + + val extendedPublicKey = when (val derivationResult = runDerivationTask(session, wallet)) { + is CompletionResult.Failure<*> -> return CompletionResult.Failure(derivationResult.error) + is CompletionResult.Success -> derivationResult.data + } + + val address = VisaUtilities.generateAddressFromExtendedKey(extendedPublicKey = extendedPublicKey) + + val derivedKeys = mapOf( + wallet.publicKey.toMapKey() to ExtendedPublicKeysMap( + mapOf(VisaUtilities.virtualAccountDerivationPath to extendedPublicKey), + ), + ) + + return CompletionResult.Success( + data = VirtualAccountActivationData(address = address, derivedKeys = derivedKeys), + ) + } + + private suspend fun runDerivationTask( + session: CardSession, + wallet: CardWallet, + ): CompletionResult { + val deferred = CompletableDeferred>() + val derivationTask = DeriveWalletPublicKeyTask( + walletPublicKey = wallet.publicKey, + derivationPath = VisaUtilities.virtualAccountDerivationPath, + ) + + derivationTask.run(session = session, callback = deferred::complete) + return deferred.await() + } + + @AssistedFactory + interface Factory { + fun create(coroutineScope: CoroutineScope): TangemPayGenerateVirtualAccountAddressTask + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt index 8198c181ce..f69787ee69 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt @@ -6,6 +6,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.pay.WithdrawalSignatureResult import com.tangem.domain.pay.datasource.TangemPayAuthDataSource import com.tangem.domain.visa.model.TangemPayInitialCredentials +import com.tangem.domain.visa.model.VirtualAccountActivationData import com.tangem.sdk.api.TangemSdkManager import javax.inject.Inject @@ -26,6 +27,18 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor( } } + override suspend fun produceVirtualAccountData( + userWallet: UserWallet, + ): Either { + return when (userWallet) { + is UserWallet.Cold -> { + val preflightReadFilter = UserWalletIdPreflightReadFilter(userWallet.walletId) + tangemSdkManager.tangemPayProduceVirtualAccountData(preflightReadFilter = preflightReadFilter) + } + is UserWallet.Hot -> tangemPayHotSdkManager.produceVirtualAccountData(userWallet) + } + } + override suspend fun getWithdrawalSignature( userWallet: UserWallet, hash: String, diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/TangemPayHotSdkManager.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/TangemPayHotSdkManager.kt index 6c2eac2122..6e79a073b5 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/TangemPayHotSdkManager.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/TangemPayHotSdkManager.kt @@ -5,6 +5,7 @@ import arrow.core.getOrElse import arrow.core.raise.Raise import arrow.core.raise.either import com.tangem.common.extensions.hexToBytes +import com.tangem.common.extensions.toMapKey import com.tangem.core.error.ext.tangemError import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.domain.card.common.visa.VisaUtilities @@ -14,11 +15,13 @@ import com.tangem.domain.visa.datasource.TangemPayRemoteDataSource import com.tangem.domain.visa.error.VisaActivationError import com.tangem.domain.visa.error.VisaCardScanError import com.tangem.domain.visa.model.TangemPayInitialCredentials +import com.tangem.domain.visa.model.VirtualAccountActivationData import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.model.DataToSign import com.tangem.hot.sdk.model.DeriveWalletRequest import com.tangem.hot.sdk.model.UnlockHotWallet +import com.tangem.operations.derivation.ExtendedPublicKeysMap import javax.inject.Inject internal class TangemPayHotSdkManager @Inject constructor( @@ -56,6 +59,34 @@ internal class TangemPayHotSdkManager @Inject constructor( ) } + suspend fun produceVirtualAccountData(hotWallet: UserWallet.Hot): Either = + withUnlockedHotWallet(hotWallet) { unlockHotWallet -> + val response = tangemHotSdk.derivePublicKey( + unlockHotWallet = unlockHotWallet, + request = DeriveWalletRequest( + requests = listOf( + DeriveWalletRequest.Request( + curve = VisaUtilities.curve, + paths = listOf(VisaUtilities.virtualAccountDerivationPath), + ), + ), + ), + ) + val curveResponse = response.responses.firstOrNull { it.curve == VisaUtilities.curve } + ?: raise(VisaActivationError.MissingWallet.tangemError) + val extendedPublicKey = curveResponse.publicKeys[VisaUtilities.virtualAccountDerivationPath] + ?: raise(VisaActivationError.MissingWallet.tangemError) + + VirtualAccountActivationData( + address = VisaUtilities.generateAddressFromExtendedKey(extendedPublicKey), + derivedKeys = mapOf( + curveResponse.seedKey.publicKey.toMapKey() to ExtendedPublicKeysMap( + mapOf(VisaUtilities.virtualAccountDerivationPath to extendedPublicKey), + ), + ), + ) + } + suspend fun getWithdrawalSignature( hotWallet: UserWallet.Hot, hash: String, diff --git a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt index 117dff144b..a85c3e8c6d 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt @@ -8,6 +8,7 @@ import com.squareup.moshi.Moshi import com.tangem.data.virtualaccount.converter.VirtualAccountStatusValueDMConverter import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusFetcher import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusProducer +import com.tangem.data.virtualaccount.repository.DefaultVirtualAccountActivationRepository import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.datastore.RuntimeSharedStore @@ -17,6 +18,8 @@ import com.tangem.datasource.utils.mapWithStringKeyTypes import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusProducer import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusSupplier +import com.tangem.domain.virtualaccount.repository.VirtualAccountActivationRepository +import com.tangem.domain.virtualaccount.usecase.ActivateVirtualAccountUseCase import com.tangem.utils.coroutines.AppCoroutineScope import dagger.Binds import dagger.Module @@ -40,6 +43,12 @@ internal interface VirtualAccountDataModule { @Singleton fun bindVirtualAccountStatusFetcher(impl: DefaultVirtualAccountStatusFetcher): VirtualAccountStatusFetcher + @Binds + @Singleton + fun bindVirtualAccountActivationRepository( + impl: DefaultVirtualAccountActivationRepository, + ): VirtualAccountActivationRepository + companion object { @Provides @@ -77,5 +86,13 @@ internal interface VirtualAccountDataModule { keyCreator = { "virtual_account_status_${it.userWalletId.stringValue}" }, ) {} } + + @Provides + @Singleton + fun provideActivateVirtualAccountUseCase( + repository: VirtualAccountActivationRepository, + ): ActivateVirtualAccountUseCase { + return ActivateVirtualAccountUseCase(repository = repository) + } } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt index 6c0249af86..6ebb61f4c5 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt @@ -21,7 +21,6 @@ import com.tangem.domain.networks.single.SingleNetworkStatusProducer import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.pay.TangemPayCurrencyFactory import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher -import com.tangem.domain.wallets.extension.hasDerivation import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal @@ -57,21 +56,9 @@ internal class DefaultVirtualAccountStatusFetcher @Inject constructor( private suspend fun getBalance(userWalletId: UserWalletId): Either { val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) - - val hasVirtualAccountDerivation = userWallet.hasDerivation( - blockchain = VisaUtilities.visaBlockchain, - derivationPath = VisaUtilities.virtualAccountDerivationPath.rawPath, - ) - if (!hasVirtualAccountDerivation) { - // TODO: Doston(VA) Derive will be implemented in [REDACTED_TASK_KEY] - TangemLogger.withTag(TAG).d("Virtual account is not derived") - return VirtualAccountStatusValue.Error.NotSynced.left() - } - val network = networkFactory.create( blockchain = VisaUtilities.visaBlockchain, - derivationPath = - Network.DerivationPath.Custom(VisaUtilities.virtualAccountDerivationPath.rawPath), + derivationPath = Network.DerivationPath.Custom(VisaUtilities.virtualAccountDerivationPath.rawPath), userWallet = userWallet, ) if (network == null) { diff --git a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepository.kt new file mode 100644 index 0000000000..65845d940a --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepository.kt @@ -0,0 +1,38 @@ +package com.tangem.data.virtualaccount.repository + +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.getSyncStrict +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.datasource.TangemPayAuthDataSource +import com.tangem.domain.virtualaccount.repository.VirtualAccountActivationRepository +import com.tangem.domain.wallets.derivations.DerivationsRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import javax.inject.Inject + +internal class DefaultVirtualAccountActivationRepository @Inject constructor( + private val authDataSource: TangemPayAuthDataSource, + private val derivationsRepository: DerivationsRepository, + private val userWalletsListRepository: UserWalletsListRepository, + private val dispatchers: CoroutineDispatcherProvider, +) : VirtualAccountActivationRepository { + + override suspend fun activateVirtualAccount(userWalletId: UserWalletId) { + withContext(dispatchers.io) { + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) + val activationData = authDataSource.produceVirtualAccountData(userWallet) + .fold( + ifLeft = { error("Can not activate virtual account: ${it.message}") }, + ifRight = { it }, + ) + + // Persist the derived VA key so the on-chain balance can be read without re-deriving (no extra tap). + derivationsRepository.storeDerivedKeys( + userWalletId = userWalletId, + derivedKeys = activationData.derivedKeys, + ) + + // TODO([REDACTED_TASK_KEY]): register activationData.address with the VA backend once the endpoint is available. + } + } +} \ No newline at end of file diff --git a/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt b/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt index 2960eaa74b..e578dcb5be 100644 --- a/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt +++ b/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt @@ -13,24 +13,14 @@ import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.pay.TangemPayCurrencyFactory import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher -import com.tangem.domain.wallets.extension.hasDerivation import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.clearMocks -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.unmockkStatic +import io.mockk.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -private const val USER_WALLET_EXTENSIONS = "com.tangem.domain.wallets.extension.UserWalletExtensionsKt" - @OptIn(ExperimentalCoroutinesApi::class) internal class DefaultVirtualAccountStatusFetcherTest { @@ -59,25 +49,40 @@ internal class DefaultVirtualAccountStatusFetcherTest { @BeforeEach fun setUp() { - mockkStatic(USER_WALLET_EXTENSIONS) clearMocks(networkFactory, tangemPayCurrencyFactory, singleNetworkStatusFetcher, userWalletsListRepository) every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) - every { - networkFactory.create(any(), any(), any()) - } returns network every { tangemPayCurrencyFactory.createVirtualAccountToken(userWalletId) } returns token coEvery { singleNetworkStatusFetcher(any()) } returns Unit.right() } - @AfterEach - fun tearDown() { - unmockkStatic(USER_WALLET_EXTENSIONS) + @Test + fun `GIVEN network created WHEN invoke THEN on-chain status fetched with VA token`() = runTest { + // Arrange + every { + networkFactory.create(any(), any(), any()) + } returns network + + // Act + fetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId)) + + // Assert + coVerify(exactly = 1) { + singleNetworkStatusFetcher( + SingleNetworkStatusFetcher.Params( + userWalletId = userWalletId, + network = network, + extraTokens = setOf(token), + ), + ) + } } @Test - fun `GIVEN VA derivation missing WHEN invoke THEN on-chain fetch skipped`() = runTest { + fun `GIVEN network cannot be created WHEN invoke THEN on-chain fetch skipped`() = runTest { // Arrange - every { userWallet.hasDerivation(any(), any()) } returns false + every { + networkFactory.create(any(), any(), any()) + } returns null // Act fetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId)) @@ -85,16 +90,4 @@ internal class DefaultVirtualAccountStatusFetcherTest { // Assert coVerify(exactly = 0) { singleNetworkStatusFetcher(any()) } } - - @Test - fun `GIVEN VA derivation present WHEN invoke THEN on-chain fetch performed`() = runTest { - // Arrange - every { userWallet.hasDerivation(any(), any()) } returns true - - // Act - fetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId)) - - // Assert - coVerify(exactly = 1) { singleNetworkStatusFetcher(any()) } - } } \ No newline at end of file diff --git a/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepositoryTest.kt b/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepositoryTest.kt new file mode 100644 index 0000000000..88f4fa2da5 --- /dev/null +++ b/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepositoryTest.kt @@ -0,0 +1,79 @@ +package com.tangem.data.virtualaccount.repository + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.datasource.TangemPayAuthDataSource +import com.tangem.domain.visa.model.VirtualAccountActivationData +import com.tangem.domain.wallets.derivations.DerivationsRepository +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class DefaultVirtualAccountActivationRepositoryTest { + + private val authDataSource: TangemPayAuthDataSource = mockk() + private val derivationsRepository: DerivationsRepository = mockk(relaxUnitFun = true) + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val dispatchers = TestingCoroutineDispatcherProvider() + + private val repository = DefaultVirtualAccountActivationRepository( + authDataSource = authDataSource, + derivationsRepository = derivationsRepository, + userWalletsListRepository = userWalletsListRepository, + dispatchers = dispatchers, + ) + + private val userWalletId = UserWalletId("011") + private val userWallet: UserWallet = mockk { every { walletId } returns userWalletId } + + private val derivedKeys: Map = mapOf( + ByteArrayKey(byteArrayOf(1, 2, 3)) to ExtendedPublicKeysMap(emptyMap()), + ) + private val activationData = VirtualAccountActivationData(address = "0xVA", derivedKeys = derivedKeys) + + @BeforeEach + fun setUp() { + clearMocks(authDataSource, derivationsRepository, userWalletsListRepository) + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) + } + + @Test + fun `GIVEN datasource returns data WHEN activate THEN derived keys persisted`() = runTest { + // Arrange + coEvery { authDataSource.produceVirtualAccountData(userWallet) } returns activationData.right() + + // Act + repository.activateVirtualAccount(userWalletId) + + // Assert + coVerify(exactly = 1) { derivationsRepository.storeDerivedKeys(userWalletId, derivedKeys) } + } + + @Test + fun `GIVEN datasource returns error WHEN activate THEN throws AND nothing persisted`() = runTest { + // Arrange + coEvery { authDataSource.produceVirtualAccountData(userWallet) } returns IllegalStateException("nope").left() + + // Act + val error = runCatching { repository.activateVirtualAccount(userWalletId) }.exceptionOrNull() + + // Assert + assertThat(error).isInstanceOf(IllegalStateException::class.java) + coVerify(exactly = 0) { derivationsRepository.storeDerivedKeys(any(), any()) } + } +} \ No newline at end of file diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/cold/DefaultColdMapDerivationsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/cold/DefaultColdMapDerivationsRepository.kt index b141b3b5ca..8f8ab2cfaa 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/cold/DefaultColdMapDerivationsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/cold/DefaultColdMapDerivationsRepository.kt @@ -100,6 +100,11 @@ internal class DefaultColdMapDerivationsRepository @Inject constructor( } } + override fun mergeDerivedKeys( + userWallet: UserWallet.Cold, + keys: Map, + ): UserWallet.Cold = userWallet.updateDerivedKeys(keys) + override suspend fun hasMissedDerivations( userWallet: UserWallet.Cold, networksWithDerivationPath: Map, diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt index 3628a45990..37dc47defe 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt @@ -77,6 +77,24 @@ internal class DefaultDerivationsRepository @Inject constructor( } } + override suspend fun storeDerivedKeys( + userWalletId: UserWalletId, + derivedKeys: Map, + ) { + if (derivedKeys.isEmpty()) { + TangemLogger.d("Nothing to store") + return + } + + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) + val updatedUserWallet = when (userWallet) { + is UserWallet.Cold -> coldDerivationsRepository.mergeDerivedKeys(userWallet, derivedKeys) + is UserWallet.Hot -> hotDerivationsRepository.mergeDerivedKeys(userWallet, derivedKeys) + } + + userWallet.update(updatedUserWallet) + } + override suspend fun getExistingDerivedKeys( userWalletId: UserWalletId, seedKey: ByteArrayKey, diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt index 3e0a758cab..91d94a7313 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt @@ -101,6 +101,11 @@ internal class DefaultHotMapDerivationsRepository @Inject constructor( return updatedUserWallet.updateWithNewKeys(newKeys) to newKeys } + override fun mergeDerivedKeys( + userWallet: UserWallet.Hot, + keys: Map, + ): UserWallet.Hot = userWallet.updateWithNewKeys(keys) + override suspend fun hasMissedDerivations( userWallet: UserWallet.Hot, networksWithDerivationPath: Map, diff --git a/domain/visa/models/build.gradle.kts b/domain/visa/models/build.gradle.kts index e528d11260..9d9560e9a9 100644 --- a/domain/visa/models/build.gradle.kts +++ b/domain/visa/models/build.gradle.kts @@ -15,4 +15,7 @@ dependencies { /** Domain models */ implementation(projects.domain.models) + + /** Tangem libraries (derived public keys types for VA activation) */ + implementation(tangemDeps.card.core) } \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VirtualAccountActivationData.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VirtualAccountActivationData.kt new file mode 100644 index 0000000000..01f01c9459 --- /dev/null +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VirtualAccountActivationData.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.visa.model + +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.operations.derivation.ExtendedPublicKeysMap + +/** + * Result of deriving the Virtual Account key on the card. + * + * @property address the VA deposit address generated from the derived key + * @property derivedKeys the derived extended public key(s) keyed by the seed wallet public key, + * ready to be persisted into the wallet (see `DerivationsRepository.storeDerivedKeys`) + */ +data class VirtualAccountActivationData( + val address: String, + val derivedKeys: Map, +) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt index 4ab30e9ff7..f9e43dc3c8 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt @@ -4,11 +4,14 @@ import arrow.core.Either import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.pay.WithdrawalSignatureResult import com.tangem.domain.visa.model.TangemPayInitialCredentials +import com.tangem.domain.visa.model.VirtualAccountActivationData interface TangemPayAuthDataSource { suspend fun produceInitialCredentials(userWallet: UserWallet): Either + suspend fun produceVirtualAccountData(userWallet: UserWallet): Either + suspend fun getWithdrawalSignature( userWallet: UserWallet, hash: String, diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/repository/VirtualAccountActivationRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/repository/VirtualAccountActivationRepository.kt new file mode 100644 index 0000000000..4b1d46f487 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/repository/VirtualAccountActivationRepository.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.virtualaccount.repository + +import com.tangem.domain.models.wallet.UserWalletId + +interface VirtualAccountActivationRepository { + + /** + * Derives the Virtual Account key on the card (NFC) and persists it into the wallet, so the + * on-chain VA balance can later be fetched without re-deriving. Throws on failure. + */ + @Throws + suspend fun activateVirtualAccount(userWalletId: UserWalletId) +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/ActivateVirtualAccountUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/ActivateVirtualAccountUseCase.kt new file mode 100644 index 0000000000..dc7db9d27b --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/ActivateVirtualAccountUseCase.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.virtualaccount.usecase + +import arrow.core.Either +import arrow.core.Either.Companion.catch +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.virtualaccount.repository.VirtualAccountActivationRepository + +class ActivateVirtualAccountUseCase( + private val repository: VirtualAccountActivationRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId): Either { + return catch { + repository.activateVirtualAccount(userWalletId) + } + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/ColdMapDerivationsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/ColdMapDerivationsRepository.kt index 86da09c677..b33b71e5eb 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/ColdMapDerivationsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/ColdMapDerivationsRepository.kt @@ -27,6 +27,9 @@ interface ColdMapDerivationsRepository { derivations: Map>, ): Pair> + /** Merges already-derived [keys] into [userWallet]'s stored derivations without deriving on the card. */ + fun mergeDerivedKeys(userWallet: UserWallet.Cold, keys: Map): UserWallet.Cold + /** Check if user [userWallet] has missed derivations using map of [Network.ID] with extraDerivationPath */ suspend fun hasMissedDerivations( userWallet: UserWallet.Cold, diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt index a3ee510fde..8f9b139f46 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt @@ -29,6 +29,14 @@ interface DerivationsRepository { derivations: Map>, ): Map + /** + * Merges already-derived [derivedKeys] into the wallet's stored derivations and persists it. + * Does NOT derive on the card (no NFC): use it to save a key that was obtained by a dedicated + * card task. Keyed by the seed wallet public key ([ByteArrayKey]). + */ + @Throws + suspend fun storeDerivedKeys(userWalletId: UserWalletId, derivedKeys: Map) + /** Returns already derived extended public keys for the given [seedKey] */ suspend fun getExistingDerivedKeys(userWalletId: UserWalletId, seedKey: ByteArrayKey): ExtendedPublicKeysMap diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/HotMapDerivationsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/HotMapDerivationsRepository.kt index 27b260db1d..f97950bf8b 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/HotMapDerivationsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/HotMapDerivationsRepository.kt @@ -29,6 +29,9 @@ interface HotMapDerivationsRepository { derivations: Map>, ): Pair> + /** Merges already-derived [keys] into [userWallet]'s stored derivations. */ + fun mergeDerivedKeys(userWallet: UserWallet.Hot, keys: Map): UserWallet.Hot + /** Check if user [userWallet] has missed derivations using map of [Network.ID] with extraDerivationPath */ suspend fun hasMissedDerivations( userWallet: UserWallet.Hot, diff --git a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt index 9ea0e0b15a..f372a258de 100644 --- a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt @@ -20,6 +20,7 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.WithdrawalSignatureResult import com.tangem.domain.visa.model.TangemPayInitialCredentials +import com.tangem.domain.visa.model.VirtualAccountActivationData import com.tangem.domain.visa.model.VisaActivationInput import com.tangem.domain.visa.model.VisaDataForApprove import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet @@ -175,6 +176,10 @@ interface TangemSdkManager { preflightReadFilter: PreflightReadFilter, ): Either + suspend fun tangemPayProduceVirtualAccountData( + preflightReadFilter: PreflightReadFilter, + ): Either + suspend fun getWithdrawalSignature( hash: String, preflightReadFilter: PreflightReadFilter, From 285bed34a40209b1653e69570febf97e437b465e Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 01:21:15 -0700 Subject: [PATCH 04/59] Updated on 2026-08-14 --- .../details/api/build.gradle.kts | 9 + .../component/VirtualAccountMainComponent.kt | 14 ++ .../details/impl/build.gradle.kts | 20 +- .../common/ui/TangemBalanceHeader.kt | 115 +++++++++ .../common/ui/TangemBalanceHeaderState.kt | 18 ++ .../common/ui/TangemCircleActionButton.kt | 70 ++++++ .../common/ui/TangemEmptyState.kt | 73 ++++++ ...sModule.kt => VirtualAccountMainModule.kt} | 2 +- .../DefaultVirtualAccountMainComponent.kt | 34 +++ .../main/VirtualAccountMainModel.kt | 46 ++++ .../main/VirtualAccountMainScreen.kt | 229 ++++++++++++++++++ .../main/VirtualAccountMainUM.kt | 29 +++ .../di/VirtualAccountMainComponentModule.kt | 18 ++ .../main/di/VirtualAccountMainModelModule.kt | 20 ++ .../extension/BaseExtensionConfigurations.kt | 1 + 15 files changed, 694 insertions(+), 4 deletions(-) create mode 100644 features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountMainComponent.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeader.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeaderState.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemCircleActionButton.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemEmptyState.kt rename features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/di/{VirtualAccountDetailsModule.kt => VirtualAccountMainModule.kt} (94%) create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainModel.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainScreen.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainUM.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainComponentModule.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainModelModule.kt diff --git a/features/virtual-accounts/details/api/build.gradle.kts b/features/virtual-accounts/details/api/build.gradle.kts index ccb34f0307..1f5657de2a 100644 --- a/features/virtual-accounts/details/api/build.gradle.kts +++ b/features/virtual-accounts/details/api/build.gradle.kts @@ -9,4 +9,13 @@ android { } dependencies { + /** Core */ + api(projects.core.decompose) + api(projects.core.ui) + + /** 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/VirtualAccountMainComponent.kt b/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountMainComponent.kt new file mode 100644 index 0000000000..cd452567a0 --- /dev/null +++ b/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountMainComponent.kt @@ -0,0 +1,14 @@ +package com.tangem.features.virtualaccount.details.component + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId + +interface VirtualAccountMainComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/build.gradle.kts b/features/virtual-accounts/details/impl/build.gradle.kts index 3fec5d85d5..1d9448064a 100644 --- a/features/virtual-accounts/details/impl/build.gradle.kts +++ b/features/virtual-accounts/details/impl/build.gradle.kts @@ -11,11 +11,25 @@ android { } dependencies { + /** Core */ + implementation(projects.core.configToggles) + implementation(projects.core.decompose) + implementation(projects.core.res) + implementation(projects.core.ui) + implementation(projects.core.utils) + + /** Domain */ + implementation(projects.domain.models) + + /** Features */ implementation(projects.features.virtualAccounts.details.api) - implementation(projects.core.configToggles) - - implementation(deps.compose.runtime) + /** Compose */ + implementation(deps.compose.foundation) + implementation(deps.compose.material3) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.decompose.ext.compose) /** DI */ implementation(deps.hilt.android) diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeader.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeader.kt new file mode 100644 index 0000000000..6ec531b3d2 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeader.kt @@ -0,0 +1,115 @@ +package com.tangem.features.virtualaccount.common.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.text.applyBladeBrush +import com.tangem.core.ui.ds2.shimmers.TextShimmer +import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.utils.StringsSigns.DASH_SIGN + +@Composable +fun TangemBalanceHeader( + state: TangemBalanceHeaderState, + label: TextReference, + modifier: Modifier = Modifier, + balanceModifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + AnimatedContent( + targetState = state, + label = "Updating the balance", + transitionSpec = { + fadeIn(animationSpec = tween(durationMillis = 220, delayMillis = 90)) togetherWith + fadeOut(animationSpec = tween(durationMillis = 90)) + }, + ) { animatedState -> + when (animatedState) { + is TangemBalanceHeaderState.Loading -> TextShimmer( + modifier = Modifier.size(width = 160.dp, height = 56.dp), + text = "1234.00", + style = TextShimmerStyle.HEADING_MEDIUM, + radius = TangemTheme.dimens2.x25, + ) + is TangemBalanceHeaderState.Content -> Text( + modifier = balanceModifier, + text = animatedState.balance + .orMaskWithStars(animatedState.isBalanceHidden) + .resolveAnnotatedReference(), + style = TangemTheme.typography3.display.medium.applyBladeBrush( + isEnabled = animatedState.isFlickering, + textColor = TangemTheme.colors3.text.primary, + ), + color = TangemTheme.colors3.text.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = TangemTheme.typography3.heading.medium.fontSize, + maxFontSize = TangemTheme.typography3.display.medium.fontSize, + ), + ) + is TangemBalanceHeaderState.Error -> Text( + modifier = balanceModifier, + text = DASH_SIGN, + style = TangemTheme.typography3.display.medium, + color = TangemTheme.colors3.text.primary, + ) + } + } + Text( + modifier = Modifier.padding(vertical = TangemTheme.dimens2.x1), + text = label.resolveReference(), + color = TangemTheme.colors3.text.secondary, + style = TangemTheme.typography3.caption.medium, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemBalanceHeaderPreview() { + TangemThemePreviewRedesign { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + TangemBalanceHeader( + modifier = Modifier.fillMaxWidth(), + state = TangemBalanceHeaderState.Content( + balance = stringReference("$0.00"), + isBalanceHidden = false, + ), + label = stringReference("Total balance"), + ) + TangemBalanceHeader( + modifier = Modifier.fillMaxWidth(), + state = TangemBalanceHeaderState.Loading, + label = stringReference("Total balance"), + ) + TangemBalanceHeader( + modifier = Modifier.fillMaxWidth(), + state = TangemBalanceHeaderState.Error, + label = stringReference("Total balance"), + ) + } + } +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeaderState.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeaderState.kt new file mode 100644 index 0000000000..2bbe065b20 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeaderState.kt @@ -0,0 +1,18 @@ +package com.tangem.features.virtualaccount.common.ui + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference + +@Immutable +sealed interface TangemBalanceHeaderState { + + data object Loading : TangemBalanceHeaderState + + data class Content( + val balance: TextReference, + val isBalanceHidden: Boolean, + val isFlickering: Boolean = false, + ) : TangemBalanceHeaderState + + data object Error : TangemBalanceHeaderState +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemCircleActionButton.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemCircleActionButton.kt new file mode 100644 index 0000000000..62ad094981 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemCircleActionButton.kt @@ -0,0 +1,70 @@ +package com.tangem.features.virtualaccount.common.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveAnnotatedReference +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_arrow_down_24 + +@Composable +fun TangemCircleActionButton( + title: TextReference, + icon: TangemIconUM, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, + isLoading: Boolean = false, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TangemButton( + variant = TangemButton.Variant.Material, + size = TangemButton.Size.X14, + onClick = onClick, + iconStart = icon, + isLoading = isLoading, + isEnabled = isEnabled, + ) + Text( + text = title.resolveAnnotatedReference(), + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.primary, + autoSize = TextAutoSize.StepBased( + minFontSize = TangemTheme.typography3.caption.medium.fontSize, + maxFontSize = TangemTheme.typography3.subheading.medium.fontSize, + ), + maxLines = 1, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemCircleActionButtonPreview() { + TangemThemePreviewRedesign { + TangemCircleActionButton( + title = stringReference("Action"), + icon = TangemIconUM.Icon( + imageVector = Icons.ic_arrow_down_24, + tintReference = { TangemTheme.colors3.icon.primary }, + ), + onClick = {}, + ) + } +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemEmptyState.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemEmptyState.kt new file mode 100644 index 0000000000..672ee4004f --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemEmptyState.kt @@ -0,0 +1,73 @@ +package com.tangem.features.virtualaccount.common.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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_binoculars_20 + +@Composable +fun TangemEmptyState( + icon: ImageVector, + text: TextReference, + modifier: Modifier = Modifier, + iconModifier: Modifier = Modifier, + textModifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x3, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier + .size(TangemTheme.dimens2.x10) + .background( + color = TangemTheme.colors3.bg.opaque.primary, + shape = CircleShape, + ) + .padding(10.dp) + .then(iconModifier), + imageVector = icon, + tint = TangemTheme.colors3.icon.secondary, + contentDescription = null, + ) + + Text( + modifier = textModifier, + textAlign = TextAlign.Center, + text = text.resolveReference(), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemEmptyStatePreview() { + TangemThemePreviewRedesign { + TangemEmptyState( + icon = Icons.ic_binoculars_20, + text = stringReference("No transactions yet\nStart spending and see history here"), + ) + } +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/di/VirtualAccountDetailsModule.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/di/VirtualAccountMainModule.kt similarity index 94% rename from features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/di/VirtualAccountDetailsModule.kt rename to features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/di/VirtualAccountMainModule.kt index 8e35f7eb24..d3a53a6e88 100644 --- a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/di/VirtualAccountDetailsModule.kt +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/di/VirtualAccountMainModule.kt @@ -11,7 +11,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal object VirtualAccountDetailsModule { +internal object VirtualAccountMainModule { @Provides @Singleton diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt new file mode 100644 index 0000000000..7ad7ef7c21 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt @@ -0,0 +1,34 @@ +package com.tangem.features.virtualaccount.main + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.virtualaccount.details.component.VirtualAccountMainComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultVirtualAccountMainComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: VirtualAccountMainComponent.Params, +) : VirtualAccountMainComponent, AppComponentContext by appComponentContext { + + private val model: VirtualAccountMainModel = getOrCreateModel(params = params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + VirtualAccountMainScreen(state = state, modifier = modifier) + } + + @AssistedFactory + interface Factory : VirtualAccountMainComponent.Factory { + override fun create( + context: AppComponentContext, + params: VirtualAccountMainComponent.Params, + ): DefaultVirtualAccountMainComponent + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..65a9fd1e60 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainModel.kt @@ -0,0 +1,46 @@ +package com.tangem.features.virtualaccount.main + +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.decompose.navigation.Router +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.virtualaccount.details.component.VirtualAccountMainComponent +import com.tangem.features.virtualaccount.details.impl.R +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@Stable +@ModelScoped +internal class VirtualAccountMainModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, +) : Model() { + + @Suppress("UnusedPrivateProperty") + private val params = paramsContainer.require() + + val uiState: StateFlow + field = MutableStateFlow( + createInitialState(), + ) + + private fun createInitialState(): VirtualAccountMainUM = VirtualAccountMainUM( + title = resourceReference(R.string.virtual_account_title), + subtitle = resourceReference(R.string.tangempay_usdc_on_polygon_network), + balance = VirtualAccountBalanceBlockState.Content( + fiatBalance = stringReference("$0.00"), + isBalanceFlickering = false, + ), + isBalanceHidden = false, + onBackClick = { router.pop() }, + onMenuClick = {}, + onAddFundsClick = {}, + onSendClick = {}, + ) +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainScreen.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainScreen.kt new file mode 100644 index 0000000000..98d4289358 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainScreen.kt @@ -0,0 +1,229 @@ +package com.tangem.features.virtualaccount.main + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Devices +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.SpacerH24 +import com.tangem.core.ui.components.topFade +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds2.button.TangemButton +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.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.icons.* +import com.tangem.features.virtualaccount.common.ui.TangemBalanceHeader +import com.tangem.features.virtualaccount.common.ui.TangemBalanceHeaderState +import com.tangem.features.virtualaccount.common.ui.TangemCircleActionButton +import com.tangem.features.virtualaccount.common.ui.TangemEmptyState +import com.tangem.features.virtualaccount.details.impl.R +import com.tangem.core.ui.R as CoreUiR + +private val InitialTopBarHeight: Dp = 64.dp +private const val TOP_FADE_MID_STOP = 0.8f +private const val TOP_FADE_MID_ALPHA = 0.8f + +@Composable +internal fun VirtualAccountMainScreen(state: VirtualAccountMainUM, modifier: Modifier = Modifier) { + val listState = rememberLazyListState() + val density = LocalDensity.current + val statusBarHeight = with(density) { WindowInsets.systemBars.getTop(this).toDp() } + val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() } + var topBarTotalHeight by remember { mutableStateOf(InitialTopBarHeight + statusBarHeight) } + val rootBackground = TangemTheme.colors3.bg.primary + + Box( + modifier = modifier + .fillMaxSize() + .background(rootBackground), + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .topFade( + height = topBarTotalHeight, + 0f to rootBackground, + TOP_FADE_MID_STOP to rootBackground.copy(alpha = TOP_FADE_MID_ALPHA), + 1f to Color.Transparent, + ), + horizontalAlignment = Alignment.CenterHorizontally, + state = listState, + contentPadding = PaddingValues( + top = topBarTotalHeight, + bottom = TangemTheme.dimens2.x4 + bottomBarHeight, + ), + ) { + body( + state = state, + listState = listState, + ) + } + TopBar( + state = state, + onHeightChange = { measuredHeight -> + if (topBarTotalHeight != measuredHeight) topBarTotalHeight = measuredHeight + }, + ) + } +} + +private fun LazyListScope.body(state: VirtualAccountMainUM, listState: LazyListState) { + item("balanceBlock") { + BalanceBlock( + state = state.balance, + isBalanceHidden = state.isBalanceHidden, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(top = TangemTheme.dimens2.x12), + ) + } + item("actionButtonsBlock") { + SpacerH24() + ActionBlock(state = state) + } + item("emptyTransactions") { + SpacerH24() + TangemEmptyState( + icon = Icons.ic_binoculars_20, + text = resourceReference(R.string.virtual_account_transactions_empty), + modifier = Modifier + .heightIn(min = rememberRemainingViewportHeight(listState, "emptyTransactions")) + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x3), + ) + } +} + +@Composable +private fun BalanceBlock( + state: VirtualAccountBalanceBlockState, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + TangemBalanceHeader( + state = when (state) { + is VirtualAccountBalanceBlockState.Loading -> TangemBalanceHeaderState.Loading + is VirtualAccountBalanceBlockState.Content -> TangemBalanceHeaderState.Content( + balance = state.fiatBalance, + isFlickering = state.isBalanceFlickering, + isBalanceHidden = isBalanceHidden, + ) + is VirtualAccountBalanceBlockState.Error -> TangemBalanceHeaderState.Error + }, + label = resourceReference(R.string.token_details_balance_total), + modifier = modifier, + ) +} + +@Composable +private fun LazyItemScope.ActionBlock(state: VirtualAccountMainUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + TangemCircleActionButton( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4), + title = resourceReference(R.string.common_add_funds), + icon = TangemIconUM.Icon( + imageVector = Icons.ic_arrow_down_24, + tintReference = { TangemTheme.colors3.icon.primary }, + ), + onClick = state.onAddFundsClick, + ) + TangemCircleActionButton( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4), + title = resourceReference(R.string.common_send), + icon = TangemIconUM.Icon( + imageVector = Icons.ic_arrow_up_24, + tintReference = { TangemTheme.colors3.icon.primary }, + ), + onClick = state.onSendClick, + ) + } +} + +@Composable +private fun TopBar(state: VirtualAccountMainUM, onHeightChange: (Dp) -> Unit, modifier: Modifier = Modifier) { + val density = LocalDensity.current + TangemTopBar( + modifier = modifier + .onSizeChanged { size -> onHeightChange(with(density) { size.height.toDp() }) } + .statusBarsPadding(), + title = state.title, + subtitle = state.subtitle, + startContent = { + TangemButton( + iconStart = TangemIconUM.Icon(iconRes = CoreUiR.drawable.ic_arrow_back_28), + onClick = state.onBackClick, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, + endContent = { + TangemButton( + iconStart = TangemIconUM.Icon(imageVector = Icons.ic_dots_vertical_24), + onClick = state.onMenuClick, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, + ) +} + +/** + * Computes the height left between the top of the item identified by [itemKey] and the bottom of the + * list's viewport (excluding bottom content padding). Returns `0.dp` until the item has been laid out. + * + * The item's own height does not affect its offset (only the items above it do), so reading the offset + * back to size the item is stable and does not loop. + */ +@Composable +private fun rememberRemainingViewportHeight(listState: LazyListState, itemKey: Any): Dp { + val density = LocalDensity.current + val remainingPx by remember(listState, itemKey) { + derivedStateOf { + val info = listState.layoutInfo + val item = info.visibleItemsInfo.firstOrNull { it.key == itemKey } + ?: return@derivedStateOf 0 + (info.viewportEndOffset - info.afterContentPadding - item.offset).coerceAtLeast(minimumValue = 0) + } + } + return with(density) { remainingPx.toDp() } +} + +@Preview(device = Devices.PIXEL_7_PRO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO) +@Composable +private fun VirtualAccountMainScreenPreview() { + TangemThemePreviewRedesign { + VirtualAccountMainScreen( + state = VirtualAccountMainUM( + title = resourceReference(R.string.virtual_account_title), + subtitle = resourceReference(R.string.tangempay_usdc_on_polygon_network), + balance = VirtualAccountBalanceBlockState.Content( + fiatBalance = stringReference("$0.00"), + isBalanceFlickering = false, + ), + isBalanceHidden = false, + onBackClick = {}, + onMenuClick = {}, + onAddFundsClick = {}, + onSendClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainUM.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainUM.kt new file mode 100644 index 0000000000..555fdd5601 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainUM.kt @@ -0,0 +1,29 @@ +package com.tangem.features.virtualaccount.main + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference + +@Immutable +internal data class VirtualAccountMainUM( + val title: TextReference, + val subtitle: TextReference, + val balance: VirtualAccountBalanceBlockState, + val isBalanceHidden: Boolean, + val onBackClick: () -> Unit, + val onMenuClick: () -> Unit, + val onAddFundsClick: () -> Unit, + val onSendClick: () -> Unit, +) + +@Immutable +internal sealed class VirtualAccountBalanceBlockState { + + data object Loading : VirtualAccountBalanceBlockState() + + data class Content( + val fiatBalance: TextReference, + val isBalanceFlickering: Boolean, + ) : VirtualAccountBalanceBlockState() + + data object Error : VirtualAccountBalanceBlockState() +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainComponentModule.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainComponentModule.kt new file mode 100644 index 0000000000..3e3ca68186 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainComponentModule.kt @@ -0,0 +1,18 @@ +package com.tangem.features.virtualaccount.main.di + +import com.tangem.features.virtualaccount.main.DefaultVirtualAccountMainComponent +import com.tangem.features.virtualaccount.details.component.VirtualAccountMainComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal interface VirtualAccountMainComponentModule { + + @Binds + fun bindVirtualAccountMainComponentFactory( + factory: DefaultVirtualAccountMainComponent.Factory, + ): VirtualAccountMainComponent.Factory +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainModelModule.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainModelModule.kt new file mode 100644 index 0000000000..9c621bc6fc --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.virtualaccount.main.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.virtualaccount.main.VirtualAccountMainModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface VirtualAccountMainModelModule { + + @Binds + @IntoMap + @ClassKey(VirtualAccountMainModel::class) + fun bindVirtualAccountMainModel(model: VirtualAccountMainModel): Model +} \ No newline at end of file diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt index e0c1ec5b25..44920f24a3 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt @@ -20,6 +20,7 @@ internal fun BaseExtension.configureCompilerOptions() { internal fun BaseExtension.configureCompose(project: Project) { val useCompose = with(project.path) { contains(":ui") || + contains(Regex(pattern = ":common-ui\$")) || // shared Composable UI component modules contains(":common:ui-charts") || contains(":features:onboarding") || // TODO: divide on api/impl after migrating all onboarding to module contains(Regex(pattern = ":presentation\$")) || From e5c441a9f0db352d391dc31dc62065b47ba56eb0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 17:31:01 +0500 Subject: [PATCH 05/59] Updated on 2026-08-14 --- app/src/main/AndroidManifest.xml | 10 + .../tangem/tap/routing/utils/ChildFactory.kt | 19 ++ .../tap/routing/utils/DeepLinkFactory.kt | 3 + .../tap/routing/utils/DeepLinkFactoryTest.kt | 6 + .../com/tangem/common/routing/AppRoute.kt | 18 ++ .../tangem/common/routing/DeepLinkRoute.kt | 4 + .../pay/models/response/CustomerMeResponse.kt | 10 + .../onboarding/api/build.gradle.kts | 9 + .../VirtualAccountOnboardingComponent.kt | 21 ++ .../OnboardVirtualAccountsDeepLinkHandler.kt | 10 + .../onboarding/impl/build.gradle.kts | 19 +- ...efaultVirtualAccountOnboardingComponent.kt | 35 +++ ...ltOnboardVirtualAccountsDeepLinkHandler.kt | 35 +++ .../VirtualAccountOnboardingFeatureModule.kt | 25 ++ .../VirtualAccountOnboardingModelsModule.kt | 20 ++ .../model/VirtualAccountOnboardingModel.kt | 96 ++++++++ .../ui/VirtualAccountOnboardingScreen.kt | 213 ++++++++++++++++++ .../ui/VirtualAccountOnboardingUM.kt | 22 ++ .../bg_virtual_account_onboarding.webp | Bin 0 -> 53770 bytes 19 files changed, 573 insertions(+), 2 deletions(-) create mode 100644 features/virtual-accounts/onboarding/api/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/component/VirtualAccountOnboardingComponent.kt create mode 100644 features/virtual-accounts/onboarding/api/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/deeplink/OnboardVirtualAccountsDeepLinkHandler.kt create mode 100644 features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/component/DefaultVirtualAccountOnboardingComponent.kt create mode 100644 features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/deeplink/DefaultOnboardVirtualAccountsDeepLinkHandler.kt create mode 100644 features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/di/VirtualAccountOnboardingFeatureModule.kt create mode 100644 features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/di/VirtualAccountOnboardingModelsModule.kt create mode 100644 features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/model/VirtualAccountOnboardingModel.kt create mode 100644 features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/ui/VirtualAccountOnboardingScreen.kt create mode 100644 features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/ui/VirtualAccountOnboardingUM.kt create mode 100644 features/virtual-accounts/onboarding/impl/src/main/res/drawable/bg_virtual_account_onboarding.webp diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dcb214db57..eb8e94ac35 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -313,6 +313,16 @@ + + + + + + + + diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index f7c46d1d64..ec6982db3a 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -43,6 +43,7 @@ import com.tangem.features.tangempay.components.TangemPayHotWalletOnboardingComp import com.tangem.features.tangempay.components.TangemPayOnboardingComponent import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.* import com.tangem.features.tokendetails.TokenDetailsComponent +import com.tangem.features.virtualaccount.onboarding.component.VirtualAccountOnboardingComponent import com.tangem.features.wallet.WalletEntryComponent import com.tangem.features.walletconnect.components.WalletConnectEntryComponent import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent @@ -114,6 +115,7 @@ internal class ChildFactory @Inject constructor( private val tangemPayDetailsContainerComponentFactory: TangemPayDetailsContainerComponent.Factory, private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory, private val tangemPayWalletOnboardingComponentFactory: TangemPayHotWalletOnboardingComponent.Factory, + private val virtualAccountOnboardingComponentFactory: VirtualAccountOnboardingComponent.Factory, private val kycComponentFactory: KycComponent.Factory, private val surveyComponentFactory: SurveyComponent.Factory, private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory, @@ -703,6 +705,23 @@ internal class ChildFactory @Inject constructor( componentFactory = tangemPayWalletOnboardingComponentFactory, ) } + is AppRoute.VirtualAccountOnboarding -> { + createComponentChild( + context = context, + params = when (val mode = route.mode) { + is AppRoute.VirtualAccountOnboarding.Mode.Deeplink -> + VirtualAccountOnboardingComponent.Params.Deeplink( + userWalletId = mode.userWalletId, + deeplink = mode.deeplink, + ) + is AppRoute.VirtualAccountOnboarding.Mode.FromMain -> + VirtualAccountOnboardingComponent.Params.FromMain(userWalletId = mode.userWalletId) + is AppRoute.VirtualAccountOnboarding.Mode.FromDetailsScreen -> + VirtualAccountOnboardingComponent.Params.FromDetailsScreen(userWalletId = mode.userWalletId) + }, + componentFactory = virtualAccountOnboardingComponentFactory, + ) + } is AppRoute.Kyc -> { createComponentChild( context = context, diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index 378e230a37..c30d377259 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -21,6 +21,7 @@ import com.tangem.features.send.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler +import com.tangem.features.virtualaccount.onboarding.deeplink.OnboardVirtualAccountsDeepLinkHandler import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler @@ -57,6 +58,7 @@ internal class DeepLinkFactory @Inject constructor( private val swapDeepLink: SwapDeepLinkHandler.Factory, private val promoDeepLink: PromoDeeplinkHandler.Factory, private val onboardVisaDeepLink: OnboardVisaDeepLinkHandler.Factory, + private val onboardVirtualAccountsDeepLink: OnboardVirtualAccountsDeepLinkHandler.Factory, private val marketsTokenExchangesDeepLink: MarketsTokenExchangesDeepLinkHandler.Factory, private val tangemPayMainDeepLink: TangemPayMainDeepLinkHandler.Factory, private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory, @@ -173,6 +175,7 @@ internal class DeepLinkFactory @Inject constructor( DeepLinkRoute.WalletConnect.host -> walletConnectDeepLink.create(deeplinkUri) DeepLinkRoute.Promo.host -> promoDeepLink.create(coroutineScope, queryParams) DeepLinkRoute.OnboardVisa.host -> onboardVisaDeepLink.create(deeplinkUri) + DeepLinkRoute.OnboardVirtualAccounts.host -> onboardVirtualAccountsDeepLink.create(deeplinkUri) DeepLinkRoute.News.host -> newsDeepLink.create(queryParams) DeepLinkRoute.Earn.host -> earnDeepLink.create(queryParams) DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams) diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index c6cfb8973c..c6744e1b77 100644 --- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt @@ -19,6 +19,7 @@ import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler import com.tangem.features.send.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler +import com.tangem.features.virtualaccount.onboarding.deeplink.OnboardVirtualAccountsDeepLinkHandler import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler @@ -84,6 +85,10 @@ class DeepLinkFactoryTest { every { create(any()) } returns mockk() } + private val onboardVirtualAccountsDeepLink = mockk(relaxed = true) { + every { create(any()) } returns mockk() + } + private val tangemPayMainDeepLink = mockk(relaxed = true) { every { create(any(), any()) } returns mockk() } @@ -140,6 +145,7 @@ class DeepLinkFactoryTest { swapDeepLink = swapDeepLinkFactory, promoDeepLink = promoDeepLinkFactory, onboardVisaDeepLink = onboardVisaDeepLink, + onboardVirtualAccountsDeepLink = onboardVirtualAccountsDeepLink, tangemPayMainDeepLink = tangemPayMainDeepLink, newsDetailsDeepLink = newsDeeplink, newsDeepLink = newsDeepLinkFactory, diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index cd1e1253a0..9536a684a0 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -509,6 +509,24 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class Kyc(val userWalletId: UserWalletId) : AppRoute(path = "/kyc") + @Serializable + data class VirtualAccountOnboarding( + val mode: Mode, + ) : AppRoute(path = "/virtual_account_onboarding/$mode") { + + @Serializable + sealed class Mode { + @Serializable + data class Deeplink(val userWalletId: UserWalletId, val deeplink: String) : Mode() + + @Serializable + data class FromMain(val userWalletId: UserWalletId) : Mode() + + @Serializable + data class FromDetailsScreen(val userWalletId: UserWalletId) : Mode() + } + } + @Serializable data class Survey(val token: String, val displayId: String? = null) : AppRoute(path = "/survey") diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt index e2e31a626c..9abdcf8b09 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt @@ -68,6 +68,10 @@ sealed class DeepLinkRoute { override val host: String = "onboard-visa" } + data object OnboardVirtualAccounts : DeepLinkRoute() { + override val host: String = "onboard-virtual-account" + } + data object PayApp : DeepLinkRoute() { override val host: String = "tangem.com" } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt index da654f8d16..17de6c4cd0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt @@ -35,7 +35,17 @@ data class CustomerMeResponse( @Json(name = "display_name") val displayName: String?, @Json(name = "actual_card_limit") val actualCardLimit: CardLimit?, @Json(name = "admin_card_limit") val adminCardLimit: CardLimit?, + @Json(name = "product_specification_data_type") val specificationDataType: SpecificationDataType, ) { + @JsonClass(generateAdapter = false) + enum class SpecificationDataType { + @Json(name = "ACCOUNT") + ACCOUNT, + + @Json(name = "CARD") + CARD, + } + @JsonClass(generateAdapter = false) enum class Status { @Json(name = "NEW") diff --git a/features/virtual-accounts/onboarding/api/build.gradle.kts b/features/virtual-accounts/onboarding/api/build.gradle.kts index bd895bec0a..a409f095d3 100644 --- a/features/virtual-accounts/onboarding/api/build.gradle.kts +++ b/features/virtual-accounts/onboarding/api/build.gradle.kts @@ -9,4 +9,13 @@ android { } dependencies { + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /** Domain */ + implementation(projects.domain.models) + + /** Compose */ + implementation(deps.compose.runtime) } \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/api/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/component/VirtualAccountOnboardingComponent.kt b/features/virtual-accounts/onboarding/api/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/component/VirtualAccountOnboardingComponent.kt new file mode 100644 index 0000000000..5aac13f9ca --- /dev/null +++ b/features/virtual-accounts/onboarding/api/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/component/VirtualAccountOnboardingComponent.kt @@ -0,0 +1,21 @@ +package com.tangem.features.virtualaccount.onboarding.component + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId + +interface VirtualAccountOnboardingComponent : ComposableContentComponent { + + sealed class Params { + + abstract val userWalletId: UserWalletId + + data class Deeplink(override val userWalletId: UserWalletId, val deeplink: String) : Params() + + data class FromMain(override val userWalletId: UserWalletId) : Params() + + data class FromDetailsScreen(override val userWalletId: UserWalletId) : Params() + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/api/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/deeplink/OnboardVirtualAccountsDeepLinkHandler.kt b/features/virtual-accounts/onboarding/api/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/deeplink/OnboardVirtualAccountsDeepLinkHandler.kt new file mode 100644 index 0000000000..62350c86f4 --- /dev/null +++ b/features/virtual-accounts/onboarding/api/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/deeplink/OnboardVirtualAccountsDeepLinkHandler.kt @@ -0,0 +1,10 @@ +package com.tangem.features.virtualaccount.onboarding.deeplink + +import android.net.Uri + +interface OnboardVirtualAccountsDeepLinkHandler { + + interface Factory { + fun create(uri: Uri): OnboardVirtualAccountsDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/build.gradle.kts b/features/virtual-accounts/onboarding/impl/build.gradle.kts index b187abb29a..8ea2dc7f4d 100644 --- a/features/virtual-accounts/onboarding/impl/build.gradle.kts +++ b/features/virtual-accounts/onboarding/impl/build.gradle.kts @@ -11,11 +11,23 @@ android { } dependencies { + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.error) + implementation(projects.core.ui) + implementation(projects.core.utils) + + /** Common */ + implementation(projects.common.routing) + implementation(projects.common.ui) + /** Api */ implementation(projects.features.virtualAccounts.onboarding.api) - /** Core modules */ - implementation(projects.core.configToggles) + /** Domain */ + implementation(projects.domain.common) + implementation(projects.domain.models) + implementation(projects.domain.visa) /** Compose */ implementation(deps.compose.foundation) @@ -27,4 +39,7 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Other */ + implementation(deps.arrow.core) } \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/component/DefaultVirtualAccountOnboardingComponent.kt b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/component/DefaultVirtualAccountOnboardingComponent.kt new file mode 100644 index 0000000000..253935756f --- /dev/null +++ b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/component/DefaultVirtualAccountOnboardingComponent.kt @@ -0,0 +1,35 @@ +package com.tangem.features.virtualaccount.onboarding.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.virtualaccount.onboarding.model.VirtualAccountOnboardingModel +import com.tangem.features.virtualaccount.onboarding.ui.VirtualAccountOnboardingScreen +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultVirtualAccountOnboardingComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: VirtualAccountOnboardingComponent.Params, +) : VirtualAccountOnboardingComponent, AppComponentContext by appComponentContext { + + private val model: VirtualAccountOnboardingModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + VirtualAccountOnboardingScreen(modifier = modifier, state = state) + } + + @AssistedFactory + interface Factory : VirtualAccountOnboardingComponent.Factory { + override fun create( + context: AppComponentContext, + params: VirtualAccountOnboardingComponent.Params, + ): DefaultVirtualAccountOnboardingComponent + } +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/deeplink/DefaultOnboardVirtualAccountsDeepLinkHandler.kt b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/deeplink/DefaultOnboardVirtualAccountsDeepLinkHandler.kt new file mode 100644 index 0000000000..87dcb0729b --- /dev/null +++ b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/deeplink/DefaultOnboardVirtualAccountsDeepLinkHandler.kt @@ -0,0 +1,35 @@ +package com.tangem.features.virtualaccount.onboarding.deeplink + +import android.net.Uri +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.utils.logging.TangemLogger +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultOnboardVirtualAccountsDeepLinkHandler @AssistedInject constructor( + @Assisted uri: Uri, + appRouter: AppRouter, + userWalletsListRepository: UserWalletsListRepository, +) : OnboardVirtualAccountsDeepLinkHandler { + + init { + val userWalletId = userWalletsListRepository.selectedUserWallet.value?.walletId + if (userWalletId == null) { + TangemLogger.e("Can not open virtual account onboarding deeplink: no selected wallet") + } else { + val mode = AppRoute.VirtualAccountOnboarding.Mode.Deeplink( + userWalletId = userWalletId, + deeplink = uri.toString(), + ) + appRouter.push(AppRoute.VirtualAccountOnboarding(mode)) + } + } + + @AssistedFactory + interface Factory : OnboardVirtualAccountsDeepLinkHandler.Factory { + override fun create(uri: Uri): DefaultOnboardVirtualAccountsDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/di/VirtualAccountOnboardingFeatureModule.kt b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/di/VirtualAccountOnboardingFeatureModule.kt new file mode 100644 index 0000000000..3d1c743658 --- /dev/null +++ b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/di/VirtualAccountOnboardingFeatureModule.kt @@ -0,0 +1,25 @@ +package com.tangem.features.virtualaccount.onboarding.di + +import com.tangem.features.virtualaccount.onboarding.component.DefaultVirtualAccountOnboardingComponent +import com.tangem.features.virtualaccount.onboarding.component.VirtualAccountOnboardingComponent +import com.tangem.features.virtualaccount.onboarding.deeplink.DefaultOnboardVirtualAccountsDeepLinkHandler +import com.tangem.features.virtualaccount.onboarding.deeplink.OnboardVirtualAccountsDeepLinkHandler +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface VirtualAccountOnboardingFeatureModule { + + @Binds + fun bindFactory(impl: DefaultVirtualAccountOnboardingComponent.Factory): VirtualAccountOnboardingComponent.Factory + + @Binds + @Singleton + fun bindOnboardVirtualAccountsDeepLinkHandlerFactory( + impl: DefaultOnboardVirtualAccountsDeepLinkHandler.Factory, + ): OnboardVirtualAccountsDeepLinkHandler.Factory +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/di/VirtualAccountOnboardingModelsModule.kt b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/di/VirtualAccountOnboardingModelsModule.kt new file mode 100644 index 0000000000..e14040c3ea --- /dev/null +++ b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/di/VirtualAccountOnboardingModelsModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.virtualaccount.onboarding.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.virtualaccount.onboarding.model.VirtualAccountOnboardingModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface VirtualAccountOnboardingModelsModule { + + @Binds + @IntoMap + @ClassKey(VirtualAccountOnboardingModel::class) + fun bindVirtualAccountOnboardingModel(model: VirtualAccountOnboardingModel): Model +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/model/VirtualAccountOnboardingModel.kt b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/model/VirtualAccountOnboardingModel.kt new file mode 100644 index 0000000000..9c30c6911e --- /dev/null +++ b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/model/VirtualAccountOnboardingModel.kt @@ -0,0 +1,96 @@ +package com.tangem.features.virtualaccount.onboarding.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.decompose.navigation.Router +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.features.virtualaccount.onboarding.component.VirtualAccountOnboardingComponent +import com.tangem.features.virtualaccount.onboarding.ui.VirtualAccountOnboardingUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class VirtualAccountOnboardingModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val onboardingRepository: OnboardingRepository, +) : Model() { + + private val params = paramsContainer.require() + + val uiState: StateFlow + field = MutableStateFlow(VirtualAccountOnboardingUM.Loading(onBack = ::back)) + + init { + when (params) { + is VirtualAccountOnboardingComponent.Params.Deeplink -> validateDeeplinkAndShow(params.deeplink) + is VirtualAccountOnboardingComponent.Params.FromMain, + is VirtualAccountOnboardingComponent.Params.FromDetailsScreen, + -> showOnboarding() + } + } + + private fun validateDeeplinkAndShow(deeplink: String) { + modelScope.launch { + onboardingRepository.validateDeeplink(deeplink) + .onRight { isValid -> if (isValid) showOnboarding() else back() } + .onLeft { back() } + } + } + + private fun showOnboarding() { + uiState.update { + VirtualAccountOnboardingUM.Content( + onBack = ::back, + isLoading = false, + onGetCardClick = ::onGetCardClick, + onTermsClick = ::onTermsClick, + onPrivacyClick = ::onPrivacyClick, + ) + } + } + + private fun onTermsClick() { + // TODO([REDACTED_TASK_KEY]): open the provider Terms of Use link. + } + + private fun onPrivacyClick() { + // TODO([REDACTED_TASK_KEY]): open the provider Privacy Policy link. + } + + private fun onGetCardClick() { + modelScope.launch { + setLoading(isLoading = true) + delay(STUB_GET_CARD_DELAY_MS) + // TODO: create order and sign challenge [REDACTED_JIRA] + setLoading(isLoading = false) + } + } + + private fun setLoading(isLoading: Boolean) { + uiState.update { state -> + when (state) { + is VirtualAccountOnboardingUM.Content -> state.copy(isLoading = isLoading) + is VirtualAccountOnboardingUM.Loading -> state + } + } + } + + private fun back() { + router.pop() + } + + private companion object { + // TODO([REDACTED_TASK_KEY]): remove the stub delay once create-order + sign-challenge is implemented. + const val STUB_GET_CARD_DELAY_MS = 3000L + } +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/ui/VirtualAccountOnboardingScreen.kt b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/ui/VirtualAccountOnboardingScreen.kt new file mode 100644 index 0000000000..537d302e29 --- /dev/null +++ b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/ui/VirtualAccountOnboardingScreen.kt @@ -0,0 +1,213 @@ +package com.tangem.features.virtualaccount.onboarding.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.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +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.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withLink +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds2.button.Close +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.extensions.annotatedReference +import com.tangem.core.ui.extensions.appendColored +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.virtualaccount.onboarding.impl.R + +private const val GRADIENT_TRANSPARENT_STOP = 0.45f +private const val GRADIENT_OPAQUE_STOP = 0.72f + +private const val TERMS_LINK_TAG = "VA_TERMS" +private const val PRIVACY_LINK_TAG = "VA_PRIVACY" + +@Composable +internal fun VirtualAccountOnboardingScreen(state: VirtualAccountOnboardingUM, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors3.bg.primary), + ) { + Image( + painter = painterResource(id = R.drawable.bg_virtual_account_onboarding), + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = Alignment.TopCenter, + modifier = Modifier.fillMaxSize(), + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colorStops = arrayOf( + 0f to TangemTheme.colors3.bg.primary.copy(alpha = 0f), + GRADIENT_TRANSPARENT_STOP to TangemTheme.colors3.bg.primary.copy(alpha = 0f), + GRADIENT_OPAQUE_STOP to TangemTheme.colors3.bg.primary, + 1f to TangemTheme.colors3.bg.primary, + ), + ), + ), + ) + + when (state) { + is VirtualAccountOnboardingUM.Loading -> Loading(modifier = Modifier.fillMaxSize()) + is VirtualAccountOnboardingUM.Content -> Content(state = state) + } + + TangemButton.Close( + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(top = 4.dp, end = 16.dp), + onClick = state.onBack, + ) + } +} + +@Composable +private fun Loading(modifier: Modifier = Modifier) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = TangemTheme.colors3.icon.primary) + } +} + +@Composable +private fun Content(state: VirtualAccountOnboardingUM.Content, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .systemBarsPadding(), + ) { + Spacer(modifier = Modifier.weight(1f)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Send USD from your bank. Receive USDC", + style = TangemTheme.typography3.heading.medium, + color = TangemTheme.colors3.text.primary, + ) + Text( + text = "A dedicated account with US banking details — no deposit or maintenance fees", + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.secondary, + ) + } + + TermsCard( + modifier = Modifier.padding(top = 24.dp, start = 8.dp, end = 8.dp), + state = state, + ) + } +} + +@Composable +private fun TermsCard(state: VirtualAccountOnboardingUM.Content, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp, bottomStart = 28.dp, bottomEnd = 28.dp) + Column( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .background(TangemTheme.colors3.bg.opaque.primary) + .border(width = 1.dp, color = TangemTheme.colors3.border.secondary, shape = shape), + ) { + Text( + modifier = Modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp), + text = buildTermsAndPolicy( + onTermsClick = state.onTermsClick, + onPrivacyClick = state.onPrivacyClick, + ).resolveAnnotatedReference(), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, + textAlign = TextAlign.Center, + ) + TangemButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + variant = TangemButton.Variant.Primary, + size = TangemButton.Size.X12, + text = stringReference("Open account"), + iconEnd = TangemIconUM.Icon(R.drawable.ic_tangem_24), + isLoading = state.isLoading, + onClick = state.onGetCardClick, + ) + } +} + +@Composable +private fun buildTermsAndPolicy(onTermsClick: () -> Unit, onPrivacyClick: () -> Unit) = annotatedReference { + val linkColor = TangemTheme.colors3.text.primary + append("By using service, you agree with provider ") + withLink( + link = LinkAnnotation.Clickable( + tag = TERMS_LINK_TAG, + linkInteractionListener = { onTermsClick() }, + ), + block = { appendColored(text = "Terms of Use", color = linkColor) }, + ) + append(" and ") + withLink( + link = LinkAnnotation.Clickable( + tag = PRIVACY_LINK_TAG, + linkInteractionListener = { onPrivacyClick() }, + ), + block = { appendColored(text = "Privacy Policy", color = linkColor) }, + ) +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun VirtualAccountOnboardingScreenPreview( + @PreviewParameter(VirtualAccountOnboardingStateProvider::class) + state: VirtualAccountOnboardingUM, +) { + TangemThemePreviewRedesign { + VirtualAccountOnboardingScreen(state = state, modifier = Modifier.fillMaxSize()) + } +} + +private class VirtualAccountOnboardingStateProvider : + CollectionPreviewParameterProvider( + listOf( + VirtualAccountOnboardingUM.Loading(onBack = {}), + VirtualAccountOnboardingUM.Content( + onBack = {}, + isLoading = false, + onGetCardClick = {}, + onTermsClick = {}, + onPrivacyClick = {}, + ), + VirtualAccountOnboardingUM.Content( + onBack = {}, + isLoading = true, + onGetCardClick = {}, + onTermsClick = {}, + onPrivacyClick = {}, + ), + ), + ) \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/ui/VirtualAccountOnboardingUM.kt b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/ui/VirtualAccountOnboardingUM.kt new file mode 100644 index 0000000000..c5ab5b0a33 --- /dev/null +++ b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/ui/VirtualAccountOnboardingUM.kt @@ -0,0 +1,22 @@ +package com.tangem.features.virtualaccount.onboarding.ui + +import androidx.compose.runtime.Immutable + +/** + * UI model for the Virtual Account onboarding screen. + */ +@Immutable +internal sealed class VirtualAccountOnboardingUM { + + abstract val onBack: () -> Unit + + data class Loading(override val onBack: () -> Unit) : VirtualAccountOnboardingUM() + + data class Content( + override val onBack: () -> Unit, + val isLoading: Boolean, + val onGetCardClick: () -> Unit, + val onTermsClick: () -> Unit, + val onPrivacyClick: () -> Unit, + ) : VirtualAccountOnboardingUM() +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/src/main/res/drawable/bg_virtual_account_onboarding.webp b/features/virtual-accounts/onboarding/impl/src/main/res/drawable/bg_virtual_account_onboarding.webp new file mode 100644 index 0000000000000000000000000000000000000000..40030c0fd99abb4a820b1cc12414d0d2a6e0ed8e GIT binary patch literal 53770 zcmbSSRaX?;0v(2ip&N-Ax;uvMlrHHH7#c)6BpiC^?oR0tP(T`_yFpO8Q>5$q8}7q- zI!|Yzj$XZlk{X=hTM~ncvF18|YTCO6+5oX+Yn9~pCXUCipYGa8Fl=n={baaK zz9B9+RgxM1-6$YIn}-Haqx9FCstj%)01%nt*X|VhV*HFUj@>)ww_-2ISkXw}gX6!> zu@8Rltneqk`EIhkkJf#aU7h$^kOc7JFhLZpZZ@Khg%C1Yl9-B4GaUs zSbz4@8;At&g!@BGC?6j`g4~(?gY6utr1&{Y4z(+3H0guctVChkJ>%qMI1i=_S0c7SzR*HxGx{3rNt8~U)4UWu~ozQ=|vum(6??HQB zq+G7Bj~(-+66~7x%8RK5Cqfh2bz}~o&J@1UYREB|T#DEYkg{x$5FYJ0nG;W^kCZ2! zy!`QITEdK$P}&D0QF9`j#l0NER#71JbjL`>dk!YYTB4tH7UAm2QJhon>;_NAGrG>^ zCp4}I%Ts1p&v8Y5|AzyGAokudZk5MYRPkEd2Mp1Z_BKYNQo(Q7k(}KrIfVsjH7cda z7=N$9?3RQNCh;9zg`Sy*qlQkSBPro5Zr$>l{8WKs1(QWYt&^*cg&SAkgRsGG!Q7{K z(nQm05T_LG^#jv3T#>fjeUZc{GSb6e7VP;+s0gBW_?RH_P>6^fmmgtp7Z`shIap2D4u<5+SX@*pxONMu-$k;bhQ_TDED@n zcP)_wAViVg=l8I@FSnWG8efzy%C#$Lhm;E~GPUZ_X96LlQYPfYBGYLr+<-vZIPBF( z1I17S!fQ)GE`+MlCAJ}M`snl0C8M>wEHT=ezPc!ip&EVixBDjVg6*Gmv*Vr_9I%3G z$xzDZ=+KfsWda6au-($gsZ9D!cP5MrziH>)5_=2RJ(L*BcT^Qn^D^X882$=)w z_I(q8EA6gnXeKVEf^aX=lLFtuVkUdv)?Fd$ojNRL>EBoiCGqgiJPr3wzu?ae%?GP` za<2w^lgG{c=3Glmru|s~nT+9xQ~9AnEWUsewWEa+G9d9YCimf&4K+&-LilXPxH-9Z z_cE}N{F_YuzD5QNSF4x^O;iY8X;nu}{*mkoKc9*QsgVKvcl%eJduyRJAD5?}Bwh5B zXo;=mQ{*R~Ylh+BLQp$x(i%kagAB5xiqB--FQ~G!pic4S?K-SMo9ckYa3q8ol{|(% zBAj2MC%>1DcAwELKeeb0%~QA+aw2Wffdd?gRz;9h~B8+sxguMx?xh(otklZ&0n zIIBP;aHTDe@Y`gFz9vZWQc(EzGe|axMlQHJ!H5jk?bFnm8?-_kI6topO23ROdWvin zKc_R3ID^_3kMIzCuzmq5m0g9*C*kH?eNl(QP(O% z7&d3J>7G=+YjS^(Gp!^>DV*c=kpI~|i^Uq=SM6VLA#tg=bn~-wM=md{6bDJXPFLmN zdJKoO5F2kpLmY((ypH|e*mQ1&IBXd+i;LJt4|NRd>o`*g&oR@oTs6W8m(ntJaOsa= zzk2ATh;&HcFAmY*Z;Zh1jj2^?`il2a=zR(Yaiqk#leq*1yrcCb6wS2f?^(qEEaAK? z<0MixuTZV_>Y0=#M+Ny-VD_Me>Sy}{Q0I>@}UZ&A2__xjA2rm{jS8y?~RmXmBN%3w7vyHP$p@czdvqlW-gm! zCn^@$nJatmY}V3k?4t;eRxHhQfVC+H+HgmFMd@+yu>Om*!25NPiYlA@su^v17+0wqvw_Zus8&mo`s^CHFgiLZmC` z0r#0cN$%A$=6|ECHDWeNYv|o-`+)e7hR8gvDTm2%=-@;USfrRQ3yWgD8|sX%!~W4Zd)G(CyK<@4qBuG6{=;t!WnvB%fhUl*0BH1 zmE7;!Q%E^bHl6!0l}ja`YZ4~9ro=LWMBhJL%4{ShL)s(ntY&3r%Vj`T732b1)#Td& z;Mkj0EkhJyTfIL`JzlC8x4i)-5S8D|A{C?L0g@ZyAh*0u!e{O8L~(9(L^PBAG=+Zy zjgPi}t2leYB2xZbnLx;Wic?cs!#3+6PmiyVatJ|{2kwj3HK}(dz~b`K`c0F>?Ug>+ zx02X1_B-E#ko<%oA3nZ$Tr%_{Fj7rPb`hGWh%m}Bzl+yv-WzKmD+n^i(Vq0B;6oKh z*%8)D;raxXrp6&G^|xaK*}8b;I~7=`9tZ9pDApz1;Kp9!gfM4Cjc|qN*+f!kzIuAZ zewRLul7!Tj1jloUT6-vqSO%+Re{tbIhGhz+VkR%T5v6ivpMl>b3>V0Ft1VoV6@x}B zLIxMJ{YpNBvM2y9u88P;%~dnYefHgwRi(LoC$is$w<_@ijG9Y`Vi`k{w(<^xAtq9qNgi5{c=cG=YT8Wj$syc-@HQT zQr6p&YXLddR6kN{9F+v;Oe|z`nrZ!%- zsH6TX!=pmM1+f?WJQk4-@dZQGIFF^FvAzcRLL$DPmV?jBKV;-OLpErnM$Ft}!c}?H3?% zm(A8?ytiqbfE{UY5bd`Q$tZ-cD&MCpbVhwn3f7QNd2u33m<9T9Q$>}CG5eNjg617S zLs4xbI>gAwsRL1iUVhFRM?c)PHkdM96EWIj0&80Ui(UGKa=55feL_#73aSpbJqB5X zAbHEn#-3QZm5S`tF&0#i;1Y>17dJ|ph#drf;qpXyvY`3#7d>kBmU39t4`BSIp=xrt zmobK_I|m9_B;b1=9B3AK83vGkL%f#l>mY$9-t(hlDL>|eJSOi(KG)RxZ?eT(yw|g# zRs}51qv(R>1X7j;KEmc;-akaWMn_~9YSLs*g2gnID1==SmBPAUwJ_~iH2wKyw&xu0 zcj?QJxb6}i=M$v8MQ;=HNeJE?74f4SQ<_-B-;Kn|EqnMtfznPU?s_0~3DtJB(BE?N^ma!@ZMlf4{tHA*(4wMl=iV7_ zon*D1B|RxHZ|xrf+Y0;md#pZGp1H|{A$68+`#ykIK|nZ?=AHkdJEW>Fkq$@eR7z_bVdKhfb~WE}cCF%{)4BK-*reehJF1>sNF{D($3833( z%xprt$#GHdh8d3`>6g1ZqQ@1aRGB|kU)Z7brY^gQWDHmPUbSTD4_r#38s(MNe|G3$;uskBLVkT&EW|IE9pc(lJmK-KNHIz7=U=IJDRoK);))iMah9ys=GQ>* z>JTsEyZDcIkbsJW%M>c+fJT?NyWAf*nk9*X)A-mfP%e^y9Bn+!+q8<(Y- zEp6sgP{&4pP*d48JEB|%1?)tUcXIwTw3~%ui)~-sd-c-;R_yxg`>_iB)sO>?O!t)0 zG%5dag)2T87)rNyNHuKeE{c6Yo0yL;8Lj-kE|d!tV7@AQyHw`DYaEC zXf29~qZTS|()IF86>=5yUVExx`{QBfmzy>1X03x?mbqB}8A6)mFW^+;Bd*H*8);^? zVeV&_*c{ZFU~$XT6U`oS!!fC_(2$^6xRz`!O7z@39FLIXK$p}@5Jn|*vyFDajx@PF zi>ag@tE#v}5OS7J-#|>guihdLSwy@9)PD(tyX?oLG?7Af_=8dY48K<9JHcAUAdZYh zeuA_vk$HZ<9=a;PY0ssP+9KUlk1)0ICk7il-f8z_`uOZ^S02EW!ViCNWd|&%ffU~G z3brm5E)R6euCU@p9k*esSOQ5e`_lAWWF1J-Wxvjsz~D?lNJ6DE^4L~!kA@^Fp`n%$ zJh@Sq^ZnIs>?-!+U76SEjJxQkWW)vUS-?|#RvXbc;yyKOac?z;Ws`mkP{d8YC1Y)< zk7*>hn7ME?V>Kda@ynlb3;3r9XiwvDcf#G|Fd;Zeu3U^ZgBnxRSO&NU$XdJkRNDjy zU;h$I|LqA~!jFbL^Bm$G8Q>%mbRiDdM1D9R(WoIiJw{>VF4}x-OEGL-9{_X<{q|_-Ee-n0Fkvi&@T%d?&v5f{2`8=aJEh`>hp(-KF0#t^zA?$4 zPzbH3Hph37lJdR-_*)ZPLGP|dKGGy@AIGCJQLq)dopi53qn2YLj+3dPrR;qOw5r*| z?7z}}C$&wx;Xxtvb?_c=Lq=)E%@Sgb+Y*}RSbLIOCHFUc3~g=X3ZrCoL|r63iI5;dCF4ZuC=F7hg-i0LzS9uKj%(Nfuqrora!+yd?Cl~sndeHhfDED!T-=)zQ3+oEA{;uH5|iMH z5{AILEiM~ru1Y*R1g8rh%fqf_tOV8hH@`VFXVF2w-cW)>rB_#=wI;*Q&YvLdiDzyhQynQj%e1Y)Fbs6-idSgxSs)%_`XF zv+!vU&wQvaZ&7j#)>-HmpeW6H2o0RTllo{iDE&oOW^7i8Chbe0r-|Eq zB@XHtu+8ipjiu(CRnGsOF?+|xDi(x77xjB(SmM>?MNtm;1{$&?VnTZ*)%e@-d5!|;K+k?%{j07nb z+#LxN47dA&>YOy0b$vF2?&J*HlP=Jp_)ZhZ(jz zAczkRVg<1$yy-moW)lw1st>w3_N~wXcW7JUg0VxF{^)+O(57^P8ZBD>t-g=)hN<== z`%*A$zc5BeeFx@m$hjDl->d-Twhj#ZnUk%t+vC{Oqf&=us zzC7=ac=dlhKqIeszE#3#q$sCx9V;z7wM5}0v`D(1T>~yLVtwu14nDvDrMjez!6id%AsHFbNo8+qoIaw)3uxafxxGHb`E4zh5DI0m zxAMSc)Fc5oPm9+9C;w0}uDbnlEtnbLyft0wmfqB_loYdTA#2e(hO!R!{B@i13Tt^u0kf|!g>uIzKV?QR>8?Ak#%Jn2b|4M@JCvL!{_rOIOw zL?H)L|5YH0-#R{n&FG+Ev#I1Cf+FUSn5+Lb4y>XQkv>BU2}V4aQ^^xCioW)QxTB)w zpiFSKwAx)*l!Od(X35Xk*8mcYZdhdx%Lr?S%4kxr18R8V#N&S&kx_ z#mLmBEuDZ?Owo-#$^(g>QR+)xG3cdv9dglGM5(q@@2OnH-Y&3AIAFsFNnfDf=6&U^ zNPj`I*y4;mSn7U7&8PE=8S18on_6*Lz<7xScYu8B>J6qFA{j~2w^a$D%3J2DyodQ9+jxwTpCicbB;Ebn{ia{3$6Eo+oW zqKcGP{qLdv9&pR7IRjd7Zel>7S?$|kj`xHQBckbYHm^iL4Pq;D%M}`C!r$Na>-{>X z2YrEAC`igD<_M?{%aHJhX8?7j48Y;@E~QPFNxU*9@wBX}6FK=GH_6o}OzjEmS4Qr* z*nMV13X^8P7P`QyS1NsFGTE^Dd!P0!@j{zK}A_Yu%KfZ z`;*~VnfHM%tnP?*&qA!bE);7l7=vN}DHB}A?FAg1 zUhInhG^KoI@*IW-o(#pF;p8*hG{{^>rpq4|T8*$0GcCTV5V_(`2j+ioSO}3(4uhdL zw|HBra8qkDzxsLvSE=3vz!uRiVq*+MsSqV`tUE7dye1YC?#>~hAo8{vNTSxXSuCT1 z&O-}T<1ShPtJBWCz5B*h2fjv5l!r;=#E?TMuw%=9U-43vG;?CR{xR?*%)P997}Xx+ z*?nG(S)d?ryCmUleBnNBxk`gY)R1VF+J)KVHl`d#keZQI*iakn#B9>BE7+S2j70^j zs$cMYG(lcJ+!dGKzAAQfh)5p$+q&}mwZT3gV@Io9uMZ2?QgB%R$7P889^}A0rIyJr z?L-X=V1rBjnT*7&IrxkZx3igA0bfgW-tr3#crRMfaJyqt1ArQq*ru0=n+$}j`4`?u z8#qD&3Z~QxGW>$$?W84*Rg%|qXCN?zGQ%Rd}QM}3p=@wq-n2?0;}k?^o;|494P$V$|*6Iz|RPXE9;P{k07^^%xk z<>GJdLoQ(zq@$pZBhFT`hUe2c9b-@-mEJmdm)wLnze*Rsk7I6nyb(H3oLXj z!->~#Oa8XUi~(d16GYE^&ALS#ca@?u$=^$t9Y9Ba$PY>tDLkt#Ah6t{!q2ZrT-ONJ zCDaE`%@{hAMe^HBy7I~++VlAlglvtLK zEGYrM`Vzzqw~>8rML#|8nzoEMy9zp=;4(K&GHBsAqz0vRXoRu)g(nK5EzG>*PrP%p$AQWTNvZ5W? zEuGnB!+N9o=I4;9QorT{R8Xw8Q3q2*fb#|-nv~e2i$3)YIQ5*UqxRP{ZwKy2vGCu zf162MBjRH}Ce?{D&_c=l{kWCv{ct}pG+*42lhnw$SP`6#VyDz@C=S2>oIF4N&x-v0 zZW|duped_74Dl%Bl|!^Bjp6+GZpxbE8?8-EIAFcZEu*pAV|t=RO=X3~J8&CZFJnt( zLgW2xVEyBtgNl%n35N-+h-^Rpp`c)T(n%do;>Y7Bs>Ab}O8tn&uK3T9hY-;M;z16b z8GQr~fpml0mf!`dLb0FplhS;o0bKflcoP^L@>CdAJ7ORIQuD0wu0Y{d-XS~v@GQmi zXf^bYpNf|$+k-g=_xU~B#@+EMAp?b74=@$t>RHK&p^R1rwVxT1ak|c}At>KS)*>b0 zH|WI5^gRR8JVE6UklrjV2RD^|oguIIZSVg3*1JfvmLo%eR_B}0*6gT{6F%fA+qs2d z)PxnWUXYM=_BIe7GUi#sWxFALWwnDY(1c5vVab$zB4q8Pq?(gfbqRss?Z|2zWtofuxChlMgHY~`3 z3yeq1hk!EKbrmsswqSj6|DB?k%IYo_2=_@d51QTI{=wXM%-l>Q1LGH29xr^`*$wPC zciuMEfH0~fo{Nu{DamKXxzO_amn65zKfvm^CLvVnjkL4`le|b6I1&Jb}j% z61wgbX7z#egJ|129vPPV+b(yEd0+0KHwBV1Z!z>%BPKW3+T>>nKF90A=S8pGTeQW( zp%epfZuTTv{yfI~JbXai+-WLy3n5J3wmarHrA8?Gh91REtDlRVE6gJy2{ZHa8NEz9 z@x?LC_EADwZk#^~l8H0mo8&9GtNa(31`Da(*ZRdB*nI`sW~4O(zaJq2hCW40C|i+| zpOE`q4lgKVT7hG&F2(95SISZC6Xpy|iada+6nPl`n6NIK+RQfK||s7 zw!}D+aBu|hP;)g9#uK!*HY`^c;=&?PC4F<%7>l1zBEJTct+9c$&$2@ZswAghwqF-Q!8Nb{*3$o-%5W^SQK_t@rVYvjPKC$CY0!g1{&rXP#3{Zz2 zhp(BaXkLWb+0EQ=cQB(YT6NynbR30e7=&Q#!)jzHDaJS^*w}vVB<+au1@e$f;uRje zJUuKZnr0nWZBtPqhZ%jVT^$aMCtMQO!h6GP;6Zffb|JN?PKOrMd)Xdmh+jd@QL5~6 zpz&NP4*qgw<#*P&W`h}y6NXKyPGrBRvtxgtnYmeT8%R$czBLiC^gyp7v6D%9E?p5# z*S&l+JLEU5mZ#CK(eEKObl%w6*-OYiXO4`0ApG~(T428taGQqCi~Na1vf?Er8@oU$ z4lOy<+Wv4$w8p}Tx=)n8ewKS5CN~{eRxk`(>c{PIL}|vw`7GK$J01ugs51=yjZS;o zweLocR~I!@6OdCBkU1dlSm@q(0C0pHJM?E(F;3!~e`Q)PKs6ATV2fXqPEs;K4DPlrqhHTa52D>xLR3X?Y6U+l>>1W*eXnchi;gnm%*mS+Ry`o z!nGNCH%SM;tYN~h$`$549g~hUZ+QI#9lX!KQjPxiZrl06HwXc7`pQI&8u*+V9xWO{ z^cbOBqHv3u8*VR`XZLMIhLXEyFShdfsMrO*&`?rOG>6dtDnTSF`}aPsDpO!uxxO(X zaw`!Y`*47?t`fpV`d7l=)NsF8;3 zWot$tTv~p~Qv+J`4qogev)_7YN1NojU+Y+n315P4GwuleBr{7mxlUv$~!Db zm_#(i(Jxenq>fyB#RxtqDBE?TP~A3@jhMz+Dc3Hb=miA)9(k=>ml48Q35I4<`mn|7|!&XE#d}RABqg_V!X-Xaq|Hj z_=zj;cyS{pz6=xeDCoQNSga~Xe=Fp7)6*$590_9DfPMw>6(fkWB?+*G-J~Yd9%QUb zpA^2^sh+2nn4qyeiKvG#9Ck+qla-NdbFzp?X|;N6hWey;Z$!>1YBX-x*2P_8Z;W@hU>(KC z-M6nxm{9*Z*A9-p3d;09DPwCCz6nn4{P>hf2ok4rDjZoqSWV2AA;r>^U3vJfr(Tiz zWrrg7Y9t&eMSuQz>1zu8@Y7Fr%9qmh=eso55{%#pdvY_~SovWdZ6K1RXuP|ON&CPw z_uZ5DtUR=G;lr>6&=w|@x;L#vcrP1;lLm-FUT@;U;tb@7EjpS&kh?B1`kV_1GtnhD zFeh%Q*AsQ@e_iN|7z!D3&Le3p#wdO+{%sI3&ysubkCKZ^nvL5Gg(_HD#SyV?8}dN$F9n{hqx+W7GNzV3Ab0*7j3DV5a zxsN%|+K7NW-I{zqniJhF(HXx80`Y0dJW21=CNSZp3oPr)!s}ZtmSq89)5ASH18JTi zfw-#|7{OO0cg&}fJg>yr87BGP2UlWCj}3Qp(4#>|`j1lpF6`DTfaQ_xO{J)K6+WO);-7ENAwa9+~=5HLhN4eON zCjm^N0POASmGX~W_(h~aj--^z&wv6Iv6^z@G`Hd4&@3ehN}kjL%PF&CS*c_sRoIEV zRc9Q!-$U7lHN7V-q#f=h33-yt`CUz&=@f&eOg6FLUl4&v0vfq_$H#D{d(BmeMe0v4 zwO70&2u7UxH# zwB%#iUCd~v12r~6RxNv12o$uItta}ZhY%Rz|J;U2bkewgnKI^*lcl)3mAwMhkT6(C zzryFU?2UocRNR^#C54slNR3QoP8kJ+xzn^e~@9tgBh~WOZ4Y_Q( zHVfKP!4`jakp&mv<2~hJ%roQ}ukj-$SW8ND`aA>3KxuzGR`jKuZ@a)MSBPiC#asc4 zY0M-$ZnrH!PA&zkyRq1~E-r7WwN6QbFNIGf_b=Iq;P**5 zV1pQYob*FAcUxD5AqN6iaXig@F=bOG`-)vhlDCII4aCw9jN?nkH(x3HgZL0Jj4HS7 ze3Yt?|=BGV(c=bldbfI=j$dKz8-d|M>&9X+M@E&BJ zXej@eTg}z=a4do;`hgcDX|q-9i^?_VbdvRPBn0bbFcyKo&eJkb(r!tany!w$A{r@( z{GQ0}NpEm*u`nBD`$HWUhp5q$wg<(ndU3{9ncpN8XZZV)TOm!q zGH4Df;J1+M{VQcTfFvC^ZHzrx(^M3?~9X672f#=@fGjGj9*!XJ={5ianB^ zv5_XT(u=#%lpT77!2l&fW{gdvKEDG6Pyl6W-;hM>g{ksJJx*{7+cNhpYQi4n(RKmh z?Rb8_$1UmV$nfQ=YfLpmV4fqHtF*0Au^I9-XePsK9ahh3-TAy{hf zEU0=3Pb@Xu@u${TVy)mI()1Ze6BO@1ZVc;2U$2|3i*ep|VEOhBk|(A^C?E@0g2yld zk|J7d^c6=&m6pPvpuWOKK0t!ElEO=zsOn*(DfEuaC5{B$owN>(s*(}$(zXlt%Z2R7 zgt=%WCj#(&Ibw5_j-ptsy|HK4uIJE(v_8uxkY75D4<1yZ>m&U=#s>jCTWbFk`9cH& z37WgA6q{2`*iX@nT=xwHJ09$dUXmgH)pWGeF=j1O)jn!TTZq>uYr;$gq~EvnHcRV5 zfqj(v*+Z1j#zCq@C#{!OEF#Hx8SQTgxsH7Ajbs>&{y~xE8o$Rj5~Fv~ly+Uyu7s+Q z`_sn1=JT%1*7P~=Eg<`3BqMv5#az}6Iv-pPE-FrBCl8_)W3WF9B7~}me+dGAO~$cS z@0?72#$350J0AF55E5A~&O%Wsbb5i$dJXp7S*!B1<(BISbF;UmKCBB!x)44+%fKPy ztW#|o+#s+uqIcThA-Si)Tm6K{$LLg0-h%Qhjq#R}YdFoQjm|ACcv{~N@AT=Et`S2C z1^)?{P||5NF}ddCJYFFyQ$kH5aGExUc6;O`Bfmtd)K_9J=L;+vbyc~Q2Sh@*ObWm4 z*@xd!c0SsuhDykIhqG-;7umVr#=|MzO%J;v)pfFi_J+Uj9t6zCo3nc2x_xdC8yyBX zGz5e7zQ+%sIvZk6R*7K>)RKIKW`=7~V?=xg;}~p~BeC+hnIaOF?jORfA{LVa^JMt( z)RAJdgKoYayod&FWOZr$n=Qapsh*Bw?IPxvb}b&eUfJD?=Z`Doc@cejmEYKGG)}yx zzkHxRVvHqden(p@(5k`#iH+jfbd|ZqA>8S(SU%-&x=L;hAs7<3Z7mSgn;LPL4-Xr9 z@fXL0dAj!S>rJ3h;X}}o_b)6k?mTHME_o!me+1-BIEeeGduQ)=6Z?vxs9#C)0edUy zT!DqSgK>LX0XIA$Jcs+!Vw--sDE5qh`v=^PcBK!H`Mryg(WpZr+InBh*9+n(?LVEt z$g54uBNQKl8{7ma?pv?<_?piZZYx{H+d3UE`4OFsn(Bv>xiw(~;BKB{T zPFfS+ZdTaC?!BwXn@HG$jo zxPMgN&JEtLDBAz{L^XgJ+N)1u=rmr?XdChwMRA{?oHEe}3%pdw#!;u9YJB=j4D=|YCy z;IDl|XxN_ARnSPH#UFMS|IRc z*YDa2s}Lcj0oR$}QYpT>xV?f3xp|xsx;lj%B8cM#@J?eMyiojEk}}`Cfq0o4wR8BA z@7g~CF0k0kajg%QzD_5$$tws=iV-5EY@vhA2mghpubhcz%<177`wAQ}4j8z>7E;Z( zqMAIvsT8I<49+>$H%A!WDPYm*T2KQjrT1^{x;USUiU|%RLJlM|-FI&r>a*~byV=}t z;ee}P3p8TNkBRir##Z>XR?9rO;r^1W_T_G-4{`@YU>K3{Jm+z=bn|jV)S2SGfc>SD zxpa*nss@=tza7fx%f@W*)}>m%o=VWmNVAF_=wEAt?;ihAUc(q!^E z4vgaOAD9R7;SdWV?YbBGH-X~~LdiUrQ~@?oVJWk-%r!*Y_9)?#MU3u47)QH`6T~WX zV-|1U(opAr|JqBg#Ne4=7wH{0Zw&aef*`IvYpzg%lZ;gBWK$6EZj6qoWF4RSnk@f; zUAtL5%}u)#(x9QHHU(RZtnW-=E6fn^pjp1fb}4o9$5d2QSU1X3P!{DM%u&EkKt6_F z2bU5x(!Hm;69e*IW3I54?Ihn}cZ7F*nmpU04iDakS?H8?SBFjUPx*Y(LleG+u~C`# zq&b~mzHD||$>0ouiF4CbV?XplJ`hu2P|iP#BSE|c7SFfqSD(?Om;yl36yV~hV!N^b zTy3N|L9$`yV)zG}&Gg?{#~b@V2Lau`Psp{zV(BmJMRjs7x(3BgC)8G5&vM}e2>_R{ zWde+mE%q>us4J_PemCOwRCo1aN&NXCQry`2F~uR7q!NvvXIWt4)ZF~=&;LbFHzNP* zD{~m?;}rEh{=f-d&aYUuU~uSVV@0p|z5*)Yu0y{An@GoG4>5zlJIlsNCi?-IYchfmk@}M;1Us> zcW6GWVg^aPZqEaLuguj^eu-QZzmx7AM>DJhuB(i@0G1@DDn}C&+lw9KlQ#T=Y!goe zUy<&x(@jC#9J-a*VTB}I+)|3Lh>At1b~pLK=3u!P@`6T3stHiF(vTIkzy3PYJ zG<7Lst^MiUJ_Hwe4a6Orf#&)Nt62UKy2xed+VF^nv`*XdIItf3OZlT`lGWft6)Wf` zO=KjWyTf@AZ$^j~t=k7p9L_&I#I(8n=4rJIU${OY#f0wW34QHr!lt$0CETARUfp)w z$A;(RMObcC>h_&?linN@eS#nz2Ab*(y00t$sA!}T)(NY*Xf(6CKq-si_u3kv=Zhcn zusdA}L61=5wFo)Z`mfCHWELxAUwf_=UY)7+Q;7YqOdmK;(+ASFWw%c|NVH<7_BZhV~_;yF`QZ(m|O4@Kt8FlBdd0n1-X zX-7}@MX_GAPE(J=n>kuN=ffvPXFXldq28aj#YOtoEUo#)NuTkRe=7A4E%!Y<8Uh{u zI~oOs92LZ=eLy_NUimd#?YN0wdc|tv-$pUiI?5L1?`zP3-X;o{&wb-jjB0&X5E&=` zXMtQ2X^O_ETNYHv(iiH9F@e9=$wp`DCdukau9~>BrJNHzD%WW>9XKK@xR$?-G2ZHn%E`SVwN*8(}_@AV}e?1PGL z@%`bWW44(4RA=$!W!KrvXj?GdgW>cqEIy7tXNHgpVZ=g}L`@zoHzTJg2eFKD!Ed>J z-Aw1$ITjxIb;Tc6N-}`CHIm@^ZIZ3NSo!e};!u}Qj*;d=7xIRd$|yM$wnB$qu80KB zI&HfKL2y`V$iLMKfKG>l@{Hso2t+QE?;o%(G24z-P*)Ab&eU5smJ<=S2I}u8OOIY7 z5o2igHv){>qmcciNS}@Rd(HWep}w|@-{;v0n|e{#y_n}e8nUJ}IEv9j1o9EUDPCT# z;wYTG2Zoup=sC3NaFT>DexMHz-xO+!9pg@gab`2Z-*e96V;)VcsMa*KZRG+4@t$Y~ zmPNhIyFGc@V52PTU*xJvoWw|73PAb()1lz=kH9c79S)_7k9skGI^GVeKl*#)#h^cA zu^)j$-VThW|Es*HGXFO7QH{6DfPW)lulUAw6Vr~(d&r65YK~;IhqVzUz$9`I>S7LZ zzy4>MOBs1ewd|2Zq=MAnos0ZSShuzZ9#Gi7itTa{Th(Ru87nO zCQLnoIi#E!r!#kf5oI57mmh2%X(qX8j{dl0B0D5j=VWlqSn@^?0RiEA?!U;P+V#f4 zi7J$wLnzR~;(I8Srn$=xb&1={7h;=_?OhElIat7V_p~VhRH&teZk@LdMw}lE5B0ml zZoqrP$y*|{pB^Qkamfjoh`X}&Ce?o(NbB1sw;%Z<0r&GYQFM0E7Pj(oD;oJE*1{4( zseHyHhb|aOLv-!;qonBe_lf%TBJ@S!s>@OGvw`7a2cuUQbmUhHlr5Iyp0P(IXVS72 z@RMc`G#P9$5?DH-^mFK`&?0;o&#hu5fgd6szWitg;B7wk(jnH!cq_6hwz<5?$8 z=tC!!kIhr^pyujTUc*}B9(<&LL0e;KyXv1nN8(LAYJOj%dqYFiuX2O&OMvZqn;erE zM09iwZkMvUY~^KfFJy1}5AYyKN!E;48;e%UaMbT;il6pni>!f)Bki$%`|a>f7z?Pi zI@=SYAybjz!P{z4On&6z-m)O9?N01{68*cn)#;hShd|C>PBrHB`EDPMTFVmEk z5MOxBiLs~5{#mmlH33rmNbey20wmQ~TXzMZgWH&ExbpKD14mp@1-8kriE-RPne0CO8?a4okJkPyJJN8yxx3ZWUs1n~D497vM*JBkc9 zdGR^r#kcX^ix_>Go)$@v1vdAuR8+)J=z2_OMTGKPMh-6An+Y57{x_%f=qLLDqlf9= z1XkWOMJ{}U${O9>QD}hdS9Gh9YCIL~y&epzuLlpg&9P24? zyxkS?j~NpBWp)V)%N4B}Vx>U4QUkkwWQRv?Lo!6_5k;9lYXo2vx|waG%;CljD%9k1 zB<%aul*tA8z-Uv2c7F2oXWvj;bcRO_zt)lkzg`6*@-g5g=EoK`rth0=K>?Cq4gJ$b zXbMTLOq~XamA;~)o#mw*XWGXH$-iiSFF57UB!xD_@EF9AG!oMeC3#9SAuC)ky-);B z!9nwyQK2zPt_{*1ijw%rLkLsPX1ehIv3J&OQN3@pA7JPjdg!6Mk&dA|l}1`xy1Tm@ zq`Mml0qGWoMg$}ULFq2f{NBPjf1dA4*n8jCb3OZB>$6r$gn~odIn&>uzFzWOU^>9OW{=3&)g=Mg*j7;6YAzwwIHM&N#Z^ZZ{qq(8wIU zwkKla(vX}oURHj{wZ7Sfea)@57z#YwG&*;}o7rxP2T|iA_Q_D|BkX&=>0IJxUs)Mg zPyz=G)R!BP<|smA-GWg3$D%v4sPr8?{h_KbREtf=#t1D zkBg}R{Mnz})95cDX>+<%%(Kb^E&~^rcZzle=nT=zuON>2YT&HeYru?=ikR~o!#4HL ztmfgP?mt1}nK51e5Adu8zz-kw7RmM+%>-=A$j!O4`jl$ie_D==DJAsD1rfhSL-NG3 z?d)o{qDcKyPyooM+$!c2Pm)nQO@eF$5)~(f)Dg!si#lZ6gdHgdHJ4|Q#5Aq~22;;; z5~W=SriIrg3?3rQ4ue*|v&~a?h|YtVcz1Th{vas-tg4WBEf|p(v=(BK{7Mw&%A_nD zs{%eH8~XZ>+JX$fMC5QASI5~r8XZ7Pt?`-Xhr6Vyr>p4HQGZzS9m~3*5~D=MSAkmNEDuI-29m0`}7+;8-$;`3h*EM@vI3KDcB7OJ((&2{IY-kW%y`AT@@ z1Ab2LU*y6HTBv<`NI^V4{%)Z0}lB`@oen zBCme!pn6+IVeLs2;-?a5I%J|dgmr2=@>n2CPlnU{L=0dR-9LpCk6;@XzNO8JS04!C zPA!EGB<$>J{JXu8y)3FXcNoXf4;3sXsie&BG^+3UeG0ny4K?^Oq;qYgR#L7fqBcd5 z&_35wu_oD~aec>Jg*$$d@xyhQ%=W5j>^@9sXEfJV0W9ROz8Z62<@#Ct`5r5+zKw&z&h4-}3z6OphPb5@Ku};)LF~j zxL+pU?Ej1YRt5nzqS$UhB2rk1e_EXUu$7(h%_$dj$B2>Y%yxWP(cK#F=+5zK(h2qh$@#++eJORQDuPS76EbblXu zcqy%!>rRZQML8F=2j?Eo#I=iAl4Pq5*|)*T74!!BRguN?j1&`Wl$|W`|z@6_OVksByMOjD3sLY%`b{-s1TylSt zYLT?k?zU9K#mQw&LQ#D7l}-8$+gx>>V5fQ(N)m0UCQeKCz(ue@*qJbvoz4}IIoYAi znBL->;_0`SaX$V~Q)f;1#_C=SE4k~)<|F^I!ztW3)TT@%U_k?$gil~LCaV|{`)$@S z6^SeurXrG5g>Q)Ri|`{%0}^({Eis;3*(38KOQ9UapP6#M+Zt~Jv`X7Txuv;QjWj;nr)yw z=%OX)A1Omy3H-*R@auyr^LxYjp8N;A*issrHBlX36SI=jg7XKt>Z&#hZPXqyG_}sR zh5^lYmX|(vak|Bh% zlcoyZN}Vv$bu^bm8}eaPal!w&^0O=6bWt}UoDSelRV2Jd%3g2dkkh%-`QiApZ+jkTYgEZI^-2_>cH zV*C*O`h#ub8M=6|#% zdNL-qHS@v!4T55bDS>b*z8=ss&JW8T{g7mJUOxCbiH+)t5JqQFI2)S4P}K(bhwvMH zrwlCj6)6#{*adEQ6w&05V1L8wpG|fodq>xAC{vjZ8{nU4J2?3{MMQ+L*n? z<}gPohZ%WFIaYHXNyMuH#GM$-$BO31GWq^Np81gK>%hVVaEL&D@)ej6+VLzS#C*hr zjmEH~Tr8x9eGkzJmH42g$2VqfDd`0GioY$!S>UoXQf=iyNpo~6jHoj2y7f+l&?50@ z?vC{b>XBE!kgx-uv;=`7B>B1MxLZK?3q`3eexBadj@Mf5VZ&KGhW2-j9_q}i=@!Yc zkr@r`TM!Zllkje}Fb*oxx$(PR)U+wlU#JcUXivD(jDD8uBvrAjx7q{7)?t3%01=6Y z2v|k2r*x$c8sZ`-w!0)|GGuD)kd>}vom74yur=CnE?7gnm#i}_Vr(zoz}Y}M2o@xD z@jVLhlAR8vac#hTymq1MI)a_hKsA3Jtq(5hKJ|df5vZ}7Rx;Ccv_2g^P>kCouUyTa z9zjjOf*GI%5c;9Ew)LOujS{ni5D9?J=_C(TXiTRd*=NBmF@}<8WSMV6!~W>_2Cinq zyeoeU&;TsIo_q-_ZJ-9l3|x(`Z+p8^$|19qD9SXtVP=^_V|xHWZO+>+-dz^{U#|Z? zJPwi5<#rK^jToWf9LBB*PmYzQat$X_mbDIcT1)XMI$$rs*AcR{ueo%?DU$5kgW+!@ zn$ZN~O70TT}^5L!R=q|?l3j}&YJbV zt(WTA2wmkmhv*HFRiheHA+E8CatGX=mg!XLkSuq|Ga`!CUq0izsjBO^@ohitbb~&( zf!t9;=z?6DIQx@%qNn{MC{Ia@&*GG{5_Th~9qC1kcGgx=4fO=+lf(VK(>(6DrX;ct zf@yhh)HsGLk!)9~2-`w-mF+RG{zOnB3t{XyYzAMfHbx z!u_W{W@#meWaF>XiRm$Ywq2Z)CHLW8BnTZn(lORgEe__ocDOQ-&}?f33PEVdNs3+i z0<#nR&3`*9OKgemkc=Tek*-cHCFY}6mrH@x)JxiV_n5+>)6KQgtJCGgM-K;H$qkwE zTA1rc$Jk?ZQ8KL>k6O+bRQl&E6hr)?9Nb;FyKwwUjTl0MN}n`Y(RUE1ABM*`R~Fns z%cLUo|H4DPh<NIQr z1My>FN}=SLhr|a`pr4ppjmGR~FRaNtxBjuLa&KH6Ri$RoY}7fE%bbn08Kf)WXS@T| z&JqNaK8+F4;U~KQ?k;dZj?C3WHg7NuXSYcQJt#dS#lKO=2w42GiI=sK16$0!@e2g{a=9P2Y>*hnL~hEV*muiS zB}3*YV!cN#e>$oQMf?ie6(bbbVo!Cfh6f&Shq)u`et0GOcK(hr%Ly%SQqec#SrPK; z1Mi-PdGey%GnwbZO5n?tiocaZ0@YJhN)bFemp(ZWniD*F z@V>1EVgWIR_ksqp7=MSrh%ppMhoXrQW9~(wDn3Oih9=GGx4|5lq?zbmVov+xUZ>qS zy#OK3klDAxJ``WQQ^sTX97qQ-Zg}W8>lGs8VzUBX`97y8=$Gnddb!1ya8B^MQD+Fp zyK~LH1*3F+1Zn?7gehKHF<_naK6OEmO(sn$%U7|%9ZmZIp00$~B2D1Bu+*J-J%mVA!V z2XTNAM~%!zSTumJTOWrJ81+Tfg=z>Ep==>EzISOW6oZK)A0tJ5CO<237)d|Lx_vNU zmNqJp{P_W{8IZeFPQw6}q@XpSAZyUJz}! zxHz1ed$+=h^yKZ5j#PHD?5qG;&(BPpqtTy=kvQu^w~*>L@Tl9u z3C<2gK*vssWF5pC5)t+$oSJ^asD(tDa(u=sS%!uGi@33*OhoygkGj3D6$$=Z%m|^- z&oUb^V6+lPP}1blvnGCfPEezUDdNSuZHFzFkOKS-%s@P{Fbyg=3W_<R~=rZb8WtVDr@F|J5A3>vwBIo$bj9{V>>(u-Y+36Ggul_$DqU(&FR z>56$^4uB+bg^hiBsjGr8K&8ac611wavScPma!D&BQ&f(5gSD$Qyzz)+XT;Ecm^D@& zF5J!iVW4=8Pj*5AAbw{R>8AYn2+^l|q=;6(n?(PSQ`(vyMA3Z;^uv#%Jz@KCw0bJVh}9LK>(0`%;?0G+MnZ6ldAyNQ9!aJKx63uwn1N`R z4Anl6I}M=ith}?M0yfYu+m6#{{MnB~Zx7X=IFhv|B*B>iB9Z88F4m zuu)6C>mVirioj9c9u-PMeOUIb2(;XLZhE;e;S&@lL~2$ZP{R1+Auau7;SweGG|bJITBamQn7(c=kVd_CI*`KX~>(c=kVd_Ww_K7VA^LDe7Xa|7R>4 za)sKr$Smn6C&Uid@hP)G=t8G5neU(x4`(MVjA*^_uF2D6`7p^R zbeN8b93;P`(e#l?A?2Y{k01UY{&c~%BaespX`Z{M5*(WX-~J#Dc~BgLefEegF+G3q zEsu54uKSrX2%H%UV*XZ=On(`PPtJ;fkJ@ANp3v}7A1t%{3GOy;R5{)H=%gx3wWtmm z{ba)AGxxF1A3GU2`MU_4iyWTplib$^tQsw$9k{gI%=Zyf+fitElPizB=#!#+4N}w< z%R#**LTsSKSF5}%bO_mpZ$gCZ->i$1wNYgnBg_D0^q`RUt-yl~q6?Lg9B#R)5#oDN z>0BSp=#@8Z-U-_=knk@#(@CiDLnCBN+G8zd(ovZ0hvC08S$^GZecPsU&4_-g&lep@ zg-kGvsXtY7ux5^sffS3mGNy(z9;vXi4dZPmj1m@eSj__qsFztczF`U!s0h=se!($W zG1_ov0-6OCS;g7W?Cs(zTR8wB5}Zfu=ecXOLe!Sdy_-uq{=U1r%<`63q&Y!Vc(*% zOodkkcAg4McV(6Cc^d+Aw~=&}Y(lX>Kll}^$`unGrr3oRkeF1{1 z2Y`6&q)qK6fJU0*d5N7rrS0zR)1J6&L zkW+R*DdWn%c~_bQX~GSWh%NzpGMc04@T_Fl9=EwJ+cMIH-F@KUOV=+~rAsbz!r~aP z-bRmD@l*Z)a>sC38(my+^JHG6Xc8Wt&j|NWJ}x_%3^@XR=(auQ zKM11D9SHG-+hxnf6tanF(D567$a4rXGipR7hd&sMuuae=(-(a2f&C?+A4)$Tm>jmw z_vjE%sM zp!G9=Ki2EkS2oQ?TzZ9W7$D~ok=Wsw%M9D^%n+AcbpRrN9iPIQmE)x>lDYWE!?)p!aO3j9_nul!Pm zgi&h5Q3(c*PB#65UY4=AF`|3e%ikX3bha&IdKc$Y~(w#Kgz4j9{4d(Dvcm0YGoiw?wMU9yxg@i~1-tzfjo{2_qEsl+e}M z>a3{!3nme5p+d|HQb&bdbri~lJV zrX-Tt(*$ynl)414<4+KD8Y$vOSq5MSd)8%qwfFuR|87`q3ie{QB3E^I^PmqoxhRs) zh&%yQ4?}8ZsNFmc2b+=2t`#8+K?pGXcczs ze{YZ#FR2wJ?IK}Vh3Y{?or{Vc&}I%P)A$q`WU!xZ?YeA&5MEjK0>b7dn?^!N;1L+N zvplqZn&^#B4HO9>iC!Hv)E03CiwEEHGe;)-yXSMzrR;d+!_2wY#UTSeCJaSmUA7_X_ThG+l`g=D zH?{o9Z(J-Ah3i*~pIwO<11Q2vov4MEt=3z^lw09Ck@zu9$Tu9ku!e4lo}yWKVGGmO zfft&}>F|+~pO&Wx{NK~L-b`!tM(ny&BdJMA%hBj@KZ|%N!Zu#$U2=S^UZ9Q)T@6c!;OPx=2>s-y4M1`R5!6rl&-apXpIInMVk#iav zXIdu?(#^EWuMC*8UW!w-{{xBR`NgM>`X8e2|Z+u%rHb+-(%n}h0mWC8r{g4`xw%KkMh`QQz;w1v60&wK>H5-O0`sAruv2pS3E#oy9|kMG7+jI*l6+@eBps>?(-*5T(UV{9 zeDKGPwnJ>H^RR^=(l{uyJA+whtSWK+K2OYs-bthwa^qeaJRGXE~VVXme+I!|N=_5sUm-H6n%U3Sau%+0PZa1wL^xoAK`EU`ZQ;G0T zFHBLk^OV=&`VYF6uZ{8?2_#d1I!4;xp@zIkW+FaNMg@hlktpq*C%bYHoALXeVxw1A zM5aDJORVKgVfcZiGjZyI@S}%TdEz9M0`DG{IobZkK-U(9xLa4PoKpTd_T#B;hs)O= z&10dWSN$y%z5k5jHLlEsW3;L}yy&sVc2quK*YESjb|j*GXZy z=5Gdf4fk0vEZXa5ud z;TO^%+$nMmQ6TIXff$Z9n+svy1!%)m_qwj5VNB35#`bEX6{1kv7Hope#92Go?rLv{ zz5_-+XI#`ATE@#@1=8qVZd6U$bN-G=^IhTlDSULMiV6*Vrm=Z6i~<>z{Up4l>BKyiG})}d ztSm7RG4GGzJV}a2RC73g$!aCCW(vWPbn_iphM65@K=MB-CCtP=#|AylX<2q#EN zOcrwMLg(Hq2#^@gym zZtcAc2akr4B}p+0v1NjPhRJL(^Md%RpL`Le|Jc_595?NF!VXfs2Z!)NWeMcw;K52$ zA%@@xv_3BgY}ii0>uru@Bq{76=<9c$VlS}5_Pj^A&`w~Y_xh*Y_MHu31h}hW1FeM) zls2k|W`KVrSYnBKlvHET zTuUY$Jy{w;-RYP@kV%65P?wq`M_Q4Iy8}}mjiH_~Url~*wsJL(5EwsU;3PD0 zGWv8Rf(<6;<2sSLqfV%MXjS0vjq2xqk0A{}eb`eZB+jDU46u+N(Zr7mRTe7TXvjvN zZVG?jAkHcvM{9;BaKZXZYaRbc9e`S%!dUdL+?`rE4Bzp=t?@I4O=%f)WiM(#<^*#tBO7QW4-q4AVkjG-O8d!B)OcX&lQ zCt>dfo9I_+lb}hl8Jh$pjA3atxRwU-z=jjmyisZkd~i$fUU&SaDw=l!e7{s&v$!xf zadmq>G|kFTpmdR%fVr@@UkPPnZ(Zx(H*cHWQ6u)i-IuXnNc&Q+k7!2|Zgk#)Ka1nw ze%N2wlCk7OE|J*XDkL-94`q=Mitb}IQnGpv$E;ANML#-5+K@;LSkTWFu(Xda&yoNH zMX`rR-d08Y4CeSVFY`|tap9?}I^m!vHBQS?*r^=NV8`;y>^8bGiOumyx`Gu&_TlV$ z6EZGVuEhXwwWMEpZmog{7~4d!W}JsH>1p^zGVu7*RT?=xf1}(7%+4 z3|y8Z&)ot&kse-|9Zv4~?4E$jAg2UMhRYGs;&TPE;4FxZTWROASBrPc z{NDS8{&IVJftzKS?nrXiEyaCAR{9a&6@gmLpgJ9YeNjmhWkU~r=CBlruhFwr+~2`f zT4pgliE*PzVSvhRHbm2LwvkBE*qom}E*q~)IDJVwvCTs%XaamQjteK-AnM=7WwT}u zP&5|gOrCpyY`H%F2HQ0l%pE3nci-b*?ujk&UeA@7yQZfj%FSs4SUbxQnsI`}3@VMX zL}>LOK)noNe{72i@3UTUJ)f!fuF<2n4#(^{QLnyf)@C(d_L7&j6~jX=w6rjmB_N-< zQ7UyOs>0UAWTQg20iK#!6=E{ZDNks1aNFVC9aSD)i-?0gi;G2PwCiL*KNw5Lc}2%8 z#|nO!Pp3HDO7CPa)2fIlBA|J7E=p*`KcRV->nCmBR1+WgW~Y3*z57E&jC;+ew+WsBI}Z&-v8w^gJO;o!I=%7i}(Hyi6@E0f)QxD7qNAQXn?>@)eVNa*$du3{#U*k z+*xnT3Y&blOt4=GLsHOnsk1{ zBy<<3+?A_PVedxVC%@CZ7nZ4Ok3e%R0U}`Ir6eN)hRe`ErSac#5ZToj6^7U1)Wdpn z{uvY2=Rn=>q@MB4(;I6Xgv%NO0=Zej!I_w-)&eoM2`4{C<`3WE~!+S-#D)G*Bpps!MtquL%-^eIAc`$AG}_>cgN#A_{?C&r4uQS z;E`QdOU-?#p7mG2bs#^dEwv#jwxQBe^l}%3^}{HanXo?Vo#}XX$|SlL*hTNnZ;%;J zqT<%Cx%C&$pNFgtm0zq#pX%>Omo(o|P-Jpdw9rzpF^LC^QJ`n@NrlXXTeDG9N}U*i zW>R(4i+ot9Wn}LZ7JQzO+4H;pZ<=SR|3XC*#*UtdofIdNKQ4g|<+8@gcj++-?Zaf< zm=UFR3;)Wg2cEU!5SopU-`R2)PHtb`_vD9(tfM6716gmE+)`{# ztX4!Q-7PQOTdhh;+^Hggw$W``S|ogxzw%13P|bI76YH+NIJH{zSC-sfMQnjW)LsiW zW4jomF$RSvaQ2Tp6fHI(w6jy)AqKSX`K9=%s za|d(>KN+IKr|6(+i6dsD!QiRjnCUCdh2_1ZaK=0=8JoB!r~dZvygWQuI+VHy@uUi?gJcKZdudC zp=`gpR}xr~95JyrM4483<|$rcp3A%>e;5?9yO@!f*OReXut+4){SHw@iQ86t=6fte zAgwOKMd|%3;u;C$A9hz{An~d<91`m8Ae8lza;9FKi7d+RVK@ zcj!L}oIO5%-BgiSQc_$84eW_zMwoovFhl4$2b_+`m?16%+Ai+qB~^kfn5=p^G4=w~#~L*tQ9`hu3^oWFUAs*CYtRMycb!17O&CBG9B6 z%zx$g$J&4RL(#7*?Exu=CzZ=@jq|V(sK#|4E+i2=7;E9QjC;;*8&cCGD=5vGwtsOY zKQhm&L_**C(z(4!n=@!iLL)&B^oy2gMw~dYcdD_|uWtygNawx}F;ME{nWJ)*KSQLLpFRb(A!ZaKWsdA}Ng^8As5DT~U}< z*tc_G#K3n$f)&-6_Ln!fw{WM8zc&r6<&y| zZxP^{9_J4#>VVzJk)eY*B!8_gh?FcBCrFb*DeZL8Mpp8Lgm9+OR~-Z(GCh?1MaD3xZ!7WZ@h^J{wF!YPcQ8` zJ4ju-HH1R|V3#O#wETP+)PKC|l`$WPOnvqib~S&dE+4hNvkRr95Al4*w*+N6i~{(N zHJpBR4_Adm(pcrn32NV7sbyBYdp`)T7S7$9WC={TG{U|u{ls*`YFD2fH!4M?Ktlza zqH2hKZ^8FTiPpa~czQg_k4S{HA)s8VY-p&yH1xEG?pP?DRx$KuqvtUmD7vS(RIX0{ zFhTe`>HbR?Flbxq%ZeP*vJmFz7>0@8{gJwK3}9e%RPzF^q_O=8sCd?&S9L2-!)Cvg z31yT@aYxX}7{pxu$lerXJhb8qn7|3lL?uRo0a5!ZtZ4P0AZE03@PRD;d6?X`{COz` z=hO^n1nbNOtA|mrwaMi4*q)2H+qD;3Ge`2yh1JviPR0J>jRJBs5PwH84>D;bX@s{- zQZt}5s1J;3$t+A|R4|+CsG%R7AibEk14vyoRp7ve5jOHU5%g0NMba6m3B=isdvxGZ zJAC9lm4}eb{HE^3oHX>^CmJn%MGTD^hFebA6PJ-z?OW!P-wNY|hS**F5hm|?YW$1~ zzlNZ*FZRr}Jdymx36@NX?OrmhoqTe`>99>Igx?muiY@I3lM&8u+u0cM$7_5CO`s*E zY+N``6)%cu`Su7>p}(Ei3n~%(-xsmrAcAr0QOr>ieS{xGES@~{+Dk2AtxP-35uxG|{(u{0yhUWzGwa5TBA&4Q zcV~)9Q^~tw77QL8MIJD>Q*vXK_2G0l^*McqN6dHkH)>wUS&{IH$OPr57$;=maVewJ z$K7J+z#1Trs`{esN$BW`lv8@P=vIa|IxQ1VHuvJs3~|yiH=a~TG`pKo`V)KLF7?+8E$nRXRxJ&>3;!gfs>%;ZFX^JikjfpG z>j?R^Q0>DQS}E|4;bf7-9U&e;gE$Mbf;6G|F3#j{N`3k`7#lzW9DFJ7mZlEkf%Q%d zkBJhf&RUddl_JYusx{WPVWG_I62v)L^yE6}Zlo01pWkb?4^`xKpu2?)^pj?K~o~u6&pPw|o$-kje{UA^MR;gZvU*CUmHi z>Bbr(B9=RDzBrD|r~*#7OyP@`UwFTZOB5g2Sa+J97T!0{3%3ZKD0drAp`zf`p}2?dpd3*s@I~tK*1taXNegIZ596py>bMSQh7u;V#bhpwOU}{Uikd zjn(KJ@rJt=E7eAXRz`)rL(^x+Zln55;Des_5vI_U6efsTq z_2fj$FEJo2B`EhUeBZjFf9p$*!*uvu+7Z@LMu+FPHU7_Vp3UW;L(>Q~pdDQ|)heLp z-ZvFhvj4OZWrqoUKXu21N9yhFOs>H=j)31Oc!HWb*g{e?gjdl-)eHGBbGm`zGR*3= zxAA63`{#k;_#}U(YX5WH&r*!m*kQEyN)_lZkBPGVx%JIyd}@Y6b8g24kNJ6MsZqEE zuo&kM`HbDYp^m2r;V?lBLVzYlqK#RGOOdl9jYNc2Z00<8AO~{NZTCH8U1D~#{q%Zh zzi=}VH;y(_#ls#sQ)dEzhn!XJPidXeMBv`=1f}goaIG}p6}5Hk*b%ly2_svux;(;_ z;G#ZNi7yr|#45s)#N_|VH#v0D)w&@PcD&6a?$zmQMn=d-k`HTzu8{q1;LK_jl zm;$AGkX&~ESnnk)+d$9X^lSKUGvA!iPZFZ?)*=r{*28Se;Dp%RqVhj9D2D28BUIl{Av34$X0-ZkA{;n>++G@;M~11i_|)>{XGU2NrCE zYaoh0&sM75mAa1B&V_ph6vf9==;!msi2*%voTP25wfwpGC2Y418+qSA9?Q5T^e{=M z!u2=00ds4PP#K;dT-3ew4cE9f4Iw?}2-n7bG5AQ+94Z?V2EvSekaoNOPdrI&< z^oEbreiG{qsiCn@J8NAgL&N?E=Xb-)HQY(eQ10-Oc4{RI=IGo4HZKO~Wsv!kUb4xV z@cidp-lxf2!24Yg?L8ww-)=2ehw2xoqM(Re-;#u!LIA5Yi;NXo8}K*(>8Ap@C9Xp- zNN^=mlTMDqS|UFV5~^(g=Sn@k7UMDvmojQG#rwt0g}w;3tMglG>BlD;fEut^CynPV zcPg?2ibhiaH(0B`w#{59uVp)x5np8QO5WiQA!l7hC%&(*ID`nSb%qtJBjdA=+=qZA zemaDeRc;yWf?s^A#u+Xcf$$};#ENB5Mp7pVna9rz7YW1qN;}49fkc6~!0{E6a48$+ zXOX=e1$2J8H_3rF2|6|lDV=TS@U|sQTam27+jqvKZg4MZ z7e1+-pttE;Re8h5c0|o5zbLA#hU?st6cJ6DD209PF*h-`|INj6Uol;AuZuG3P|8Dh z_KHf(8|dMo%vXqF$>&$D-4-Df7|+mfC`2a^VIYpCOg^gt|6f@A3dqHlV7?$B(lWM0 zP6TnbN$z{KDU>;yKiP#UiZ)l}4XhBF|9}w2u~2B5NT_iwUR@qYhpqh5Z-pyEn~ld$ zu-_HDj8`|${F%rwPImDb?H2%O7k{sP#>w+6`BKEA++ec3lr~v&_DDEFEH7?^*^{O z+3IYOn&DZI=X)^o+AGmLA z*7~P9j)6?6J>RVxTE~A=6CfSGNTcvu(8Q9RmZiY+JoE;YUu0n1>_Af6C?yfkf}+?E!p68L zw7H~MH-DMkRyf=SxabLcx#oAr`W};8#DSkmWF8~_GzUnnQHJeZlQ!HLl%QCLnZaF$ z)Xv2&LUiXtQ%cYn+e9CrY^gU*8u7=nh7d>||Bwe7leSJ#6h<`Z`FZJh>uRar@saNW zEgn?^eUDx$HqxOa)-YkC(+}}qo0W87<|t|XoaUjU`g?0P7BRz^E0f^uq6k;%XbIX@ zi4}e){=PJ+JAwlC``nIQEkZlHHzr+&Hs%cn zuGEFUx6$||oORh^UK2_VsbE966_(?SMOoU^cJ9%NP&-ll`98X^TcI%Ca3vz-W?jPxVA`zrb`%emMA0KS1ZPYvU7 z{N6zxBhIX41S{0XjJU8K5f&v{^7+r$LLOm>T2LLUDqE5d zd>Nz@XFr@Ygh}gE6*QYwl`vcK9OL;B`P_j*iFqv83PwmBtTd;^FjCaP z-s+&cz+BYt6`s?WxabjdcTpP7ZWU1sInU}Bus~Lwql~tMVnE^=vy>Rr0~6#;q`{#G zu%y2%lJAF*SafGV-x;!&FjkIM$*+rd2zcQ>#xk z9|?6pk1F}?lAg!imFc1u*^yg_Q_x<)B(W8bN^l>I2x&4wuN?kYTywNE# z%ann`w&D3>I7-K3tI|J-&}S%IMvlCwFR|O=jGy!jbU2b`?Z#}aE7 zOjz{efJP+Gols&xB81uk3(6H3Y!WoL)Z(7O$6rA0$2pCy)VNN+0GjtpiyJJjx8UcM zQJNz_9v+lnDq2mL(W*mI+x; zKoV5Um6^p9{6uQgf{vXGcv?ddBP2mO0i^)C*q2`?x*uZslY9lgzwU!i;oQV7f`zl@ z7LhFs;;o?xMiK{u!ZFgwFa&Wt^xA!%i~1gHG6|17PmXaI8ENC(rF_VWu@w*90x*P6 zW)2q~@5ia10)(gqP$<$FXQjhv5aGc**&Pw=b(Hn#TLn)wjyrO*kMhQz69ue33 zoF`u58Syvm&RC!%c{yCf&4~MkX%8Hbe+)~cQVRjIg0Ka+ZGI#wcx4g)T5r8z{Uk25 zS4my{!&I7PejkIpKWlew9sv_Dtl1YBDs0yc0%In7HIyI(Aj3&&g&H4rae$x>P1Ntl zKX7u3CG-i7rL;8<36F)-5s>zqkaG{Lr_~k?Fqrn@R+8pL^yfiYNpCC>K7%5+<$v&Ns(5|$+L880h-d?n0eSF=c zJ-69Vs9_GxEk>_^uVip&j7C&Tg)wfEtRuzfix9f1f0(c7NZhW)1kw3e#3<1Hmh^&3 zqVE8ZRr7+zLM`&MK*V+x#A!MruKKFtz(2$eZJJ^jfOc#kJ)UWgd!!mRr~@{a?%Y^k zn0S$C1@?pP#C|U0AP8tYx-p~Cq^9=PqouU=R}F*a2;=omkRKN7Ttb-dv!nl0Dy;wE;ZiqLmH@;%H+E zxqoTQ7Fe?!o8TY=MlNdzBouTPV@Z*eel16Axxr(~8hJjN9QZshgwtl?+}2_9aoV5& zuQ=MdQM{4`X7l*#ejhubT&XE?0U86{@B3=PXA#Z)b&s7Eyc~0wwuiz5EMym@Z^CTc zG7@0~Zua*-ESe_* zIg~Dyu!2Ka4RTd+ydaTz2)fr~t4UrZJCg!HnM23vMMW2Fpu3DiFKtH=1e6YSn5+kg zxuOx)IIpu4Kmj3SE)}GMqyQk(9;iK#m0Aej=X2BNWnQ4;8IX@wk|g(3bjVmvXjp;ftEi>CxK_YmAy;NI6f479iSuxdoA3t476m{5sn31EY3YG z4hZo>UVSV?fuv)RqzSDA_j1gmK{QmrA@1Hs1qnDyA9it?g)*Kb1cb|JqK!cNOu&Oe zt31#ht58qtiiB7@9CUF5x)Q<;V_na4ipvbG@W5zFjY!5wBZcM>8K9@g+ByP90O`FN zm*C+M4hLkS`&Q9KuvTV$o_Y5Rq>gL!q65AYYdQ}@_CZEctrl4USvn5#bGvPU3C2+R zF*Q#Cwz*&`&r_>J7NvB(`IB~kRW*tT3NmE<(kQ*|;xd3x`lF6JUe|AqTKSP8CPo`j zDLRgFF_H04Xi>jXHKKxCV&VII1sr!ocTbaXdl!igSzV<`hUr4Se|Y~^;sxRRYT
o8#i|=PIKVXpk zY|zF$6viZVZJA5&SP*U4X$XmP7`M1WOx+AR!bl?5Mw$PZg}^Z_^_e4!A8|D zopg_v!jr-BxIL_|Sq8Z*y3Jx%ZFbb*FA!#KS&F^V#`ZDS(eRT#PR&khtW-h{O^bbq zP9)^))WP$uz;i#O6casHCplo=M0iMP=VSb&>mZ044J#5G#uJ8$TcRl3l48K2Z~Pb6 zJv`>r3qW+W#}$GM%=gMiA`qy1&eQ<%@VE(&lcC;l0tY*i0N9aN8?%^DKJu`@peK)7 zi$xZjs1b-I!P${677t+lsU$UX|EAsd)%XBF#FG#ba>yKL%n)Mj(Q-raE>}t|d`5-|zZ4~M z_(g-jy5R9K`2m_V01Ir6Xl`B>Z&wcQD z8!rA~SFJi0095bBAg*SjUh^HsSW@En zuZ=3s7SUM3@31Q3amD=4(?n6SJoktLeSRuz9&h)u-;E?4%;#{bXoVlcoJ50ApaMFs z4nltZ>%5}Z9;W@Y$^)4UP);-cSWy|uKuvXt`bj!KsZe*)&WK8270(`d=wj-sFG|P@ zJ1vaC8U3&^r(*&qW`LEJzYt6vgqbw>LZe(vZVDeEhdKsGfB0dS~>dGe0oq(V|H?{oZpaj0UB4w^N5T=BbmvQKpZ=G{*Vwto%DR<+#P!G6ND~e;dY%Av*-L#WBN;o9{Y(X zl4^)62)sU6GGJn-aJQ^qE2Lqa5B!7$tsn1U4!8+46i9#4?i+*g9us0M3#s)$Ua61fS19f4mw>c^eWoq(KHB^Y4+J%Ej3&@Y00|iAPV6gbs))c<{c51A5k4vzo0=ZZWNuyX-}~nn^T!m=UOx3(XPicbU8r^&@;p1 zP7p&B6*0>>42A1{f$09rbq2j3vnq9ft;I%C02#APw^;f3vdE73V;nkO+n=)yB%JUU+FG zh;Tl}Dx{II%+jo)vP~XIt|QY2gW>Z4itf*^Uv0#dxvo0Sp)o+1b=#_qJm@HK%>(tY zabh($fgcC|6W%0Z;1?XP+lU2$iorsP^!9?M;pP}o4YV9_*zW-UdI zhW>;Z{(0Xwf>9{mY2hG&!r0X<1_jCLn+uIu~u`=y7 zuz-F;zfMMTo(gbZc@Q)3SK))|KdHI!dDLhiIY@?r6Gx93^ay~W$-ERhz zI1l8qz{8fK12>M758nD)Vsu9dTIC~SAE|ssja;dRyJV)brHcUzpg=)*HmzT0M4#f? zK=(ld3pzWZLTYj=!RRRQF5lBYL5r)bfHamDu3MF9>o8 z41KbI?!=wKgswUQu%ehp%;~?Zlt^l04Kn;ZEbkr_R3l3L1&UoG1J6_?pq$cKb?pup z?IiIF{-E8X!)5?1G>sCVd@3ImpT=pc;9`Fi!zaW)P4W*I$WirA!SZw@IAT5d-d>E2 zf79*(4Tq^|3VwS(`U*6kNA+|4R6ZDNGtk}#nMI27NKi=Uej|cCJR{M;{W!Jd5OfIa zxm!{Rn3GHw3)O)^`W!}amU|2nA0I8**p9LG#aQt50n|cd*j1Z+Zo_~VYcqPnjk*V+ z5c57bj(d1?9D86qDP=<{!K4!Qe!?Y=4%d_Zfzx)Wc&z! z+3s5Na}BwBJ+Bmu*KiOqaMe=H@FP(A1n!Ux%tuep@k3^Ey8j^~7(*g!=biArPygIy zx|945!>+vWAyiEtJ6s>w;-S>6V9@oZ`OhnaQjiaaf`USJa#9ps-ST2ebB;#DU$r}N zU)Pj7q^Gr+DvJi8QjC9iMOpGQ^q6kH42iv|cKJD7+ujXdt>;X0DZf@XGT}!N;*IG3 zUD}xvZY?IRfFUfMeG~k*wUMGj2oYT_ik4^)BqX+;lAf>%p?N1Z*8GeWR%p|G!TS*8 zyh*^oi39y3LdP=&8BAeAwk$wU|3hRNKMpJsqdS5&3oND)afpBNy|@y8)9xp>+t}ym zAH9e9hOeodU_fmg#(+=(Sf<3>X@R^d)kGVg6(Tn>{=AVJF;ATkyhuBpf9xN$yCI*q z|6>$YvBIFyZZ&O(N0)HUlh>~9W6(8j(8pXakf&Q1_$y!<^bJ) z)K52@UqkFk@MvZrExe4j86*8uj1Y94iF{yxb^j98y9khqkJ%l2xlMEiy7-f-19TT6 zqLlEtvUx#Jq6OjnAh2A5^WE36O9>lGtE#f;dNxMAMH5F4nWUbf98^K`hF#)2-y#m|>;1d3U)l45P&bt=Uv|fTR2j$6dIqJ;7Da|7tTl`78mwR6n zRRl5rHkq+e5vGnaZq<&Ts_7w<8EKx~eirU)^fbZsymLKaW9uej#nK~A4W#XosPWv-|ATI*CuqH6~F%Xb%Lqea|bJwLHd~6NoHa7 z%0qvEYZFjKl1B=QJBLFjphW+C0Zgy`zdRu78UB!2GCA4yXqI@&BmZYck>XKjpfA3;xwnoh48iKmQ+X z`|t7B%Vf5ye!U0i{{PMY|M=^F|L)V*o#F)*#iY``PtuEb3-CX&k@49<@O_XyYdUXi z_&=zF*R*I1T(EWctzk8}5r65pauJl49&k71aC1?DTs3tCA;Gqsw!PU6> zI+jo)-nlFD+)bSWY9&1?N_fIkajro3XX##oV26;qNFTCK>ObhdO!akis3{m+ilLto zAGiEJQv1GJFe9Of9VqS&t3dY~gA}cl-2z!4Z3K`#8d1)Vy&UT~+NkKZMp~bgLPh!?tGa*3 z67a#G<3FSX-LIVNPZ)0%tpBKS|9R98*}u~M0m8LlqU^)|pKy5pPW#LS-LI8@>GSP> z7ViI1f8Eh#oVmgb*x&F!z`paru3%+B%7yL&V=>CAQ zn=!tjm&*TwRsJFVdy&|!m;9%K|HKJu%53@4g6>a#r0D-Ke}V2V(i_3jHoq7D11s9_ zuOI*H{K-|5=gfKFcGc>)Isvh;n@0wAem(hN69 zBJ7q2qrH*HAQLjM159X(a?^mVLtKJW@S9-`Of!0^s^EF^Vp{6er!GCfAoIMf6jl~`v7#u z;DUeX^gHms^1OrmZ}*@6bKm-o?EmfaCI7$p56BM@pZ9pH{`u`oUNyaZ^ZgGPA60z} ze?#(L#V_N(=|9qYfPWhPiT`8zudCn@DNT%*|UAnT{;kg(OYlpy@~?5a9Coxy8Q3C7hrRx;;MXAFMsi@~_> zoqBoY^9<5>+{Fn8h9AO^eY3}mf1ha%pmWF&fGrsp^z55On-#f>%Y(GA(vL8@Q6xKJ z3(Po)n8QbSz6Y}W4(c&V1(tsp=dyNZQ|<Rx|_iJUA#jAPTrR ze~Lk->A1T;@G>3~P+h)>6z6dCipwn5y0|20N}pkt^}v|Qwu=Ld+`Ai6{{57}uiwct zm;!5&U#GNZX^9tF+7t_aYKjv)gCxQKw9qWAmywN0VIpc%iTjdopgse01herqgKPi~Os@z@Ni|0adW39<58N62xb9GF7`k7L5C%`&?locY= zVQLg*n%V4E``HI`#yDY$pD@?6R-ObFGwC69_Q*$!z*kL-e^6dZ=BPvq8=~p*_yh#mn~=oG}+K4~N}2Z_#F{ z2ae?Yy1c5t!~)2QnV~mXGtjxuC1Y=v`e_r(aBsRxAd>{-v1Y7)_Uy2HlMzMiGsD8V5|P-{rv-}*#f;Ot1tuD1O7v>;$^1X2&^_yCgvfm*Vo zlCwcQPo4*BXrq`coF+im>x!Y##xw4?9Q=rqb zf0>H1$MQ7>XXRI`trcX}5eB^pJ^&dA3NBFJFNCsBSk;mT?S((CkpBqSJr zM>JGPP0^;6EQB`1_29tB{8mtxmL#n&`n@?K){lt%dvg9Zb1ZbT zhBIgst8a8-IXWuZt0VX^Kht<`y7`{{%o*9z@eXf=%pJ+#^#dl>QzHkoLeNN>`ngDV zAA=sv$IZReu|t9_J-O$(V}dE`tWMOlpO46 z9ohc{Xkq^+u#b&@87A4M{1^ji>MSTui@dTd!E+rY0<0>ignwsG-M{7@plr8tj`Du; zUE130%o^s~-I!@Y4SSKhXrR1P~M_ftX#?>(s_lUB>1i&7?T1tv&oD*8)>_2Gx0_oH}S? zdO3Kk%I=6GjwzkELZ5#_lN5DiT_tg#St^+uLpD2q`0nDlq=S{4tL8UaJtEUk_x`~r z|5Y`#imkH=U{~+0sJYyE49Z!@8^c|ZhMCf^jdxgU01z#wr)VT!{EUs--N({i6yHZ0 z%s>YOGB+vFUh0M|kZu)7T;kF8?yzB3eKG1!=x;#doe{qRnpf;Yt~{_x-$VI)Tn@li za&I~%f(#(!{&O?(_VGM1n2t)dT%3@^Tl137I?BUp+fyKH3?PLi$J$``F}-?O`z;zI z82H{er8!y|DD$(U3^Tsu0wf;613(XmaT$%RizN&s)@hhyF!LWJEFcy_fhHN($7L6q z1=5u$@yiUPEv4*g72*dQeRa|6W0}|PmtfDBu$6Gd?Fig`?n4DqgeCze@gG(#W#ACU!CAX9>2ftMwwRgc z2*j%uudpsm6Aoih7YGq0wl+aW1ehI=V>>gMwt0QN*P#`lll)^fpC`Z*`2YP)9Fi#! zpe>sN+$;=y3hQH+BzS1n{Hg{jm|GvaA5A9|CM~f+QeE)H1}@8W=7$i&H*>Ob@{)ZC zVsPL~*%+L>$+)-xh)Ch)JlvA&VAkwlk`=aj^!?}k_oN_IBc>bM^D6nn7khv3)Yq!D zZF&{*U`y$(h*7zes)+S!Jn)aft)WsUv!k*#nTvxADg$=6NGDrhTvm!>lvjY>bKvSW zhTyA3BhhxwAB+lBS@+|4SU&ns*{qJS(#ZJTCp6ucYZdo*exo0V zlE00FaSstqv_Z#38vHcTN~BCM6rTa=9RXg#JmN)S|1bZrvFbWI@KM`5b5buIdL1$+ z!1StNQ1s}MNIGa4*kW}l^2-KEMWY%k;^>!vs@3oJzp1ZqzBQS9^L($BXiQHk=pd8G z+2?eVX>;0xt5TJ>O2FzJoT%Y{RmZj^O^`(C^8hA&oy}OQ){DXot@_A$@I~gEX2N^( zD@a%DLn|91Nr}S{Y7dH6Xrz@802d;KASgcXVD&Xhy7#2&4#?c}$FT8e|AI)Ej?p}U zE;io5Pq?BvYyK~d!$Z467nOL5`+M)rc5(XjkQA3a`+-~b4V1px__b;UP8T&Svn&4y zONT>y22hXUomt&H?mcbZDp}eeh>@`gvb;-CGV+d=OgorHzsm?`D`eaU7+d!ad>Nyy zzP{Zq42Sd96XZpgC08y}r9DJ~f(VfW#cjz#*aZGJXi`&H7n4Oy}~m1{}$Z)hHf%A4be4Ly4^&An9{`^#BfYt@Gi zeeThRinn8PO|M!q{k~=1IX4Zb(6zPzfP(5;)kt}-6NV{4P=M9sYlo`1mIaBG`I_@W zdEGUINnXSL8-Dk@EkoK%A-tA?)R;XAV_S+tIf&;AYs}85KaeZPVYUbjI!Y4}IlR}6 zN02_bZr9by*%C!|#WEPM1D-LTIeBQU>^3j|TO$Ex(H>CAqaXI^Gcl7LVqvGoi6^s~ z_s^RGw_U?BIEs=g=%>r?&Fzb*)^I{G`|4*Pix|40Q2^&_A)r6Vfj;pZp3$e$vND0FF2QE6>wq^t_?`8(1djin7C zL~GN$_L}b$fBP=2eHC^bUPsZ`s%y?Y>5LQPJN`G8%}yYC3pe4=d+Jc|{hySHPzaJ3H14=F3 z&^*@*ZHe2*sM72RLl|Nqi5Te6uo?_7TEE{y^6 z%d7#pAhjS1@{i5A<(9$!|NSjdwlFY}g@U??qk+OfM`}8V5i?#4YN;JWhvChC{oD#` z5MvaWH<45Pn%DeJal`k`ahas~C53guN80C+HeoGbBOhE(|Nr)J4RF4LGq?I~1?D9} zd+FVL=3@<0l0&>>S2lzI8)m6+wB{Cc(DVQMX+j8Qdj(5Jf0ti#6m{fLyZ?d=vx& z_6VH9rAN$1{7zq^rebQh5^T&VtF?5zpJs9v8*z?5gz3`D$rLK&UTdW=u%fd~j}aD0 zPQYei~(dx_Pl+VYHUHncrN?u_{Y4B^$+zRe%>MYFme4mmo>(Q zz5!z(uI-~#Lqm~UXRVa?CcPpgj>=v4A9!J1+dD@f{~N*EiWzkOBN^h$Vn$O+c(wox zKm-vD|4LzngS0|#m<=n!JK?wxuNiB>6n|t$^tNq>$%U5wWsWX*&t@X zN<*FJ83bBH%+xdpk=no=H5hhfElfYFU~6JF4Qjf!yrk+!#s3Htj@njT7yZcw7@ckb zll*7I_aQXQ!`V}?+z}~U-ZGGTpUI|vMXiCZ9X||dldsxzO>Vocik-AULNoK&b0`BK z(aI~+1@NDEKFgKLXaA?2_m#xFhy%9Kw4NO9n|Crf=E9qa{!KvU+;9pYwZqc-w|<8{ zNvBi$Flm1VTg*`aMe8>kQwVu7^(jP+9aLLCPVcq++sbjxezz1ei$&KJfx*1oe2n@f zY>-x8QXhf8!v|MjYC_OMQR$VD^0U6rnjPjy%}XB4)%grP^h)7-HkEepA=K-lyb(Um zPO*c8)md2gb_h|RkR6|icP_KyA5IxH&0qZ84n6RcAu=O{8R=P2bR9h~cy z+~@Q&CQA>2DqtjRl(fqb{`v zGbQHbe9pr1nSK`yxeA%uy2+8L_Mh3YmDHb#o2v@s1+A-Q8C2=vyN&N}D?`*3*r%mc z;e?$>Nb_z~0*szDsK|7zBp~9nY~)*zMCVcd4&UZL%UNwvKN94a)!zpBN&h+X(NM z*1CmAofVS~^4Elr_K1xdd&!=K$}Ce6e6UrZ#y_&9$>QqfT~0a@A=zhow3y}K zyF}W)I~}(<6ZyGY0&W)u<~O;252UjFvbWlmTC764zG=oGNc^>u6{|n4i2UWXWh2Kg zezzgZr%OkV{X#Jhlju+aCKRoq{J-=Vj$E#`S+}nVU^q2k%xc;5%pX+;u8Y?8w`gNB z&3GRitT>zy?Rzp{nA46=LW9OwbF?FXPo2P#NyrUQ-38ivYJ#nB4j9g+LU>xqwMq{# zNvRp3!ui3_U$?zpZq*!v9!V@%A*3M9{`C_bUe-sJpz<9#qeayP+WT9nzt&Eo?V5_i zE3}9i4vt$o(Qo|=IHR;~s}tv9_Nh zH)NjRbRHfx0>%E_zoEhd;;E`c6>!Mz6J4T#NXTL?d-9(BUg+cVi*hzz4s^U#+d%Fv zNK;Y9YD|d+>T;s4_ygxQn?gFO!N$tJh^}WIJY%rUq$m=Y2JA>9e6Y}?Gv7i;kJ65h zw=l6|eoe=S5Lcb85!f>T1`du+rCEsiz4>HX2uy%Y-D z-p^1aB=xj9bpt>WackNyl#BuBiA!Z9(~SpQ-DB=ZMVgx1aT1Kgj$*OuQ4rS8BZ^JO`7A4CYS~d zG9As65SA4&x?XzOei<~9EPsp40oDJDXEp>pX-$Q`re<+HUYW??;A4jg78lXA-wegv zFwc!H5!k)}TzgoL57-Z>2CzWaBg2`-0BY9QQ5n2qwXIvq4$ahw49vPMt;Xy7DIVvHtDkk(*1wdmoi+6~Wb`>`5054;j-tUDeFkfh zLAnj%L7IzhKv>rlAu9zuie$znC|E4o3qgc4qMXV#ZUH&5>|{W)$1<+L8t72w#BoAM zR+{ze#VmA*OsM%059Hgb6jxe@xjQtIXWJJKLpn!_AuH!|cOGR05ZtJn#fLue@#-3h zMlJZ)k}WOlX)uHbq4xO+9R#mLd&V13mmnbYSK-;%DFtACIx{fzcc_n4b2tE|WPA@e zi4f+XBQqyG4sA4)xc5BO^0PjU>xWRk;!EmjRB$#mkY7hR-R{y4LFEQ;u)hYc+cy0K z=e~`fpv2p)!cHvcZe5HBa_RBuVy@IZFz%U(TXwEKPtu8WTT~dwQYqT2#1pPNC_xa% z`BI^u$&nN|aybY+q$lrq2rmKtDZ*&8J1F`Hc6+?V-jB)F9v% zfhgcxizOld(2F+rG8_M!HQ5z5Dj1MHfMBwag+y<^F}G^jfs$ZrEasLn#1a%4|Gu{T zg;u|T_n7GzX?n`0sP6QysI*Y07=8!PW-mdDLm-KXIo+7TlMo|8(eUQ&jeYiOO@`@1 zOA(fcHQgeY^EU^iNne6~xw6lSY_lhl2Jx}Vo9+I$T`jLl0?xdgyF#|Qz_W5iPtR2&JWq|0*@J4JsG%1EI{%feYy;m4(ExFXl7B<}nF zLv$q_kgd}g!8yr;_0B?|yMt$rz)$bv;q#`R8Dq#GV*yEeSw!{W=?m?Vpg0a{^?5^h zl0rm7i87f9cXsNA3J@E=RyGXkqod)w>`xYiiJ!ir51E$I`vU%WYmzp1rxaJsqSQ|p z8fFy_udine1lqZ?pI!k)BVzK>%s33@EN6-*wsrSuG6Rz|6fSQul$6Vs>-U*~h%Y+4 zIHw$tpC{<9h={iuwohr9Wut-tBVg_xD`&j@$*;xts`9Py|l!fS|)ha&}9jAodea>22@SlJIa8J$5IOy%g- zGWo11u)gOk=1&Rlx5i@-YXCTk!pogRy}J(-*hB>z5pdhjl6xwLF~+^55o;)S1#oKk)dR;@ zlQp*I#k$?pnDJ1MGj=4kz%_aFL##|#8t+AGCe4eV;}mI=J&sLOUav!Ua4;7QfL{{?kDz2dkk2$jw!fokcnT6gV@i_mp0JG$6F^rnod3~VyxyK6BelS=&IJ)!@!i{Ts@5RLIOdZ(6j8{ElkxzyAo1MiO_cgo zZU<}yx59Hem#e*26%t>fLTd z-BtB+>n?8g!M~|_C8V65M1eCTyI#}~@Mr6dEN$HLs&!da*HYJv%0X|(O1A1ZZ{GNZ zS(?n0DV1wbrxcCM_w^4Ol9&}`J;K!?g?|lj)}>9MJVN^@psGjgh}))x4uz$uUr$xz zlbvxL0Hch_QzdyQFkIyGb8@&ESm4h*s#MmW`LHGwGt`Hv$n z=MN@H>;DuziycEr651ib#6J9I^3_9(Eh^5+GE8EiSLR?04!;3`vc=0f!_0f=;DH1s-!|-{YKNh+%ou^NM<&DTLw0?ppwl#h zEKj-4DS>xVz4uaLg;(6B{`(Xr5*f6)-(D|1K*xuu^ZX7zX$ct62PR6EKW^`v?CSulZa%MBrk3rSdTg ze1)qou)_xBz^?*&?9&abE-3(?rj6DQCesmjf58zx9dcU1X#pJ_>Y4hh?Hx|KaX|Y| zhnr6QN73XG-{i4H0_oE1ejSH})R9U4OHBMr?4WQk%XdT3w4l=>I9!KXLRzB?soQ7t z;t)aC07cp;VIXAjwDbu=3&v+4nW}(Pb~&Abe%PGEuDhGgIPSq zq`*@wt?JyYG-KVU1eU-OqQyI>w!9%A9?NlFE`L4}xgq)=D9W7@ufSs%rPbY26kwl( z(&W}3%LfSFJis5M1OSe&_*)o*B6lPXX4c?v_nY;cVaPP?ji-mN@*rT3!rJ+>6ZfzezZqDC!2+_k&!1jMhm?bO< zK_BVjO3gB)@Q@W1LO2yP8atgL_$0LN=EEwU&b}|y)6MTC=-}IAFCwB|``CdMZ)llq zvtZ&`o=SVtoBZqZOBc3~sqLO_)jyCo-u06hGF|jbq1k(IaM^)KJwq{yJ6AoopD(iS zbzi2k>qaXXBKms^IU!=y$fSTV>_hlN(3D;VJvrso23ne0N*|j;VpSiAhx0lwRL%y| zwx>Xg1hQn{DGt$aTsglU__~6T0yA?-wh&NziF;oaV!c#>)Bw~DjQ8&CTxDvtr_(Zi z4a}`8pu`KYQ(Knhl2dEUMm&W5AIvU(~eeJz%m(FXv@2muw&9q^@Cyl3hyLOVp z@a4M7;rZJ#+;{dn_i_nwRYmwhm2)zFz?~XbaEJm$tetv#b}Y z((iz?v*e>{%A(q5vWP|E1XX*;rGdO)r#a7jpS?1MmV=Rbc~&Yf_nq`cR*X8PMyVtFRcn=g*e;eNBx0e8+5}58NdFOg<%V$N2OF1k~LgG zTY?oEGCASS`^d}~u8+B>>eDTq&G&j&?#VhOs<40sm9v>k?vcR2(ziK02PXT*bKGL4 zwtcf0`7Z%o5oyKu4%pC*{I>LC3=?na)s|^Eo3e;y7FN`_xgJW8vwV-Iyha25kQQ18saoRQFl!^~*FqaR}bFM#>;qZS5 zYPaR6?g95$v{vMW+jc)i2TxjRoTrKXv}qf*>|Hy(6NXs3$2j4J4baYuMJ*_Z0AnNH z$W%QfYVm8+BQzY3v+eCO#$Vg$u)GMv8&4xC`das@qWLzd1)mPfst$YJY`dRYCfa~P z;9tft46)(>kBr~I@D{(b=-sou{zALmK1=@#X=c6n;e)yU*Q6rZW7XjVOQjzw8_WnY z<{*_ObM40~K|NAAIWzbHj^rDW)HX^JOs7#_fBH~>!{ti>lg9`a94S&@b&@~)HP%e_ zmJl7{Rls!Vt$V}o)`L?jvh<^uM(Syo?Uo`Vr&465QLbrI;|GKX5=Z%Thx79bV7`oT zfS9(D-$<^d_jpDp}4U z^$yB4y4X}W0HeI1Vw*y4>lilwdkGy{f9>vfAH8R-sW~NZ&_bqTt1fj@<#WLeM^+Vc zG?7Ty^Uv#Lx7fbF>ek?cnq+h0!es(GXbA_w`u zzk^p1lk!`B)~2EhZi>h95itD>rrjML9DfT29|Zm=isd=)y;d8krHUEa2d$_8V6`S6 z#Bp3_kQQ}w%HQ9bBJ7~~7SCRoQewcgG}L|(6V+;OGWTBFVkP>lh1Weifu|6fniEvv z+uc-~La+tog=5MNk~+fJTg&^$(n<@DLS#BY#N3uEs$OHS_bbV(@F513d^SkS?S}l` z6u@)sTc;c=$QV_qi|`_R%+=BzC}dBU>>sIzWeIy|0c-fKIKi}}%gsT}Bp-AfI4u0; z*2BFB!*hpq$f&W8rWr7cMQex-er^i9dVN(o^3FbulJ5s8Hu52`h1$c%a(=>a6btp_ z7yAGS+v@^s8F7^3j`QcnhF?443r762(>2e=@xa!D5a#rxsujG3p@|+qwS+0&^{o=J z=GmDJx$}=Gm7ozcF>-;HHSSj32SWkxXf7FB!5&&pO9g!WTbm(UuEV(48i);ox_WC{ z7{gh{A-VWT*0D*JSOzjjXm!bAgFKmb-lWzMpQft`dJId=qAYAx6^X! zR9@hTu>O6yF7~~y$x8vG7>o|-DyOI*5CBz#rG6FBENCT-Syg7~%{$8h+tXq?Cop-- z+Z+#0EnnP#s0j2;d*QC&8xmFxnUK8`sHq& za1*c>ABGgiqURa5tb6F=k|h$)m`h1^4;oOD039Ks`BH!iOr! z2shfM%G_X<#*jzCnjU5rR;<}ANCqg;n_LddYjAM^?J{~O`s`zJbE2Py^|zPR8^(n2 z)VLX0cBrnKusl$}xY#x@S=itW)ZR=`4m~>v6tZCOF2bMyNNByavpOyj9p?d#+rU81 z=#%@e5CC*P<6M0Q{xf&w5HSytIciZk;%pxr-0ie<1&5vicjW^AfC@^nQ9ErpBkLyA zMIXpEN$C%&utHj32Vz!U4g7@gDi;Lq*9{H?E^AEUBz4IO8?)jGRph?{S*L%|9De@NuHv&oj4w%va-3DBuV>@Rr;$%ae5M}W+sv}z2z zqap@~b^g4gD~rukp-Ku+@$OsIt%ZVN?1wVGrzYyM>-z~W2j&ckZ^_!h5V8CGy5GNq zYMCR_5pz$5Mu}zM7i#HzmX}?xwFbpNz6ALN@h9Oe6}2S4{+lPFv;gu0p}h_gUNxM) zprjMe;cV?-(b`zhv1o>K{83qi)jWM%t{D+W_0r1HDBuvQFz9b_6M!{fDgnq*a3tjv zJQiC!zu&pF+aJcO3rtxnv2;19v+stBAN8L$5uZ*l9sXJ418AfH-pBOo;Q+NaN6rZC z*5ltJZ<(v_H+&-?j9U^w?n5HgG8agt2g8)Q)gZBO1|;DFIBaml+vUE85ZSfIEGuPJ zcO&W(-PF|aHuh!b2b@^`npS-y%FjZ?7=E++4tv*lNX~!?JU>8Fzz-lT!xLmyhGiDG z_w#oFkr~D11WiUa;Z1&#>dw;NJiPikuH7-@{GPzcIYbysmRvj(v=4T= zgFnvQx`43H*L5iqoO4G@HrX%|JiQRj$!T3mr3A!O0m;-&goS>{6?t;wmcbo-db{dB z(z=hH;;tqtAXVdGFUQ`pK)MZM*H3DqVDvxHY&RMu(ghI&k;=lr0nVZkQYRw2584$` z(DBQk(VS?Hl+v@#ZHvG0sK=laewo;$q9ue?G>wiZd;@QNd1^EF7hXH;8nQq>JMnk= z$R)F}VFwU+AY(GOBlA!nJztc6OtU%4^wkOnIjx`p9~SqORqJ(CiBelLS2iO%DMdGNa00B6a0 zrv_CHc@c2 zZ|mhl`PQK4+4Os@QL=*X zuJ)g*|2EShqyXfxld#6b$Vh*BT~d1^M^+U1_e!vNSLw&P4Bu#jShu`W)p*%rY523y zspjOaAg~F4{~a|79#Ki+Me9MyeWw4UE^D~XefZIS(1i}5_qHw$bEvrv1L~YLbwTSy z{$|783Rc6c)NM;DvR(Rj0wD4ZW;fkV$Y#!06r+D*Z$-bLi;R%Rs7T>nKqM9hQ(juL z%!)2@qL_lvhWQji!)h14fhVafCN@ffY|Ez2l3JWZ=c~Gbx&l=~zp;4xGXP4FM}S@xfRiZBh;2v7D-YXOx-kO2UQ0NW#A<^xamX>wNh%~17WesKHmDjpiI SH3wiS4mt~UaJXuC00018KS4YI literal 0 HcmV?d00001 From acb79b24900550b5f8c842a09ee9b63bd73c47af Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 03:20:02 -0700 Subject: [PATCH 06/59] Updated on 2026-08-14 --- .../local/preferences/PreferencesKeys.kt | 2 +- data/visa/build.gradle.kts | 1 + .../pay/DefaultTangemPayEligibilityManager.kt | 13 +- .../di/VirtualAccountDataModule.kt | 26 +++ .../models/pay/TangemPayEligibilityType.kt | 36 +++- .../tangem/domain/models/wallet/UserWallet.kt | 9 +- domain/virtual-account/build.gradle.kts | 20 ++ .../virtual-account/models/build.gradle.kts | 1 + .../model/VirtualAccountEligibility.kt | 12 ++ .../model/VirtualAccountEntryPoint.kt | 7 + .../GetVirtualAccountEligibilityUseCase.kt | 69 +++++++ ...GetVirtualAccountSuitableWalletsUseCase.kt | 17 ++ ...GetVirtualAccountEligibilityUseCaseTest.kt | 174 ++++++++++++++++++ ...irtualAccountSuitableWalletsUseCaseTest.kt | 52 ++++++ features/details/impl/build.gradle.kts | 1 + .../features/details/model/DetailsModel.kt | 32 ++++ .../features/details/utils/ItemsBuilder.kt | 34 ++++ 17 files changed, 491 insertions(+), 15 deletions(-) create mode 100644 domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEligibility.kt create mode 100644 domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEntryPoint.kt create mode 100644 domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCase.kt create mode 100644 domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCase.kt create mode 100644 domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCaseTest.kt create mode 100644 domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCaseTest.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index 96ea2c114d..ca59f2ca23 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -154,7 +154,7 @@ object PreferencesKeys { val TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY by lazy { stringPreferencesKey(name = "tangemPayActiveWithdrawOrdersKey") } - val TANGEM_PAY_ELIGIBILITY_KEY by lazy { stringSetPreferencesKey(name = "tangemPayEligibilityList") } + val TANGEM_PAY_ELIGIBILITY_KEY by lazy { stringSetPreferencesKey(name = "tangemPayEligibilityListV2") } fun getShouldShowNotificationKey(key: String) = booleanPreferencesKey("showShowNotificationUM_$key") // endregion diff --git a/data/visa/build.gradle.kts b/data/visa/build.gradle.kts index 5a5ccd9dec..2cc54a86b8 100644 --- a/data/visa/build.gradle.kts +++ b/data/visa/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { /** Project - Domain */ implementation(projects.domain.visa) + implementation(projects.domain.virtualAccount) implementation(projects.domain.card) implementation(projects.domain.wallets) implementation(projects.domain.legacy) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt index 8ca4cbc613..6f439a26bd 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt @@ -1,17 +1,17 @@ package com.tangem.data.pay -import com.tangem.common.card.FirmwareVersion import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.pay.TangemPayEligibilityType +import com.tangem.domain.models.pay.isTangemPayType import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.models.wallet.isTangemPayCompatible import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.model.TangemPayEntryPoint import com.tangem.domain.pay.repository.OnboardingRepository -import com.tangem.hot.sdk.model.HotWalletId import kotlinx.coroutines.* import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.sync.Mutex @@ -85,7 +85,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor( val wallets = userWalletsListRepository.userWallets.value ?: return emptyList() val candidates = wallets.filter { wallet -> - wallet.isMultiCurrency && !wallet.isLocked && wallet.isCompatible() && + wallet.isMultiCurrency && !wallet.isLocked && wallet.isTangemPayCompatible && !onboardingRepository.isTangemPayDeactivated(wallet.walletId) } @@ -98,11 +98,6 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor( return candidates } - private fun UserWallet.isCompatible(): Boolean = when (this) { - is UserWallet.Cold -> scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable - is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword - } - private suspend fun List.addPaeraCustomersData(): List { if (isEmpty()) return emptyList() @@ -139,7 +134,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor( onboardingRepository.checkCustomerEligibility() } return if (entryPoint == null) { - eligibility.isNotEmpty() + eligibility.any { it.isTangemPayType } } else { eligibility.any { it == entryPoint.toEligibilityType() } } diff --git a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt index a85c3e8c6d..89d3b807c2 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt @@ -18,8 +18,13 @@ import com.tangem.datasource.utils.mapWithStringKeyTypes import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusProducer import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusSupplier +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.virtualaccount.repository.VirtualAccountActivationRepository import com.tangem.domain.virtualaccount.usecase.ActivateVirtualAccountUseCase +import com.tangem.domain.virtualaccount.usecase.GetVirtualAccountEligibilityUseCase +import com.tangem.domain.virtualaccount.usecase.GetVirtualAccountSuitableWalletsUseCase +import com.tangem.security.DeviceSecurityInfoProvider import com.tangem.utils.coroutines.AppCoroutineScope import dagger.Binds import dagger.Module @@ -94,5 +99,26 @@ internal interface VirtualAccountDataModule { ): ActivateVirtualAccountUseCase { return ActivateVirtualAccountUseCase(repository = repository) } + + @Provides + @Singleton + fun provideGetVirtualAccountSuitableWalletsUseCase( + userWalletsListRepository: UserWalletsListRepository, + ): GetVirtualAccountSuitableWalletsUseCase { + return GetVirtualAccountSuitableWalletsUseCase(userWalletsListRepository = userWalletsListRepository) + } + + @Provides + fun provideGetVirtualAccountEligibilityUseCase( + getVirtualAccountSuitableWalletsUseCase: GetVirtualAccountSuitableWalletsUseCase, + onboardingRepository: OnboardingRepository, + deviceSecurityInfoProvider: DeviceSecurityInfoProvider, + ): GetVirtualAccountEligibilityUseCase { + return GetVirtualAccountEligibilityUseCase( + getVirtualAccountSuitableWalletsUseCase = getVirtualAccountSuitableWalletsUseCase, + onboardingRepository = onboardingRepository, + deviceSecurityInfoProvider = deviceSecurityInfoProvider, + ) + } } } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt index 2431867555..8c2bf08afa 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt @@ -4,14 +4,42 @@ enum class TangemPayEligibilityType { BANNER, DETAILS, + DEEPLINK, + + BANNER_VIRTUAL_ACCOUNT, + DETAILS_VIRTUAL_ACCOUNT, + DEEPLINK_VIRTUAL_ACCOUNT, + UNKNOWN, ; companion object { - fun fromString(value: String): TangemPayEligibilityType = when (value.lowercase()) { - "banner" -> BANNER - "details" -> DETAILS + fun fromString(value: String): TangemPayEligibilityType = when (value.uppercase()) { + "BANNER" -> BANNER + "DETAILS" -> DETAILS + "DEEPLINK" -> DEEPLINK + "BANNER_VIRTUAL_ACCOUNT" -> BANNER_VIRTUAL_ACCOUNT + "DETAILS_VIRTUAL_ACCOUNT" -> DETAILS_VIRTUAL_ACCOUNT + "DEEPLINK_VIRTUAL_ACCOUNT" -> DEEPLINK_VIRTUAL_ACCOUNT else -> UNKNOWN } } -} \ No newline at end of file +} + +val TangemPayEligibilityType.isVirtualAccountType: Boolean + get() = this in VIRTUAL_ACCOUNT_TYPES + +val TangemPayEligibilityType.isTangemPayType: Boolean + get() = this in TANGEM_PAY_TYPES + +private val VIRTUAL_ACCOUNT_TYPES = setOf( + TangemPayEligibilityType.BANNER_VIRTUAL_ACCOUNT, + TangemPayEligibilityType.DETAILS_VIRTUAL_ACCOUNT, + TangemPayEligibilityType.DEEPLINK_VIRTUAL_ACCOUNT, +) + +private val TANGEM_PAY_TYPES = setOf( + TangemPayEligibilityType.BANNER, + TangemPayEligibilityType.DETAILS, + TangemPayEligibilityType.DEEPLINK, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt index acc0333fe9..2fea13cb92 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt @@ -1,5 +1,6 @@ package com.tangem.domain.models.wallet +import com.tangem.common.card.FirmwareVersion import com.tangem.domain.models.MobileWallet import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse @@ -118,4 +119,10 @@ val UserWallet.isLocked } inline val UserWallet.isHotWallet get() = this is UserWallet.Hot -inline val UserWallet.isColdWallet get() = this is UserWallet.Cold \ No newline at end of file +inline val UserWallet.isColdWallet get() = this is UserWallet.Cold + +val UserWallet.isTangemPayCompatible: Boolean + get() = when (this) { + is UserWallet.Cold -> scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable + is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword + } \ No newline at end of file diff --git a/domain/virtual-account/build.gradle.kts b/domain/virtual-account/build.gradle.kts index ff053920b6..618b957012 100644 --- a/domain/virtual-account/build.gradle.kts +++ b/domain/virtual-account/build.gradle.kts @@ -10,4 +10,24 @@ android { } dependencies { + /** Project - Domain */ + api(projects.domain.models) + api(projects.domain.virtualAccount.models) + implementation(projects.domain.common) + implementation(projects.domain.visa) + + /** Project - Core */ + implementation(projects.core.security) + + /** Coroutines */ + implementation(deps.kotlin.coroutines) + + /** Tests */ + testImplementation(deps.test.junit5) + testImplementation(deps.test.mockk) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(projects.test.core) + testImplementation(projects.common.test) + testImplementation(projects.domain.card) } \ No newline at end of file diff --git a/domain/virtual-account/models/build.gradle.kts b/domain/virtual-account/models/build.gradle.kts index d587d7c152..0604c48d68 100644 --- a/domain/virtual-account/models/build.gradle.kts +++ b/domain/virtual-account/models/build.gradle.kts @@ -10,4 +10,5 @@ android { } dependencies { + api(projects.domain.models) } \ No newline at end of file diff --git a/domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEligibility.kt b/domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEligibility.kt new file mode 100644 index 0000000000..23d9bf4583 --- /dev/null +++ b/domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEligibility.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.virtualaccount.model + +import com.tangem.domain.models.wallet.UserWallet + +sealed interface VirtualAccountEligibility { + + data class Available( + val wallets: List, + ) : VirtualAccountEligibility + + data object NotAvailable : VirtualAccountEligibility +} \ No newline at end of file diff --git a/domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEntryPoint.kt b/domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEntryPoint.kt new file mode 100644 index 0000000000..fdc50dcb4a --- /dev/null +++ b/domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEntryPoint.kt @@ -0,0 +1,7 @@ +package com.tangem.domain.virtualaccount.model + +enum class VirtualAccountEntryPoint { + BANNER, + DETAILS, + DEEPLINK, +} \ No newline at end of file diff --git a/domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCase.kt b/domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCase.kt new file mode 100644 index 0000000000..1d4a854342 --- /dev/null +++ b/domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCase.kt @@ -0,0 +1,69 @@ +package com.tangem.domain.virtualaccount.usecase + +import com.tangem.domain.models.pay.TangemPayEligibilityType +import com.tangem.domain.models.pay.isVirtualAccountType +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.virtualaccount.model.VirtualAccountEligibility +import com.tangem.domain.virtualaccount.model.VirtualAccountEntryPoint +import com.tangem.security.DeviceSecurityInfoProvider +import com.tangem.security.isSecurityExposed +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +class GetVirtualAccountEligibilityUseCase( + private val getVirtualAccountSuitableWalletsUseCase: GetVirtualAccountSuitableWalletsUseCase, + private val onboardingRepository: OnboardingRepository, + private val deviceSecurityInfoProvider: DeviceSecurityInfoProvider, +) { + suspend operator fun invoke(entryPoint: VirtualAccountEntryPoint?): VirtualAccountEligibility { + if (deviceSecurityInfoProvider.isSecurityExposed()) { + return VirtualAccountEligibility.NotAvailable + } + + val suitableWallets = getVirtualAccountSuitableWalletsUseCase() + if (suitableWallets.isEmpty()) { + return VirtualAccountEligibility.NotAvailable + } + + val isEligible = checkEligibility(entryPoint) + if (isEligible) { + return VirtualAccountEligibility.Available(suitableWallets) + } + + val eligibleWallets = coroutineScope { + suitableWallets + .map { wallet -> + async { + val isExistingCustomer = onboardingRepository.hasTangemPayInWallet(wallet.walletId).getOrNull() + wallet.takeIf { isExistingCustomer == true } + } + } + .awaitAll() + .filterNotNull() + } + + return if (eligibleWallets.isEmpty()) { + VirtualAccountEligibility.NotAvailable + } else { + VirtualAccountEligibility.Available(eligibleWallets) + } + } + + private suspend fun checkEligibility(entryPoint: VirtualAccountEntryPoint?): Boolean { + val eligibility = onboardingRepository.getCustomerEligibility().ifEmpty { + onboardingRepository.checkCustomerEligibility() + } + return if (entryPoint == null) { + eligibility.any { it.isVirtualAccountType } + } else { + eligibility.contains(entryPoint.toEligibilityType()) + } + } + + private fun VirtualAccountEntryPoint.toEligibilityType(): TangemPayEligibilityType = when (this) { + VirtualAccountEntryPoint.BANNER -> TangemPayEligibilityType.BANNER_VIRTUAL_ACCOUNT + VirtualAccountEntryPoint.DETAILS -> TangemPayEligibilityType.DETAILS_VIRTUAL_ACCOUNT + VirtualAccountEntryPoint.DEEPLINK -> TangemPayEligibilityType.DEEPLINK_VIRTUAL_ACCOUNT + } +} \ No newline at end of file diff --git a/domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCase.kt b/domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCase.kt new file mode 100644 index 0000000000..8a14d69c48 --- /dev/null +++ b/domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCase.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.virtualaccount.usecase + +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.isLocked +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.models.wallet.isTangemPayCompatible + +class GetVirtualAccountSuitableWalletsUseCase( + private val userWalletsListRepository: UserWalletsListRepository, +) { + operator fun invoke(): List { + return userWalletsListRepository.userWallets.value + .orEmpty() + .filter { it.isMultiCurrency && !it.isLocked && it.isTangemPayCompatible } + } +} \ No newline at end of file diff --git a/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCaseTest.kt b/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCaseTest.kt new file mode 100644 index 0000000000..2e83b585b0 --- /dev/null +++ b/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCaseTest.kt @@ -0,0 +1,174 @@ +package com.tangem.domain.virtualaccount.usecase + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.pay.TangemPayEligibilityType +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.virtualaccount.model.VirtualAccountEligibility +import com.tangem.domain.virtualaccount.model.VirtualAccountEntryPoint +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.security.DeviceSecurityInfoProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class GetVirtualAccountEligibilityUseCaseTest { + + private val getVirtualAccountSuitableWalletsUseCase: GetVirtualAccountSuitableWalletsUseCase = mockk() + private val onboardingRepository: OnboardingRepository = mockk() + private val deviceSecurityInfoProvider: DeviceSecurityInfoProvider = mockk() + + private val useCase = GetVirtualAccountEligibilityUseCase( + getVirtualAccountSuitableWalletsUseCase = getVirtualAccountSuitableWalletsUseCase, + onboardingRepository = onboardingRepository, + deviceSecurityInfoProvider = deviceSecurityInfoProvider, + ) + + @BeforeEach + fun setup() { + clearMocks(getVirtualAccountSuitableWalletsUseCase, onboardingRepository, deviceSecurityInfoProvider) + every { deviceSecurityInfoProvider.isRooted } returns false + every { deviceSecurityInfoProvider.isBootloaderUnlocked } returns false + every { deviceSecurityInfoProvider.isXposed } returns false + } + + @Test + fun `GIVEN device is rooted WHEN invoke THEN returns NotAvailable`() = runTest { + // GIVEN + every { deviceSecurityInfoProvider.isRooted } returns true + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.NotAvailable) + } + + @Test + fun `GIVEN no suitable wallets WHEN invoke THEN returns NotAvailable`() = runTest { + // GIVEN + every { getVirtualAccountSuitableWalletsUseCase() } returns emptyList() + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.NotAvailable) + } + + @Test + fun `GIVEN entry point eligibility passes WHEN invoke THEN returns Available with all suitable wallets`() = runTest { + // GIVEN + val wallets = listOf(mockWallet(), mockWallet()) + every { getVirtualAccountSuitableWalletsUseCase() } returns wallets + coEvery { + onboardingRepository.getCustomerEligibility() + } returns listOf(TangemPayEligibilityType.BANNER_VIRTUAL_ACCOUNT) + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.Available(wallets)) + } + + @Test + fun `GIVEN null entry point AND any VA eligibility present WHEN invoke THEN returns Available`() = runTest { + // GIVEN + val wallets = listOf(mockWallet()) + every { getVirtualAccountSuitableWalletsUseCase() } returns wallets + coEvery { + onboardingRepository.getCustomerEligibility() + } returns listOf(TangemPayEligibilityType.DEEPLINK_VIRTUAL_ACCOUNT) + + // WHEN + val result = useCase(entryPoint = null) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.Available(wallets)) + } + + @Test + fun `GIVEN cached eligibility empty WHEN invoke THEN falls back to fetched eligibility`() = runTest { + // GIVEN + val wallets = listOf(mockWallet()) + every { getVirtualAccountSuitableWalletsUseCase() } returns wallets + coEvery { onboardingRepository.getCustomerEligibility() } returns emptyList() + coEvery { + onboardingRepository.checkCustomerEligibility() + } returns listOf(TangemPayEligibilityType.BANNER_VIRTUAL_ACCOUNT) + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.Available(wallets)) + } + + @Test + fun `GIVEN eligibility fails AND wallet is existing customer WHEN invoke THEN returns Available with wallet`() = + runTest { + // GIVEN + val wallet = mockWallet() + every { getVirtualAccountSuitableWalletsUseCase() } returns listOf(wallet) + coEvery { onboardingRepository.getCustomerEligibility() } returns listOf(TangemPayEligibilityType.BANNER) + coEvery { onboardingRepository.hasTangemPayInWallet(wallet.walletId) } returns true.right() + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.Available(listOf(wallet))) + } + + @Test + fun `GIVEN eligibility fails AND wallet is not a customer WHEN invoke THEN returns NotAvailable`() = runTest { + // GIVEN + val wallet = mockWallet() + every { getVirtualAccountSuitableWalletsUseCase() } returns listOf(wallet) + coEvery { onboardingRepository.getCustomerEligibility() } returns listOf(TangemPayEligibilityType.BANNER) + coEvery { + onboardingRepository.hasTangemPayInWallet(wallet.walletId) + } returns VisaApiError.NotPaeraCustomer.left() + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.NotAvailable) + } + + @Test + fun `GIVEN eligibility fails AND only some wallets are customers WHEN invoke THEN returns Available with customers`() = + runTest { + // GIVEN + val customerWallet = mockWallet() + val nonCustomerWallet = mockWallet() + every { + getVirtualAccountSuitableWalletsUseCase() + } returns listOf(customerWallet, nonCustomerWallet) + coEvery { onboardingRepository.getCustomerEligibility() } returns listOf(TangemPayEligibilityType.BANNER) + coEvery { onboardingRepository.hasTangemPayInWallet(customerWallet.walletId) } returns true.right() + coEvery { onboardingRepository.hasTangemPayInWallet(nonCustomerWallet.walletId) } returns false.right() + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.Available(listOf(customerWallet))) + } + + private fun mockWallet(): UserWallet { + val id = mockk() + return mockk { every { walletId } returns id } + } +} \ No newline at end of file diff --git a/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCaseTest.kt b/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCaseTest.kt new file mode 100644 index 0000000000..40883c03ce --- /dev/null +++ b/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCaseTest.kt @@ -0,0 +1,52 @@ +package com.tangem.domain.virtualaccount.usecase + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.card.MockScanResponseFactory +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.domain.card.configs.GenericCardConfig +import com.tangem.domain.card.configs.Wallet2CardConfig +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWallet +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.Test + +internal class GetVirtualAccountSuitableWalletsUseCaseTest { + + private val userWalletsListRepository: UserWalletsListRepository = mockk() + + private val useCase = GetVirtualAccountSuitableWalletsUseCase(userWalletsListRepository = userWalletsListRepository) + + @Test + fun `GIVEN compatible, single-currency and outdated wallets WHEN invoke THEN returns only the compatible one`() { + // GIVEN + val compatible = MockUserWalletFactory.create( + MockScanResponseFactory.create(cardConfig = Wallet2CardConfig, derivedKeys = emptyMap()), + ) + val singleCurrency = MockUserWalletFactory.createSingleWalletWithToken() + val outdatedFirmware = MockUserWalletFactory.create( + MockScanResponseFactory.create(cardConfig = GenericCardConfig(maxWalletCount = 2), derivedKeys = emptyMap()), + ) + every { userWalletsListRepository.userWallets } returns + MutableStateFlow(listOf(compatible, singleCurrency, outdatedFirmware)) + + // WHEN + val result = useCase() + + // THEN + assertThat(result).containsExactly(compatible) + } + + @Test + fun `GIVEN no wallets WHEN invoke THEN returns empty list`() { + // GIVEN + every { userWalletsListRepository.userWallets } returns MutableStateFlow(null) + + // WHEN + val result = useCase() + + // THEN + assertThat(result).isEmpty() + } +} \ No newline at end of file diff --git a/features/details/impl/build.gradle.kts b/features/details/impl/build.gradle.kts index 3b6e587c78..51324ed3de 100644 --- a/features/details/impl/build.gradle.kts +++ b/features/details/impl/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { implementation(projects.domain.legacy) implementation(projects.domain.settings) implementation(projects.domain.visa) + 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 e1538a81b3..ce9efd9331 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 @@ -22,6 +22,9 @@ import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.model.TangemPayEntryPoint import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents +import com.tangem.domain.virtualaccount.model.VirtualAccountEligibility +import com.tangem.domain.virtualaccount.model.VirtualAccountEntryPoint +import com.tangem.domain.virtualaccount.usecase.GetVirtualAccountEligibilityUseCase import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase @@ -67,6 +70,7 @@ internal class DetailsModel @Inject constructor( private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, private val analyticsEventHandler: AnalyticsEventHandler, private val tangemPayEligibilityManager: TangemPayEligibilityManager, + private val getVirtualAccountEligibilityUseCase: GetVirtualAccountEligibilityUseCase, ) : Model() { private val params: DetailsComponent.Params = paramsContainer.require() @@ -99,6 +103,7 @@ internal class DetailsModel @Inject constructor( ) addTangemPayItemIfEligible() + addVirtualAccountItemIfEligible() state = MutableStateFlow( value = DetailsUM( @@ -285,5 +290,32 @@ internal class DetailsModel @Inject constructor( } } + private fun addVirtualAccountItemIfEligible() { + modelScope.launch { + val eligibility = getVirtualAccountEligibilityUseCase(VirtualAccountEntryPoint.DETAILS) + if (eligibility is VirtualAccountEligibility.Available) { + items.update { items -> + itemsBuilder.addVirtualAccountItem( + items = items, + onClick = ::onVirtualAccountItemClicked, + ) + } + } + } + } + + private fun onVirtualAccountItemClicked() { + modelScope.launch { + when (val eligibility = getVirtualAccountEligibilityUseCase(VirtualAccountEntryPoint.DETAILS)) { + is VirtualAccountEligibility.Available -> router.push( + AppRoute.VirtualAccountOnboarding( + AppRoute.VirtualAccountOnboarding.Mode.FromDetailsScreen(eligibility.wallets.first().walletId), + ), + ) + VirtualAccountEligibility.NotAvailable -> items.update { itemsBuilder.removeVirtualAccountItem(it) } + } + } + } + private fun getAppVersion(): String = "${appInfoProvider.appVersion} (${appInfoProvider.appVersionCode})" } \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt index df2e653821..6ce7de09cd 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt @@ -16,6 +16,7 @@ import kotlinx.collections.immutable.toPersistentList import javax.inject.Inject private const val TANGEM_PAY_ITEM_ID = "get_tangem_pay" +private const val VIRTUAL_ACCOUNT_ITEM_ID = "get_virtual_account" @ModelScoped internal class ItemsBuilder @Inject constructor( @@ -81,6 +82,30 @@ internal class ItemsBuilder @Inject constructor( }.toImmutableList() } + fun addVirtualAccountItem(items: ImmutableList, onClick: () -> Unit): ImmutableList { + return items.map { block -> + if (block.id == "shop" && block is DetailsItemUM.Basic) { + val newItems = block + .items + .toMutableList() + .apply { add(getVirtualAccountItem(onClick = onClick)) } + block.copy(items = newItems.toImmutableList()) + } else { + block + } + }.toImmutableList() + } + + fun removeVirtualAccountItem(items: ImmutableList): ImmutableList { + return items.map { block -> + if (block is DetailsItemUM.Basic && block.items.any { it.id == VIRTUAL_ACCOUNT_ITEM_ID }) { + block.copy(items = block.items.filter { it.id != VIRTUAL_ACCOUNT_ITEM_ID }.toImmutableList()) + } else { + block + } + }.toImmutableList() + } + private fun buildWalletConnectBlock(isWalletConnectAvailable: Boolean, userWalletId: UserWalletId): DetailsItemUM? { return if (isWalletConnectAvailable) { DetailsItemUM.WalletConnect( @@ -194,4 +219,13 @@ internal class ItemsBuilder @Inject constructor( onClick = onClick, ), ) + + private fun getVirtualAccountItem(onClick: () -> Unit): DetailsItemUM.Basic.Item = DetailsItemUM.Basic.Item( + id = VIRTUAL_ACCOUNT_ITEM_ID, + block = BlockUM( + text = resourceReference(R.string.virtual_account_title), + iconRes = R.drawable.ic_tangem_pay_24, + onClick = onClick, + ), + ) } \ No newline at end of file From 7f1d49444cfb94183138de63c0bb811d482eb746 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 04:31:35 -0700 Subject: [PATCH 07/59] Updated on 2026-08-14 --- .../details/model/DetailsModelTestBase.kt | 12 +- .../details/impl/build.gradle.kts | 8 + .../DefaultVirtualAccountMainComponent.kt | 34 +- .../main/VirtualAccountMainModel.kt | 71 +++- ...lAccountMainNavigationBottomSheetConfig.kt | 12 + .../VirtualAccountAddFundsBottomSheet.kt | 328 ++++++++++++++++++ ...tualAccountAddFundsBottomSheetComponent.kt | 45 +++ .../addfunds/VirtualAccountAddFundsModel.kt | 70 ++++ .../main/addfunds/VirtualAccountAddFundsUM.kt | 33 ++ .../main/di/VirtualAccountMainModelModule.kt | 6 + 10 files changed, 610 insertions(+), 9 deletions(-) create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainNavigationBottomSheetConfig.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheet.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheetComponent.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsModel.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsUM.kt 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 6c48d43c14..d693d28483 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 @@ -16,6 +16,8 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase +import com.tangem.domain.virtualaccount.model.VirtualAccountEligibility +import com.tangem.domain.virtualaccount.usecase.GetVirtualAccountEligibilityUseCase import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase @@ -27,12 +29,7 @@ import com.tangem.features.details.utils.ItemsBuilder import com.tangem.features.details.utils.SocialsBuilder import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.info.AppInfoProvider -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.slot -import io.mockk.unmockkObject +import io.mockk.* import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -66,6 +63,7 @@ internal abstract class DetailsModelTestBase { protected val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase = mockk() protected val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true) protected val tangemPayEligibilityManager: TangemPayEligibilityManager = mockk() + protected val getVirtualAccountEligibilityUseCase: GetVirtualAccountEligibilityUseCase = mockk() // Captured from itemsBuilder.buildAll(...) so the feature buttons can be driven. protected val wcSlot = slot() @@ -91,6 +89,7 @@ internal abstract class DetailsModelTestBase { every { appInfoProvider.appVersion } returns "1.2.3" every { appInfoProvider.appVersionCode } returns 456 coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns emptyList() + coEvery { getVirtualAccountEligibilityUseCase(any()) } returns VirtualAccountEligibility.NotAvailable every { itemsBuilder.buildAll( @@ -132,6 +131,7 @@ internal abstract class DetailsModelTestBase { generateBuyTangemCardLinkUseCase = generateBuyTangemCardLinkUseCase, analyticsEventHandler = analyticsEventHandler, tangemPayEligibilityManager = tangemPayEligibilityManager, + getVirtualAccountEligibilityUseCase = getVirtualAccountEligibilityUseCase, ) protected fun stubBuildAllReturns(list: ImmutableList) { diff --git a/features/virtual-accounts/details/impl/build.gradle.kts b/features/virtual-accounts/details/impl/build.gradle.kts index 1d9448064a..0993a9f074 100644 --- a/features/virtual-accounts/details/impl/build.gradle.kts +++ b/features/virtual-accounts/details/impl/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) alias(deps.plugins.kotlin.kapt) alias(deps.plugins.hilt.android) id("configuration") @@ -14,12 +15,15 @@ dependencies { /** Core */ implementation(projects.core.configToggles) implementation(projects.core.decompose) + implementation(projects.core.navigation) implementation(projects.core.res) implementation(projects.core.ui) implementation(projects.core.utils) /** Domain */ implementation(projects.domain.models) + implementation(projects.domain.feedback) + implementation(projects.domain.feedback.models) /** Features */ implementation(projects.features.virtualAccounts.details.api) @@ -31,6 +35,10 @@ dependencies { implementation(deps.compose.ui.tooling) implementation(deps.decompose.ext.compose) + /** Other */ + implementation(deps.arrow.core) + implementation(deps.kotlin.immutable.collections) + /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt index 7ad7ef7c21..50ce82e412 100644 --- a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt @@ -4,24 +4,56 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier 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.decompose.ComposableBottomSheetComponent import com.tangem.features.virtualaccount.details.component.VirtualAccountMainComponent +import com.tangem.features.virtualaccount.main.addfunds.VirtualAccountAddFundsBottomSheetComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject internal class DefaultVirtualAccountMainComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, - @Assisted params: VirtualAccountMainComponent.Params, + @Assisted private val params: VirtualAccountMainComponent.Params, ) : VirtualAccountMainComponent, AppComponentContext by appComponentContext { private val model: VirtualAccountMainModel = getOrCreateModel(params = params) + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = VirtualAccountMainNavigationBottomSheetConfig.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() VirtualAccountMainScreen(state = state, modifier = modifier) + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + config: VirtualAccountMainNavigationBottomSheetConfig, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent { + return when (config) { + is VirtualAccountMainNavigationBottomSheetConfig.AddFunds -> VirtualAccountAddFundsBottomSheetComponent( + appComponentContext = childByContext(componentContext), + params = VirtualAccountAddFundsBottomSheetComponent.Params( + userWalletId = params.userWalletId, + listener = model, + requisites = config.requisites, + dailyDepositLimit = config.dailyDepositLimit, + ), + ) + } } @AssistedFactory 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 65a9fd1e60..a05c327e16 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 @@ -1,6 +1,9 @@ package com.tangem.features.virtualaccount.main import androidx.compose.runtime.Stable +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -9,6 +12,8 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.features.virtualaccount.details.component.VirtualAccountMainComponent import com.tangem.features.virtualaccount.details.impl.R +import com.tangem.features.virtualaccount.main.addfunds.VirtualAccountAddFundsBottomSheetComponent +import com.tangem.features.virtualaccount.main.addfunds.VirtualAccountAddFundsListener import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -20,16 +25,22 @@ internal class VirtualAccountMainModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val router: Router, -) : Model() { +) : Model(), VirtualAccountAddFundsListener { @Suppress("UnusedPrivateProperty") private val params = paramsContainer.require() + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val uiState: StateFlow field = MutableStateFlow( createInitialState(), ) + override fun onAddFundsDismiss() { + bottomSheetNavigation.dismiss() + } + private fun createInitialState(): VirtualAccountMainUM = VirtualAccountMainUM( title = resourceReference(R.string.virtual_account_title), subtitle = resourceReference(R.string.tangempay_usdc_on_polygon_network), @@ -40,7 +51,63 @@ internal class VirtualAccountMainModel @Inject constructor( isBalanceHidden = false, onBackClick = { router.pop() }, onMenuClick = {}, - onAddFundsClick = {}, + onAddFundsClick = ::onAddFundsClick, onSendClick = {}, ) + + private fun buildRequisites(details: VirtualAccountDepositDetails) = listOf( + VirtualAccountAddFundsBottomSheetComponent.RequisitesRow( + title = stringReference("Beneficiary name and address"), + titleForShare = "Beneficiary name and address", + value = "${details.beneficiaryName}\n${details.beneficiaryAddress}", + ), + VirtualAccountAddFundsBottomSheetComponent.RequisitesRow( + title = stringReference("Bank name and address"), + titleForShare = "Bank name and address", + value = "${details.bankName}\n${details.bankAddress}", + ), + VirtualAccountAddFundsBottomSheetComponent.RequisitesRow( + title = stringReference("Account number"), + titleForShare = "Account number", + value = details.accountNumber, + ), + VirtualAccountAddFundsBottomSheetComponent.RequisitesRow( + title = stringReference("Routing number"), + titleForShare = "Routing number", + value = details.routingNumber, + ), + ) + + private fun onAddFundsClick() { + val details = getDepositDetails() + bottomSheetNavigation.activate( + VirtualAccountMainNavigationBottomSheetConfig.AddFunds( + requisites = buildRequisites(details), + dailyDepositLimit = details.dailyDepositLimit, + ), + ) + } + + // TODO v_rodionov: HARDCODE - get this data from backend + private fun getDepositDetails(): VirtualAccountDepositDetails { + return VirtualAccountDepositDetails( + beneficiaryName = "Ivan Ivanov", + beneficiaryAddress = "18, Rue Rubens 20, Paris, Ile-de-France 75013, US", + bankName = "SSB Bank", + bankAddress = "8700 Perry Highway, Pittsburgh, PA 15237, US", + accountNumber = "707613210122", + routingNumber = "043087080", + dailyDepositLimit = "$10,000", + ) + } + + private data class VirtualAccountDepositDetails( + val beneficiaryName: String, + val beneficiaryAddress: String, + val bankName: String, + val bankAddress: String, + val accountNumber: String, + val routingNumber: String, + val dailyDepositLimit: String, + ) } \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainNavigationBottomSheetConfig.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainNavigationBottomSheetConfig.kt new file mode 100644 index 0000000000..f0de59ec19 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainNavigationBottomSheetConfig.kt @@ -0,0 +1,12 @@ +package com.tangem.features.virtualaccount.main + +import com.tangem.features.virtualaccount.main.addfunds.VirtualAccountAddFundsBottomSheetComponent.RequisitesRow +import kotlinx.serialization.Serializable + +@Serializable +internal sealed interface VirtualAccountMainNavigationBottomSheetConfig { + data class AddFunds( + val requisites: List, + val dailyDepositLimit: String, + ) : VirtualAccountMainNavigationBottomSheetConfig +} \ No newline at end of file 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 new file mode 100644 index 0000000000..4537376d28 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheet.kt @@ -0,0 +1,328 @@ +package com.tangem.features.virtualaccount.main.addfunds + +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.style.TextAlign +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.image.TangemIconUM +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.resourceReference +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_copy_24 +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.virtualaccount.details.impl.R +import kotlinx.collections.immutable.persistentListOf +import com.tangem.core.ui.R as CoreUiR + +@Composable +internal fun VirtualAccountAddFundsBottomSheet(state: VirtualAccountAddFundsUM) { + val title = stringReference("Account details") + .takeIf { state.content is VirtualAccountAddFundsUM.Content.Details } + + 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 = title, + endContent = { TangemButton.Close(onClick = state.onDismiss) }, + ) + }, + content = { _ -> + when (val content = state.content) { + is VirtualAccountAddFundsUM.Content.Intro -> IntroContent(content) + is VirtualAccountAddFundsUM.Content.Details -> DetailsContent(content) + } + }, + ) +} + +@Composable +private fun IntroContent(content: VirtualAccountAddFundsUM.Content.Intro, 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("Received USD will be converted to USDC by 1:1 rate"), + modifier = Modifier.padding(top = TangemTheme.dimens2.x8), + ) + SubtitleText( + text = stringReference("It might take 1-3 days to receive the money"), + modifier = Modifier.padding(top = TangemTheme.dimens2.x2), + ) + InfoNotification( + title = stringReference("Only ACH and domestic wire transfers are available"), + subtitle = stringReference("SWIFT won't pass"), + modifier = Modifier.padding(top = TangemTheme.dimens2.x6), + ) + TangemButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens2.x4), + text = stringReference("Show details"), + variant = TangemButton.Variant.Primary, + size = TangemButton.Size.X12, + onClick = content.onShowDetailsClick, + ) + } +} + +@Composable +private fun DetailsContent(content: VirtualAccountAddFundsUM.Content.Details, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(bottom = TangemTheme.dimens2.x4), + ) { + content.items.forEachIndexed { index, item -> + CopyableRow( + item = item, + divider = index != content.items.lastIndex, + ) + } + InfoNotification( + title = stringReference("Available to deposit per day: ${content.dailyLimit}"), + subtitle = stringReference("Limit is resetting every day"), + modifier = Modifier + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(top = TangemTheme.dimens2.x3), + ) + TangemButton( + text = resourceReference(R.string.common_share), + variant = TangemButton.Variant.Primary, + size = TangemButton.Size.X12, + onClick = content.onShareClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(top = TangemTheme.dimens2.x4), + ) + } +} + +@Composable +private fun CopyableRow(item: VirtualAccountAddFundsUM.DetailItem, divider: Boolean, modifier: Modifier = Modifier) { + TangemRow( + modifier = modifier, + divider = divider, + contentLead = TangemRowContentLead.Start, + verticalAlignment = TangemRowVerticalAlignment.Center, + titleSlot = { + TangemRowText( + text = item.label, + role = TangemRowTextRole.Subtitle, + ) + }, + subtitleSlot = { + TangemRowText( + text = item.value, + role = TangemRowTextRole.Title, + maxLines = Int.MAX_VALUE, + ) + }, + endSlot = { + TangemButton( + iconStart = TangemIconUM.Icon(imageVector = Icons.ic_copy_24), + onClick = item.onCopyClick, + size = TangemButton.Size.X9, + variant = TangemButton.Variant.Ghost, + contentDescription = item.label.resolveReference(), + ) + }, + ) +} + +@Composable +private fun InfoNotification(title: TextReference, subtitle: 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, + ) + Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x0_5)) { + Text( + text = title.resolveReference(), + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.primary, + ) + Text( + text = subtitle.resolveReference(), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, + ) + } + } +} + +@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, + ) +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun VirtualAccountAddFundsIntroPreview() { + TangemThemePreviewRedesign { + IntroContent( + content = VirtualAccountAddFundsUM.Content.Intro( + onShowDetailsClick = {}, + ), + modifier = Modifier.background(TangemTheme.colors3.bg.secondary), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun VirtualAccountAddFundsDetailsPreview() { + TangemThemePreviewRedesign { + DetailsContent( + content = VirtualAccountAddFundsUM.Content.Details( + items = persistentListOf( + VirtualAccountAddFundsUM.DetailItem( + label = stringReference("Beneficiary name and address"), + value = "Ivan Ivanov\n18, Rue Rubens 20, Paris, Ile-de-France 75013, US", + onCopyClick = {}, + ), + VirtualAccountAddFundsUM.DetailItem( + label = stringReference("Account number"), + value = "707613210122", + onCopyClick = {}, + ), + ), + dailyLimit = "$10,000", + onShareClick = {}, + ), + modifier = Modifier.background(TangemTheme.colors3.bg.secondary), + ) + } +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheetComponent.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheetComponent.kt new file mode 100644 index 0000000000..4faaffcba5 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheetComponent.kt @@ -0,0 +1,45 @@ +package com.tangem.features.virtualaccount.main.addfunds + +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.core.ui.extensions.TextReference +import com.tangem.domain.models.wallet.UserWalletId + +internal class VirtualAccountAddFundsBottomSheetComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: VirtualAccountAddFundsModel = getOrCreateModel(params = params) + + override fun dismiss() { + model.onDismiss() + } + + @Composable + override fun BottomSheet() { + val state by model.uiState.collectAsStateWithLifecycle() + VirtualAccountAddFundsBottomSheet(state = state) + } + + data class Params( + val userWalletId: UserWalletId, + val requisites: List, + val dailyDepositLimit: String, + val listener: VirtualAccountAddFundsListener, + ) + + data class RequisitesRow( + val title: TextReference, + val titleForShare: String, + val value: String, + ) +} + +internal interface VirtualAccountAddFundsListener { + fun onAddFundsDismiss() +} \ No newline at end of file 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 new file mode 100644 index 0000000000..d78ef65a65 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsModel.kt @@ -0,0 +1,70 @@ +package com.tangem.features.virtualaccount.main.addfunds + +import androidx.compose.runtime.Stable +import androidx.compose.ui.util.fastForEach +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.share.ShareManager +import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.core.ui.extensions.TextReference +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@Stable +@ModelScoped +internal class VirtualAccountAddFundsModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val clipboardManager: ClipboardManager, + private val shareManager: ShareManager, +) : Model() { + + private val params = paramsContainer.require() + + val uiState: StateFlow + field = MutableStateFlow( + VirtualAccountAddFundsUM( + onDismiss = ::onDismiss, + content = VirtualAccountAddFundsUM.Content.Intro( + onShowDetailsClick = { showDetailsContent() }, + ), + ), + ) + + fun onDismiss() { + params.listener.onAddFundsDismiss() + } + + private fun showDetailsContent() { + uiState.update { state -> + state.copy( + content = VirtualAccountAddFundsUM.Content.Details( + items = params.requisites + .map { detailItem(label = it.title, value = it.value) } + .toImmutableList(), + dailyLimit = params.dailyDepositLimit, + onShareClick = { shareManager.shareText(buildShareText()) }, + ), + ) + } + } + + private fun detailItem(label: TextReference, value: String) = VirtualAccountAddFundsUM.DetailItem( + label = label, + value = value, + onCopyClick = { clipboardManager.setText(text = value, isSensitive = true) }, + ) + + private fun buildShareText(): String { + return buildString { + params.requisites.fastForEach { item -> + appendLine("${item.titleForShare}: ${item.value}") + } + } + } +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsUM.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsUM.kt new file mode 100644 index 0000000000..4665eddc8f --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsUM.kt @@ -0,0 +1,33 @@ +package com.tangem.features.virtualaccount.main.addfunds + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class VirtualAccountAddFundsUM( + val onDismiss: () -> Unit, + val content: Content, +) { + + @Immutable + sealed interface Content { + + data class Intro( + val onShowDetailsClick: () -> Unit, + ) : Content + + data class Details( + val items: ImmutableList, + val dailyLimit: String, + val onShareClick: () -> Unit, + ) : Content + } + + @Immutable + data class DetailItem( + val label: TextReference, + val value: String, + val onCopyClick: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainModelModule.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainModelModule.kt index 9c621bc6fc..4b85e1f7d4 100644 --- a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainModelModule.kt +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainModelModule.kt @@ -3,6 +3,7 @@ package com.tangem.features.virtualaccount.main.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model import com.tangem.features.virtualaccount.main.VirtualAccountMainModel +import com.tangem.features.virtualaccount.main.addfunds.VirtualAccountAddFundsModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -17,4 +18,9 @@ internal interface VirtualAccountMainModelModule { @IntoMap @ClassKey(VirtualAccountMainModel::class) fun bindVirtualAccountMainModel(model: VirtualAccountMainModel): Model + + @Binds + @IntoMap + @ClassKey(VirtualAccountAddFundsModel::class) + fun bindVirtualAccountAddFundsModel(model: VirtualAccountAddFundsModel): Model } \ No newline at end of file From 53bde31f8961031334c92c7fd3cf730ca40c6fbb Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 18:24:12 +0500 Subject: [PATCH 08/59] Updated on 2026-08-14 --- .../tangem/datasource/api/pay/TangemPayApi.kt | 13 ++++ .../response/BankCredentialsResponse.kt | 19 ++++++ data/visa/build.gradle.kts | 1 + .../PaymentAccountStatusValueDMConverter.kt | 2 + .../DefaultPaymentAccountStatusFetcher.kt | 59 +++++++++++++--- .../repository/DefaultOnboardingRepository.kt | 21 ++++++ .../data/pay/util/BankCredentialsConverter.kt | 19 ++++++ .../data/pay/util/CustomerInfoConverter.kt | 8 +++ .../MockAwareOnboardingRepository.kt | 10 +++ .../pay/util/BankCredentialsConverterTest.kt | 67 +++++++++++++++++++ .../domain/models/account/BankCredentials.kt | 20 ++++++ .../account/PaymentAccountStatusValue.kt | 3 + .../models/account/VirtualAccountOnramp.kt | 28 ++++++++ .../models/pay/TangemPayEligibilityType.kt | 3 + .../tangem/domain/pay/model/CustomerInfo.kt | 7 ++ .../pay/repository/OnboardingRepository.kt | 15 +++++ 16 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BankCredentialsResponse.kt create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/util/BankCredentialsConverter.kt create mode 100644 data/visa/src/test/kotlin/com/tangem/data/pay/util/BankCredentialsConverterTest.kt create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/account/BankCredentials.kt create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/account/VirtualAccountOnramp.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 80fcf19f81..37f8928d79 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -23,6 +23,13 @@ interface TangemPayApi { @GET("v1/customer/me") suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse + /** Fiat bank requisites for the Virtual Account on-ramp (VA MVP0, TWI-1638). */ + @GET("v1/account/bank-credentials/{product_instance_id}") + suspend fun getBankCredentials( + @Header("Authorization") authHeader: String, + @Path("product_instance_id") productInstanceId: String, + ): ApiResponse + @GET("v1/customer/wallets/{customer_wallet_id}") suspend fun checkCustomerWalletId( @Path("customer_wallet_id") customerWalletId: String, @@ -40,6 +47,12 @@ interface TangemPayApi { @GET("v1/eligibility/channels") suspend fun getEligibilityChannels(): ApiResponse + /** Eligibility channels fetched with the user (customer-wallet) token (VA MVP0, TWI-1638). */ + @GET("v1/eligibility/channels") + suspend fun getUserEligibilityChannels( + @Header("Authorization") authHeader: String, + ): ApiResponse + @GET("v1/order/{order_id}") suspend fun getOrder( @Header("Authorization") authHeader: String, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BankCredentialsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BankCredentialsResponse.kt new file mode 100644 index 0000000000..409984fabc --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BankCredentialsResponse.kt @@ -0,0 +1,19 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Response of `bff-v2/v1/account/bank-credentials/{product_instance_id}` — fiat bank requisites for the + * Virtual Account on-ramp (VA MVP0, TWI-1638). + */ +@JsonClass(generateAdapter = true) +data class BankCredentialsResponse( + @Json(name = "type") val type: String?, + @Json(name = "beneficiary_name") val beneficiaryName: String?, + @Json(name = "beneficiary_address") val beneficiaryAddress: String?, + @Json(name = "beneficiary_bank_name") val beneficiaryBankName: String?, + @Json(name = "beneficiary_bank_address") val beneficiaryBankAddress: String?, + @Json(name = "account_number") val accountNumber: String?, + @Json(name = "routing_number") val routingNumber: String?, +) \ No newline at end of file diff --git a/data/visa/build.gradle.kts b/data/visa/build.gradle.kts index 2cc54a86b8..420ab0b3d2 100644 --- a/data/visa/build.gradle.kts +++ b/data/visa/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(projects.domain.quotes) implementation(projects.domain.common) implementation(projects.features.swap.domain) + implementation(projects.features.virtualAccounts.details.api) /** Project - Utils */ 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 3b7fe7face..55b9d892dd 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,6 +5,7 @@ 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 @@ -118,6 +119,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( ) }, error = null, + virtualAccount = VirtualAccountOnramp.None, ) is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview( source = StatusSource.CACHE, 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 57447e1f15..0863949d7e 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 @@ -4,21 +4,16 @@ import arrow.core.Either import com.tangem.data.pay.store.PaymentAccountStatusesStore import com.tangem.domain.core.utils.catchOn 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.hasAccountData +import com.tangem.domain.models.account.* 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.TangemPayCardLimitData -import com.tangem.domain.models.pay.TangemPayCardState +import com.tangem.domain.models.pay.* import com.tangem.domain.models.quote.QuoteStatus 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.model.CustomerInfo.ProductInstance.SpecificationDataType import com.tangem.domain.pay.model.OrderData import com.tangem.domain.pay.model.OrderStatus import com.tangem.domain.pay.model.TangemPayEntryPoint @@ -26,6 +21,7 @@ import com.tangem.domain.pay.repository.* import com.tangem.domain.quotes.single.SingleQuoteStatusProducer 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.security.isSecurityExposed import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -66,6 +62,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( private val closeCardRepository: TangemPayCloseCardRepository, private val cardDetailsRepository: TangemPayCardDetailsRepository, private val issueCardRepository: TangemPayIssueCardRepository, + private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles, ) : PaymentAccountStatusFetcher { private val logger = TangemLogger.withTag(TAG) @@ -376,6 +373,8 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( // the previously shown order and append newly seen cards at the end. val orderedCards = tangemPayCards.stableOrder(previousRealCardOrder(userWalletId)) + val virtualAccount = resolveVirtualAccountOnramp(userWalletId) + return PaymentAccountStatusValue.Loaded( source = StatusSource.ACTUAL, customerId = customerId, @@ -389,6 +388,50 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( availableForWithdrawal = availableForWithdrawal.orZero(), ), error = null, + virtualAccount = virtualAccount, + ) + } + + /** + * 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]. + */ + private suspend fun CustomerInfo.resolveVirtualAccountOnramp(userWalletId: UserWalletId): VirtualAccountOnramp { + if (!virtualAccountFeatureToggles.isVaMvp0Enabled) return VirtualAccountOnramp.None + + val accountInstance = productInstances.firstOrNull { + it.specificationDataType == SpecificationDataType.ACCOUNT + } + if (accountInstance != null) { + return onboardingRepository.getBankCredentials(userWalletId, accountInstance.id).fold( + ifLeft = { error -> + logger.e("getBankCredentials failed for ${accountInstance.id}: $error") + VirtualAccountOnramp.None + }, + ifRight = { credentials -> + VirtualAccountOnramp.Available( + productInstanceId = accountInstance.id, + bankCredentials = credentials, + ) + }, + ) + } + + return onboardingRepository.fetchCustomerEligibility(userWalletId).fold( + ifLeft = { error -> + logger.e("fetchCustomerEligibility failed for $userWalletId: $error") + VirtualAccountOnramp.None + }, + ifRight = { channels -> + if (channels.contains(TangemPayEligibilityType.VISA_VIRTUAL_ACCOUNT)) { + VirtualAccountOnramp.Eligible + } else { + VirtualAccountOnramp.None + } + }, ) } 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 932e7041bc..786fc9397c 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 @@ -6,6 +6,7 @@ import arrow.core.left import arrow.core.right import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.data.pay.store.PaymentAccountStatusesStore +import com.tangem.data.pay.util.BankCredentialsConverter import com.tangem.data.pay.util.CustomerInfoConverter import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest @@ -18,6 +19,7 @@ import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.common.wallets.UserWalletsListRepository 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 import com.tangem.domain.models.kyc.KycStatus import com.tangem.domain.models.pay.TangemPayEligibilityType @@ -105,6 +107,15 @@ internal class DefaultOnboardingRepository @Inject constructor( } } + override suspend fun getBankCredentials( + userWalletId: UserWalletId, + productInstanceId: String, + ): Either { + return requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.getBankCredentials(authHeader = authHeader, productInstanceId = productInstanceId) + }.map { response -> BankCredentialsConverter.convert(response) } + } + override suspend fun isTangemPayDeactivated(userWalletId: UserWalletId): Boolean { return tangemPayStorage.isTangemPayDeactivated(userWalletId) } @@ -226,6 +237,16 @@ internal class DefaultOnboardingRepository @Inject constructor( return tangemPayStorage.getTangemPayEligibility().map(TangemPayEligibilityType::fromString) } + override suspend fun fetchCustomerEligibility( + userWalletId: UserWalletId, + ): Either> { + return requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.getUserEligibilityChannels(authHeader) + }.map { response -> + response.result.channels.map(TangemPayEligibilityType::fromString) + } + } + override suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean { return tangemPayStorage.getHideMainOnboardingBanner(userWalletId) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/BankCredentialsConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/BankCredentialsConverter.kt new file mode 100644 index 0000000000..4f027487a6 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/BankCredentialsConverter.kt @@ -0,0 +1,19 @@ +package com.tangem.data.pay.util + +import com.tangem.datasource.api.pay.models.response.BankCredentialsResponse +import com.tangem.domain.models.account.BankCredentials +import com.tangem.utils.converter.Converter + +internal object BankCredentialsConverter : Converter { + override fun convert(value: BankCredentialsResponse): BankCredentials { + return BankCredentials( + type = value.type.orEmpty(), + beneficiaryName = value.beneficiaryName.orEmpty(), + beneficiaryAddress = value.beneficiaryAddress.orEmpty(), + beneficiaryBankName = value.beneficiaryBankName.orEmpty(), + beneficiaryBankAddress = value.beneficiaryBankAddress.orEmpty(), + accountNumber = value.accountNumber.orEmpty(), + routingNumber = value.routingNumber.orEmpty(), + ) + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt index cc7ac9f9a8..2fd76d8480 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt @@ -14,6 +14,7 @@ import com.tangem.domain.models.pay.TangemPayCardLimitPeriod import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.CustomerInfo.CardInfo import com.tangem.domain.pay.model.CustomerInfo.ProductInstance +import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.SpecificationDataType import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.Status import com.tangem.utils.converter.Converter import com.tangem.utils.extensions.orZero @@ -62,6 +63,7 @@ internal object CustomerInfoConverter : Converter Status.CANCELED CustomerMeResponse.ProductInstance.Status.UNKNOWN -> Status.UNKNOWN } + + private fun CustomerMeResponse.ProductInstance.SpecificationDataType.toDomain(): SpecificationDataType = + when (this) { + CustomerMeResponse.ProductInstance.SpecificationDataType.ACCOUNT -> SpecificationDataType.ACCOUNT + CustomerMeResponse.ProductInstance.SpecificationDataType.CARD -> SpecificationDataType.CARD + } } \ No newline at end of file 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 38ccbe554a..14dcad9d79 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 @@ -6,6 +6,7 @@ import com.tangem.core.error.UniversalError import com.tangem.datasource.api.common.config.ApiConfig import com.tangem.datasource.api.common.config.ApiEnvironment import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.domain.models.account.BankCredentials import com.tangem.domain.models.pay.TangemPayEligibilityType import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.CustomerInfo @@ -47,6 +48,11 @@ internal class MockAwareOnboardingRepository @Inject constructor( override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either = real.getCustomerInfo(userWalletId) + override suspend fun getBankCredentials( + userWalletId: UserWalletId, + productInstanceId: String, + ): Either = real.getBankCredentials(userWalletId, productInstanceId) + override suspend fun createOrder(userWalletId: UserWalletId): Either { if (isMockMode) { mockOrderIds.add(userWalletId) @@ -77,6 +83,10 @@ internal class MockAwareOnboardingRepository @Inject constructor( override suspend fun getCustomerEligibility(): List = real.getCustomerEligibility() + override suspend fun fetchCustomerEligibility( + userWalletId: UserWalletId, + ): Either> = real.fetchCustomerEligibility(userWalletId) + override fun getSavedCustomerInfo(userWalletId: UserWalletId): CustomerInfo? = real.getSavedCustomerInfo(userWalletId) diff --git a/data/visa/src/test/kotlin/com/tangem/data/pay/util/BankCredentialsConverterTest.kt b/data/visa/src/test/kotlin/com/tangem/data/pay/util/BankCredentialsConverterTest.kt new file mode 100644 index 0000000000..6ec1cceb6c --- /dev/null +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/util/BankCredentialsConverterTest.kt @@ -0,0 +1,67 @@ +package com.tangem.data.pay.util + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.pay.models.response.BankCredentialsResponse +import com.tangem.domain.models.account.BankCredentials +import org.junit.jupiter.api.Test + +internal class BankCredentialsConverterTest { + + @Test + fun `GIVEN full response WHEN convert THEN all fields mapped`() { + // Arrange + val response = BankCredentialsResponse( + type = "fiat", + beneficiaryName = "Ivan Ivanov", + beneficiaryAddress = "18, Rue Rubens 20, Paris, Ile-de-France 75013, US", + beneficiaryBankName = "SSB BANK", + beneficiaryBankAddress = "8700 Perry Highway, Pittsburgh, PA 15237, US", + accountNumber = "707613210122", + routingNumber = "043087080", + ) + + // Act + val actual = BankCredentialsConverter.convert(response) + + // Assert + val expected = BankCredentials( + type = "fiat", + beneficiaryName = "Ivan Ivanov", + beneficiaryAddress = "18, Rue Rubens 20, Paris, Ile-de-France 75013, US", + beneficiaryBankName = "SSB BANK", + beneficiaryBankAddress = "8700 Perry Highway, Pittsburgh, PA 15237, US", + accountNumber = "707613210122", + routingNumber = "043087080", + ) + assertThat(actual).isEqualTo(expected) + } + + @Test + fun `GIVEN null fields WHEN convert THEN mapped to empty strings`() { + // Arrange + val response = BankCredentialsResponse( + type = null, + beneficiaryName = null, + beneficiaryAddress = null, + beneficiaryBankName = null, + beneficiaryBankAddress = null, + accountNumber = null, + routingNumber = null, + ) + + // Act + val actual = BankCredentialsConverter.convert(response) + + // Assert + val expected = BankCredentials( + type = "", + beneficiaryName = "", + beneficiaryAddress = "", + beneficiaryBankName = "", + beneficiaryBankAddress = "", + accountNumber = "", + routingNumber = "", + ) + assertThat(actual).isEqualTo(expected) + } +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/BankCredentials.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/BankCredentials.kt new file mode 100644 index 0000000000..adbeacdb40 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/BankCredentials.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.models.account + +import kotlinx.serialization.Serializable + +/** + * Bank (fiat) credentials for a Virtual Account on-ramp — the wire/ACH requisites a user transfers funds to. + * + * Returned by `bff-v2/v1/account/bank-credentials/{product_instance_id}`. Sensitive data — kept transient + * (never persisted in the local payment-account cache). + */ +@Serializable +data class BankCredentials( + val type: String, + val beneficiaryName: String, + val beneficiaryAddress: String, + val beneficiaryBankName: String, + val beneficiaryBankAddress: String, + val accountNumber: String, + val routingNumber: String, +) \ 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 d32bc79f81..e9699e8118 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,6 +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). + * Transient: not persisted in the local cache. */ @Serializable data class Loaded( @@ -160,6 +162,7 @@ sealed class PaymentAccountStatusValue { val cards: List, val fiatRate: SerializedBigDecimal?, val error: Error?, + val virtualAccount: VirtualAccountOnramp, ) : PaymentAccountStatusValue() { val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( currency = cryptoCurrency, 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 new file mode 100644 index 0000000000..2b504b4c0f --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/VirtualAccountOnramp.kt @@ -0,0 +1,28 @@ +package com.tangem.domain.models.account + +import kotlinx.serialization.Serializable + +/** + * Virtual Account (Visa on-ramp) availability for a payment account — VA MVP0 (TWI-1638). + * + * Computed in the payment-account fetcher and surfaced on [PaymentAccountStatusValue.Loaded]. + * Transient: [Available.bankCredentials] is never persisted in the local cache. + */ +@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 + + /** VA product instance exists; [bankCredentials] are the fiat requisites for the bank-transfer top-up. */ + @Serializable + data class Available( + val productInstanceId: String, + val bankCredentials: BankCredentials, + ) : VirtualAccountOnramp +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt index 8c2bf08afa..2bac433902 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt @@ -10,6 +10,8 @@ enum class TangemPayEligibilityType { DETAILS_VIRTUAL_ACCOUNT, DEEPLINK_VIRTUAL_ACCOUNT, + VISA_VIRTUAL_ACCOUNT, + UNKNOWN, ; @@ -21,6 +23,7 @@ enum class TangemPayEligibilityType { "BANNER_VIRTUAL_ACCOUNT" -> BANNER_VIRTUAL_ACCOUNT "DETAILS_VIRTUAL_ACCOUNT" -> DETAILS_VIRTUAL_ACCOUNT "DEEPLINK_VIRTUAL_ACCOUNT" -> DEEPLINK_VIRTUAL_ACCOUNT + "VISA_VIRTUAL_ACCOUNT" -> VISA_VIRTUAL_ACCOUNT else -> UNKNOWN } } diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index 2b96bd312f..752773bb5f 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -67,6 +67,7 @@ data class CustomerInfo( val actualCardLimit: TangemPayCardLimit?, val adminCardLimit: TangemPayCardLimit?, val status: Status, + val specificationDataType: SpecificationDataType, ) { enum class Status { NEW, @@ -82,6 +83,12 @@ data class CustomerInfo( CANCELED, UNKNOWN, } + + /** `ACCOUNT` marks a Virtual Account instance (vs. a `CARD`); used by VA MVP0 (TWI-1638). */ + enum class SpecificationDataType { + ACCOUNT, + CARD, + } } data class CardInfo( diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt index bce59b45c7..169c6ed172 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt @@ -2,6 +2,7 @@ package com.tangem.domain.pay.repository import arrow.core.Either import com.tangem.core.error.UniversalError +import com.tangem.domain.models.account.BankCredentials import com.tangem.domain.models.pay.TangemPayEligibilityType import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.CustomerInfo @@ -17,6 +18,12 @@ interface OnboardingRepository { suspend fun getCustomerInfo(userWalletId: UserWalletId): Either + /** Fiat bank requisites for the wallet's Virtual Account on-ramp instance (VA MVP0, TWI-1638). */ + suspend fun getBankCredentials( + userWalletId: UserWalletId, + productInstanceId: String, + ): Either + suspend fun createOrder(userWalletId: UserWalletId): Either suspend fun clearOrderId(userWalletId: UserWalletId) @@ -28,6 +35,14 @@ interface OnboardingRepository { suspend fun checkCustomerEligibility(): List suspend fun getCustomerEligibility(): List + /** + * Fetches eligibility channels fresh via the user token (always hits the network, no cache read/write). + * Differs from [checkCustomerEligibility] (static token, caches) and [getCustomerEligibility] (cache only). + */ + suspend fun fetchCustomerEligibility( + userWalletId: UserWalletId, + ): Either> + fun getSavedCustomerInfo(userWalletId: UserWalletId): CustomerInfo? suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean From 7443421bb1eea960216ddd2b71ca239078de6acf Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Jun 2026 21:18:17 +0500 Subject: [PATCH 09/59] 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 55b9d892dd..42bc75ffee 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, ) is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview( source = StatusSource.CACHE, 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 0863949d7e..c962f2dcdc 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 @@ -396,11 +396,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 @@ -409,7 +408,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( @@ -423,13 +422,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 e9699e8118..3cb70b9aaf 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. */ @Serializable @@ -162,7 +163,7 @@ sealed class PaymentAccountStatusValue { val cards: List, val fiatRate: SerializedBigDecimal?, val error: Error?, - val virtualAccount: VirtualAccountOnramp, + val virtualAccount: VirtualAccountOnramp?, ) : PaymentAccountStatusValue() { val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( currency = cryptoCurrency, 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 29e3faebb5..20e73435b6 100644 --- a/features/tangempay/details/impl/build.gradle.kts +++ b/features/tangempay/details/impl/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(projects.features.tokenRecieve.api) implementation(projects.features.txhistory.api) implementation(projects.features.tokendetails.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 26e12e9976..aa4019105b 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 @@ -126,6 +126,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 0cc4f2aa1d..941998d5d0 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 @@ -45,6 +45,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 076230a318..489666b5b0 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 @@ -175,6 +175,7 @@ internal class TangemPayDetailsModel @Inject constructor( cryptoBalance = balance.availableForWithdrawal, depositAddress = balance.cryptoBalance.depositAddress, cryptoCurrency = cryptoCurrency, + virtualAccountOnramp = currentStatus.value.ifLoadedOrNull { it.virtualAccount }, ), ) } @@ -309,6 +310,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 From b83b9d4ef63da6d1de6ad9bd6414634630b94c55 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 15:00:40 +0500 Subject: [PATCH 10/59] Updated on 2026-08-14 --- ...faultTangemPayDetailsContainerComponent.kt | 4 ++ .../components/TangemPayCardPageComponent.kt | 3 ++ .../TangemPayCardPageScreenComponent.kt | 16 +++++++ .../components/TangemPayDetailsComponent.kt | 16 +++++++ ...TangemPayVirtualAccountDepositComponent.kt | 1 + .../entity/TangemPayCardNavigation.kt | 7 +++ .../entity/TangemPayDetailsNavigation.kt | 7 +++ .../tangempay/model/TangemPayCardPageModel.kt | 11 +++++ .../tangempay/model/TangemPayDetailsModel.kt | 11 +++++ .../TangemPayVirtualAccountDepositModel.kt | 5 ++- .../utils/VirtualAccountRequisites.kt | 38 ++++++++++++++++ ...tualAccountAddFundsBottomSheetComponent.kt | 34 ++++++++++++++ .../DefaultVirtualAccountMainComponent.kt | 7 +-- .../main/VirtualAccountMainModel.kt | 12 ++--- ...lAccountMainNavigationBottomSheetConfig.kt | 2 +- ...tualAccountAddFundsBottomSheetComponent.kt | 37 +++++++++++++++ ...tualAccountAddFundsBottomSheetComponent.kt | 45 ------------------- .../addfunds/VirtualAccountAddFundsModel.kt | 35 ++++++++------- .../di/VirtualAccountMainComponentModule.kt | 9 +++- 19 files changed, 226 insertions(+), 74 deletions(-) create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/VirtualAccountRequisites.kt create mode 100644 features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountAddFundsBottomSheetComponent.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/DefaultVirtualAccountAddFundsBottomSheetComponent.kt delete mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheetComponent.kt diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt index ab9078108e..11d5561e44 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -19,16 +19,19 @@ import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRout import com.tangem.features.tangempay.utils.userWalletId import com.tangem.features.tokendetails.ExpressTransactionsComponent import com.tangem.features.tokenreceive.TokenReceiveComponent +import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +@Suppress("LongParameterList") internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constructor( @Assisted private val appComponentContext: AppComponentContext, @Assisted private val params: TangemPayDetailsContainerComponent.Params, private val tangemPayCardPageFactory: TangemPayCardPageComponent.Factory, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, private val expressTransactionsComponentFactory: ExpressTransactionsComponent.Factory, + private val virtualAccountAddFundsComponentFactory: VirtualAccountAddFundsBottomSheetComponent.Factory, ) : AppComponentContext by appComponentContext, TangemPayDetailsContainerComponent { private val stackNavigation = StackNavigation() @@ -65,6 +68,7 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru params = params, tokenReceiveComponentFactory = tokenReceiveComponentFactory, expressTransactionsComponentFactory = expressTransactionsComponentFactory, + virtualAccountAddFundsComponentFactory = virtualAccountAddFundsComponentFactory, ) is TangemPayAccountDetailsInnerRoute.CardDetails -> tangemPayCardPageFactory.create( context = childByContext(componentContext = componentContext, router = innerRouter), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt index 51137888da..189fe07e48 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt @@ -23,6 +23,7 @@ import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupSuccessC import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute import com.tangem.features.tangempay.utils.userWalletId import com.tangem.features.tokenreceive.TokenReceiveComponent +import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -31,6 +32,7 @@ internal class TangemPayCardPageComponent @AssistedInject constructor( @Assisted private val appComponentContext: AppComponentContext, @Assisted private val params: Params, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, + private val virtualAccountAddFundsComponentFactory: VirtualAccountAddFundsBottomSheetComponent.Factory, private val tangemPayFeatureToggles: TangemPayFeatureToggles, ) : ComposableContentComponent, AppComponentContext by appComponentContext { @@ -69,6 +71,7 @@ internal class TangemPayCardPageComponent @AssistedInject constructor( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), params = params, tokenReceiveComponentFactory = tokenReceiveComponentFactory, + virtualAccountAddFundsComponentFactory = virtualAccountAddFundsComponentFactory, ) is TangemPayCardDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), 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 7a193232f2..19541d7568 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 @@ -20,13 +20,18 @@ import com.tangem.features.tangempay.closure.TangemPayCloseCardComponent import com.tangem.features.tangempay.entity.TangemPayCardNavigation import com.tangem.features.tangempay.model.TangemPayCardPageModel import com.tangem.features.tangempay.ui.TangemPayCardPageScreen +import com.tangem.features.tangempay.utils.VA_DAILY_DEPOSIT_LIMIT_PLACEHOLDER +import com.tangem.features.tangempay.utils.toRequisitesRows import com.tangem.features.tangempay.utils.userWalletId import com.tangem.features.tokenreceive.TokenReceiveComponent +import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent +import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsListener internal class TangemPayCardPageScreenComponent( private val appComponentContext: AppComponentContext, private val params: TangemPayCardPageComponent.Params, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, + private val virtualAccountAddFundsComponentFactory: VirtualAccountAddFundsBottomSheetComponent.Factory, ) : AppComponentContext by appComponentContext, ComposableContentComponent { private val model: TangemPayCardPageModel = getOrCreateModel(params = params) @@ -105,6 +110,17 @@ internal class TangemPayCardPageScreenComponent( params = TangemPayVirtualAccountDepositComponent.Params( virtualAccountOnramp = navigation.virtualAccountOnramp, onDismiss = model.bottomSheetNavigation::dismiss, + onShowDetails = model::onShowVirtualAccountRequisites, + ), + ) + is TangemPayCardNavigation.VirtualAccountRequisites -> virtualAccountAddFundsComponentFactory.create( + context = context, + params = VirtualAccountAddFundsBottomSheetComponent.Params( + userWalletId = navigation.userWalletId, + requisites = navigation.bankCredentials.toRequisitesRows(), + dailyDepositLimit = VA_DAILY_DEPOSIT_LIMIT_PLACEHOLDER, + shouldSkipIntro = true, + listener = VirtualAccountAddFundsListener { 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 aa4019105b..bbd64c84ac 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 @@ -24,16 +24,21 @@ import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation import com.tangem.features.tangempay.model.TangemPayDetailsModel import com.tangem.features.tangempay.ui.TangemPayDetailsScreen import com.tangem.features.tangempay.ui.TangemPayDetailsScreenV2 +import com.tangem.features.tangempay.utils.VA_DAILY_DEPOSIT_LIMIT_PLACEHOLDER import com.tangem.features.tangempay.utils.requireLoaded +import com.tangem.features.tangempay.utils.toRequisitesRows import com.tangem.features.tangempay.utils.userWalletId import com.tangem.features.tokendetails.ExpressTransactionsComponent import com.tangem.features.tokenreceive.TokenReceiveComponent +import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent +import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsListener internal class TangemPayDetailsComponent( private val appComponentContext: AppComponentContext, private val params: TangemPayDetailsContainerComponent.Params, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, private val expressTransactionsComponentFactory: ExpressTransactionsComponent.Factory, + private val virtualAccountAddFundsComponentFactory: VirtualAccountAddFundsBottomSheetComponent.Factory, ) : AppComponentContext by appComponentContext, ComposableContentComponent { private val model: TangemPayDetailsModel = getOrCreateModel(params = params) @@ -134,6 +139,17 @@ internal class TangemPayDetailsComponent( params = TangemPayVirtualAccountDepositComponent.Params( virtualAccountOnramp = navigation.virtualAccountOnramp, onDismiss = model.bottomSheetNavigation::dismiss, + onShowDetails = model::onShowVirtualAccountRequisites, + ), + ) + is TangemPayDetailsNavigation.VirtualAccountRequisites -> virtualAccountAddFundsComponentFactory.create( + context = context, + params = VirtualAccountAddFundsBottomSheetComponent.Params( + userWalletId = navigation.userWalletId, + requisites = navigation.bankCredentials.toRequisitesRows(), + dailyDepositLimit = VA_DAILY_DEPOSIT_LIMIT_PLACEHOLDER, + shouldSkipIntro = true, + listener = VirtualAccountAddFundsListener { 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 index 6aed0c69e9..d7ab98fe71 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 @@ -31,5 +31,6 @@ internal class TangemPayVirtualAccountDepositComponent( data class Params( val virtualAccountOnramp: VirtualAccountOnramp, val onDismiss: () -> Unit, + val onShowDetails: (VirtualAccountOnramp.Available) -> Unit, ) } \ No newline at end of file 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 98b7a9ecc5..76de57b42b 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.BankCredentials import com.tangem.domain.models.account.VirtualAccountOnramp import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.serialization.SerializedBigDecimal @@ -39,6 +40,12 @@ internal sealed class TangemPayCardNavigation { val virtualAccountOnramp: VirtualAccountOnramp, ) : TangemPayCardNavigation() + @Serializable + data class VirtualAccountRequisites( + val userWalletId: UserWalletId, + val bankCredentials: BankCredentials, + ) : 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 67ba299b4a..5e1f43d8ed 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.BankCredentials import com.tangem.domain.models.account.VirtualAccountOnramp import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.serialization.SerializedBigDecimal @@ -30,6 +31,12 @@ internal sealed class TangemPayDetailsNavigation { val virtualAccountOnramp: VirtualAccountOnramp, ) : TangemPayDetailsNavigation() + @Serializable + data class VirtualAccountRequisites( + val userWalletId: UserWalletId, + val bankCredentials: BankCredentials, + ) : TangemPayDetailsNavigation() + @Serializable data class TransactionDetails( val transaction: TangemPayTxHistoryItem, 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 6f57b78a10..05e00caa00 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 @@ -28,6 +28,7 @@ import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokenReceiveConfig 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.account.findCardWithId import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.pay.TangemPayCardLimitPeriod @@ -491,6 +492,16 @@ internal class TangemPayCardPageModel @Inject constructor( bottomSheetNavigation.activate(TangemPayCardNavigation.VirtualAccountDeposit(onramp)) } + fun onShowVirtualAccountRequisites(onramp: VirtualAccountOnramp.Available) { + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate( + TangemPayCardNavigation.VirtualAccountRequisites( + userWalletId = userWalletId, + bankCredentials = onramp.bankCredentials, + ), + ) + } + 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 489666b5b0..392587392f 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 @@ -25,6 +25,7 @@ import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.models.TokenReceiveConfig 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.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier @@ -316,6 +317,16 @@ internal class TangemPayDetailsModel @Inject constructor( bottomSheetNavigation.activate(TangemPayDetailsNavigation.VirtualAccountDeposit(onramp)) } + fun onShowVirtualAccountRequisites(onramp: VirtualAccountOnramp.Available) { + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate( + TangemPayDetailsNavigation.VirtualAccountRequisites( + userWalletId = userWalletId, + bankCredentials = onramp.bankCredentials, + ), + ) + } + 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 index 06d81c0b50..cc9bb49005 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 @@ -40,7 +40,10 @@ internal class TangemPayVirtualAccountDepositModel @Inject constructor( } private fun onShowDetailsClick() { - // TODO([REDACTED_TASK_KEY]): VA MVP0 — open the bank-transfer requisites screen (separate PR). + when (params.virtualAccountOnramp) { + is VirtualAccountOnramp.Available -> params.onShowDetails(params.virtualAccountOnramp) + VirtualAccountOnramp.Eligible -> TODO() + } } private companion object { 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 new file mode 100644 index 0000000000..d933edbc10 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/VirtualAccountRequisites.kt @@ -0,0 +1,38 @@ +package com.tangem.features.tangempay.utils + +import com.tangem.domain.models.account.BankCredentials +import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent.RequisitesRow + +/** + * MVP0 placeholder for the daily deposit limit shown by the reused VA requisites bottom sheet. + * + * [REDACTED_TODO_COMMENT] + */ +internal const val VA_DAILY_DEPOSIT_LIMIT_PLACEHOLDER = "$10,000" + +/** + * Maps VA on-ramp [BankCredentials] to the requisites rows consumed by the reused + * `VirtualAccountAddFundsBottomSheetComponent` (mirrors `VirtualAccountMainModel.buildRequisites`). + */ +internal fun BankCredentials.toRequisitesRows(): List = listOf( + RequisitesRow( + title = "Beneficiary name and address", + titleForShare = "Beneficiary name and address", + value = "$beneficiaryName\n$beneficiaryAddress", + ), + RequisitesRow( + title = "Bank name and address", + titleForShare = "Bank name and address", + value = "$beneficiaryBankName\n$beneficiaryBankAddress", + ), + RequisitesRow( + title = "Account number", + titleForShare = "Account number", + value = accountNumber, + ), + RequisitesRow( + title = "Routing number", + titleForShare = "Routing number", + value = routingNumber, + ), +) \ 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 new file mode 100644 index 0000000000..da2bf05f5a --- /dev/null +++ b/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountAddFundsBottomSheetComponent.kt @@ -0,0 +1,34 @@ +package com.tangem.features.virtualaccount.details.component + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Bottom sheet showing Virtual Account bank-transfer requisites (beneficiary, bank, account & routing numbers). + * + * Two stages: an educational intro and the requisites. Set [Params.shouldSkipIntro] to open straight at the + * requisites — used by callers (e.g. TangemPay) that already show their own intro. + */ +interface VirtualAccountAddFundsBottomSheetComponent : ComposableBottomSheetComponent { + + data class Params( + val userWalletId: UserWalletId, + val requisites: List, + val dailyDepositLimit: String, + val listener: VirtualAccountAddFundsListener, + val shouldSkipIntro: Boolean = false, + ) + + data class RequisitesRow( + val title: String, + val titleForShare: String, + val value: String, + ) + + interface Factory : ComponentFactory +} + +fun interface VirtualAccountAddFundsListener { + fun onAddFundsDismiss() +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt index 50ce82e412..d402fa6c06 100644 --- a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt @@ -11,8 +11,8 @@ 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.decompose.ComposableBottomSheetComponent +import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent import com.tangem.features.virtualaccount.details.component.VirtualAccountMainComponent -import com.tangem.features.virtualaccount.main.addfunds.VirtualAccountAddFundsBottomSheetComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -20,6 +20,7 @@ import dagger.assisted.AssistedInject internal class DefaultVirtualAccountMainComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: VirtualAccountMainComponent.Params, + private val addFundsComponentFactory: VirtualAccountAddFundsBottomSheetComponent.Factory, ) : VirtualAccountMainComponent, AppComponentContext by appComponentContext { private val model: VirtualAccountMainModel = getOrCreateModel(params = params) @@ -44,8 +45,8 @@ internal class DefaultVirtualAccountMainComponent @AssistedInject constructor( componentContext: ComponentContext, ): ComposableBottomSheetComponent { return when (config) { - is VirtualAccountMainNavigationBottomSheetConfig.AddFunds -> VirtualAccountAddFundsBottomSheetComponent( - appComponentContext = childByContext(componentContext), + is VirtualAccountMainNavigationBottomSheetConfig.AddFunds -> addFundsComponentFactory.create( + context = childByContext(componentContext), params = VirtualAccountAddFundsBottomSheetComponent.Params( userWalletId = params.userWalletId, listener = model, 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 a05c327e16..8546b2d775 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 @@ -10,10 +10,10 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent +import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsListener import com.tangem.features.virtualaccount.details.component.VirtualAccountMainComponent import com.tangem.features.virtualaccount.details.impl.R -import com.tangem.features.virtualaccount.main.addfunds.VirtualAccountAddFundsBottomSheetComponent -import com.tangem.features.virtualaccount.main.addfunds.VirtualAccountAddFundsListener import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -57,22 +57,22 @@ internal class VirtualAccountMainModel @Inject constructor( private fun buildRequisites(details: VirtualAccountDepositDetails) = listOf( VirtualAccountAddFundsBottomSheetComponent.RequisitesRow( - title = stringReference("Beneficiary name and address"), + title = "Beneficiary name and address", titleForShare = "Beneficiary name and address", value = "${details.beneficiaryName}\n${details.beneficiaryAddress}", ), VirtualAccountAddFundsBottomSheetComponent.RequisitesRow( - title = stringReference("Bank name and address"), + title = "Bank name and address", titleForShare = "Bank name and address", value = "${details.bankName}\n${details.bankAddress}", ), VirtualAccountAddFundsBottomSheetComponent.RequisitesRow( - title = stringReference("Account number"), + title = "Account number", titleForShare = "Account number", value = details.accountNumber, ), VirtualAccountAddFundsBottomSheetComponent.RequisitesRow( - title = stringReference("Routing number"), + title = "Routing number", titleForShare = "Routing number", value = details.routingNumber, ), diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainNavigationBottomSheetConfig.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainNavigationBottomSheetConfig.kt index f0de59ec19..9f5a2e601b 100644 --- a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainNavigationBottomSheetConfig.kt +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainNavigationBottomSheetConfig.kt @@ -1,6 +1,6 @@ package com.tangem.features.virtualaccount.main -import com.tangem.features.virtualaccount.main.addfunds.VirtualAccountAddFundsBottomSheetComponent.RequisitesRow +import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent.RequisitesRow import kotlinx.serialization.Serializable @Serializable diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/DefaultVirtualAccountAddFundsBottomSheetComponent.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/DefaultVirtualAccountAddFundsBottomSheetComponent.kt new file mode 100644 index 0000000000..83e5b84446 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/DefaultVirtualAccountAddFundsBottomSheetComponent.kt @@ -0,0 +1,37 @@ +package com.tangem.features.virtualaccount.main.addfunds + +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.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultVirtualAccountAddFundsBottomSheetComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: VirtualAccountAddFundsBottomSheetComponent.Params, +) : VirtualAccountAddFundsBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: VirtualAccountAddFundsModel = getOrCreateModel(params = params) + + override fun dismiss() { + model.onDismiss() + } + + @Composable + override fun BottomSheet() { + val state by model.uiState.collectAsStateWithLifecycle() + VirtualAccountAddFundsBottomSheet(state = state) + } + + @AssistedFactory + interface Factory : VirtualAccountAddFundsBottomSheetComponent.Factory { + override fun create( + context: AppComponentContext, + params: VirtualAccountAddFundsBottomSheetComponent.Params, + ): DefaultVirtualAccountAddFundsBottomSheetComponent + } +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheetComponent.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheetComponent.kt deleted file mode 100644 index 4faaffcba5..0000000000 --- a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheetComponent.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.tangem.features.virtualaccount.main.addfunds - -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.core.ui.extensions.TextReference -import com.tangem.domain.models.wallet.UserWalletId - -internal class VirtualAccountAddFundsBottomSheetComponent( - appComponentContext: AppComponentContext, - params: Params, -) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { - - private val model: VirtualAccountAddFundsModel = getOrCreateModel(params = params) - - override fun dismiss() { - model.onDismiss() - } - - @Composable - override fun BottomSheet() { - val state by model.uiState.collectAsStateWithLifecycle() - VirtualAccountAddFundsBottomSheet(state = state) - } - - data class Params( - val userWalletId: UserWalletId, - val requisites: List, - val dailyDepositLimit: String, - val listener: VirtualAccountAddFundsListener, - ) - - data class RequisitesRow( - val title: TextReference, - val titleForShare: String, - val value: String, - ) -} - -internal interface VirtualAccountAddFundsListener { - fun onAddFundsDismiss() -} \ No newline at end of file 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 d78ef65a65..56a80d6ce1 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,8 @@ 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.TextReference +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 import kotlinx.coroutines.flow.MutableStateFlow @@ -30,9 +31,7 @@ internal class VirtualAccountAddFundsModel @Inject constructor( field = MutableStateFlow( VirtualAccountAddFundsUM( onDismiss = ::onDismiss, - content = VirtualAccountAddFundsUM.Content.Intro( - onShowDetailsClick = { showDetailsContent() }, - ), + content = if (params.shouldSkipIntro) buildDetailsContent() else buildIntroContent(), ), ) @@ -40,22 +39,24 @@ internal class VirtualAccountAddFundsModel @Inject constructor( params.listener.onAddFundsDismiss() } + private fun buildIntroContent() = VirtualAccountAddFundsUM.Content.Intro( + onShowDetailsClick = ::showDetailsContent, + ) + private fun showDetailsContent() { - uiState.update { state -> - state.copy( - content = VirtualAccountAddFundsUM.Content.Details( - items = params.requisites - .map { detailItem(label = it.title, value = it.value) } - .toImmutableList(), - dailyLimit = params.dailyDepositLimit, - onShareClick = { shareManager.shareText(buildShareText()) }, - ), - ) - } + uiState.update { state -> state.copy(content = buildDetailsContent()) } } - private fun detailItem(label: TextReference, value: String) = VirtualAccountAddFundsUM.DetailItem( - label = label, + private fun buildDetailsContent() = VirtualAccountAddFundsUM.Content.Details( + items = params.requisites + .map { detailItem(label = it.title, value = it.value) } + .toImmutableList(), + dailyLimit = params.dailyDepositLimit, + onShareClick = { shareManager.shareText(buildShareText()) }, + ) + + private fun detailItem(label: String, value: String) = VirtualAccountAddFundsUM.DetailItem( + label = stringReference(label), value = value, onCopyClick = { clipboardManager.setText(text = value, isSensitive = true) }, ) diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainComponentModule.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainComponentModule.kt index 3e3ca68186..2733530929 100644 --- a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainComponentModule.kt +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainComponentModule.kt @@ -1,7 +1,9 @@ package com.tangem.features.virtualaccount.main.di -import com.tangem.features.virtualaccount.main.DefaultVirtualAccountMainComponent +import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent import com.tangem.features.virtualaccount.details.component.VirtualAccountMainComponent +import com.tangem.features.virtualaccount.main.DefaultVirtualAccountMainComponent +import com.tangem.features.virtualaccount.main.addfunds.DefaultVirtualAccountAddFundsBottomSheetComponent import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -15,4 +17,9 @@ internal interface VirtualAccountMainComponentModule { fun bindVirtualAccountMainComponentFactory( factory: DefaultVirtualAccountMainComponent.Factory, ): VirtualAccountMainComponent.Factory + + @Binds + fun bindVirtualAccountAddFundsComponentFactory( + factory: DefaultVirtualAccountAddFundsBottomSheetComponent.Factory, + ): VirtualAccountAddFundsBottomSheetComponent.Factory } \ No newline at end of file From 9db2ebaef437386eedd3acea1187c13f5801757e Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 6 Jul 2026 18:45:24 +0500 Subject: [PATCH 11/59] Updated on 2026-08-14 --- .../tap/data/DefaultTangemPayStorage.kt | 17 +++++ .../tap/data/MockAwareTangemPayStorage.kt | 9 +++ .../tangem/datasource/api/pay/TangemPayApi.kt | 7 +++ .../request/VirtualAccountOrderRequest.kt | 23 +++++++ .../response/BankCredentialsResponse.kt | 21 ++++--- .../pay/models/response/CustomerMeResponse.kt | 2 +- .../local/preferences/PreferencesKeys.kt | 3 + .../datasource/local/visa/TangemPayStorage.kt | 6 ++ .../tangem/data/pay/di/TangemPayDataModule.kt | 11 ++++ .../DefaultPaymentAccountStatusFetcher.kt | 2 +- .../repository/DefaultOnboardingRepository.kt | 38 ++++++++++- .../data/pay/util/BankCredentialsConverter.kt | 4 +- .../data/pay/util/CustomerInfoConverter.kt | 2 +- .../data/pay/util/TangemPayErrorConverter.kt | 4 +- .../MockAwareOnboardingRepository.kt | 26 ++++++++ .../DefaultPaymentAccountStatusFetcherTest.kt | 2 +- .../pay/util/BankCredentialsConverterTest.kt | 4 +- .../tangem/domain/pay/model/CustomerInfo.kt | 4 ++ .../pay/repository/OnboardingRepository.kt | 11 ++++ .../CreateVirtualAccountOrderUseCase.kt | 40 ++++++++++++ .../CreateVirtualAccountOrderUseCaseTest.kt | 63 +++++++++++++++++++ 21 files changed, 279 insertions(+), 20 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/VirtualAccountOrderRequest.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCase.kt create mode 100644 domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCaseTest.kt diff --git a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt index 85108219f0..6b48f51b6f 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt @@ -134,6 +134,23 @@ internal class DefaultTangemPayStorage @Inject constructor( appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), "") } + override suspend fun storeVirtualAccountOrderId(customerWalletAddress: String, vaOrderId: String) { + appPreferencesStore.store( + key = PreferencesKeys.getTangemPayVirtualAccountOrderIdKey(customerWalletAddress), + value = vaOrderId, + ) + } + + override suspend fun getVirtualAccountOrderId(customerWalletAddress: String): String? { + return appPreferencesStore.getSyncOrNull( + key = PreferencesKeys.getTangemPayVirtualAccountOrderIdKey(customerWalletAddress), + ).takeIf { !it.isNullOrEmpty() } + } + + override suspend fun clearVirtualAccountOrderId(customerWalletAddress: String) { + appPreferencesStore.store(PreferencesKeys.getTangemPayVirtualAccountOrderIdKey(customerWalletAddress), "") + } + override suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean) { appPreferencesStore.store( PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId), diff --git a/app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt b/app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt index baea0ab1a5..cbbcb0c848 100644 --- a/app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt +++ b/app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt @@ -81,6 +81,15 @@ internal class MockAwareTangemPayStorage @Inject constructor( override suspend fun clearOrderId(customerWalletAddress: String) = real.clearOrderId(customerWalletAddress) + override suspend fun storeVirtualAccountOrderId(customerWalletAddress: String, vaOrderId: String) = + real.storeVirtualAccountOrderId(customerWalletAddress, vaOrderId) + + override suspend fun getVirtualAccountOrderId(customerWalletAddress: String): String? = + real.getVirtualAccountOrderId(customerWalletAddress) + + override suspend fun clearVirtualAccountOrderId(customerWalletAddress: String) = + real.clearVirtualAccountOrderId(customerWalletAddress) + override suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean) = real.storeCheckCustomerWalletResult(userWalletId, isPaeraCustomer) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 37f8928d79..44f6df51f2 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -77,6 +77,13 @@ interface TangemPayApi { @Body body: OrderRequest, ): ApiResponse + // TODO: Doston: [REDACTED_TASK_KEY] Unify with method above + @POST("v1/order") + suspend fun createVirtualAccountOrder( + @Header("Authorization") authHeader: String, + @Body body: VirtualAccountOrderRequest, + ): ApiResponse + /** Customer offers — used to gate the issue-additional-card flow. */ @GET("v1/customer/offers") suspend fun getCustomerOffers(@Header("Authorization") authHeader: String): ApiResponse diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/VirtualAccountOrderRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/VirtualAccountOrderRequest.kt new file mode 100644 index 0000000000..7b78b64c6f --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/VirtualAccountOrderRequest.kt @@ -0,0 +1,23 @@ +package com.tangem.datasource.api.pay.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Request body for creating a Virtual Account on-ramp order (VA MVP0, TWI-1638). + * + * `wallet_address` is the customer's managing (collateral-managing) wallet address; `payment_account_address` + * is the existing collateral address. Distinct from the card-issue [OrderRequest] contract. + */ +@JsonClass(generateAdapter = true) +data class VirtualAccountOrderRequest( + @Json(name = "data") val data: Data, + @Json(name = "idempotency_key") val idempotencyKey: String, +) { + @JsonClass(generateAdapter = true) + data class Data( + @Json(name = "deposit_address") val depositAddress: String, + @Json(name = "type") val type: String = "ACCOUNT_ISSUE_VIRTUAL_RAIN", + @Json(name = "specification_name") val specificationName: String = "SP_000006", + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BankCredentialsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BankCredentialsResponse.kt index 409984fabc..b3564320f4 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BankCredentialsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BankCredentialsResponse.kt @@ -9,11 +9,16 @@ import com.squareup.moshi.JsonClass */ @JsonClass(generateAdapter = true) data class BankCredentialsResponse( - @Json(name = "type") val type: String?, - @Json(name = "beneficiary_name") val beneficiaryName: String?, - @Json(name = "beneficiary_address") val beneficiaryAddress: String?, - @Json(name = "beneficiary_bank_name") val beneficiaryBankName: String?, - @Json(name = "beneficiary_bank_address") val beneficiaryBankAddress: String?, - @Json(name = "account_number") val accountNumber: String?, - @Json(name = "routing_number") val routingNumber: String?, -) \ No newline at end of file + @Json(name = "result") val result: Result?, +) { + @JsonClass(generateAdapter = true) + data class Result( + @Json(name = "type") val type: String?, + @Json(name = "beneficiary_name") val beneficiaryName: String?, + @Json(name = "beneficiary_address") val beneficiaryAddress: String?, + @Json(name = "beneficiary_bank_name") val beneficiaryBankName: String?, + @Json(name = "beneficiary_bank_address") val beneficiaryBankAddress: String?, + @Json(name = "account_number") val accountNumber: String?, + @Json(name = "routing_number") val routingNumber: String?, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt index 17de6c4cd0..21adcc2e76 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt @@ -27,7 +27,7 @@ data class CustomerMeResponse( data class ProductInstance( @Json(name = "id") val id: String, @Json(name = "cid") val cid: String?, - @Json(name = "card_id") val cardId: String, + @Json(name = "card_id") val cardId: String?, @Json(name = "card_wallet_address") val cardWalletAddress: String?, @Json(name = "status") val status: Status, @Json(name = "updated_at") val updatedAt: String, diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index ca59f2ca23..eba1fa1049 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -189,6 +189,9 @@ object PreferencesKeys { fun getTangemPayOrderIdKey(customerWalletAddress: String) = stringPreferencesKey("tangem_pay_order_id_key_$customerWalletAddress") + fun getTangemPayVirtualAccountOrderIdKey(customerWalletAddress: String) = + stringPreferencesKey("tangem_pay_va_order_id_key_$customerWalletAddress") + fun getTangemPayCustomerWalletAddressKey(userWalletId: UserWalletId) = stringPreferencesKey("tangem_pay_customer_wallet_address_key_${userWalletId.stringValue}") diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt index e6b2a37527..60c91d5d63 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt @@ -23,6 +23,12 @@ interface TangemPayStorage { suspend fun clearOrderId(customerWalletAddress: String) + suspend fun storeVirtualAccountOrderId(customerWalletAddress: String, vaOrderId: String) + + suspend fun getVirtualAccountOrderId(customerWalletAddress: String): String? + + suspend fun clearVirtualAccountOrderId(customerWalletAddress: String) + suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean) 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 30297c0d46..a363e421d0 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 @@ -293,5 +293,16 @@ internal interface TangemPayDataModule { appCoroutineScope = appCoroutineScope, ) } + + @Provides + fun provideCreateVirtualAccountOrderUseCase( + onboardingRepository: OnboardingRepository, + pollingUseCase: StartTangemPayOrderPollingUseCase, + ): CreateVirtualAccountOrderUseCase { + return CreateVirtualAccountOrderUseCase( + onboardingRepository = onboardingRepository, + pollingUseCase = pollingUseCase, + ) + } } } \ No newline at end of file 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 c962f2dcdc..e77fc8bec5 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 @@ -338,7 +338,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( fiatRate: BigDecimal?, ): PaymentAccountStatusValue { val cardsById = cards.associateBy { it.cardId } - val tangemPayCards = productInstances.mapNotNull { productInstance -> + val tangemPayCards = cardProductInstances.mapNotNull { productInstance -> val cardInfo = cardsById[productInstance.cardId] ?: return@mapNotNull null val cardId = productInstance.cardId val cardFrozenState = cardDetailsRepository.cardFrozenStateSync(cardId) 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 786fc9397c..10b5639f3e 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 @@ -12,6 +12,7 @@ import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest import com.tangem.datasource.api.pay.models.request.OrderRequest import com.tangem.datasource.api.pay.models.request.SetTangemPayEnabledRequest +import com.tangem.datasource.api.pay.models.request.VirtualAccountOrderRequest import com.tangem.datasource.api.pay.models.response.CustomerMeResponse import com.tangem.datasource.api.pay.models.response.OrderResponse import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore @@ -38,7 +39,7 @@ import javax.inject.Inject private const val VALID_STATUS = "valid" -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") internal class DefaultOnboardingRepository @Inject constructor( private val analytics: AnalyticsEventHandler, private val dispatcherProvider: CoroutineDispatcherProvider, @@ -113,7 +114,10 @@ internal class DefaultOnboardingRepository @Inject constructor( ): Either { return requestHelper.performRequest(userWalletId) { authHeader -> tangemPayApi.getBankCredentials(authHeader = authHeader, productInstanceId = productInstanceId) - }.map { response -> BankCredentialsConverter.convert(response) } + }.flatMap { response -> + val result = response.result ?: return@flatMap VisaApiError.UnknownWithoutCode.left() + BankCredentialsConverter.convert(result).right() + } } override suspend fun isTangemPayDeactivated(userWalletId: UserWalletId): Boolean { @@ -167,6 +171,34 @@ internal class DefaultOnboardingRepository @Inject constructor( } } + override suspend fun createVirtualAccountOrder( + userWalletId: UserWalletId, + paymentAccountAddress: String, + ): Either = withContext(dispatcherProvider.io) { + requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.createVirtualAccountOrder( + authHeader = authHeader, + body = VirtualAccountOrderRequest( + data = VirtualAccountOrderRequest.Data(depositAddress = paymentAccountAddress), + idempotencyKey = UUID.randomUUID().toString(), + ), + ) + }.map { response -> requireNotNull(response.result).id } + } + + override suspend fun getVirtualAccountOrderId(userWalletId: UserWalletId): String? = + withContext(dispatcherProvider.io) { + val customerWalletAddress = requestHelper.getCustomerWalletAddress(userWalletId) + tangemPayStorage.getVirtualAccountOrderId(customerWalletAddress) + } + + override suspend fun storeVirtualAccountOrderId(userWalletId: UserWalletId, vaOrderId: String) { + withContext(dispatcherProvider.io) { + val customerWalletAddress = requestHelper.getCustomerWalletAddress(userWalletId) + tangemPayStorage.storeVirtualAccountOrderId(customerWalletAddress, vaOrderId) + } + } + private fun getUserWallet(userWalletId: UserWalletId): UserWallet { return userWalletsListRepository.userWallets.value?.firstOrNull { it.walletId == userWalletId } ?: error("no userWallet found") @@ -181,7 +213,7 @@ internal class DefaultOnboardingRepository @Inject constructor( sendKycAnalytics(customerInfo.kycStatus) // Keep the per-card frozen state up to date for every card. - customerInfo.productInstances.forEach { instance -> + customerInfo.cardProductInstances.forEach { instance -> cardFrozenStateStore.store(key = instance.cardId, value = instance.frozenState) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/BankCredentialsConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/BankCredentialsConverter.kt index 4f027487a6..2f028c7ba4 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/BankCredentialsConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/BankCredentialsConverter.kt @@ -4,8 +4,8 @@ import com.tangem.datasource.api.pay.models.response.BankCredentialsResponse import com.tangem.domain.models.account.BankCredentials import com.tangem.utils.converter.Converter -internal object BankCredentialsConverter : Converter { - override fun convert(value: BankCredentialsResponse): BankCredentials { +internal object BankCredentialsConverter : Converter { + override fun convert(value: BankCredentialsResponse.Result): BankCredentials { return BankCredentials( type = value.type.orEmpty(), beneficiaryName = value.beneficiaryName.orEmpty(), diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt index 2fd76d8480..1a2a8c5d54 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt @@ -57,7 +57,7 @@ internal object CustomerInfoConverter : Converter = ConcurrentHashMap.newKeySet() + private val mockVaOrderIds: MutableSet = ConcurrentHashMap.newKeySet() private val isMockMode: Boolean get() = apiConfigsManager @@ -74,6 +75,30 @@ internal class MockAwareOnboardingRepository @Inject constructor( return real.getOrderId(userWalletId) } + override suspend fun createVirtualAccountOrder( + userWalletId: UserWalletId, + paymentAccountAddress: String, + ): Either { + if (isMockMode) { + mockVaOrderIds.add(userWalletId) + return MOCK_VA_ORDER_ID.right() + } + return real.createVirtualAccountOrder(userWalletId, paymentAccountAddress) + } + + override suspend fun getVirtualAccountOrderId(userWalletId: UserWalletId): String? { + if (isMockMode) return MOCK_VA_ORDER_ID.takeIf { userWalletId in mockVaOrderIds } + return real.getVirtualAccountOrderId(userWalletId) + } + + override suspend fun storeVirtualAccountOrderId(userWalletId: UserWalletId, vaOrderId: String) { + if (isMockMode) { + mockVaOrderIds.add(userWalletId) + return + } + real.storeVirtualAccountOrderId(userWalletId, vaOrderId) + } + override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either = real.hasTangemPayInWallet(userWalletId) @@ -112,5 +137,6 @@ internal class MockAwareOnboardingRepository @Inject constructor( private companion object { const val MOCK_ORDER_ID = "mock-order-id" + const val MOCK_VA_ORDER_ID = "mock-va-order-id" } } \ No newline at end of file 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 e0c4bcc677..e916df8fbc 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 @@ -193,7 +193,7 @@ internal class DefaultPaymentAccountStatusFetcherTest { @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) - inner class `resolveVirtualAccountOnramp` { + inner class ResolveVirtualAccountOnramp { @Test fun `GIVEN feature toggle is off WHEN invoke THEN virtualAccount is null`() = runTest { diff --git a/data/visa/src/test/kotlin/com/tangem/data/pay/util/BankCredentialsConverterTest.kt b/data/visa/src/test/kotlin/com/tangem/data/pay/util/BankCredentialsConverterTest.kt index 6ec1cceb6c..c514bd2cfe 100644 --- a/data/visa/src/test/kotlin/com/tangem/data/pay/util/BankCredentialsConverterTest.kt +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/util/BankCredentialsConverterTest.kt @@ -10,7 +10,7 @@ internal class BankCredentialsConverterTest { @Test fun `GIVEN full response WHEN convert THEN all fields mapped`() { // Arrange - val response = BankCredentialsResponse( + val response = BankCredentialsResponse.Result( type = "fiat", beneficiaryName = "Ivan Ivanov", beneficiaryAddress = "18, Rue Rubens 20, Paris, Ile-de-France 75013, US", @@ -39,7 +39,7 @@ internal class BankCredentialsConverterTest { @Test fun `GIVEN null fields WHEN convert THEN mapped to empty strings`() { // Arrange - val response = BankCredentialsResponse( + val response = BankCredentialsResponse.Result( type = null, beneficiaryName = null, beneficiaryAddress = null, diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index 752773bb5f..f7456bdec2 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -38,6 +38,10 @@ data class CustomerInfo( /** Transitional single-card accessor — returns the first card, or null if none. */ val cardInfo: CardInfo? get() = cards.firstOrNull() + /** Card-level product instances only (excludes the VA ACCOUNT instance). */ + val cardProductInstances: List + get() = productInstances.filter { it.specificationDataType == ProductInstance.SpecificationDataType.CARD } + enum class State { NEW, ACTIVE, diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt index 169c6ed172..0144c57488 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt @@ -8,6 +8,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.visa.error.VisaApiError +@Suppress("TooManyFunctions") interface OnboardingRepository { suspend fun validateDeeplink(link: String): Either @@ -30,6 +31,16 @@ interface OnboardingRepository { suspend fun getOrderId(userWalletId: UserWalletId): String? + /** Creates a Virtual Account on-ramp order (VA MVP0, TWI-1638); returns the created order id. */ + suspend fun createVirtualAccountOrder( + userWalletId: UserWalletId, + paymentAccountAddress: String, + ): Either + + suspend fun getVirtualAccountOrderId(userWalletId: UserWalletId): String? + + suspend fun storeVirtualAccountOrderId(userWalletId: UserWalletId, vaOrderId: String) + suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either 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 new file mode 100644 index 0000000000..40829912ba --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCase.kt @@ -0,0 +1,40 @@ +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.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayOrderInfo +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.visa.error.VisaApiError + +/** + * Creates the Virtual Account on-ramp order (VA MVP0, TWI-1638) and persists the returned id as `vaOrderId`. + * + * Idempotent: if an order id was already stored for the wallet, it is returned without hitting the network. + * Otherwise creates the order (`ACCOUNT_ISSUE_VIRTUAL_RAIN`) and stores the returned id. + * + * @property onboardingRepository resolves the customer wallet address, creates the order, and persists the id. + */ +class CreateVirtualAccountOrderUseCase( + private val onboardingRepository: OnboardingRepository, + private val pollingUseCase: StartTangemPayOrderPollingUseCase, +) { + suspend operator fun invoke( + userWalletId: UserWalletId, + paymentAccountAddress: String, + ): Either = either { + onboardingRepository.getVirtualAccountOrderId(userWalletId) + ?: run { + val vaOrderId = onboardingRepository.createVirtualAccountOrder( + userWalletId = userWalletId, + paymentAccountAddress = paymentAccountAddress, + ).bind() + onboardingRepository.storeVirtualAccountOrderId(userWalletId = userWalletId, vaOrderId = vaOrderId) + 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 new file mode 100644 index 0000000000..f285cf4bcf --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCaseTest.kt @@ -0,0 +1,63 @@ +package com.tangem.domain.pay.usecase + +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.repository.OnboardingRepository +import com.tangem.domain.visa.error.VisaApiError +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +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 userWalletId = UserWalletId("1234567890ABCDEF") + private val paymentAccountAddress = "0xcollateral" + + @Test + fun `GIVEN stored va order id WHEN invoke THEN skips creation and polling`() = runTest { + coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns "existing-id" + + val result = useCase(userWalletId, paymentAccountAddress) + + assertThat(result.isRight()).isTrue() + coVerify(exactly = 0) { onboardingRepository.createVirtualAccountOrder(any(), any()) } + coVerify(exactly = 0) { onboardingRepository.storeVirtualAccountOrderId(any(), any()) } + coVerify(exactly = 0) { pollingUseCase.invoke(any(), any()) } + } + + @Test + fun `GIVEN no stored id and create succeeds WHEN invoke THEN stores id and starts polling`() = runTest { + coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns null + coEvery { + onboardingRepository.createVirtualAccountOrder(userWalletId, paymentAccountAddress) + } returns "new-id".right() + + val result = useCase(userWalletId, paymentAccountAddress) + + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { onboardingRepository.storeVirtualAccountOrderId(userWalletId, "new-id") } + coVerify(exactly = 1) { pollingUseCase.invoke(any(), userWalletId) } + } + + @Test + fun `GIVEN no stored id and create fails WHEN invoke THEN returns error and does not store or poll`() = runTest { + coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns null + coEvery { + onboardingRepository.createVirtualAccountOrder(userWalletId, paymentAccountAddress) + } returns VisaApiError.Unspecified.left() + + val result = useCase(userWalletId, paymentAccountAddress) + + assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified) + coVerify(exactly = 0) { onboardingRepository.storeVirtualAccountOrderId(any(), any()) } + coVerify(exactly = 0) { pollingUseCase.invoke(any(), any()) } + } +} \ No newline at end of file From 8e9417d04c4d06b3a8c0e833ecf54dffc095f493 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Jul 2026 17:10:29 +0500 Subject: [PATCH 12/59] Updated on 2026-08-14 --- .../repository/DefaultOnboardingRepository.kt | 3 +- .../pay/repository/OnboardingRepository.kt | 1 + .../CreateVirtualAccountOrderUseCase.kt | 2 + .../CreateVirtualAccountOrderUseCaseTest.kt | 6 +- ...faultTangemPayDetailsContainerComponent.kt | 4 + .../components/TangemPayCardPageComponent.kt | 4 + .../TangemPayCardPageScreenComponent.kt | 4 + .../components/TangemPayDetailsComponent.kt | 4 + ...TangemPayVirtualAccountDepositComponent.kt | 9 +- ...ayVirtualAccountDepositSuccessComponent.kt | 35 ++++ .../entity/TangemPayCardNavigation.kt | 2 + .../entity/TangemPayDetailsNavigation.kt | 2 + .../TangemPayVirtualAccountDepositUM.kt | 1 + .../tangempay/model/TangemPayCardPageModel.kt | 16 +- .../tangempay/model/TangemPayDetailsModel.kt | 16 +- .../TangemPayVirtualAccountDepositModel.kt | 66 ++++++-- .../TangemPayAddFundsUMConverter.kt | 5 +- .../TangemPayAccountDetailsInnerRoute.kt | 3 + .../TangemPayCardDetailsInnerRoute.kt | 3 + ...ngemPayVirtualAccountDepositBottomSheet.kt | 56 +++++-- ...TangemPayVirtualAccountDepositModelTest.kt | 155 ++++++++++++++++++ .../component/VirtualAccountMainComponent.kt | 2 + 22 files changed, 360 insertions(+), 39 deletions(-) create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVirtualAccountDepositSuccessComponent.kt create mode 100644 features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayVirtualAccountDepositModelTest.kt 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 10b5639f3e..c97e31cb39 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 @@ -174,13 +174,14 @@ internal class DefaultOnboardingRepository @Inject constructor( override suspend fun createVirtualAccountOrder( userWalletId: UserWalletId, paymentAccountAddress: String, + idempotencyKey: String, ): Either = withContext(dispatcherProvider.io) { requestHelper.performRequest(userWalletId) { authHeader -> tangemPayApi.createVirtualAccountOrder( authHeader = authHeader, body = VirtualAccountOrderRequest( data = VirtualAccountOrderRequest.Data(depositAddress = paymentAccountAddress), - idempotencyKey = UUID.randomUUID().toString(), + idempotencyKey = idempotencyKey, ), ) }.map { response -> requireNotNull(response.result).id } diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt index 0144c57488..402d5a6250 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt @@ -35,6 +35,7 @@ interface OnboardingRepository { suspend fun createVirtualAccountOrder( userWalletId: UserWalletId, paymentAccountAddress: String, + idempotencyKey: String, ): Either suspend fun getVirtualAccountOrderId(userWalletId: UserWalletId): String? 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 40829912ba..d2c74028d8 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 @@ -7,6 +7,7 @@ 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 java.util.UUID /** * Creates the Virtual Account on-ramp order (VA MVP0, TWI-1638) and persists the returned id as `vaOrderId`. @@ -29,6 +30,7 @@ class CreateVirtualAccountOrderUseCase( val vaOrderId = onboardingRepository.createVirtualAccountOrder( userWalletId = userWalletId, paymentAccountAddress = paymentAccountAddress, + idempotencyKey = UUID.randomUUID().toString(), ).bind() onboardingRepository.storeVirtualAccountOrderId(userWalletId = userWalletId, vaOrderId = vaOrderId) pollingUseCase.invoke( 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 f285cf4bcf..c300a5e09e 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 @@ -28,7 +28,7 @@ internal class CreateVirtualAccountOrderUseCaseTest { val result = useCase(userWalletId, paymentAccountAddress) assertThat(result.isRight()).isTrue() - coVerify(exactly = 0) { onboardingRepository.createVirtualAccountOrder(any(), any()) } + coVerify(exactly = 0) { onboardingRepository.createVirtualAccountOrder(any(), any(), any()) } coVerify(exactly = 0) { onboardingRepository.storeVirtualAccountOrderId(any(), any()) } coVerify(exactly = 0) { pollingUseCase.invoke(any(), any()) } } @@ -37,7 +37,7 @@ internal class CreateVirtualAccountOrderUseCaseTest { fun `GIVEN no stored id and create succeeds WHEN invoke THEN stores id and starts polling`() = runTest { coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns null coEvery { - onboardingRepository.createVirtualAccountOrder(userWalletId, paymentAccountAddress) + onboardingRepository.createVirtualAccountOrder(userWalletId, paymentAccountAddress, any()) } returns "new-id".right() val result = useCase(userWalletId, paymentAccountAddress) @@ -51,7 +51,7 @@ internal class CreateVirtualAccountOrderUseCaseTest { fun `GIVEN no stored id and create fails WHEN invoke THEN returns error and does not store or poll`() = runTest { coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns null coEvery { - onboardingRepository.createVirtualAccountOrder(userWalletId, paymentAccountAddress) + onboardingRepository.createVirtualAccountOrder(userWalletId, paymentAccountAddress, any()) } returns VisaApiError.Unspecified.left() val result = useCase(userWalletId, paymentAccountAddress) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt index 11d5561e44..24852106a9 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -84,6 +84,10 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru userWalletId = params.initialStatus.userWalletId, ), ) + TangemPayAccountDetailsInnerRoute.VirtualAccountDepositSuccess -> + TangemPayVirtualAccountDepositSuccessComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + ) } private fun onChildBack() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt index 189fe07e48..14d96d6643 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt @@ -112,6 +112,10 @@ internal class TangemPayCardPageComponent @AssistedInject constructor( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), isRedesignEnabled = tangemPayFeatureToggles.isRedesignEnabled, ) + TangemPayCardDetailsInnerRoute.VirtualAccountDepositSuccess -> + TangemPayVirtualAccountDepositSuccessComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + ) } private fun onChildBack() { 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 19541d7568..be3f9eae88 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 @@ -63,6 +63,7 @@ internal class TangemPayCardPageScreenComponent( } } + @Suppress("LongMethod") private fun bottomSheetChild( navigation: TangemPayCardNavigation, componentContext: ComponentContext, @@ -109,8 +110,11 @@ internal class TangemPayCardPageScreenComponent( appComponentContext = context, params = TangemPayVirtualAccountDepositComponent.Params( virtualAccountOnramp = navigation.virtualAccountOnramp, + userWalletId = navigation.userWalletId, + paymentAccountAddress = navigation.paymentAccountAddress, onDismiss = model.bottomSheetNavigation::dismiss, onShowDetails = model::onShowVirtualAccountRequisites, + onOrderCreated = model::onVirtualAccountOrderCreated, ), ) is TangemPayCardNavigation.VirtualAccountRequisites -> virtualAccountAddFundsComponentFactory.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 bbd64c84ac..052c5c99cf 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 @@ -99,6 +99,7 @@ internal class TangemPayDetailsComponent( } } + @Suppress("LongMethod") private fun bottomSheetChild( navigation: TangemPayDetailsNavigation, componentContext: ComponentContext, @@ -138,8 +139,11 @@ internal class TangemPayDetailsComponent( appComponentContext = context, params = TangemPayVirtualAccountDepositComponent.Params( virtualAccountOnramp = navigation.virtualAccountOnramp, + userWalletId = navigation.userWalletId, + paymentAccountAddress = navigation.paymentAccountAddress, onDismiss = model.bottomSheetNavigation::dismiss, onShowDetails = model::onShowVirtualAccountRequisites, + onOrderCreated = model::onVirtualAccountOrderCreated, ), ) is TangemPayDetailsNavigation.VirtualAccountRequisites -> virtualAccountAddFundsComponentFactory.create( 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 d7ab98fe71..0e7bb6baa7 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 @@ -1,10 +1,13 @@ 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.TangemPayVirtualAccountDepositModel import com.tangem.features.tangempay.ui.TangemPayVirtualAccountDepositBottomSheet @@ -25,12 +28,16 @@ internal class TangemPayVirtualAccountDepositComponent( @Composable override fun BottomSheet() { - TangemPayVirtualAccountDepositBottomSheet(state = model.uiState) + val state by model.uiState.collectAsStateWithLifecycle() + TangemPayVirtualAccountDepositBottomSheet(state = state) } data class Params( val virtualAccountOnramp: VirtualAccountOnramp, + val userWalletId: UserWalletId, + val paymentAccountAddress: String, val onDismiss: () -> Unit, val onShowDetails: (VirtualAccountOnramp.Available) -> Unit, + val onOrderCreated: () -> Unit, ) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVirtualAccountDepositSuccessComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVirtualAccountDepositSuccessComponent.kt new file mode 100644 index 0000000000..4b1ce2d19b --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVirtualAccountDepositSuccessComponent.kt @@ -0,0 +1,35 @@ +package com.tangem.features.tangempay.components + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.ui.components.TangemPaySuccessScreenWrapper + +/** + + * "Preparing your banking details". Close pops back to the previous screen. + */ +internal class TangemPayVirtualAccountDepositSuccessComponent( + private val appComponentContext: AppComponentContext, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + @Composable + override fun Content(modifier: Modifier) { + BackHandler(onBack = ::onClose) + TangemPaySuccessScreenWrapper( + modifier = modifier, + title = resourceReference(R.string.tangempay_bank_transfer_success_title), + subtitle = resourceReference(R.string.tangempay_bank_transfer_success_subtitle), + buttonText = resourceReference(R.string.common_close), + onButtonClick = ::onClose, + ) + } + + private fun onClose() { + router.pop() + } +} \ No newline at end of file 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 76de57b42b..74c99dd180 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 @@ -38,6 +38,8 @@ internal sealed class TangemPayCardNavigation { @Serializable data class VirtualAccountDeposit( val virtualAccountOnramp: VirtualAccountOnramp, + val userWalletId: UserWalletId, + val paymentAccountAddress: String, ) : 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 5e1f43d8ed..82b020d3f7 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 @@ -29,6 +29,8 @@ internal sealed class TangemPayDetailsNavigation { @Serializable data class VirtualAccountDeposit( val virtualAccountOnramp: VirtualAccountOnramp, + val userWalletId: UserWalletId, + val paymentAccountAddress: String, ) : 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 index e6c98fa99d..069d11a08f 100644 --- 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 @@ -13,6 +13,7 @@ import kotlinx.collections.immutable.ImmutableList internal data class TangemPayVirtualAccountDepositUM( val fees: ImmutableList, val shouldShowTermsAndConditions: Boolean, + val isLoading: Boolean, val onShowDetailsClick: () -> Unit, val onDismiss: () -> Unit, val onTermsClick: () -> Unit, 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 05e00caa00..3911bfae65 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 @@ -487,9 +487,21 @@ internal class TangemPayCardPageModel @Inject constructor( } override fun onClickBankTransfer() { - val onramp = currentStatus.value.ifLoadedOrNull { it.virtualAccount } ?: return + val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return + val onramp = loaded.virtualAccount ?: return bottomSheetNavigation.dismiss() - bottomSheetNavigation.activate(TangemPayCardNavigation.VirtualAccountDeposit(onramp)) + bottomSheetNavigation.activate( + TangemPayCardNavigation.VirtualAccountDeposit( + virtualAccountOnramp = onramp, + userWalletId = userWalletId, + paymentAccountAddress = loaded.balance.cryptoBalance.depositAddress, + ), + ) + } + + fun onVirtualAccountOrderCreated() { + bottomSheetNavigation.dismiss() + router.push(TangemPayCardDetailsInnerRoute.VirtualAccountDepositSuccess) } fun onShowVirtualAccountRequisites(onramp: VirtualAccountOnramp.Available) { 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 392587392f..e5aafdf942 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 @@ -312,9 +312,21 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onClickBankTransfer() { - val onramp = currentStatus.value.ifLoadedOrNull { it.virtualAccount } ?: return + val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return + val onramp = loaded.virtualAccount ?: return bottomSheetNavigation.dismiss() - bottomSheetNavigation.activate(TangemPayDetailsNavigation.VirtualAccountDeposit(onramp)) + bottomSheetNavigation.activate( + TangemPayDetailsNavigation.VirtualAccountDeposit( + virtualAccountOnramp = onramp, + userWalletId = userWalletId, + paymentAccountAddress = loaded.balance.cryptoBalance.depositAddress, + ), + ) + } + + fun onVirtualAccountOrderCreated() { + bottomSheetNavigation.dismiss() + router.push(TangemPayAccountDetailsInnerRoute.VirtualAccountDepositSuccess) } fun onShowVirtualAccountRequisites(onramp: VirtualAccountOnramp.Available) { 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 cc9bb49005..d11441c9b5 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 @@ -4,13 +4,21 @@ 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.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.models.account.VirtualAccountOnramp +import com.tangem.domain.pay.usecase.CreateVirtualAccountOrderUseCase import com.tangem.features.tangempay.components.TangemPayVirtualAccountDepositComponent +import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayVirtualAccountDepositUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @Stable @@ -19,21 +27,33 @@ internal class TangemPayVirtualAccountDepositModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val urlOpener: UrlOpener, + private val uiMessageSender: UiMessageSender, + private val createVirtualAccountOrderUseCase: CreateVirtualAccountOrderUseCase, ) : 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) }, - ) + val uiState: StateFlow + field = MutableStateFlow( + TangemPayVirtualAccountDepositUM( + fees = persistentListOf( + TangemPayVirtualAccountDepositUM.FeeRow( + title = resourceReference(R.string.tangempay_bank_transfer_fee_ach), + value = "$1", + ), + TangemPayVirtualAccountDepositUM.FeeRow( + title = resourceReference(R.string.tangempay_bank_transfer_fee_fedwire), + value = "$11", + ), + ), + shouldShowTermsAndConditions = params.virtualAccountOnramp is VirtualAccountOnramp.Eligible, + isLoading = false, + onShowDetailsClick = ::onShowDetailsClick, + onDismiss = ::onDismiss, + onTermsClick = { urlOpener.openUrl(TERMS_OF_USE_URL) }, + onPrivacyClick = { urlOpener.openUrl(PRIVACY_POLICY_URL) }, + ), + ) fun onDismiss() { params.onDismiss() @@ -42,7 +62,27 @@ internal class TangemPayVirtualAccountDepositModel @Inject constructor( private fun onShowDetailsClick() { when (params.virtualAccountOnramp) { is VirtualAccountOnramp.Available -> params.onShowDetails(params.virtualAccountOnramp) - VirtualAccountOnramp.Eligible -> TODO() + VirtualAccountOnramp.Eligible -> createVirtualAccountOrder() + } + } + + private fun createVirtualAccountOrder() { + if (uiState.value.isLoading) return + uiState.update { it.copy(isLoading = true) } + modelScope.launch { + createVirtualAccountOrderUseCase( + userWalletId = params.userWalletId, + paymentAccountAddress = params.paymentAccountAddress, + ).fold( + ifLeft = { + uiState.update { state -> state.copy(isLoading = false) } + uiMessageSender.send(ToastMessage(resourceReference(R.string.common_unknown_error))) + }, + ifRight = { + uiState.update { state -> state.copy(isLoading = false) } + params.onOrderCreated() + }, + ) } } 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 4a6b08f099..dae806fdf8 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 @@ -2,7 +2,6 @@ package com.tangem.features.tangempay.model.transformers import com.tangem.core.ui.ds.image.TangemIconUM 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 @@ -75,8 +74,8 @@ internal class TangemPayAddFundsUMConverter( imageVector = Icons.ic_sign_usd_20, tintReference = { TangemTheme.colors3.icon.brand }, ), - title = stringReference("Bank transfer"), - description = stringReference("Receive fiat USD via ACH/FedWire"), + title = resourceReference(R.string.tangempay_topup_bank_transfer_title), + description = resourceReference(R.string.tangempay_topup_bank_transfer_body), onClick = listener::onClickBankTransfer, ) }, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt index 4e7fdfe512..a59dd7d19a 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt @@ -14,4 +14,7 @@ internal sealed class TangemPayAccountDetailsInnerRoute : Route { @Serializable data class AddToWallet(val card: TangemPayCard) : TangemPayAccountDetailsInnerRoute() + + @Serializable + data object VirtualAccountDepositSuccess : TangemPayAccountDetailsInnerRoute() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt index 6aaba8da25..933d8aa15a 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt @@ -27,4 +27,7 @@ internal sealed class TangemPayCardDetailsInnerRoute : Route { @Serializable data object LimitSetupSuccess : TangemPayCardDetailsInnerRoute() + + @Serializable + data object VirtualAccountDepositSuccess : TangemPayCardDetailsInnerRoute() } \ 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 583004082d..4ef20b4232 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 @@ -33,12 +33,15 @@ 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.resourceReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe 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.details.impl.R import com.tangem.features.tangempay.entity.TangemPayVirtualAccountDepositUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -76,11 +79,11 @@ private fun DepositContent(state: TangemPayVirtualAccountDepositUM, modifier: Mo ) { IntroIcons(modifier = Modifier.padding(top = TangemTheme.dimens2.x4)) TitleText( - text = stringReference("Bank transfer might take 1-2 business days"), + text = resourceReference(R.string.tangempay_bank_transfer_intro_title), modifier = Modifier.padding(top = TangemTheme.dimens2.x8), ) SubtitleText( - text = stringReference("Received USD will be converted to USDC by 1:1 rate"), + text = resourceReference(R.string.tangempay_bank_transfer_intro_subtitle), modifier = Modifier.padding(top = TangemTheme.dimens2.x2), ) FeesBlock( @@ -88,16 +91,18 @@ private fun DepositContent(state: TangemPayVirtualAccountDepositUM, modifier: Mo modifier = Modifier.padding(top = TangemTheme.dimens2.x6), ) InfoNotification( - text = stringReference("Deposit via ACH or FedWire only. SWIFT transfers will be returned."), + text = resourceReference(R.string.tangempay_bank_transfer_swift_warning), modifier = Modifier.padding(top = TangemTheme.dimens2.x4), ) TangemButton( modifier = Modifier .fillMaxWidth() .padding(top = TangemTheme.dimens2.x4), - text = stringReference("Show details"), + text = resourceReference(R.string.tangempay_bank_transfer_show_details), variant = TangemButton.Variant.Primary, size = TangemButton.Size.X12, + isLoading = state.isLoading, + isEnabled = !state.isLoading, onClick = state.onShowDetailsClick, ) if (state.shouldShowTermsAndConditions) { @@ -118,7 +123,7 @@ private fun FeesBlock(fees: ImmutableList Unit, onPrivacyClick: () -> Unit, modifier: Modifier = Modifier) { val linkStyle = SpanStyle(color = TangemTheme.colors3.text.primary) + val termsTitle = stringResourceSafe(R.string.common_terms_of_use) + val privacyTitle = stringResourceSafe(R.string.common_privacy_policy) + val fullText = stringResourceSafe(R.string.tangempay_bank_transfer_legal, termsTitle, privacyTitle) + + // Locate each link title in the resolved (localized) string and splice them in appearance order. + // Handles translations that reorder the %1$s/%2$s placeholders and skips a title that a translation + // does not contain verbatim — falling back to plain text instead of crashing on an invalid substring range. + val links = listOf( + Triple(fullText.indexOf(termsTitle), termsTitle, onTermsClick), + Triple(fullText.indexOf(privacyTitle), privacyTitle, onPrivacyClick), + ) + .filter { it.first >= 0 } + .sortedBy { it.first } + 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") } + var cursor = 0 + links.forEach { (index, title, onClick) -> + if (index < cursor) return@forEach + append(fullText.substring(cursor, index)) + withLink(LinkAnnotation.Clickable(tag = title, linkInteractionListener = { onClick() })) { + withStyle(linkStyle) { append(title) } + } + cursor = index + title.length } + append(fullText.substring(cursor)) } Text( modifier = modifier.fillMaxWidth(), @@ -267,10 +288,17 @@ private fun SubtitleText(text: TextReference, modifier: Modifier = Modifier) { private fun previewState(shouldShowTermsAndConditions: Boolean) = TangemPayVirtualAccountDepositUM( fees = persistentListOf( - TangemPayVirtualAccountDepositUM.FeeRow(title = stringReference("ACH"), value = "$1"), - TangemPayVirtualAccountDepositUM.FeeRow(title = stringReference("FedWire"), value = "$11"), + TangemPayVirtualAccountDepositUM.FeeRow( + title = resourceReference(R.string.tangempay_bank_transfer_fee_ach), + value = "$1", + ), + TangemPayVirtualAccountDepositUM.FeeRow( + title = resourceReference(R.string.tangempay_bank_transfer_fee_fedwire), + value = "$11", + ), ), shouldShowTermsAndConditions = shouldShowTermsAndConditions, + isLoading = false, onShowDetailsClick = {}, onDismiss = {}, onTermsClick = {}, 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 new file mode 100644 index 0000000000..522a9deb45 --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayVirtualAccountDepositModelTest.kt @@ -0,0 +1,155 @@ +package com.tangem.features.tangempay.model + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.message.ToastMessage +import com.tangem.domain.models.account.BankCredentials +import com.tangem.domain.models.account.VirtualAccountOnramp +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.usecase.CreateVirtualAccountOrderUseCase +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.features.tangempay.components.TangemPayVirtualAccountDepositComponent +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.mockk +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +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 TangemPayVirtualAccountDepositModelTest { + + private val userWalletId = UserWalletId("1234567890ABCDEF") + private val paymentAccountAddress = "0xcollateral" + + private val urlOpener: UrlOpener = mockk(relaxed = true) + private val uiMessageSender: UiMessageSender = mockk(relaxed = true) + private val createVirtualAccountOrderUseCase: CreateVirtualAccountOrderUseCase = mockk() + private val onShowDetails: (VirtualAccountOnramp.Available) -> Unit = mockk(relaxed = true) + private val onOrderCreated: () -> Unit = mockk(relaxed = true) + + @BeforeEach + fun resetMocks() { + clearMocks(createVirtualAccountOrderUseCase, onShowDetails, onOrderCreated, uiMessageSender) + } + + @Test + fun `GIVEN available WHEN show details THEN opens requisites and does not create order`() = runTest { + // Arrange + val onramp = VirtualAccountOnramp.Available(productInstanceId = "pi_1", bankCredentials = bankCredentials()) + val model = createModel(onramp) + + // Act + model.uiState.value.onShowDetailsClick() + advanceUntilIdle() + + // Assert + verify(exactly = 1) { onShowDetails(onramp) } + coVerify(exactly = 0) { createVirtualAccountOrderUseCase(any(), any()) } + } + + @Test + fun `GIVEN eligible and create succeeds WHEN show details THEN order created and loading reset`() = runTest { + // Arrange + coEvery { createVirtualAccountOrderUseCase(userWalletId, paymentAccountAddress) } returns Unit.right() + val model = createModel(VirtualAccountOnramp.Eligible) + + // Act + model.uiState.value.onShowDetailsClick() + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { createVirtualAccountOrderUseCase(userWalletId, paymentAccountAddress) } + verify(exactly = 1) { onOrderCreated() } + assertThat(model.uiState.value.isLoading).isFalse() + } + + @Test + fun `GIVEN eligible and create fails WHEN show details THEN toast shown and loading reset`() = runTest { + // Arrange + coEvery { + createVirtualAccountOrderUseCase(userWalletId, paymentAccountAddress) + } returns VisaApiError.Unspecified.left() + val model = createModel(VirtualAccountOnramp.Eligible) + + // Act + model.uiState.value.onShowDetailsClick() + advanceUntilIdle() + + // Assert + verify(exactly = 1) { uiMessageSender.send(any()) } + verify { onOrderCreated wasNot Called } + assertThat(model.uiState.value.isLoading).isFalse() + } + + @Test + fun `GIVEN already loading WHEN show details twice THEN use case invoked once`() = runTest { + // Arrange + val pending = CompletableDeferred>() + coEvery { createVirtualAccountOrderUseCase(userWalletId, paymentAccountAddress) } coAnswers { pending.await() } + val model = createModel(VirtualAccountOnramp.Eligible) + + // Act + model.uiState.value.onShowDetailsClick() // starts loading, use case suspends + advanceUntilIdle() + model.uiState.value.onShowDetailsClick() // gated by isLoading — must be ignored + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.isLoading).isTrue() + coVerify(exactly = 1) { createVirtualAccountOrderUseCase(userWalletId, paymentAccountAddress) } + + pending.complete(Unit.right()) // let the in-flight call finish cleanly + advanceUntilIdle() + } + + private fun TestScope.createModel(onramp: VirtualAccountOnramp) = TangemPayVirtualAccountDepositModel( + paramsContainer = MutableParamsContainer( + TangemPayVirtualAccountDepositComponent.Params( + virtualAccountOnramp = onramp, + userWalletId = userWalletId, + paymentAccountAddress = paymentAccountAddress, + onDismiss = {}, + onShowDetails = onShowDetails, + onOrderCreated = onOrderCreated, + ), + ), + dispatchers = createTestingCoroutineDispatcherProvider(), + urlOpener = urlOpener, + uiMessageSender = uiMessageSender, + createVirtualAccountOrderUseCase = createVirtualAccountOrderUseCase, + ) + + private fun bankCredentials() = BankCredentials( + type = "ACH", + beneficiaryName = "Test Beneficiary", + beneficiaryAddress = "Addr", + beneficiaryBankName = "Bank", + beneficiaryBankAddress = "Bank Addr", + accountNumber = "123", + routingNumber = "456", + ) + + 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/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountMainComponent.kt b/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountMainComponent.kt index cd452567a0..9473fb0f4d 100644 --- a/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountMainComponent.kt +++ b/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountMainComponent.kt @@ -2,12 +2,14 @@ package com.tangem.features.virtualaccount.details.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.account.VirtualAccountOnramp import com.tangem.domain.models.wallet.UserWalletId interface VirtualAccountMainComponent : ComposableContentComponent { data class Params( val userWalletId: UserWalletId, + val virtualAccountOnramp: VirtualAccountOnramp.Available, ) interface Factory : ComponentFactory From ee846a27387122aac159cd2382bb95764e2b6348 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 8 Jul 2026 13:19:07 +0500 Subject: [PATCH 13/59] Updated on 2026-08-14 --- .../tangempay/TangemPayAnalyticsEvents.kt | 51 +++++++++++++++++++ .../TangemPayCardPageScreenComponent.kt | 3 ++ .../components/TangemPayDetailsComponent.kt | 3 ++ .../tangempay/model/TangemPayAddFundsModel.kt | 15 +++++- .../tangempay/model/TangemPayCardPageModel.kt | 14 +++++ .../tangempay/model/TangemPayDetailsModel.kt | 14 +++++ .../TangemPayVirtualAccountDepositModel.kt | 22 +++++++- ...TangemPayVirtualAccountDepositModelTest.kt | 10 +++- ...tualAccountAddFundsBottomSheetComponent.kt | 4 ++ .../addfunds/VirtualAccountAddFundsModel.kt | 18 ++++++- 10 files changed, 147 insertions(+), 7 deletions(-) diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt index 052ae672ca..65dc402e4f 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt @@ -293,4 +293,55 @@ sealed class TangemPayAnalyticsEvents( categoryName = "Visa Card Management", event = "Visa Extra Card Issuance Confirmed", ) + + class VaTopupButtonShowed : TangemPayAnalyticsEvents( + categoryName = "Visa VA Topup", + event = "Visa VA Topup Button Showed", + ) + + class VaTopupButtonClicked : TangemPayAnalyticsEvents( + categoryName = "Visa VA Topup", + event = "Visa VA Topup Button Clicked", + ) + + class VaConditionsPopupShowedFirstTime : TangemPayAnalyticsEvents( + categoryName = "Visa VA Topup", + event = "Conditions Popup Showed First Time", + ) + + class VaShowDetailsFirstTimeClicked : TangemPayAnalyticsEvents( + categoryName = "Visa VA Topup", + event = "Show Details First Time Clicked", + ) + + class VaSuccessScreenActivation : TangemPayAnalyticsEvents( + categoryName = "Visa VA Topup", + event = "Success Screen Activation", + ) + + class VaConditionsPopupShowed : TangemPayAnalyticsEvents( + categoryName = "Visa VA Topup", + event = "Conditions Popup Showed", + ) + + class VaShowDetailsClicked : TangemPayAnalyticsEvents( + categoryName = "Visa VA Topup", + event = "Show Details Clicked", + ) + + class VaBankingDetailsShowed : TangemPayAnalyticsEvents( + categoryName = "Visa VA Topup", + event = "Banking Details Showed", + ) + + class VaShareDetailsButtonClicked : TangemPayAnalyticsEvents( + categoryName = "Visa VA Topup", + event = "Share Details Button Clicked", + ) + + data class VaCopyFieldClicked(val field: String) : TangemPayAnalyticsEvents( + categoryName = "Visa VA Topup", + event = "Copy Field Clicked", + params = mapOf("field" to field), + ) } \ 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 be3f9eae88..a6ec072508 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 @@ -125,6 +125,9 @@ internal class TangemPayCardPageScreenComponent( dailyDepositLimit = VA_DAILY_DEPOSIT_LIMIT_PLACEHOLDER, shouldSkipIntro = true, listener = VirtualAccountAddFundsListener { model.bottomSheetNavigation.dismiss() }, + onDetailsShown = model::onVaBankingDetailsShown, + onShareClicked = model::onVaShareDetailsClicked, + onFieldCopied = model::onVaFieldCopied, ), ) 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 052c5c99cf..edfe81adf4 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 @@ -154,6 +154,9 @@ internal class TangemPayDetailsComponent( dailyDepositLimit = VA_DAILY_DEPOSIT_LIMIT_PLACEHOLDER, shouldSkipIntro = true, listener = VirtualAccountAddFundsListener { model.bottomSheetNavigation.dismiss() }, + onDetailsShown = model::onVaBankingDetailsShown, + onShareClicked = model::onVaShareDetailsClicked, + onFieldCopied = model::onVaFieldCopied, ), ) is TangemPayDetailsNavigation.IssueAdditionalCard -> TangemPayIssueAdditionalCardComponent( 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 f8e50e9509..b385c734da 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 @@ -1,12 +1,14 @@ package com.tangem.features.tangempay.model import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler 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.ReceiveAddressModel import com.tangem.domain.models.ReceiveAddressModel.DisplayType import com.tangem.domain.pay.model.TangemPayTopUpData +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.features.tangempay.components.TangemPayAddFundsComponent import com.tangem.features.tangempay.entity.TangemPayAddFundsUM @@ -21,13 +23,22 @@ internal class TangemPayAddFundsModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val tangemPayFeatureToggles: TangemPayFeatureToggles, - private val virtualAccountToggles: VirtualAccountFeatureToggles, + virtualAccountToggles: VirtualAccountFeatureToggles, + analytics: AnalyticsEventHandler, ) : Model() { private val params = paramsContainer.require() + private val isBankTransferShown = virtualAccountToggles.isVaMvp0Enabled && params.virtualAccountOnramp != null + val uiState: TangemPayAddFundsUM = getInitialState() + init { + if (isBankTransferShown) { + analytics.send(TangemPayAnalyticsEvents.VaTopupButtonShowed()) + } + } + private fun getInitialState(): TangemPayAddFundsUM { val data = TangemPayTopUpData( currency = params.cryptoCurrency, @@ -45,7 +56,7 @@ internal class TangemPayAddFundsModel @Inject constructor( return TangemPayAddFundsUMConverter( listener = params.listener, isRedesignEnabled = tangemPayFeatureToggles.isRedesignEnabled, - shouldShowBankTransfer = virtualAccountToggles.isVaMvp0Enabled && params.virtualAccountOnramp != null, + shouldShowBankTransfer = isBankTransferShown, ).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 3911bfae65..2468874e6a 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 @@ -489,6 +489,7 @@ internal class TangemPayCardPageModel @Inject constructor( override fun onClickBankTransfer() { val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return val onramp = loaded.virtualAccount ?: return + analytics.send(TangemPayAnalyticsEvents.VaTopupButtonClicked()) bottomSheetNavigation.dismiss() bottomSheetNavigation.activate( TangemPayCardNavigation.VirtualAccountDeposit( @@ -500,6 +501,7 @@ internal class TangemPayCardPageModel @Inject constructor( } fun onVirtualAccountOrderCreated() { + analytics.send(TangemPayAnalyticsEvents.VaSuccessScreenActivation()) bottomSheetNavigation.dismiss() router.push(TangemPayCardDetailsInnerRoute.VirtualAccountDepositSuccess) } @@ -514,6 +516,18 @@ internal class TangemPayCardPageModel @Inject constructor( ) } + fun onVaBankingDetailsShown() { + analytics.send(TangemPayAnalyticsEvents.VaBankingDetailsShowed()) + } + + fun onVaShareDetailsClicked() { + analytics.send(TangemPayAnalyticsEvents.VaShareDetailsButtonClicked()) + } + + fun onVaFieldCopied(field: String) { + analytics.send(TangemPayAnalyticsEvents.VaCopyFieldClicked(field)) + } + 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 e5aafdf942..00a93a43f5 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 @@ -314,6 +314,7 @@ internal class TangemPayDetailsModel @Inject constructor( override fun onClickBankTransfer() { val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return val onramp = loaded.virtualAccount ?: return + analytics.send(TangemPayAnalyticsEvents.VaTopupButtonClicked()) bottomSheetNavigation.dismiss() bottomSheetNavigation.activate( TangemPayDetailsNavigation.VirtualAccountDeposit( @@ -325,6 +326,7 @@ internal class TangemPayDetailsModel @Inject constructor( } fun onVirtualAccountOrderCreated() { + analytics.send(TangemPayAnalyticsEvents.VaSuccessScreenActivation()) bottomSheetNavigation.dismiss() router.push(TangemPayAccountDetailsInnerRoute.VirtualAccountDepositSuccess) } @@ -339,6 +341,18 @@ internal class TangemPayDetailsModel @Inject constructor( ) } + fun onVaBankingDetailsShown() { + analytics.send(TangemPayAnalyticsEvents.VaBankingDetailsShowed()) + } + + fun onVaShareDetailsClicked() { + analytics.send(TangemPayAnalyticsEvents.VaShareDetailsButtonClicked()) + } + + fun onVaFieldCopied(field: String) { + analytics.send(TangemPayAnalyticsEvents.VaCopyFieldClicked(field)) + } + 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 index d11441c9b5..8850ca7669 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 @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.model import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -10,6 +11,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.models.account.VirtualAccountOnramp import com.tangem.domain.pay.usecase.CreateVirtualAccountOrderUseCase +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.TangemPayVirtualAccountDepositComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayVirtualAccountDepositUM @@ -29,6 +31,7 @@ internal class TangemPayVirtualAccountDepositModel @Inject constructor( private val urlOpener: UrlOpener, private val uiMessageSender: UiMessageSender, private val createVirtualAccountOrderUseCase: CreateVirtualAccountOrderUseCase, + private val analytics: AnalyticsEventHandler, ) : Model() { private val params = paramsContainer.require() @@ -55,14 +58,29 @@ internal class TangemPayVirtualAccountDepositModel @Inject constructor( ), ) + init { + val event = if (params.virtualAccountOnramp is VirtualAccountOnramp.Eligible) { + TangemPayAnalyticsEvents.VaConditionsPopupShowedFirstTime() + } else { + TangemPayAnalyticsEvents.VaConditionsPopupShowed() + } + analytics.send(event) + } + fun onDismiss() { params.onDismiss() } private fun onShowDetailsClick() { when (params.virtualAccountOnramp) { - is VirtualAccountOnramp.Available -> params.onShowDetails(params.virtualAccountOnramp) - VirtualAccountOnramp.Eligible -> createVirtualAccountOrder() + is VirtualAccountOnramp.Available -> { + analytics.send(TangemPayAnalyticsEvents.VaShowDetailsClicked()) + params.onShowDetails(params.virtualAccountOnramp) + } + VirtualAccountOnramp.Eligible -> { + analytics.send(TangemPayAnalyticsEvents.VaShowDetailsFirstTimeClicked()) + createVirtualAccountOrder() + } } } 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 522a9deb45..91df97f7c9 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 @@ -4,6 +4,7 @@ import arrow.core.Either import arrow.core.left import arrow.core.right import com.google.common.truth.Truth.assertThat +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener @@ -12,6 +13,7 @@ import com.tangem.domain.models.account.BankCredentials import com.tangem.domain.models.account.VirtualAccountOnramp import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.usecase.CreateVirtualAccountOrderUseCase +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.domain.visa.error.VisaApiError import com.tangem.features.tangempay.components.TangemPayVirtualAccountDepositComponent import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider @@ -39,10 +41,11 @@ internal class TangemPayVirtualAccountDepositModelTest { private val createVirtualAccountOrderUseCase: CreateVirtualAccountOrderUseCase = mockk() private val onShowDetails: (VirtualAccountOnramp.Available) -> 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) + clearMocks(createVirtualAccountOrderUseCase, onShowDetails, onOrderCreated, uiMessageSender, analytics) } @Test @@ -58,6 +61,8 @@ internal class TangemPayVirtualAccountDepositModelTest { // Assert verify(exactly = 1) { onShowDetails(onramp) } coVerify(exactly = 0) { createVirtualAccountOrderUseCase(any(), any()) } + verify(exactly = 1) { analytics.send(ofType()) } + verify(exactly = 1) { analytics.send(ofType()) } } @Test @@ -74,6 +79,8 @@ internal class TangemPayVirtualAccountDepositModelTest { coVerify(exactly = 1) { createVirtualAccountOrderUseCase(userWalletId, paymentAccountAddress) } verify(exactly = 1) { onOrderCreated() } assertThat(model.uiState.value.isLoading).isFalse() + verify(exactly = 1) { analytics.send(ofType()) } + verify(exactly = 1) { analytics.send(ofType()) } } @Test @@ -130,6 +137,7 @@ internal class TangemPayVirtualAccountDepositModelTest { urlOpener = urlOpener, uiMessageSender = uiMessageSender, createVirtualAccountOrderUseCase = createVirtualAccountOrderUseCase, + analytics = analytics, ) private fun bankCredentials() = BankCredentials( 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 da2bf05f5a..91c9c5e583 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 @@ -18,6 +18,10 @@ interface VirtualAccountAddFundsBottomSheetComponent : ComposableBottomSheetComp val dailyDepositLimit: String, val listener: VirtualAccountAddFundsListener, val shouldSkipIntro: Boolean = false, + // Analytics hooks — supplied by callers that track this sheet (e.g. TangemPay VA topup); no-op otherwise. + val onDetailsShown: () -> Unit = {}, + val onShareClicked: () -> Unit = {}, + val onFieldCopied: (fieldName: String) -> Unit = {}, ) data class RequisitesRow( 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 56a80d6ce1..3de23e754e 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 @@ -35,6 +35,13 @@ internal class VirtualAccountAddFundsModel @Inject constructor( ), ) + init { + if (params.shouldSkipIntro) { + // send analytics + params.onDetailsShown() + } + } + fun onDismiss() { params.listener.onAddFundsDismiss() } @@ -44,6 +51,7 @@ internal class VirtualAccountAddFundsModel @Inject constructor( ) private fun showDetailsContent() { + params.onDetailsShown() uiState.update { state -> state.copy(content = buildDetailsContent()) } } @@ -52,13 +60,19 @@ internal class VirtualAccountAddFundsModel @Inject constructor( .map { detailItem(label = it.title, value = it.value) } .toImmutableList(), dailyLimit = params.dailyDepositLimit, - onShareClick = { shareManager.shareText(buildShareText()) }, + onShareClick = { + params.onShareClicked() + shareManager.shareText(buildShareText()) + }, ) private fun detailItem(label: String, value: String) = VirtualAccountAddFundsUM.DetailItem( label = stringReference(label), value = value, - onCopyClick = { clipboardManager.setText(text = value, isSensitive = true) }, + onCopyClick = { + params.onFieldCopied(label) + clipboardManager.setText(text = value, isSensitive = true) + }, ) private fun buildShareText(): String { From e50d2d9452a0dfdfb8655ce31285d9b587c94a8c Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 8 Jul 2026 11:23:05 +0300 Subject: [PATCH 14/59] Updated on 2026-08-14 --- .../data/pay/repository/MockAwareOnboardingRepository.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 7411509b11..03b0abddb9 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 @@ -78,12 +78,13 @@ internal class MockAwareOnboardingRepository @Inject constructor( override suspend fun createVirtualAccountOrder( userWalletId: UserWalletId, paymentAccountAddress: String, + idempotencyKey: String, ): Either { if (isMockMode) { mockVaOrderIds.add(userWalletId) return MOCK_VA_ORDER_ID.right() } - return real.createVirtualAccountOrder(userWalletId, paymentAccountAddress) + return real.createVirtualAccountOrder(userWalletId, paymentAccountAddress, idempotencyKey) } override suspend fun getVirtualAccountOrderId(userWalletId: UserWalletId): String? { From 642937b819104dcdb695b5a03f14263acdb50ee7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Jul 2026 14:17:55 +0500 Subject: [PATCH 15/59] Updated on 2026-08-14 --- .../DefaultPaymentAccountStatusFetcherTest.kt | 1 - .../tangempay/model/TangemPayDetailsModel.kt | 2 +- .../utils/VirtualAccountRequisites.kt | 28 ++++++++---- ...tualAccountAddFundsBottomSheetComponent.kt | 3 +- .../common/ui/TangemBalanceHeader.kt | 9 ++-- .../main/VirtualAccountMainModel.kt | 15 +++---- .../addfunds/VirtualAccountAddFundsModel.kt | 43 +++++++++++-------- 7 files changed, 55 insertions(+), 46 deletions(-) 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 e916df8fbc..a5e65e1f9c 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 @@ -127,7 +127,6 @@ internal class DefaultPaymentAccountStatusFetcherTest { availableForWithdrawal = BigDecimal.TEN, cards = listOf(cardInfo), productInstances = productInstances, - tariffPlan = null, ) @BeforeEach 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 00a93a43f5..1da0574cd4 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 @@ -62,7 +62,7 @@ import kotlinx.coroutines.launch import java.math.BigDecimal import javax.inject.Inject -@Suppress("LongParameterList", "LargeClass") +@Suppress("LongParameterList", "LargeClass", "TooManyFunctions") @Stable @ModelScoped internal class TangemPayDetailsModel @Inject constructor( 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..e7d6f208b7 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 = beneficiaryBankAddress, ), 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/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/common/ui/TangemBalanceHeader.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeader.kt index 6ec531b3d2..85990403bd 100644 --- a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeader.kt +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeader.kt @@ -16,8 +16,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.text.applyBladeBrush -import com.tangem.core.ui.ds2.shimmers.TextShimmer -import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle +import com.tangem.core.ui.ds2.shimmers.TangemShimmer import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign @@ -44,11 +43,9 @@ fun TangemBalanceHeader( }, ) { animatedState -> when (animatedState) { - is TangemBalanceHeaderState.Loading -> TextShimmer( + is TangemBalanceHeaderState.Loading -> TangemShimmer( modifier = Modifier.size(width = 160.dp, height = 56.dp), - text = "1234.00", - style = TextShimmerStyle.HEADING_MEDIUM, - radius = TangemTheme.dimens2.x25, + style = TangemTheme.typography3.heading.medium, ) is TangemBalanceHeaderState.Content -> Text( modifier = balanceModifier, 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/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 { From dd9a84521a9419da36cdff3e03b799d48d926ac8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Jul 2026 15:32:34 +0300 Subject: [PATCH 16/59] Updated on 2026-08-14 --- .../tangempay/ui/components/TangemPaySuccessScreenWrapper.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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( From 5f1a711c8cab9f86446212f5432af66c26e7a767 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Jul 2026 18:15:05 +0500 Subject: [PATCH 17/59] Updated on 2026-08-14 --- .../ui/TangemPayVirtualAccountDepositBottomSheet.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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, ) } } From bd13529d6968186b5ef36caeaa13c7034d321c2b Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 14 Jul 2026 00:14:47 +0500 Subject: [PATCH 18/59] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 82 ++------- .../DefaultPaymentAccountStatusFetcher.kt | 2 +- .../DefaultPaymentAccountStatusFetcherTest.kt | 4 +- .../models/account/VirtualAccountOnramp.kt | 8 + .../TangemPayCardPageScreenComponent.kt | 9 + .../components/TangemPayDetailsComponent.kt | 9 + ...TangemPayVaBankingDetailsErrorComponent.kt | 44 +++++ .../tangempay/di/TangemPayModelModule.kt | 5 + .../entity/TangemPayCardNavigation.kt | 5 + .../entity/TangemPayDetailsNavigation.kt | 5 + .../TangemPayVaBankingDetailsErrorUM.kt | 17 ++ .../tangempay/model/TangemPayCardPageModel.kt | 43 ++++- .../tangempay/model/TangemPayDetailsModel.kt | 25 ++- .../TangemPayVaBankingDetailsErrorModel.kt | 63 +++++++ .../TangemPayVirtualAccountDepositModel.kt | 3 + ...ngemPayVaBankingDetailsErrorBottomSheet.kt | 171 ++++++++++++++++++ ...TangemPayVaBankingDetailsErrorModelTest.kt | 132 ++++++++++++++ 17 files changed, 550 insertions(+), 77 deletions(-) create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayVaBankingDetailsErrorComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayVaBankingDetailsErrorUM.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayVaBankingDetailsErrorModel.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayVaBankingDetailsErrorBottomSheet.kt create mode 100644 features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayVaBankingDetailsErrorModelTest.kt diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 2d1d56553e..933fd42368 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -93,7 +93,7 @@ Credit card or bank account Fund token Share your address or QR-code - Exchange one crypto for another + Between your portfolios You receive Add address Add address and select network @@ -105,7 +105,6 @@ %d addresses Choose address - Clear All Contact Contact name Copy address @@ -131,12 +130,10 @@ No contacts yet Contacts added will appear here Remove address - Save address Save contact Save to Wallet This contact will be linked to this wallet’s address book. No results found.\nTry another name - Select All Select network Address book Unsaved changes @@ -639,8 +636,6 @@ Long transaction time The transaction amount was refunded in %1$s to your wallet due to OKX or bridge rules. %2$s The amount was refunded in %1$s (%2$s network) - Your funds have been refunded in %1$s to your wallet on the %2$s network, in accordance with OKX exchange rules. - Refunded in %s Visit provider’s website for verification KYC verification required by provider Purchase completed @@ -726,11 +721,6 @@ Can\'t send a transaction Coin description error Review portfolio and explore earn opportunities - Earn opportunities - All your assets are at work, explore new opportunities - Get up to %1$s annually - Max potential rewards %1$s - %1$s/year Portfolio review For You Update now @@ -933,14 +923,12 @@ In your portfolio Your portfolio **Token not supported**. This token is currently not supported in the wallet - Other eligible tokens Market Pulse Quick actions Clear all Search tokens - Recent searches + Recent\'s In your portfolio - Recent tokens Result See tokens under 100k USD market cap Show tokens @@ -1311,21 +1299,6 @@ By balance Organize tokens Ungroup - Eligible cashback will be distributed to: - You\'re already enrolled in %1$s - Eligible tokens - Enroll - You\'re successfully enrolled in %1$s - This campaign no longer exists or has expired - Campaign not active - Earn 0.5% cashback on every swap over $500, on any pair except stable to stable. Max payout $50 per swap.\n\nComplete five qualifying swaps and unlock an extra $10 bonus.\n\nRewards are paid weekly in USDT or USDC on the address selected. - Select cashback account - Select token - Enroll in %1$s - I agree with %1$s - I agree with - %1$s Terms - Earn cashback on every swap from $10K until the end of July.\n\nRates step up with size: 0.10% from $10K, 0.20% from $20K, 0.50% from $100K.\n\nMax payout: $500 per swap, and $10,000 per wallet per swap direction until campaign lasts. Stable coin into stablecoin swaps are excluded.\n\nPayout arrives weekly in USDT or USDC address of your choice. %s support Push Notifications are enabled but won\'t work until you allow them Allow notifications @@ -1358,12 +1331,12 @@ No supported tokens found This QR code contains parameters that are not recognized: %s. Some payment details may be lost if you continue. Unknown Parameters - Get crypto by card, bank transfer & more + Credit card or bank account Share your address or QR-code - Convert crypto to fiat currency - Exchange and send in one step - Transfer crypto to another wallet - Exchange one crypto for another + Sell crypto securely + Send with swap to another token + Send to another wallet + Between your portfolios Other Quick top up No memo required @@ -1549,7 +1522,7 @@ Total amount exceeds balance Are you sure you want to change the receiving token? This will reset your previously entered data. Changing token - Swap & Send + Swap and send Proceed with swap? This will clear your previous data. Confirm Conversion Sending any other currency will result in its irreversible loss. @@ -1657,9 +1630,9 @@ To begin staking, you need to activate your TON account first. Account activation To start staking in TON, first send a small transaction to your own address — this will activate your wallet. - Up to 0.2 GRAM may be required in addition to the network fee to complete the transaction. Any unused amount will be refunded. - 0.2 GRAM is required to proceed with this operation, in addition to the network fee. Please top up your balance. - GRAM reserve required + Up to 0.2 TON may be required in addition to the network fee to complete the transaction. Any unused amount will be refunded. + 0.2 TON is required to proceed with this operation, in addition to the network fee. Please top up your balance. + TON reserve required This action will close other positions or switch them to withdrawal status, according to network rules. Positions status Unlock your money to withdraw it from staking process. Unlocking takes %s. @@ -1864,9 +1837,6 @@ Add funds Top-up options Add to Google Wallet - Cancel %1$s, move to %2$s - To pay monthly fee for plan and start use card - Top-up your account on %1$s Card Number PIN code The card is fully ready for payments. @@ -1901,8 +1871,6 @@ Card name Reveal Details - If it will remain below zero your %1$s cards will be closed on %2$s - Top up your account shortly Details Please try again later Unfreeze Card @@ -1930,22 +1898,12 @@ %d card %d cards - It was made due to your suspicious behavior. Contact support to learn more - Cashback deactivated - Will be deposited on %1$s - %1$s cashback in %2$s Change PIN-code Come back to the app if you forget it. Card - Error loading - Your %1$s plan is active till %2$s, then we will move you to %3$s. %4$s won\'t be charged. Change plan - %1$s monthly fee will be charged on %2$s Card related Plan related - Stay on %1$s - Your transition on %1$s will be canceled - Do you want to stay on %1$s? Current plan Set a limit from %s to %s Set limits @@ -2002,7 +1960,6 @@ Hide KYC block Sorry, we couldn\'t verify your profile. - Select plan You can have up to 3 cards. Delete one to add a new card. Maximum Cards Issued Yes – to operate a regulated Visa card, identity verification is mandatory. KYC is handled by Sumsub (compliance partner). @@ -2057,16 +2014,7 @@ Select Upgrade plan Compare plans - Your %1$s plan and %2$s cards will be active till %3$s - You can cancel this transition till %1$s - Your %1$s cards will be closed - %1$s monthly fee will be taken from your account - On %1$s we will move you to %2$s plan - No fee applied - You will get your virtual %1$s in minutes - You are switching to %1$s Confirm selection - We will issue %1$s for you Select plan We’re fixing a technical issue. Please try again later. Service temporarily unavailable @@ -2075,7 +2023,6 @@ Set up new PIN Set PIN Account closed - Inactive Replacing your card Use your card or ring to renew session Use your card or ring to renew session @@ -2093,7 +2040,6 @@ USDC on Polygon network Please try again or contact support if the issue persists Couldn\'t load banking details - Visa Benefits Funds from refunded purchases won’t be returned your on-chain Polygon balance or be available for withdrawal, but will stay on your card balance for purchases Please note Your PIN code @@ -2137,10 +2083,6 @@ Show QR code Can’t load data of the token Go to swap - Last update: %1$s - Negative outlook - Neutral outlook - Positive outlook Token summary Exchange this token for another at %1$s service fees from February %2$s-%3$s. Swap with Changelly, %s fees @@ -2658,7 +2600,7 @@ The fee will be deducted, and your assets will be resupplied. To continue generating yield, approval is required. Confirm approval - Current APY %1$s%% + Average APY %1$s%% Your funds are currently supplied to the Aave protocol, but you can manage them at any time. Your %s is supplied to Aave Unable to load chart... 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 e77fc8bec5..6c262fdb70 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 @@ -408,7 +408,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( return onboardingRepository.getBankCredentials(userWalletId, accountInstance.id).fold( ifLeft = { error -> logger.e("getBankCredentials failed for ${accountInstance.id}: $error") - null + VirtualAccountOnramp.BankCredentialsError }, ifRight = { credentials -> VirtualAccountOnramp.Available( 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 a5e65e1f9c..4c7bafec4f 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 @@ -238,7 +238,7 @@ internal class DefaultPaymentAccountStatusFetcherTest { } @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( @@ -256,7 +256,7 @@ internal class DefaultPaymentAccountStatusFetcherTest { // Assert val loaded = storedStatuses.lastLoaded() - assertThat(loaded.virtualAccount).isNull() + assertThat(loaded.virtualAccount).isEqualTo(VirtualAccountOnramp.BankCredentialsError) } @Test 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..e3ba1c80bd 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,12 @@ sealed interface VirtualAccountOnramp { val productInstanceId: String, val bankCredentials: BankCredentials, ) : 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/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 a6ec072508..a014677344 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 @@ -130,6 +130,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 edfe81adf4..c7520ca8aa 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 @@ -159,6 +159,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/di/TangemPayModelModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt index 941998d5d0..03b6a31a11 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 @@ -50,6 +50,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 82b020d3f7..ac57f7b8ec 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 2468874e6a..fea873c6c6 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 @@ -72,11 +76,12 @@ import com.tangem.core.ui.R as CoreUiR @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, @@ -488,7 +493,16 @@ 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 + is VirtualAccountOnramp.BankCredentialsError -> showVaBankingDetailsError() + is VirtualAccountOnramp.Available, + VirtualAccountOnramp.Eligible, + -> openVirtualAccountDeposit(onramp, loaded) + } + } + + private fun openVirtualAccountDeposit(onramp: VirtualAccountOnramp, loaded: PaymentAccountStatusValue.Loaded) { analytics.send(TangemPayAnalyticsEvents.VaTopupButtonClicked()) bottomSheetNavigation.dismiss() bottomSheetNavigation.activate( @@ -500,6 +514,31 @@ internal class TangemPayCardPageModel @Inject constructor( ) } + private fun showVaBankingDetailsError() { + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate( + TangemPayCardNavigation.VaBankingDetailsError(userWalletId = userWalletId), + ) + } + + fun onVaBankingDetailsResolved(onramp: VirtualAccountOnramp) { + 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 1da0574cd4..09649cb4ee 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 @@ -67,7 +67,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, @@ -313,7 +313,16 @@ 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 + is VirtualAccountOnramp.BankCredentialsError -> showVaBankingDetailsError() + is VirtualAccountOnramp.Available, + VirtualAccountOnramp.Eligible, + -> openVirtualAccountDeposit(onramp, loaded) + } + } + + private fun openVirtualAccountDeposit(onramp: VirtualAccountOnramp, loaded: PaymentAccountStatusValue.Loaded) { analytics.send(TangemPayAnalyticsEvents.VaTopupButtonClicked()) bottomSheetNavigation.dismiss() bottomSheetNavigation.activate( @@ -325,6 +334,18 @@ internal class TangemPayDetailsModel @Inject constructor( ) } + private fun showVaBankingDetailsError() { + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate( + TangemPayDetailsNavigation.VaBankingDetailsError(userWalletId = userWalletId), + ) + } + + fun onVaBankingDetailsResolved(onramp: VirtualAccountOnramp) { + 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..6bc0a4a958 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() } + // Error onramp is intercepted before this sheet opens (a dedicated error sheet is shown instead); + // the branch only keeps the `when` exhaustive. + VirtualAccountOnramp.BankCredentialsError -> 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..50b720bdce --- /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.alert_button_try_again), + 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/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 From 0bd23be25ffd169f9d41aa0f9d112491f734fead Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 15 Jul 2026 17:28:02 +0500 Subject: [PATCH 19/59] Updated on 2026-08-14 --- .../com/tangem/data/pay/di/TangemPayDataModule.kt | 2 ++ .../pay/usecase/CreateVirtualAccountOrderUseCase.kt | 13 +++++++++---- .../usecase/CreateVirtualAccountOrderUseCaseTest.kt | 7 ++++++- 3 files changed, 17 insertions(+), 5 deletions(-) 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 a363e421d0..ae74b0c56c 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 @@ -298,10 +298,12 @@ internal interface TangemPayDataModule { fun provideCreateVirtualAccountOrderUseCase( onboardingRepository: OnboardingRepository, pollingUseCase: StartTangemPayOrderPollingUseCase, + appCoroutineScope: AppCoroutineScope, ): CreateVirtualAccountOrderUseCase { return CreateVirtualAccountOrderUseCase( onboardingRepository = onboardingRepository, pollingUseCase = pollingUseCase, + appCoroutineScope = appCoroutineScope, ) } } 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..2ad979a933 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 @@ -7,6 +7,8 @@ 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 +22,7 @@ import java.util.UUID class CreateVirtualAccountOrderUseCase( private val onboardingRepository: OnboardingRepository, private val pollingUseCase: StartTangemPayOrderPollingUseCase, + private val appCoroutineScope: AppCoroutineScope, ) { suspend operator fun invoke( userWalletId: UserWalletId, @@ -33,10 +36,12 @@ class CreateVirtualAccountOrderUseCase( idempotencyKey = UUID.randomUUID().toString(), ).bind() onboardingRepository.storeVirtualAccountOrderId(userWalletId = userWalletId, vaOrderId = vaOrderId) - pollingUseCase.invoke( - order = TangemPayOrderInfo(orderId = vaOrderId, orderStatus = OrderStatus.NEW), - userWalletId = 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..306f9da329 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 @@ -6,6 +6,7 @@ import com.google.common.truth.Truth.assertThat import com.tangem.domain.models.wallet.UserWalletId 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 +17,11 @@ 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 useCase = CreateVirtualAccountOrderUseCase( + onboardingRepository = onboardingRepository, + pollingUseCase = pollingUseCase, + appCoroutineScope = TestAppCoroutineScope(), + ) private val userWalletId = UserWalletId("1234567890ABCDEF") private val paymentAccountAddress = "0xcollateral" From 2c754243f592faea12a5e51ffb2a456534f4db87 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 16 Jul 2026 10:38:00 +0500 Subject: [PATCH 20/59] Updated on 2026-08-14 --- .../tangem/data/pay/di/TangemPayDataModule.kt | 2 + .../DefaultPaymentAccountStatusFetcher.kt | 45 +++- .../repository/DefaultOnboardingRepository.kt | 7 + .../pay/store/PaymentAccountStatusesStore.kt | 23 ++ .../MockAwareOnboardingRepository.kt | 8 + .../DefaultPaymentAccountStatusFetcherTest.kt | 251 ++++++++++++++++-- .../models/account/VirtualAccountOnramp.kt | 9 + .../pay/flow/PaymentAccountStatusFetcher.kt | 9 + .../pay/repository/OnboardingRepository.kt | 2 + .../CreateVirtualAccountOrderUseCase.kt | 5 + .../CreateVirtualAccountOrderUseCaseTest.kt | 7 + .../tangempay/model/TangemPayCardPageModel.kt | 6 + .../tangempay/model/TangemPayDetailsModel.kt | 6 + .../TangemPayVirtualAccountDepositModel.kt | 8 +- .../utils/TangemPayMessagesFactory.kt | 17 ++ .../utils/VirtualAccountRequisites.kt | 2 +- 16 files changed, 378 insertions(+), 29 deletions(-) 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 ae74b0c56c..67842924ad 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 @@ -298,11 +298,13 @@ 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 6c262fdb70..fbe30d141d 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 @@ -111,6 +111,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, @@ -394,9 +398,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 @@ -405,6 +416,8 @@ 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") @@ -419,6 +432,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 c97e31cb39..3c7090cfab 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 @@ -200,6 +200,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 c9ff2fd990..6faf08545b 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 03b0abddb9..c54d37c768 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 @@ -100,6 +100,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) + } + override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either = real.hasTangemPayInWallet(userWalletId) 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 4c7bafec4f..178766c64b 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 @@ -18,11 +25,15 @@ 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.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.visa.error.VisaApiError 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 @@ -107,27 +118,26 @@ internal class DefaultPaymentAccountStatusFetcherTest { 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, - ) + 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, + ) @BeforeEach fun setUp() { @@ -187,7 +197,38 @@ 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, + ) } @Nested @@ -219,6 +260,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) @@ -235,6 +277,7 @@ internal class DefaultPaymentAccountStatusFetcherTest { bankCredentials = bankCredentialsFixture, ), ) + coVerify(exactly = 1) { onboardingRepository.clearVirtualAccountOrderId(userWalletId) } } @Test @@ -246,6 +289,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() @@ -257,6 +301,7 @@ internal class DefaultPaymentAccountStatusFetcherTest { // Assert val loaded = storedStatuses.lastLoaded() assertThat(loaded.virtualAccount).isEqualTo(VirtualAccountOnramp.BankCredentialsError) + coVerify(exactly = 1) { onboardingRepository.clearVirtualAccountOrderId(userWalletId) } } @Test @@ -266,6 +311,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)) @@ -286,6 +332,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() @@ -298,5 +345,165 @@ 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, + ) + + 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) + } } } \ No newline at end of file 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 e3ba1c80bd..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 @@ -22,6 +22,15 @@ sealed interface VirtualAccountOnramp { 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 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 2ad979a933..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,6 +3,7 @@ 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 @@ -22,6 +23,7 @@ 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( @@ -36,6 +38,9 @@ class CreateVirtualAccountOrderUseCase( idempotencyKey = UUID.randomUUID().toString(), ).bind() onboardingRepository.storeVirtualAccountOrderId(userWalletId = userWalletId, vaOrderId = vaOrderId) + // 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), 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 306f9da329..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,6 +4,7 @@ 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 @@ -17,9 +18,12 @@ internal class CreateVirtualAccountOrderUseCaseTest { private val onboardingRepository: OnboardingRepository = mockk(relaxUnitFun = true) private val pollingUseCase: StartTangemPayOrderPollingUseCase = mockk(relaxed = true) + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk(relaxUnitFun = true) + private val useCase = CreateVirtualAccountOrderUseCase( onboardingRepository = onboardingRepository, pollingUseCase = pollingUseCase, + paymentAccountStatusFetcher = paymentAccountStatusFetcher, appCoroutineScope = TestAppCoroutineScope(), ) @@ -36,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 @@ -50,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 @@ -64,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/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 fea873c6c6..fbd27c82ec 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 @@ -496,6 +496,7 @@ internal class TangemPayCardPageModel @Inject constructor( when (val onramp = loaded.virtualAccount) { null -> return is VirtualAccountOnramp.BankCredentialsError -> showVaBankingDetailsError() + VirtualAccountOnramp.Processing -> showVaPreparing() is VirtualAccountOnramp.Available, VirtualAccountOnramp.Eligible, -> openVirtualAccountDeposit(onramp, loaded) @@ -521,6 +522,11 @@ internal class TangemPayCardPageModel @Inject constructor( ) } + private fun showVaPreparing() { + bottomSheetNavigation.dismiss() + uiMessageSender.send(message = TangemPayMessagesFactory.createVaPreparingMessage()) + } + fun onVaBankingDetailsResolved(onramp: VirtualAccountOnramp) { val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return openVirtualAccountDeposit(onramp, loaded) 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 09649cb4ee..202898ca9a 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 @@ -316,6 +316,7 @@ internal class TangemPayDetailsModel @Inject constructor( when (val onramp = loaded.virtualAccount) { null -> return is VirtualAccountOnramp.BankCredentialsError -> showVaBankingDetailsError() + VirtualAccountOnramp.Processing -> showVaPreparing() is VirtualAccountOnramp.Available, VirtualAccountOnramp.Eligible, -> openVirtualAccountDeposit(onramp, loaded) @@ -341,6 +342,11 @@ internal class TangemPayDetailsModel @Inject constructor( ) } + private fun showVaPreparing() { + bottomSheetNavigation.dismiss() + uiMessageSender.send(message = TangemPayMessagesFactory.createVaPreparingMessage()) + } + fun onVaBankingDetailsResolved(onramp: VirtualAccountOnramp) { val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return openVirtualAccountDeposit(onramp, loaded) 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 6bc0a4a958..69909b584b 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,9 +81,11 @@ internal class TangemPayVirtualAccountDepositModel @Inject constructor( analytics.send(TangemPayAnalyticsEvents.VaShowDetailsFirstTimeClicked()) createVirtualAccountOrder() } - // Error onramp is intercepted before this sheet opens (a dedicated error sheet is shown instead); - // the branch only keeps the `when` exhaustive. - VirtualAccountOnramp.BankCredentialsError -> onDismiss() + // Processing/Error onramps never reach this sheet (a message/error sheet is shown instead); + // these branches only keep the `when` exhaustive. + VirtualAccountOnramp.Processing, + VirtualAccountOnramp.BankCredentialsError, + -> onDismiss() } } 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 55c4d6944f..7acda5692e 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 @@ -177,6 +177,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 e7d6f208b7..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 @@ -25,7 +25,7 @@ internal fun BankCredentials.toRequisitesRows(): List = listOf( RequisitesRow( title = resourceReference(R.string.virtual_account_requisites_beneficiary_address), titleForShare = "Beneficiary address", - value = beneficiaryBankAddress, + value = beneficiaryAddress, ), RequisitesRow( title = resourceReference(R.string.virtual_account_requisites_bank_name), From a94f946a282d5508b0cd4fae6f5fa54088b71824 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 16 Jul 2026 12:44:40 +0500 Subject: [PATCH 21/59] Updated on 2026-08-14 --- features/details/impl/build.gradle.kts | 1 + .../kotlin/com/tangem/features/details/model/DetailsModel.kt | 5 ++++- .../tangem/features/details/model/DetailsModelTestBase.kt | 4 ++++ .../main/addfunds/VirtualAccountAddFundsBottomSheet.kt | 3 +++ 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/features/details/impl/build.gradle.kts b/features/details/impl/build.gradle.kts index 51324ed3de..8ead337482 100644 --- a/features/details/impl/build.gradle.kts +++ b/features/details/impl/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(projects.features.createWalletSelection.api) implementation(projects.features.onboardingV2.api) implementation(projects.features.addressBook.api) + implementation(projects.features.virtualAccounts.details.api) /* Project - Core */ implementation(projects.core.decompose) 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 ce9efd9331..bd6e60655f 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 @@ -37,6 +37,7 @@ import com.tangem.features.details.entity.DetailsUM 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 @@ -69,6 +70,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() { @@ -292,8 +294,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 d693d28483..0770d2baea 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() @@ -90,6 +92,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( @@ -132,6 +135,7 @@ internal abstract class DetailsModelTestBase { analyticsEventHandler = analyticsEventHandler, tangemPayEligibilityManager = tangemPayEligibilityManager, getVirtualAccountEligibilityUseCase = getVirtualAccountEligibilityUseCase, + virtualAccountFeatureToggles = virtualAccountFeatureToggles, ) protected fun stubBuildAllReturns(list: ImmutableList) { 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 -> From 7afc6735c12a04498e628037613d0336249cf820 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 16 Jul 2026 14:58:27 +0500 Subject: [PATCH 22/59] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 1 + .../tangempay/model/TangemPayCardPageModel.kt | 11 +++++++++-- .../features/tangempay/model/TangemPayDetailsModel.kt | 11 +++++++++-- .../ui/TangemPayVaBankingDetailsErrorBottomSheet.kt | 2 +- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 933fd42368..94ee32219c 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -438,6 +438,7 @@ Rename Required Reset + Retry Save Save changes Search 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 fbd27c82ec..b8c87b5103 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 @@ -528,8 +528,15 @@ internal class TangemPayCardPageModel @Inject constructor( } fun onVaBankingDetailsResolved(onramp: VirtualAccountOnramp) { - val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return - openVirtualAccountDeposit(onramp, loaded) + 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() { 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 202898ca9a..77d48ac081 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 @@ -348,8 +348,15 @@ internal class TangemPayDetailsModel @Inject constructor( } fun onVaBankingDetailsResolved(onramp: VirtualAccountOnramp) { - val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return - openVirtualAccountDeposit(onramp, loaded) + 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() { 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 index 50b720bdce..0d92ed8afa 100644 --- 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 @@ -88,7 +88,7 @@ private fun Content(state: TangemPayVaBankingDetailsErrorUM, modifier: Modifier modifier = Modifier .fillMaxWidth() .padding(top = TangemTheme.dimens2.x2), - text = resourceReference(R.string.alert_button_try_again), + text = resourceReference(R.string.common_retry), variant = TangemButton.Variant.Primary, size = TangemButton.Size.X12, isLoading = state.isRetryLoading, From c1e1458f3f92131fb4f2b8e979604228cfd27845 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 17 Jul 2026 13:35:59 +0500 Subject: [PATCH 23/59] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 40 ++++++ core/res/src/main/res/values-es/strings.xml | 1 + core/res/src/main/res/values-fr/strings.xml | 1 + core/res/src/main/res/values-ja/strings.xml | 1 + .../src/main/res/values-pt-rBR/strings.xml | 3 +- core/res/src/main/res/values-ru/strings.xml | 2 + .../src/main/res/values-uk-rUA/strings.xml | 2 + .../src/main/res/values-zh-rCN/strings.xml | 1 + .../src/main/res/values-zh-rTW/strings.xml | 1 + core/res/src/main/res/values/strings.xml | 114 ++++++++++++++++-- .../TangemPayCardPageScreenComponent.kt | 1 + .../components/TangemPayDetailsComponent.kt | 1 + ...TangemPayVirtualAccountDepositComponent.kt | 1 + .../tangempay/model/TangemPayCardPageModel.kt | 8 +- .../tangempay/model/TangemPayDetailsModel.kt | 6 +- .../TangemPayVirtualAccountDepositModel.kt | 8 +- ...TangemPayVirtualAccountDepositModelTest.kt | 26 +++- 17 files changed, 193 insertions(+), 24 deletions(-) diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 92e5d754e5..2227083170 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -441,6 +441,7 @@ Umbenennen Erforderlich Zurücksetzen + Wiederholen Speichern Änderungen speichern Suchen @@ -907,9 +908,11 @@ Vermögenswert Vermögenswerte + Kein Betrag 
auf Token Keine Daten Gesamtwert Daten konnten nicht geladen werden + Du hast keine Token mit diesem Betrag. Top-Halterung %s Über diesen Coin Um dieses Asset zu kaufen, zu tauschen oder zu erhalten, füge diesen Deinem Portfolio hinzu @@ -1863,6 +1866,9 @@ Guthaben hinzufügen Aufladeoptionen Zu Google Wallet hinzufügen + Stornieren %1$s, umziehen nach %2$s + Um die monatliche Gebühr für den Tarif zu bezahlen und die Karte zu nutzen + Laden Sie Ihr Konto auf unter %1$s Kartennummer PIN-Code Die Karte ist vollständig für Zahlungen bereit. @@ -1897,6 +1903,8 @@ Kartenname Aufdecken Kartendetails + Sollte der Kontostand unter null bleiben, werden Ihre „ %1$s “-Karten am %2$s + Laden Sie Ihr Konto in Kürze auf. Kartendetails Bitte versuche es später noch einmal. Karte entsperren @@ -1924,17 +1932,40 @@ %d Karte %d Karten + Wir bearbeiten Käufe innerhalb von 5 Tagen nach der Transaktion und berücksichtigen nur abgeschlossene Transaktionen. + Wie berechnen wir Cashback? + Für Einkäufe vor Ort bei Händlern in der EU wird kein Cashback gewährt; dies gilt ebenfalls für Abhebungen, Überweisungen, bargeldähnliche Zahlungen, Mobilfunkrechnungen, behördliche Dienstleistungen und bestimmte andere Kategorien. + Ausnahmen + Vom 2. bis zum 5. des nächsten Monats + Wie erfolgt die Auszahlung von Cashback? + Grenzen und Ausnahmen + Abgrenzungen + Dauerhaft + Zusätzliches Cashback + Bis %1$s Dies geschah aufgrund Ihres verdächtigen Verhaltens. Wenden Sie sich an den Support, um mehr zu erfahren. Cashback deaktiviert Wird eingezahlt am %1$s + %1$s maximal pro Monat + Kein Cashback für Einkäufe vor Ort bei Händlern in der EU + Bezahlt in %1$s + %1$s%% Bei allen Einkäufen mit Ihren „ %2$s “-Karten gilt ein Mindestumsatz von %3$s + Die Seite konnte nicht geladen werden.\nZum Neuladen bitte antippen + Wir haben eine Rückerstattung für einen Kauf erhalten, für den zuvor bereits Cashback gewährt worden war + %1$s insgesamt verdient %1$s Cashback in %2$s PIN-Code ändern Kehren Sie zur App zurück, falls Sie ihn vergessen. Karte Fehler beim Laden + Ihr „ %1$s “-Tarif ist bis zum %2$sgültig; danach werden wir Sie auf %3$sumstellen. Für %4$s fallen keine Kosten an. Tarif wechseln + %1$s Die monatliche Gebühr wird am %2$s Kartenbezogen Planbezogen + Bleib dran %1$s + Ihr Übergang auf „ %1$s “ wird storniert. + Möchtest du auf „ %1$s“ bleiben? Aktueller Plan Limit von %s bis %s festlegen Limits festlegen @@ -2046,7 +2077,16 @@ Auswählen Tarif wechseln Tarife vergleichen + Ihr „ %1$s “-Tarif und Ihre „ %2$s “-Karten sind gültig bis %3$s + Sie können diesen Übergang bis zum %1$s + Dein %1$s Die Karten werden geschlossen + %1$s Die monatliche Gebühr wird von Ihrem Konto abgebucht. + Am %1$s werden wir Sie auf den Tarif „ %2$s “ umstellen. + Es fällt keine Gebühr an + In wenigen Minuten erhalten Sie Ihre virtuelle „ %1$s “. + Sie wechseln zu %1$s Auswahl bestätigen + Wir stellen für Sie eine „ %1$s “ aus. Plan auswählen Wir beheben ein technisches Problem. Bitte versuchen Sie es später erneut. Service vorübergehend nicht verfügbar diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index f51169f55d..4d4c4c1dc1 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -438,6 +438,7 @@ Renombrar Requerido Resetear + Reintentar Guarde Guardar cambios Buscar diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 614d42139d..e742d4ffbb 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -416,6 +416,7 @@ Renommer Obligatoire Réinitialiser + Réessayer Enregistrez Sauvegarder les modifications Rechercher diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index ba11a5d289..0e6eb3ad2f 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -427,6 +427,7 @@ 名前を変更 必須 リセット + リトライ 保存 変更内容を保存 検索 diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index e9757a1f75..31fa6e79c4 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -441,6 +441,7 @@ Renomear Obrigatório Reiniciar + Tentar novamente Salvar Salvar alterações Procurar @@ -1732,7 +1733,7 @@ Chat de suporte Anexar logs do aplicativo Dados da operação SWAP:\nDe: %1$s %2$s\nPara: %3$s %4$s\nPor %5$s - %6$s - Abra o e-mail + Abra o chat Abra o e-mail Ao aprovar, você permite que o contrato inteligente utilize seus tokens em transações futuras. Modo detalhado diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index b01216084f..e30bf3ee83 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -133,6 +133,7 @@ Нет добавленных контактов Здесь отобразятся добавленные вами контакты. Удалить адрес + Сохранить адрес Сохранить контакт Сохранить в кошелек Этот контакт будет привязан к этому кошельку в адресной книге. @@ -458,6 +459,7 @@ Переименовать Требуется Сброс + Повторить Сохранить Сохранить изменения Поиск diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index eac9ee3549..38b71c0874 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -133,6 +133,7 @@ Немає доданих контактів Тут відображатимуться додані вами контакти. Видалити адресу + Зберегти адресу Зберегти контакт Зберегти в гаманець Цей контакт буде прив\'язано до цього гаманця в адресній книзі. @@ -458,6 +459,7 @@ Перейменувати Обов\'язково Скинути + Повторити Зберегти Зберегти зміни Пошук diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index 36172e947b..aed9d2ec5e 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -426,6 +426,7 @@ 重命名 必需的 重置 + 重试 节省 保存更改 搜索 diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index 6ba6530f44..c6ad1a4e30 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -77,6 +77,7 @@ %1$s-%2$s 拒絕 重新命名 + 重試 保存設置 搜索 搜尋代幣 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 94ee32219c..60e733758d 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -93,7 +93,7 @@ Credit card or bank account Fund token Share your address or QR-code - Between your portfolios + Exchange one crypto for another You receive Add address Add address and select network @@ -105,6 +105,7 @@ %d addresses Choose address + Clear All Contact Contact name Copy address @@ -130,10 +131,12 @@ No contacts yet Contacts added will appear here Remove address + Save address Save contact Save to Wallet This contact will be linked to this wallet’s address book. No results found.\nTry another name + Select All Select network Address book Unsaved changes @@ -637,6 +640,8 @@ Long transaction time The transaction amount was refunded in %1$s to your wallet due to OKX or bridge rules. %2$s The amount was refunded in %1$s (%2$s network) + Your funds have been refunded in %1$s to your wallet on the %2$s network, in accordance with OKX exchange rules. + Refunded in %s Visit provider’s website for verification KYC verification required by provider Purchase completed @@ -722,6 +727,11 @@ Can\'t send a transaction Coin description error Review portfolio and explore earn opportunities + Earn opportunities + All your assets are at work, explore new opportunities + Get up to %1$s annually + Max potential rewards %1$s + %1$s/year Portfolio review For You Update now @@ -732,6 +742,7 @@ Update Your operating system is out of date. Please update it to continue using the app. Update Your OS + Update app Please update the app to its latest version to ensure proper functionality. Update required Not enough funds @@ -899,9 +910,11 @@ %d asset %d assets + No amount 
on tokens No data Total value Can’t load data + You don’t have any tokens with amount Top holding %s About coin To buy, exchange, or receive this asset, add it to your portfolio @@ -924,12 +937,14 @@ In your portfolio Your portfolio **Token not supported**. This token is currently not supported in the wallet + Other eligible tokens Market Pulse Quick actions Clear all Search tokens - Recent\'s + Recent searches In your portfolio + Recent tokens Result See tokens under 100k USD market cap Show tokens @@ -1300,6 +1315,21 @@ By balance Organize tokens Ungroup + Eligible cashback will be distributed to: + You\'re already enrolled in %1$s + Eligible tokens + Enroll + You\'re successfully enrolled in %1$s + This campaign no longer exists or has expired + Campaign not active + Earn 0.5% cashback on every swap over $500, on any pair except stable to stable. Max payout $50 per swap.\n\nComplete five qualifying swaps and unlock an extra $10 bonus.\n\nRewards are paid weekly in USDT or USDC on the address selected. + Select cashback account + Select token + Enroll in %1$s + I agree with %1$s + I agree with + %1$s Terms + Earn cashback on every swap from $10K until the end of July.\n\nRates step up with size: 0.10% from $10K, 0.20% from $20K, 0.50% from $100K.\n\nMax payout: $500 per swap, and $10,000 per wallet per swap direction until campaign lasts. Stable coin into stablecoin swaps are excluded.\n\nPayout arrives weekly in USDT or USDC address of your choice. %s support Push Notifications are enabled but won\'t work until you allow them Allow notifications @@ -1332,12 +1362,12 @@ No supported tokens found This QR code contains parameters that are not recognized: %s. Some payment details may be lost if you continue. Unknown Parameters - Credit card or bank account + Get crypto by card, bank transfer & more Share your address or QR-code - Sell crypto securely - Send with swap to another token - Send to another wallet - Between your portfolios + Convert crypto to fiat currency + Exchange and send in one step + Transfer crypto to another wallet + Exchange one crypto for another Other Quick top up No memo required @@ -1523,7 +1553,7 @@ Total amount exceeds balance Are you sure you want to change the receiving token? This will reset your previously entered data. Changing token - Swap and send + Swap & Send Proceed with swap? This will clear your previous data. Confirm Conversion Sending any other currency will result in its irreversible loss. @@ -1631,9 +1661,9 @@ To begin staking, you need to activate your TON account first. Account activation To start staking in TON, first send a small transaction to your own address — this will activate your wallet. - Up to 0.2 TON may be required in addition to the network fee to complete the transaction. Any unused amount will be refunded. - 0.2 TON is required to proceed with this operation, in addition to the network fee. Please top up your balance. - TON reserve required + Up to 0.2 GRAM may be required in addition to the network fee to complete the transaction. Any unused amount will be refunded. + 0.2 GRAM is required to proceed with this operation, in addition to the network fee. Please top up your balance. + GRAM reserve required This action will close other positions or switch them to withdrawal status, according to network rules. Positions status Unlock your money to withdraw it from staking process. Unlocking takes %s. @@ -1838,6 +1868,9 @@ Add funds Top-up options Add to Google Wallet + Cancel %1$s, move to %2$s + To pay monthly fee for plan and start use card + Top-up your account on %1$s Card Number PIN code The card is fully ready for payments. @@ -1872,6 +1905,8 @@ Card name Reveal Details + If it will remain below zero your %1$s cards will be closed on %2$s + Top up your account shortly Details Please try again later Unfreeze Card @@ -1899,12 +1934,48 @@ %d card %d cards + We process purchases within 5 days after the operation and count only completed transactions + How we calculate cashback? + No cashback will be awarded for in-person/in-store purchases at EU merchants; also for withdrawals, transfers, quasi-cash, mobile phone bills, government services and certain other categories + Exceptions + From the 2nd and the 5th of the next month + How we pay cashback? + Limits and exceptions + Accruals + Permanent + Additional cashback + Until %1$s + It was made due to your suspicious behavior. Contact support to learn more + Cashback deactivated + Cashback %1$s for %2$s will be deposited till %3$s + Will be deposited on %1$s + %1$s max per month + No cashback for in-person purchases at EU merchants + Paid in %1$s + %1$s%% for all purchases with your %2$s cards, min purchase %3$s + %1$s earned in %2$s + Collected amount will be shown here + Start spending\nand earn cashback + Failed to load page.\nTap to reload + With your %1$s plan + Cashback %1$s%% + Cashback up to %1$s%% + We received a refund for a purchase for which cashback had previously been awarded + Cashback + %1$s earned in total + %1$s cashback in %2$s Change PIN-code Come back to the app if you forget it. Card + Error loading + Your %1$s plan is active till %2$s, then we will move you to %3$s. %4$s won\'t be charged. Change plan + %1$s monthly fee will be charged on %2$s Card related Plan related + Stay on %1$s + Your transition on %1$s will be canceled + Do you want to stay on %1$s? Current plan Set a limit from %s to %s Set limits @@ -1961,6 +2032,7 @@ Hide KYC block Sorry, we couldn\'t verify your profile. + Select plan You can have up to 3 cards. Delete one to add a new card. Maximum Cards Issued Yes – to operate a regulated Visa card, identity verification is mandatory. KYC is handled by Sumsub (compliance partner). @@ -2015,7 +2087,16 @@ Select Upgrade plan Compare plans + Your %1$s plan and %2$s cards will be active till %3$s + You can cancel this transition till %1$s + Your %1$s cards will be closed + %1$s monthly fee will be taken from your account + On %1$s we will move you to %2$s plan + No fee applied + You will get your virtual %1$s in minutes + You are switching to %1$s Confirm selection + We will issue %1$s for you Select plan We’re fixing a technical issue. Please try again later. Service temporarily unavailable @@ -2024,6 +2105,7 @@ Set up new PIN Set PIN Account closed + Inactive Replacing your card Use your card or ring to renew session Use your card or ring to renew session @@ -2039,8 +2121,12 @@ Use crypto from your wallet to top up your payment account From your Tangem Wallet USDC on Polygon network + Account details + Available to deposit per day: Please try again or contact support if the issue persists Couldn\'t load banking details + Limit is resetting every day + Visa Benefits Funds from refunded purchases won’t be returned your on-chain Polygon balance or be available for withdrawal, but will stay on your card balance for purchases Please note Your PIN code @@ -2084,6 +2170,10 @@ Show QR code Can’t load data of the token Go to swap + Last update: %1$s + Negative outlook + Neutral outlook + Positive outlook Token summary Exchange this token for another at %1$s service fees from February %2$s-%3$s. Swap with Changelly, %s fees @@ -2601,7 +2691,7 @@ The fee will be deducted, and your assets will be resupplied. To continue generating yield, approval is required. Confirm approval - Average APY %1$s%% + Current APY %1$s%% Your funds are currently supplied to the Aave protocol, but you can manage them at any time. Your %s is supplied to Aave Unable to load chart... 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 a014677344..410520e500 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 @@ -114,6 +114,7 @@ internal class TangemPayCardPageScreenComponent( paymentAccountAddress = navigation.paymentAccountAddress, onDismiss = model.bottomSheetNavigation::dismiss, onShowDetails = model::onShowVirtualAccountRequisites, + onShowBankingDetailsError = model::showVaBankingDetailsError, onOrderCreated = model::onVirtualAccountOrderCreated, ), ) 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 c7520ca8aa..0e719fcde2 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,7 @@ internal class TangemPayDetailsComponent( paymentAccountAddress = navigation.paymentAccountAddress, onDismiss = model.bottomSheetNavigation::dismiss, onShowDetails = model::onShowVirtualAccountRequisites, + onShowBankingDetailsError = model::showVaBankingDetailsError, onOrderCreated = model::onVirtualAccountOrderCreated, ), ) 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/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index b8c87b5103..1adc1a6cf4 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 @@ -71,7 +71,7 @@ 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( @@ -495,10 +495,12 @@ internal class TangemPayCardPageModel @Inject constructor( val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return when (val onramp = loaded.virtualAccount) { null -> return - is VirtualAccountOnramp.BankCredentialsError -> showVaBankingDetailsError() 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) } } @@ -515,7 +517,7 @@ internal class TangemPayCardPageModel @Inject constructor( ) } - private fun showVaBankingDetailsError() { + fun showVaBankingDetailsError() { bottomSheetNavigation.dismiss() bottomSheetNavigation.activate( TangemPayCardNavigation.VaBankingDetailsError(userWalletId = userWalletId), 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 77d48ac081..ca2b4df6b9 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 @@ -315,10 +315,12 @@ internal class TangemPayDetailsModel @Inject constructor( val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return when (val onramp = loaded.virtualAccount) { null -> return - is VirtualAccountOnramp.BankCredentialsError -> showVaBankingDetailsError() 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) } } @@ -335,7 +337,7 @@ internal class TangemPayDetailsModel @Inject constructor( ) } - private fun showVaBankingDetailsError() { + fun showVaBankingDetailsError() { bottomSheetNavigation.dismiss() bottomSheetNavigation.activate( TangemPayDetailsNavigation.VaBankingDetailsError(userWalletId = userWalletId), 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 69909b584b..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,11 +81,9 @@ internal class TangemPayVirtualAccountDepositModel @Inject constructor( analytics.send(TangemPayAnalyticsEvents.VaShowDetailsFirstTimeClicked()) createVirtualAccountOrder() } - // Processing/Error onramps never reach this sheet (a message/error sheet is shown instead); - // these branches only keep the `when` exhaustive. - VirtualAccountOnramp.Processing, - VirtualAccountOnramp.BankCredentialsError, - -> onDismiss() + 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/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, ), ), From 0de667c1d120aa2a3df95524e0b691c643d55df0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Jul 2026 09:42:01 +0300 Subject: [PATCH 24/59] Updated on 2026-08-14 --- .../bigdecimal/BigDecimalCryptoFormat.kt | 8 ++-- .../format/bigdecimal/BigDecimalFiatFormat.kt | 4 +- .../bigdecimal/BigDecimalCryptoFormatTest.kt | 39 ++++++++++++++++++ .../bigdecimal/BigDecimalFiatFormatTest.kt | 41 +++++++++++++++++++ core/ui/token-gen/README.md | 12 +++--- 5 files changed, 94 insertions(+), 10 deletions(-) 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 89aaa014aa..54db33f525 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 @@ -143,7 +143,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)), @@ -165,8 +166,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 e170af92ef..e86fe2fd31 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: From 2b928cfa9d1419a2fdc556f0e95ce53bc270789d Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Jul 2026 15:07:08 +0500 Subject: [PATCH 25/59] Updated on 2026-08-14 --- .../configs/feature_toggles_config.json | 4 + features/promo-banners/api/build.gradle.kts | 7 +- .../api/swapcashback/CampaignsComponent.kt | 14 ++ .../api/toggles/PromoBannersFeatureToggles.kt | 6 + features/promo-banners/impl/build.gradle.kts | 28 ++-- .../ActivateCampaignBottomSheetComponent.kt | 64 +++++++++ .../CampaignEnrolledBottomSheetComponent.kt | 50 +++++++ .../component/DefaultCampaignsComponent.kt | 72 ++++++++++ .../NotActiveCampaignBottomSheetComponent.kt | 44 ++++++ .../converters/CampaignIdConverter.kt | 22 +++ .../impl/campaigns/di/CampaignsModule.kt | 39 +++++ .../campaigns/entity/ActivateCampaignUM.kt | 20 +++ .../impl/campaigns/entity/CampaignType.kt | 12 ++ .../impl/campaigns/entity/CampaignTypeExt.kt | 7 + .../entity/CampaignsBottomSheetConfig.kt | 20 +++ .../campaigns/model/ActivateCampaignsModel.kt | 110 ++++++++++++++ .../impl/campaigns/model/CampaignsModel.kt | 56 ++++++++ .../ui/ActivateCampaignBottomSheet.kt | 50 +++++++ .../campaigns/ui/ActivateCampaignContent.kt | 135 ++++++++++++++++++ .../impl/di/PromoBannersFeatureModule.kt | 6 + .../DefaultPromoBannersFeatureToggles.kt | 16 +++ 21 files changed, 772 insertions(+), 10 deletions(-) create mode 100644 features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/swapcashback/CampaignsComponent.kt create mode 100644 features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/toggles/PromoBannersFeatureToggles.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/ActivateCampaignBottomSheetComponent.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignEnrolledBottomSheetComponent.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/DefaultCampaignsComponent.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/NotActiveCampaignBottomSheetComponent.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverter.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/ActivateCampaignUM.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignType.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignTypeExt.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModel.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignBottomSheet.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignContent.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/toggles/DefaultPromoBannersFeatureToggles.kt 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 a3a71db20e..0f7b86fa5d 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 @@ -174,5 +174,9 @@ { "name": "TWI_1638_VA_MVP0_ENABLED", "version": "6.0.1" + }, + { + "name": "TWI_1637_CASHBACK_AND_REACTIVATION_CAMPAIGNS_ENABLED", + "version": "6.0.1" } ] diff --git a/features/promo-banners/api/build.gradle.kts b/features/promo-banners/api/build.gradle.kts index cfa0100f41..9bba5dce8f 100644 --- a/features/promo-banners/api/build.gradle.kts +++ b/features/promo-banners/api/build.gradle.kts @@ -9,6 +9,9 @@ android { } dependencies { - implementation(projects.core.decompose) - implementation(projects.core.ui) + api(deps.compose.runtime) + api(deps.compose.ui) + + api(projects.core.decompose) + api(projects.core.ui) } \ No newline at end of file diff --git a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/swapcashback/CampaignsComponent.kt b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/swapcashback/CampaignsComponent.kt new file mode 100644 index 0000000000..bfc1ca6169 --- /dev/null +++ b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/swapcashback/CampaignsComponent.kt @@ -0,0 +1,14 @@ +package com.tangem.features.promobanners.api.swapcashback + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent + +/** + + * once at app startup (hence [Unit] params) and reacts to campaign requests coming through the + * promo-campaigns bus, not to navigation. + */ +interface CampaignsComponent : ComposableContentComponent { + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/toggles/PromoBannersFeatureToggles.kt b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/toggles/PromoBannersFeatureToggles.kt new file mode 100644 index 0000000000..ca4c07bc8a --- /dev/null +++ b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/toggles/PromoBannersFeatureToggles.kt @@ -0,0 +1,6 @@ +package com.tangem.features.promobanners.api.toggles + +interface PromoBannersFeatureToggles { + + val isCampaignsToggleEnabled: Boolean +} \ No newline at end of file diff --git a/features/promo-banners/impl/build.gradle.kts b/features/promo-banners/impl/build.gradle.kts index a510cfa91b..f1a7fb8504 100644 --- a/features/promo-banners/impl/build.gradle.kts +++ b/features/promo-banners/impl/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) alias(deps.plugins.kotlin.kapt) alias(deps.plugins.hilt.android) id("configuration") @@ -12,28 +13,39 @@ android { dependencies { /** Project - API */ - implementation(projects.features.promoBanners.api) + api(projects.features.promoBanners.api) + implementation(projects.features.commonFeatures.api) + implementation(projects.common.routing) + implementation(projects.common.ui) /** Domain */ - implementation(projects.domain.common) + api(projects.domain.common) implementation(projects.domain.models) + implementation(projects.domain.appCurrency) /** Core */ - implementation(projects.core.decompose) - implementation(projects.core.navigation) - implementation(projects.core.ui) + api(projects.core.configToggles) + api(projects.core.analytics) + api(projects.core.datasource) + api(projects.core.decompose) + api(projects.core.navigation) + api(projects.core.utils) implementation(projects.core.analytics) implementation(projects.core.analytics.models) - implementation(projects.core.utils) - implementation(projects.core.datasource) + implementation(projects.core.ui) /** Compose */ - implementation(deps.compose.foundation) + api(deps.compose.foundation) implementation(deps.compose.ui) implementation(deps.compose.ui.tooling) implementation(deps.lifecycle.compose) /** Other */ + implementation(deps.androidx.appCompat) + implementation(deps.androidx.core.ktx) + implementation(deps.decompose) + implementation(deps.decompose.ext.compose) + implementation(deps.kotlin.coroutines) implementation(deps.arrow.core) implementation(deps.kotlin.immutable.collections) 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 new file mode 100644 index 0000000000..97ffbc4835 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/ActivateCampaignBottomSheetComponent.kt @@ -0,0 +1,64 @@ +package com.tangem.features.promobanners.impl.campaigns.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent +import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel +import com.tangem.features.promobanners.impl.campaigns.ui.ActivateCampaignContent + +internal class ActivateCampaignBottomSheetComponent( + appComponentContext: AppComponentContext, + chooseTokenComponentFactory: ChooseTokenComponent.Factory, + params: ActivateCampaignsModel.Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: ActivateCampaignsModel = getOrCreateModel(params) + + private val chooseTokenComponent: ChooseTokenComponent = chooseTokenComponentFactory.create( + context = child(key = "swapCashbackChooseToken"), + params = ChooseTokenComponent.Params(bridge = model.bridge), + ) + + override fun dismiss() = model.onDismiss() + + @Composable + override fun BottomSheet() { + val state by model.uiState.collectAsStateWithLifecycle() + + ActivateCampaignContent( + state = state, + onSelectTokenClick = model::onSelectTokenClick, + onEnrollClick = model::onEnrollClick, + onLearnMoreClick = { /* [REDACTED_TODO_COMMENT] */ }, + onDismiss = ::dismiss, + ) + + if (state.isChoosingToken) { + ChooseTokenBottomSheet() + } + } + + @Composable + private fun ChooseTokenBottomSheet() { + TangemBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = model::onChooseTokenDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + onBack = model::onChooseTokenDismiss, + ) { + chooseTokenComponent.Content(modifier = Modifier.fillMaxWidth()) + } + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignEnrolledBottomSheetComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignEnrolledBottomSheetComponent.kt new file mode 100644 index 0000000000..8bdbfb6ac7 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignEnrolledBottomSheetComponent.kt @@ -0,0 +1,50 @@ +package com.tangem.features.promobanners.impl.campaigns.component + +import androidx.compose.runtime.Composable +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheet +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM +import com.tangem.core.ui.components.bottomsheets.message.infoBlock +import com.tangem.core.ui.components.bottomsheets.message.messageBottomSheetUM +import com.tangem.core.ui.components.bottomsheets.message.onClick +import com.tangem.core.ui.components.bottomsheets.message.onDismiss +import com.tangem.core.ui.components.bottomsheets.message.primaryButton +import com.tangem.core.ui.components.bottomsheets.message.vector +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_success_24 +import com.tangem.features.promobanners.impl.R +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import com.tangem.features.promobanners.impl.campaigns.entity.campaignName + +internal class CampaignEnrolledBottomSheetComponent( + private val campaignType: CampaignType, + private val onDismissRequest: () -> Unit, +) : ComposableBottomSheetComponent { + + override fun dismiss() = onDismissRequest() + + @Composable + override fun BottomSheet() { + MessageBottomSheet( + state = messageBottomSheetUM { + onDismiss(onDismissRequest) + infoBlock { + vector(Icons.ic_success_24) { + type = MessageBottomSheetUM.Vector.Type.Accent + backgroundType = MessageBottomSheetUM.Vector.BackgroundType.SameAsTint + } + + title = stringReference("You're successfully enrolled in ${campaignType.campaignName()}") + body = stringReference("Your cashback will be applied to eligible swaps automatically.") + } + primaryButton { + text = resourceReference(R.string.common_close) + onClick { closeBs() } + } + }, + onDismissRequest = onDismissRequest, + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..e815a741c6 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/DefaultCampaignsComponent.kt @@ -0,0 +1,72 @@ +package com.tangem.features.promobanners.impl.campaigns.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +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.decompose.ComposableBottomSheetComponent +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent +import com.tangem.features.promobanners.api.swapcashback.CampaignsComponent +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignsBottomSheetConfig +import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel +import com.tangem.features.promobanners.impl.campaigns.model.CampaignsModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultCampaignsComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: Unit, + private val chooseTokenComponentFactory: ChooseTokenComponent.Factory, +) : CampaignsComponent, AppComponentContext by appComponentContext { + + private val model: CampaignsModel = getOrCreateModel() + + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = CampaignsBottomSheetConfig.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + + @Composable + override fun Content(modifier: Modifier) { + val bottomSheet by bottomSheetSlot.subscribeAsState() + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + config: CampaignsBottomSheetConfig, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent { + val context = childByContext(componentContext) + return when (config) { + CampaignsBottomSheetConfig.NotActive -> NotActiveCampaignBottomSheetComponent( + onDismissRequest = model::onDismiss, + ) + is CampaignsBottomSheetConfig.Enrolled -> CampaignEnrolledBottomSheetComponent( + campaignType = config.campaignType, + onDismissRequest = model::onDismiss, + ) + is CampaignsBottomSheetConfig.Activate -> ActivateCampaignBottomSheetComponent( + appComponentContext = context, + chooseTokenComponentFactory = chooseTokenComponentFactory, + params = ActivateCampaignsModel.Params( + campaignType = config.campaignType, + onDismiss = model::onDismiss, + onActivated = model::onActivated, + ), + ) + } + } + + @AssistedFactory + interface Factory : CampaignsComponent.Factory { + override fun create(context: AppComponentContext, params: Unit): DefaultCampaignsComponent + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/NotActiveCampaignBottomSheetComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/NotActiveCampaignBottomSheetComponent.kt new file mode 100644 index 0000000000..005d3c057e --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/NotActiveCampaignBottomSheetComponent.kt @@ -0,0 +1,44 @@ +package com.tangem.features.promobanners.impl.campaigns.component + +import androidx.compose.runtime.Composable +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheet +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM +import com.tangem.core.ui.components.bottomsheets.message.icon +import com.tangem.core.ui.components.bottomsheets.message.infoBlock +import com.tangem.core.ui.components.bottomsheets.message.messageBottomSheetUM +import com.tangem.core.ui.components.bottomsheets.message.onClick +import com.tangem.core.ui.components.bottomsheets.message.onDismiss +import com.tangem.core.ui.components.bottomsheets.message.primaryButton +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.promobanners.impl.R + +internal class NotActiveCampaignBottomSheetComponent( + private val onDismissRequest: () -> Unit, +) : ComposableBottomSheetComponent { + + override fun dismiss() = onDismissRequest() + + @Composable + override fun BottomSheet() { + MessageBottomSheet( + state = messageBottomSheetUM { + onDismiss(onDismissRequest) + infoBlock { + icon(R.drawable.ic_alert_circle_24) { + type = MessageBottomSheetUM.Icon.Type.Warning + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint + } + title = stringReference("Campaign not active") // TODO localization + body = stringReference("This campaign no longer exists or has expired.") // TODO localization + } + primaryButton { + text = resourceReference(R.string.common_close) + onClick { closeBs() } + } + }, + onDismissRequest = onDismissRequest, + ) + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverter.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverter.kt new file mode 100644 index 0000000000..bf2a879216 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverter.kt @@ -0,0 +1,22 @@ +package com.tangem.features.promobanners.impl.campaigns.converters + +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import com.tangem.utils.converter.Converter +import javax.inject.Inject + +internal class CampaignIdConverter @Inject constructor() : + Converter { + + override fun convert(value: String): CampaignType? { + return when (value) { + CAMPAIGN_ID_REACTIVATION -> CampaignType.ReactivationCashback(campaignId = value) + CAMPAIGN_ID_WHALE -> CampaignType.WhaleSwapCashback(campaignId = value) + else -> null + } + } + + private companion object { + const val CAMPAIGN_ID_WHALE = "1" + const val CAMPAIGN_ID_REACTIVATION = "2" + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt new file mode 100644 index 0000000000..bc1085694b --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt @@ -0,0 +1,39 @@ +package com.tangem.features.promobanners.impl.campaigns.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.promobanners.api.swapcashback.CampaignsComponent +import com.tangem.features.promobanners.impl.campaigns.component.DefaultCampaignsComponent +import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel +import com.tangem.features.promobanners.impl.campaigns.model.CampaignsModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface CampaignsModule { + + @Binds + @Singleton + fun bindCampaignsComponentFactory(factory: DefaultCampaignsComponent.Factory): CampaignsComponent.Factory +} + +@Module +@InstallIn(ModelComponent::class) +internal interface CampaignsModelModule { + + @Binds + @IntoMap + @ClassKey(CampaignsModel::class) + fun bindCampaignModel(model: CampaignsModel): Model + + @Binds + @IntoMap + @ClassKey(ActivateCampaignsModel::class) + fun bindCampaignActivateModel(model: ActivateCampaignsModel): Model +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/ActivateCampaignUM.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/ActivateCampaignUM.kt new file mode 100644 index 0000000000..40e312763e --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/ActivateCampaignUM.kt @@ -0,0 +1,20 @@ +package com.tangem.features.promobanners.impl.campaigns.entity + +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference + +/** + * State of the campaign activation bottom sheet. + * + * The intro promo screen (image + title + description) is always shown. When [selectedToken] is `null` + * the footer shows "Select token"; once a token is chosen it shows the account block, the terms agreement + * and the "Enroll" button. When [isChoosingToken] is `true` the token selector is shown on top of the + * intro (as a stacked bottom sheet), not instead of it. + */ +internal data class ActivateCampaignUM( + val campaignName: String, + val title: TextReference, + val description: TextReference, + val selectedToken: TokenItemState?, + val isChoosingToken: Boolean, +) \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignType.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignType.kt new file mode 100644 index 0000000000..f3d6574f6d --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignType.kt @@ -0,0 +1,12 @@ +package com.tangem.features.promobanners.impl.campaigns.entity + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface CampaignType { + + val campaignId: String + + data class ReactivationCashback(override val campaignId: String) : CampaignType + data class WhaleSwapCashback(override val campaignId: String) : CampaignType +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignTypeExt.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignTypeExt.kt new file mode 100644 index 0000000000..3c202361c9 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignTypeExt.kt @@ -0,0 +1,7 @@ +package com.tangem.features.promobanners.impl.campaigns.entity + +internal fun CampaignType.campaignName(): String = when (this) { + // TODO([REDACTED_TASK_KEY]): source real campaign display names. + is CampaignType.ReactivationCashback -> "Reactivation Cashback" + is CampaignType.WhaleSwapCashback -> "Whale Swap Cashback" +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt new file mode 100644 index 0000000000..50bf5c6cca --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt @@ -0,0 +1,20 @@ +package com.tangem.features.promobanners.impl.campaigns.entity + +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class CampaignsBottomSheetConfig { + + @Serializable + data object NotActive : CampaignsBottomSheetConfig() + + @Serializable + data class Enrolled( + val campaignType: CampaignType, + ) : CampaignsBottomSheetConfig() + + @Serializable + data class Activate( + val campaignType: CampaignType, + ) : CampaignsBottomSheetConfig() +} \ No newline at end of file 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 new file mode 100644 index 0000000000..a4774195bf --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt @@ -0,0 +1,110 @@ +package com.tangem.features.promobanners.impl.campaigns.model + +import com.tangem.common.ui.tokens.TokenItemStateConverter +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.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult +import com.tangem.features.promobanners.impl.R +import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import com.tangem.features.promobanners.impl.campaigns.entity.campaignName +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ModelScoped +internal class ActivateCampaignsModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + chooseTokenBridgeFactory: ChooseTokenBridge.Factory, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, +) : Model() { + + private val params = paramsContainer.require() + + private var appCurrency: AppCurrency = AppCurrency.Default + + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) + + val bridge: ChooseTokenBridge = chooseTokenBridgeFactory.create( + modelScope = modelScope, + settings = ChooseTokenBridge.Settings( + title = resourceReference(R.string.common_choose_token), + isShowMarketBlock = false, + isShowPaymentAccount = false, + isShowSingleCurrencyWallets = true, + ), + ) + + init { + getSelectedAppCurrencyUseCase.invokeOrDefault() + .onEach { appCurrency = it } + .launchIn(modelScope) + + bridge.onCurrencyChosen.receiveAsFlow() + .onEach { result -> onTokenChosen(result) } + .launchIn(modelScope) + + bridge.onClose.receiveAsFlow() + .onEach { onChooseTokenDismiss() } + .launchIn(modelScope) + } + + fun onSelectTokenClick() { + uiState.update { it.copy(isChoosingToken = true) } + } + + fun onChooseTokenDismiss() { + uiState.update { it.copy(isChoosingToken = false) } + } + + fun onEnrollClick() { + // TODO([REDACTED_TASK_KEY]): call the real campaign enrollment use case with the chosen token before proceeding. + params.onActivated(params.campaignType) + } + + fun onDismiss() = params.onDismiss() + + private fun onTokenChosen(result: ChooseTokenResult) { + val tokenItem = TokenItemStateConverter(appCurrency = appCurrency).convert(result.currency) + + uiState.update { state -> + state.copy( + isChoosingToken = false, + selectedToken = tokenItem, + ) + } + } + + private fun getInitialState(): ActivateCampaignUM { + return ActivateCampaignUM( + campaignName = params.campaignType.campaignName(), + // TODO([REDACTED_TASK_KEY]): source real campaign copy. + title = stringReference("Enroll in ${params.campaignType.campaignName()}"), + description = stringReference( + "Earn cashback on every swap from \$10K until the end of July.\n\n" + + "Rates step up with size: 0.10% from \$10K, 0.20% from \$20K, 0.50% from \$100K.\n\n", + ), + selectedToken = null, + isChoosingToken = false, + ) + } + + data class Params( + val campaignType: CampaignType, + val onDismiss: () -> Unit, + val onActivated: (CampaignType) -> Unit, + ) +} \ No newline at end of file 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 new file mode 100644 index 0000000000..d0d49dc073 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModel.kt @@ -0,0 +1,56 @@ +package com.tangem.features.promobanners.impl.campaigns.model + +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignsBottomSheetConfig +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import javax.inject.Inject + +@ModelScoped +internal class CampaignsModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + // private val campaignIdConverter: CampaignIdConverter, + // campaignsService: CampaignsService, +) : Model() { + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + + init { + // campaignsService.campaignFlow + // .onEach { campaignId -> resolveStartNavigation(campaignIdConverter.convert(campaignId)) } + // .launchIn(modelScope) + } + + @Suppress("UnusedPrivateMember") + private fun resolveStartNavigation(campaignType: CampaignType?) { + val config = when (campaignType) { + is CampaignType.ReactivationCashback -> checkReactivationCashbackCampaignState(campaignType) + is CampaignType.WhaleSwapCashback -> checkWhaleSwapCashbackCampaignState(campaignType) + null -> CampaignsBottomSheetConfig.NotActive + } + + bottomSheetNavigation.activate(config) + } + + // TODO + private fun checkReactivationCashbackCampaignState(campaignType: CampaignType): CampaignsBottomSheetConfig { + return CampaignsBottomSheetConfig.Activate(campaignType) + } + + // TODO + private fun checkWhaleSwapCashbackCampaignState(campaignType: CampaignType): CampaignsBottomSheetConfig { + return CampaignsBottomSheetConfig.Activate(campaignType) + } + + fun onDismiss() { + bottomSheetNavigation.dismiss() + } + + fun onActivated(campaignType: CampaignType) { + bottomSheetNavigation.activate(CampaignsBottomSheetConfig.Enrolled(campaignType)) + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignBottomSheet.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignBottomSheet.kt new file mode 100644 index 0000000000..4d3c39aa1c --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignBottomSheet.kt @@ -0,0 +1,50 @@ +package com.tangem.features.promobanners.impl.campaigns.ui + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.runtime.Composable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.ds2.button.Close +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM + +@Composable +internal fun ActivateCampaignContent( + state: ActivateCampaignUM, + onSelectTokenClick: () -> Unit, + onEnrollClick: () -> Unit, + onLearnMoreClick: () -> Unit, + onDismiss: () -> Unit, +) { + TangemBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + containerColor = TangemTheme.colors.background.tertiary, + onBack = onDismiss, + title = { + TangemTopNavigation( + windowInsets = WindowInsets(0), + blurBackground = false, + endButton = { + TangemButton.Close( + onClick = onDismiss, + ) + }, + ) + }, + content = { + ActivateCampaignContent( + state = state, + onSelectTokenClick = onSelectTokenClick, + onEnrollClick = onEnrollClick, + onLearnMoreClick = onLearnMoreClick, + ) + }, + ) +} \ 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 new file mode 100644 index 0000000000..4ad65c2d5f --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignContent.kt @@ -0,0 +1,135 @@ +package com.tangem.features.promobanners.impl.campaigns.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +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.style.TextAlign +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.SpacerH24 +import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.promobanners.impl.R +import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM + +@Composable +internal fun ActivateCampaignContent( + state: ActivateCampaignUM, + onSelectTokenClick: () -> Unit, + onEnrollClick: () -> Unit, + onLearnMoreClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16) + .navigationBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + // TODO([REDACTED_TASK_KEY]): replace placeholder with the real campaign illustration. + painter = painterResource(R.drawable.ill_businessman_3d), + contentDescription = null, + modifier = Modifier + .size(TangemTheme.dimens.size96) + .clip(CircleShape), + ) + SpacerH16() + Text( + text = state.title.resolveReference(), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth(), + ) + SpacerH8() + Text( + text = state.description.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + modifier = Modifier.fillMaxWidth(), + ) + SpacerH12() + Text( + // TODO([REDACTED_TASK_KEY]): localize + text = "Learn more", + style = TangemTheme.typography.button, + color = TangemTheme.colors.text.accent, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onLearnMoreClick), + ) + + val selectedToken = state.selectedToken + if (selectedToken != null) { + SpacerH24() + Text( + text = "Select cashback account", // TODO([REDACTED_TASK_KEY]): localize + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.fillMaxWidth(), + ) + SpacerH12() + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(TangemTheme.dimens.radius16)) + .background(TangemTheme.colors.background.primary) + .padding(vertical = TangemTheme.dimens.spacing12), + ) { + TokenItem(state = selectedToken, isBalanceHidden = false) + } + } + + SpacerH24() + + Footer( + campaignName = state.campaignName, + hasSelectedToken = selectedToken != null, + onSelectTokenClick = onSelectTokenClick, + onEnrollClick = onEnrollClick, + ) + } +} + +@Composable +private fun Footer( + campaignName: String, + hasSelectedToken: Boolean, + onSelectTokenClick: () -> Unit, + onEnrollClick: () -> Unit, +) { + if (hasSelectedToken) { + Text( + text = "I agree with $campaignName Terms", // TODO([REDACTED_TASK_KEY]): localize + clickable terms + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + SpacerH12() + } + + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + text = if (hasSelectedToken) { + "Enroll" // TODO([REDACTED_TASK_KEY]): localize + } else { + "Select token" // TODO([REDACTED_TASK_KEY]): localize + }, + onClick = if (hasSelectedToken) onEnrollClick else onSelectTokenClick, + ) + SpacerH16() +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/di/PromoBannersFeatureModule.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/di/PromoBannersFeatureModule.kt index 32414701c1..bc877dedf3 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/di/PromoBannersFeatureModule.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/di/PromoBannersFeatureModule.kt @@ -5,10 +5,12 @@ import com.tangem.core.decompose.model.Model import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.features.promobanners.api.PromoBannersBlockComponent +import com.tangem.features.promobanners.api.toggles.PromoBannersFeatureToggles import com.tangem.features.promobanners.impl.DefaultPromoBannersBlockComponent import com.tangem.features.promobanners.impl.model.PromoBannersBlockModel import com.tangem.features.promobanners.impl.repository.DefaultPromoBannersRepository import com.tangem.features.promobanners.impl.repository.PromoBannersRepository +import com.tangem.features.promobanners.impl.toggles.DefaultPromoBannersFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Binds import dagger.Module @@ -29,6 +31,10 @@ internal interface PromoBannersFeatureModule { factory: DefaultPromoBannersBlockComponent.Factory, ): PromoBannersBlockComponent.Factory + @Binds + @Singleton + fun bindPromoBannersFeatureToggles(impl: DefaultPromoBannersFeatureToggles): PromoBannersFeatureToggles + companion object { @Provides diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/toggles/DefaultPromoBannersFeatureToggles.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/toggles/DefaultPromoBannersFeatureToggles.kt new file mode 100644 index 0000000000..d92298c675 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/toggles/DefaultPromoBannersFeatureToggles.kt @@ -0,0 +1,16 @@ +package com.tangem.features.promobanners.impl.toggles + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.promobanners.api.toggles.PromoBannersFeatureToggles +import javax.inject.Inject + +class DefaultPromoBannersFeatureToggles @Inject constructor( + private val featureTogglesManager: FeatureTogglesManager, +) : PromoBannersFeatureToggles { + + override val isCampaignsToggleEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled( + FeatureToggles.TWI_1637_CASHBACK_AND_REACTIVATION_CAMPAIGNS_ENABLED, + ) +} \ No newline at end of file From 7472e7b1dcb125c97be2b1cc6443bfe85734025d Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Jul 2026 13:31:41 +0200 Subject: [PATCH 26/59] Updated on 2026-08-14 --- app/src/main/AndroidManifest.xml | 10 +++++ .../com/tangem/tap/routing/RootContent.kt | 3 ++ .../component/impl/DefaultRoutingComponent.kt | 8 ++++ .../tap/routing/utils/DeepLinkFactory.kt | 3 ++ .../tap/routing/utils/DeepLinkFactoryTest.kt | 6 +++ .../tangem/common/routing/DeepLinkRoute.kt | 4 ++ .../common/routing/deeplink/DeeplinkConst.kt | 1 + .../api/deeplink/CampaignsDeepLinkHandler.kt | 8 ++++ .../DefaultCampaignsDeepLinkHandler.kt | 37 +++++++++++++++++++ .../impl/campaigns/di/CampaignsModule.kt | 14 +++++++ .../impl/campaigns/model/CampaignsModel.kt | 14 ++++--- .../campaigns/service/CampaignsService.kt | 17 +++++++++ .../service/DefaultCampaignsService.kt | 18 +++++++++ 13 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/deeplink/CampaignsDeepLinkHandler.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/deeplink/DefaultCampaignsDeepLinkHandler.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/service/CampaignsService.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/service/DefaultCampaignsService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index eb8e94ac35..d77020adb5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -347,6 +347,16 @@ android:host="yield" android:scheme="tangem" /> + + + + + + + + diff --git a/app/src/main/java/com/tangem/tap/routing/RootContent.kt b/app/src/main/java/com/tangem/tap/routing/RootContent.kt index 762f6b21a6..53ea204a2e 100644 --- a/app/src/main/java/com/tangem/tap/routing/RootContent.kt +++ b/app/src/main/java/com/tangem/tap/routing/RootContent.kt @@ -42,6 +42,7 @@ internal fun RootContent( onBack: () -> Unit, modifier: Modifier = Modifier, wcContent: @Composable (modifier: Modifier) -> Unit, + promoContent: @Composable (modifier: Modifier) -> Unit, hotAccessCodeContent: @Composable (modifier: Modifier) -> Unit, rootDetectedWarningContent: @Composable (modifier: Modifier) -> Unit, scanFailsContent: @Composable (modifier: Modifier) -> Unit, @@ -80,6 +81,8 @@ internal fun RootContent( wcContent(Modifier.fillMaxSize()) + promoContent(Modifier.fillMaxSize()) + hotAccessCodeContent(Modifier.fillMaxSize()) rootDetectedWarningContent(Modifier.fillMaxSize()) diff --git a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt index 305d341eda..436c9bc257 100644 --- a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt +++ b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt @@ -47,6 +47,7 @@ import com.tangem.features.hotwallet.HotAccessCodeRequestComponent import com.tangem.features.hotwallet.accesscoderequest.proxy.HotWalletPasswordRequesterProxy import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION +import com.tangem.features.promobanners.api.swapcashback.CampaignsComponent import com.tangem.features.walletconnect.components.WcRoutingComponent import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.android.create @@ -83,6 +84,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor( private val appRouterConfig: AppRouterConfig, private val uiDependencies: UiDependencies, private val wcRoutingComponentFactory: WcRoutingComponent.Factory, + private val campaignsComponentFactory: CampaignsComponent.Factory, private val deeplinkFactory: DeepLinkFactory, private val tangemHotSDKProxy: TangemHotSDKProxy, private val hotAccessCodeRequestComponentFactory: HotAccessCodeRequestComponent.Factory, @@ -112,6 +114,11 @@ internal class DefaultRoutingComponent @AssistedInject constructor( .create(child("wcRoutingComponent"), params = Unit) } + private val campaignsComponent: CampaignsComponent by lazy { + campaignsComponentFactory + .create(child("swapCashbackCampaign"), params = Unit) + } + private val hotAccessCodeRequestComponent: HotAccessCodeRequestComponent by lazy { hotAccessCodeRequestComponentFactory .create(child("hotAccessCodeRequestComponent"), Unit) @@ -278,6 +285,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor( onBack = router::pop, modifier = modifier, wcContent = { wcRoutingComponent.Content(it) }, + promoContent = { campaignsComponent.Content(it) }, hotAccessCodeContent = { hotAccessCodeRequestComponent.Content(it) }, rootDetectedWarningContent = { rootDetectedWarningComponent.Content(it) }, scanFailsContent = { scanFailsComponent.Content(it) }, diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index c30d377259..397a018e74 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -17,6 +17,7 @@ import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler import com.tangem.features.onramp.deeplink.SellDeepLinkHandler import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler +import com.tangem.features.promobanners.api.deeplink.CampaignsDeepLinkHandler import com.tangem.features.send.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler @@ -66,6 +67,7 @@ internal class DeepLinkFactory @Inject constructor( private val earnDeepLink: EarnDeepLinkHandler.Factory, private val yieldDeepLink: YieldDeepLinkHandler.Factory, private val surveyDeepLink: SurveyDeepLinkHandler.Factory, + private val promoCampaignsDeepLink: CampaignsDeepLinkHandler.Factory, ) { private val permittedAppRoute = MutableStateFlow(false) @@ -181,6 +183,7 @@ internal class DeepLinkFactory @Inject constructor( DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams) DeepLinkRoute.PayAppMain.host -> tangemPayMainDeepLink.create(coroutineScope, queryParams) DeepLinkRoute.Survey.host -> surveyDeepLink.create(queryParams) + DeepLinkRoute.Campaigns.host -> promoCampaignsDeepLink.create(queryParams) else -> { TangemLogger.i( """ diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index c6744e1b77..0f6292f7fd 100644 --- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt @@ -16,6 +16,7 @@ import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler import com.tangem.features.onramp.deeplink.SellDeepLinkHandler import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler +import com.tangem.features.promobanners.api.deeplink.CampaignsDeepLinkHandler import com.tangem.features.send.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler @@ -117,6 +118,10 @@ class DeepLinkFactoryTest { every { create(any(), any()) } returns mockk() } + private val campaignsDeepLinkHandlerFactory = mockk(relaxed = true) { + every { create(any()) } returns mockk() + } + private val marketsTokenExchangesDeepLinkFactory = mockk(relaxed = true) { every { create(any(), any()) } returns mockk() @@ -152,6 +157,7 @@ class DeepLinkFactoryTest { earnDeepLink = earnDeepLinkFactory, yieldDeepLink = yieldDeepLinkFactory, surveyDeepLink = surveyDeepLinkFactory, + promoCampaignsDeepLink = campaignsDeepLinkHandlerFactory, ) @OptIn(ExperimentalCoroutinesApi::class) diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt index 9abdcf8b09..8127bcb689 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt @@ -95,6 +95,10 @@ sealed class DeepLinkRoute { data object Survey : DeepLinkRoute() { override val host: String = "survey" } + + data object Campaigns : DeepLinkRoute() { + override val host: String = "campaigns" + } } enum class DeepLinkScheme(val scheme: String) { diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt index 677f35d658..167bd3bec2 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt @@ -16,6 +16,7 @@ object DeeplinkConst { const val PROMO_CODE_KEY = "promo_code" const val REF_KEY = "ref" const val CAMPAIGN_KEY = "campaign" + const val CAMPAIGN_ID_KEY = "campaignId" const val NAME_KEY = "name" const val ORDER_KEY = "order" const val INTERVAL_KEY = "interval" diff --git a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/deeplink/CampaignsDeepLinkHandler.kt b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/deeplink/CampaignsDeepLinkHandler.kt new file mode 100644 index 0000000000..3304191034 --- /dev/null +++ b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/deeplink/CampaignsDeepLinkHandler.kt @@ -0,0 +1,8 @@ +package com.tangem.features.promobanners.api.deeplink + +interface CampaignsDeepLinkHandler { + + interface Factory { + fun create(queryParams: Map): CampaignsDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/deeplink/DefaultCampaignsDeepLinkHandler.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/deeplink/DefaultCampaignsDeepLinkHandler.kt new file mode 100644 index 0000000000..4d9c7cdac9 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/deeplink/DefaultCampaignsDeepLinkHandler.kt @@ -0,0 +1,37 @@ +package com.tangem.features.promobanners.impl.campaigns.deeplink + +import com.tangem.common.routing.deeplink.DeeplinkConst +import com.tangem.features.promobanners.api.deeplink.CampaignsDeepLinkHandler +import com.tangem.features.promobanners.api.toggles.PromoBannersFeatureToggles +import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService +import com.tangem.utils.logging.TangemLogger +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** + * Handles the campaigns deeplink (`tangem://campaigns?campaignId=1&lang=ru`): extracts the `campaignId` and + * pushes it to the promo-campaigns bus. The always-alive [com.tangem.features.promobanners.api.swapcashback + * .SwapCashbackCampaignComponent] listens to the bus, maps the id to a campaign type, resolves the campaign + * state and shows the right sheet over the current screen. + */ +internal class DefaultCampaignsDeepLinkHandler @AssistedInject constructor( + @Assisted private val queryParams: Map, + campaignsService: CampaignsService, + private val promoBannersFeatureToggles: PromoBannersFeatureToggles, +) : CampaignsDeepLinkHandler { + + init { + if (promoBannersFeatureToggles.isCampaignsToggleEnabled) { + val campaignId = queryParams[DeeplinkConst.CAMPAIGN_ID_KEY].orEmpty() + campaignsService.show(campaignId) + } else { + TangemLogger.i("Campaigns feature is disabled") + } + } + + @AssistedFactory + interface Factory : CampaignsDeepLinkHandler.Factory { + override fun create(queryParams: Map): DefaultCampaignsDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt index bc1085694b..a3ad2a34b4 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt @@ -2,10 +2,14 @@ package com.tangem.features.promobanners.impl.campaigns.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model +import com.tangem.features.promobanners.api.deeplink.CampaignsDeepLinkHandler import com.tangem.features.promobanners.api.swapcashback.CampaignsComponent import com.tangem.features.promobanners.impl.campaigns.component.DefaultCampaignsComponent +import com.tangem.features.promobanners.impl.campaigns.deeplink.DefaultCampaignsDeepLinkHandler import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel import com.tangem.features.promobanners.impl.campaigns.model.CampaignsModel +import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService +import com.tangem.features.promobanners.impl.campaigns.service.DefaultCampaignsService import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -21,6 +25,16 @@ internal interface CampaignsModule { @Binds @Singleton fun bindCampaignsComponentFactory(factory: DefaultCampaignsComponent.Factory): CampaignsComponent.Factory + + @Binds + @Singleton + fun bindCampaignsDeepLinkHandlerFactory( + factory: DefaultCampaignsDeepLinkHandler.Factory, + ): CampaignsDeepLinkHandler.Factory + + @Binds + @Singleton + fun bindCampaignsService(service: DefaultCampaignsService): CampaignsService } @Module 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 d0d49dc073..ab5bba4e11 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 @@ -5,24 +5,28 @@ import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model +import com.tangem.features.promobanners.impl.campaigns.converters.CampaignIdConverter import com.tangem.features.promobanners.impl.campaigns.entity.CampaignsBottomSheetConfig import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import javax.inject.Inject @ModelScoped internal class CampaignsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, - // private val campaignIdConverter: CampaignIdConverter, - // campaignsService: CampaignsService, + private val campaignIdConverter: CampaignIdConverter, + campaignsService: CampaignsService, ) : Model() { val bottomSheetNavigation: SlotNavigation = SlotNavigation() init { - // campaignsService.campaignFlow - // .onEach { campaignId -> resolveStartNavigation(campaignIdConverter.convert(campaignId)) } - // .launchIn(modelScope) + campaignsService.campaignFlow + .onEach { campaignId -> resolveStartNavigation(campaignIdConverter.convert(campaignId)) } + .launchIn(modelScope) } @Suppress("UnusedPrivateMember") diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/service/CampaignsService.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/service/CampaignsService.kt new file mode 100644 index 0000000000..a05d5ccbb7 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/service/CampaignsService.kt @@ -0,0 +1,17 @@ +package com.tangem.features.promobanners.impl.campaigns.service + +import kotlinx.coroutines.flow.Flow + +/** + * App-wide bus that decouples the promo-campaigns deeplink handler from the UI that shows the campaign + * bottom sheet. A producer (deeplink handler) calls [show]; the always-alive campaign component listens + * to [campaignFlow] and activates the appropriate sheet over the current screen. + */ +internal interface CampaignsService { + + /** Emits the campaignId requested via [show]. */ + val campaignFlow: Flow + + /** Requests showing the campaign identified by [campaignId]. */ + fun show(campaignId: String) +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/service/DefaultCampaignsService.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/service/DefaultCampaignsService.kt new file mode 100644 index 0000000000..cc51f81c6f --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/service/DefaultCampaignsService.kt @@ -0,0 +1,18 @@ +package com.tangem.features.promobanners.impl.campaigns.service + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class DefaultCampaignsService @Inject constructor() : CampaignsService { + + private val _campaignFlow: Channel = Channel(Channel.BUFFERED) + override val campaignFlow: Flow = _campaignFlow.receiveAsFlow() + + override fun show(campaignId: String) { + _campaignFlow.trySend(campaignId) + } +} \ No newline at end of file From 2debaafcbb5268f2a426cc2ed67b3e812c4e149a Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 8 Jul 2026 20:13:28 +0500 Subject: [PATCH 27/59] Updated on 2026-08-14 --- app/build.gradle.kts | 2 + .../tangem/tap/di/domain/PromoDomainModule.kt | 27 ++ .../models/CreatePromotionRegistrationBody.kt | 18 ++ .../models/PromotionRegistrationResponse.kt | 19 ++ .../api/tangemTech/TangemTechApi.kt | 10 +- .../tangem/datasource/di/PromotionModule.kt | 32 +++ .../local/preferences/PreferencesKeys.kt | 2 + .../promotion/DefaultPromotionsSupplier.kt | 27 ++ .../local/promotion/PromotionsSupplier.kt | 15 ++ .../DefaultPromotionsSupplierTest.kt | 119 +++++++++ data/promo/build.gradle.kts | 46 ++++ .../data/promo/DefaultPromoRepository.kt | 97 +++++++ .../promo/converter/PromoCampaignConverter.kt | 29 ++ .../tangem/data/promo/di/PromoDataModule.kt | 46 ++++ .../store/DefaultPromoEnrollmentStore.kt | 27 ++ .../data/promo/store/PromoEnrollmentStore.kt | 11 + .../data/promo/DefaultPromoRepositoryTest.kt | 208 +++++++++++++++ .../converter/PromoCampaignConverterTest.kt | 54 ++++ .../yield/supply/di/YieldSupplyDataModule.kt | 3 + .../promo/DefaultYieldPromoRepository.kt | 16 +- .../promo/DefaultYieldPromoRepositoryTest.kt | 248 ++++++++++++++++++ domain/promo/build.gradle.kts | 34 +++ domain/promo/models/build.gradle.kts | 20 ++ .../domain/promo/models/PromoCampaignId.kt | 12 + .../domain/promo/models/PromoCampaignState.kt | 21 ++ .../tangem/domain/promo/models/PromoModels.kt | 27 ++ .../promo/models/PromoCampaignIdTest.kt | 29 ++ .../tangem/domain/promo/PromoRepository.kt | 38 +++ .../usecase/EnrollPromoCampaignUseCase.kt | 21 ++ .../usecase/GetPromoCampaignStateUseCase.kt | 20 ++ .../usecase/EnrollPromoCampaignUseCaseTest.kt | 57 ++++ .../GetPromoCampaignStateUseCaseTest.kt | 55 ++++ settings.gradle.kts | 3 + 33 files changed, 1386 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/di/domain/PromoDomainModule.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/CreatePromotionRegistrationBody.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionRegistrationResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/di/PromotionModule.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/promotion/DefaultPromotionsSupplier.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/promotion/PromotionsSupplier.kt create mode 100644 core/datasource/src/test/java/com/tangem/datasource/local/promotion/DefaultPromotionsSupplierTest.kt create mode 100644 data/promo/build.gradle.kts create mode 100644 data/promo/src/main/kotlin/com/tangem/data/promo/DefaultPromoRepository.kt create mode 100644 data/promo/src/main/kotlin/com/tangem/data/promo/converter/PromoCampaignConverter.kt create mode 100644 data/promo/src/main/kotlin/com/tangem/data/promo/di/PromoDataModule.kt create mode 100644 data/promo/src/main/kotlin/com/tangem/data/promo/store/DefaultPromoEnrollmentStore.kt create mode 100644 data/promo/src/main/kotlin/com/tangem/data/promo/store/PromoEnrollmentStore.kt create mode 100644 data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt create mode 100644 data/promo/src/test/kotlin/com/tangem/data/promo/converter/PromoCampaignConverterTest.kt create mode 100644 data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt create mode 100644 domain/promo/build.gradle.kts create mode 100644 domain/promo/models/build.gradle.kts create mode 100644 domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignId.kt create mode 100644 domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignState.kt create mode 100644 domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt create mode 100644 domain/promo/models/src/test/kotlin/com/tangem/domain/promo/models/PromoCampaignIdTest.kt create mode 100644 domain/promo/src/main/kotlin/com/tangem/domain/promo/PromoRepository.kt create mode 100644 domain/promo/src/main/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCase.kt create mode 100644 domain/promo/src/main/kotlin/com/tangem/domain/promo/usecase/GetPromoCampaignStateUseCase.kt create mode 100644 domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCaseTest.kt create mode 100644 domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/GetPromoCampaignStateUseCaseTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 530687a6d1..5a4feed03c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -164,6 +164,7 @@ dependencies { implementation(projects.domain.walletManager) implementation(projects.domain.walletManager.models) implementation(projects.domain.yieldSupply) + implementation(projects.domain.promo) implementation(projects.domain.blockaid) implementation(projects.domain.hotWallet) implementation(projects.domain.news) @@ -225,6 +226,7 @@ dependencies { implementation(projects.data.swap) implementation(projects.data.walletManager) implementation(projects.data.yieldSupply) + implementation(projects.data.promo) implementation(projects.data.hotWallet) implementation(projects.data.news) implementation(projects.data.earn) diff --git a/app/src/main/java/com/tangem/tap/di/domain/PromoDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/PromoDomainModule.kt new file mode 100644 index 0000000000..6c4db55e1e --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/PromoDomainModule.kt @@ -0,0 +1,27 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.promo.PromoRepository +import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase +import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object PromoDomainModule { + + @Provides + @Singleton + fun provideGetPromoCampaignStateUseCase(repository: PromoRepository): GetPromoCampaignStateUseCase { + return GetPromoCampaignStateUseCase(repository) + } + + @Provides + @Singleton + fun provideEnrollPromoCampaignUseCase(repository: PromoRepository): EnrollPromoCampaignUseCase { + return EnrollPromoCampaignUseCase(repository) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/CreatePromotionRegistrationBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/CreatePromotionRegistrationBody.kt new file mode 100644 index 0000000000..dd02a67af8 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/CreatePromotionRegistrationBody.kt @@ -0,0 +1,18 @@ +package com.tangem.datasource.api.promotion.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class CreatePromotionRegistrationBody( + @Json(name = "campaignId") val campaignId: String, + @Json(name = "walletIds") val walletIds: List, + @Json(name = "tokenReward") val tokenReward: TokenRewardDto, +) { + + @JsonClass(generateAdapter = true) + data class TokenRewardDto( + @Json(name = "tokenAddress") val tokenAddress: String, + @Json(name = "networkId") val networkId: String, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionRegistrationResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionRegistrationResponse.kt new file mode 100644 index 0000000000..7f6da1f3ec --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionRegistrationResponse.kt @@ -0,0 +1,19 @@ +package com.tangem.datasource.api.promotion.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class PromotionRegistrationResponse( + @Json(name = "status") val status: String, + @Json(name = "message") val message: String?, + @Json(name = "data") val data: RegistrationData, +) { + + @JsonClass(generateAdapter = true) + data class RegistrationData( + @Json(name = "campaignId") val campaignId: String, + @Json(name = "registeredAt") val registeredAt: String?, + @Json(name = "tokenReward") val tokenReward: CreatePromotionRegistrationBody.TokenRewardDto, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index 7530892018..9fe1bd2211 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -1,6 +1,9 @@ package com.tangem.datasource.api.tangemTech import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody +import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse +import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse import com.tangem.datasource.api.promotion.models.PromotionsResponse import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse import com.tangem.datasource.api.stories.models.StoryContentResponse @@ -120,7 +123,7 @@ interface TangemTechApi { @GET("v1/stories/{story_id}") suspend fun getStoryById(@Path("story_id") storyId: String): ApiResponse - // region yield-boost promo + // region promotions @GET("/v2/promotion") suspend fun getPromotions( @Query("walletId") walletId: String, @@ -130,6 +133,11 @@ interface TangemTechApi { @Suppress("FunctionSignature", "TrailingCommaOnDeclarationSite") @GET("/v2/promotion/yield-apr-boost/status") suspend fun getYieldBoostStatus(@Query("walletId") walletId: String): ApiResponse + + @POST("/v2/promotion/registrations") + suspend fun createPromotionRegistration( + @Body body: CreatePromotionRegistrationBody, + ): ApiResponse // endregion // region push notifications diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/PromotionModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/PromotionModule.kt new file mode 100644 index 0000000000..298c6c602d --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/PromotionModule.kt @@ -0,0 +1,32 @@ +package com.tangem.datasource.di + +import com.tangem.datasource.api.promotion.models.PromotionsResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.datasource.local.promotion.DefaultPromotionsSupplier +import com.tangem.datasource.local.promotion.PromotionsSupplier +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object PromotionModule { + + @Provides + @Singleton + fun providePromotionsSupplier( + tangemApi: TangemTechApi, + dispatchers: CoroutineDispatcherProvider, + ): PromotionsSupplier { + return DefaultPromotionsSupplier( + tangemApi = tangemApi, + store = RuntimeSharedStore>(), + dispatchers = dispatchers, + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index eba1fa1049..f986a34931 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -129,6 +129,8 @@ object PreferencesKeys { val PENDING_ASSETS_DISCOVERY_KEY by lazy { stringPreferencesKey(name = "pendingAssetsDiscovery") } + val PROMO_ENROLLMENTS_KEY by lazy { stringPreferencesKey(name = "promoEnrollments") } + // region Notifications val NOTIFICATIONS_APPLICATION_ID_KEY by lazy { stringPreferencesKey(name = "notificationsApplicationId") } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/promotion/DefaultPromotionsSupplier.kt b/core/datasource/src/main/java/com/tangem/datasource/local/promotion/DefaultPromotionsSupplier.kt new file mode 100644 index 0000000000..63ed044753 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/promotion/DefaultPromotionsSupplier.kt @@ -0,0 +1,27 @@ +package com.tangem.datasource.local.promotion + +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.promotion.models.PromotionsResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext + +internal class DefaultPromotionsSupplier( + private val tangemApi: TangemTechApi, + private val store: RuntimeSharedStore>, + private val dispatchers: CoroutineDispatcherProvider, +) : PromotionsSupplier { + + override suspend fun getPromotions(userWalletId: UserWalletId, forceRefresh: Boolean): PromotionsResponse { + if (!forceRefresh) { + store.getSyncOrNull()?.get(userWalletId)?.let { return it } + } + val fresh = withContext(dispatchers.io) { + tangemApi.getPromotions(walletId = userWalletId.stringValue).getOrThrow() + } + store.update(emptyMap()) { it + (userWalletId to fresh) } + return fresh + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/promotion/PromotionsSupplier.kt b/core/datasource/src/main/java/com/tangem/datasource/local/promotion/PromotionsSupplier.kt new file mode 100644 index 0000000000..1c3395f5bd --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/promotion/PromotionsSupplier.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.local.promotion + +import com.tangem.datasource.api.promotion.models.PromotionsResponse +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Shared cache-first fetch of GET /v2/promotion. Keeps one in-memory entry per [UserWalletId]: + * a non-forced call returns the cached response when present, otherwise it fetches. A fetch failure + * propagates to the caller (no stale-cache fallback), so the caller decides how to handle it. + */ +interface PromotionsSupplier { + + @Throws(Exception::class) + suspend fun getPromotions(userWalletId: UserWalletId, forceRefresh: Boolean = false): PromotionsResponse +} \ No newline at end of file diff --git a/core/datasource/src/test/java/com/tangem/datasource/local/promotion/DefaultPromotionsSupplierTest.kt b/core/datasource/src/test/java/com/tangem/datasource/local/promotion/DefaultPromotionsSupplierTest.kt new file mode 100644 index 0000000000..2742d52c09 --- /dev/null +++ b/core/datasource/src/test/java/com/tangem/datasource/local/promotion/DefaultPromotionsSupplierTest.kt @@ -0,0 +1,119 @@ +package com.tangem.datasource.local.promotion + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.promotion.models.PromotionsResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.io.IOException + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultPromotionsSupplierTest { + + private val tangemApi: TangemTechApi = mockk() + + private fun newSupplier() = DefaultPromotionsSupplier( + tangemApi = tangemApi, + store = RuntimeSharedStore(), + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val userWalletId = UserWalletId("abcdef012345") + private val response = PromotionsResponse(promotions = emptyList()) + private val response2 = PromotionsResponse( + promotions = listOf( + PromotionsResponse.PromotionDto(name = "dummy", all = null), + ), + ) + + @BeforeEach + fun setUp() { + clearMocks(tangemApi) + } + + @Test + fun `GIVEN empty cache WHEN getPromotions THEN fetches and returns response`() = runTest { + // Arrange + coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(response) + val supplier = newSupplier() + + // Act + val result = supplier.getPromotions(userWalletId) + + // Assert + assertThat(result).isEqualTo(response) + coVerify(exactly = 1) { tangemApi.getPromotions(userWalletId.stringValue, any()) } + } + + @Test + fun `GIVEN cached value and no refresh WHEN getPromotions THEN returns cache without api`() = runTest { + // Arrange + coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(response) + val supplier = newSupplier() + supplier.getPromotions(userWalletId) + clearMocks(tangemApi) + + // Act + val result = supplier.getPromotions(userWalletId, forceRefresh = false) + + // Assert + assertThat(result).isEqualTo(response) + coVerify(exactly = 0) { tangemApi.getPromotions(any(), any()) } + } + + @Test + fun `GIVEN cached value WHEN getPromotions forceRefresh THEN hits api again and rebinds value`() = runTest { + // Arrange + coEvery { tangemApi.getPromotions(any(), any()) } returnsMany listOf( + ApiResponse.Success(response), + ApiResponse.Success(response2), + ) + val supplier = newSupplier() + supplier.getPromotions(userWalletId) + + // Act + val result = supplier.getPromotions(userWalletId, forceRefresh = true) + + // Assert + assertThat(result).isEqualTo(response2) + coVerify(exactly = 2) { tangemApi.getPromotions(any(), any()) } + } + + @Test + fun `GIVEN fetch fails and cache present WHEN getPromotions forceRefresh THEN rethrows`() = runTest { + // Arrange + coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(response) + val supplier = newSupplier() + supplier.getPromotions(userWalletId) + coEvery { tangemApi.getPromotions(any(), any()) } throws IOException("boom") + + // Act + val error = runCatching { supplier.getPromotions(userWalletId, forceRefresh = true) }.exceptionOrNull() + + // Assert + assertThat(error).isInstanceOf(IOException::class.java) + } + + @Test + fun `GIVEN fetch fails and empty cache WHEN getPromotions THEN rethrows`() = runTest { + // Arrange + coEvery { tangemApi.getPromotions(any(), any()) } throws IOException("boom") + val supplier = newSupplier() + + // Act + val error = runCatching { supplier.getPromotions(userWalletId) }.exceptionOrNull() + + // Assert + assertThat(error).isInstanceOf(IOException::class.java) + } +} \ No newline at end of file diff --git a/data/promo/build.gradle.kts b/data/promo/build.gradle.kts new file mode 100644 index 0000000000..690ad6c659 --- /dev/null +++ b/data/promo/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.data.promo" +} + +dependencies { + + // region Kotlin + implementation(deps.kotlin.coroutines) + implementation(deps.kotlin.datetime) + // endregion + + // region DI + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + // endregion + + // region Core + api(projects.core.datasource) + api(projects.core.utils) + // endregion + + // region Domain + api(projects.domain.promo) + // endregion + + // region Domain models + implementation(projects.domain.models) + implementation(projects.domain.promo.models) + // endregion + + // region Tests + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit5) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(deps.moshi.kotlin) + // endregion +} \ No newline at end of file diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/DefaultPromoRepository.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/DefaultPromoRepository.kt new file mode 100644 index 0000000000..f1f3d5d51a --- /dev/null +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/DefaultPromoRepository.kt @@ -0,0 +1,97 @@ +package com.tangem.data.promo + +import com.squareup.moshi.Moshi +import com.tangem.data.promo.converter.PromoCampaignConverter +import com.tangem.data.promo.store.PromoEnrollmentStore +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody +import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.promotion.PromotionsSupplier +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.promo.PromoRepository +import com.tangem.domain.promo.models.EnrollResult +import com.tangem.domain.promo.models.PromoCampaignId +import com.tangem.domain.promo.models.PromoCampaignState +import com.tangem.domain.promo.models.TokenReward +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext + +internal class DefaultPromoRepository( + private val promotionsSupplier: PromotionsSupplier, + private val tangemApi: TangemTechApi, + private val enrollmentStore: PromoEnrollmentStore, + private val moshi: Moshi, + private val dispatchers: CoroutineDispatcherProvider, +) : PromoRepository { + + override suspend fun getCampaignState( + campaign: PromoCampaignId, + userWalletId: UserWalletId, + forceRefresh: Boolean, + ): PromoCampaignState = withContext(dispatchers.io) { + enrollmentStore.getSyncOrNull(campaign)?.let { + return@withContext PromoCampaignState.Enrolled(campaign, it) + } + val all = promotionsSupplier.getPromotions(userWalletId, forceRefresh) + .promotions.firstOrNull { it.name == campaign.slug }?.all + when { + all == null -> PromoCampaignState.NotActive(campaign) + all.status == ACTIVE_STATUS -> PromoCampaignConverter.toAvailable(campaign, all) + else -> PromoCampaignState.NotActive(campaign) + } + } + + override suspend fun enroll( + campaign: PromoCampaignId, + tokenReward: TokenReward, + walletIds: List, + ): EnrollResult = withContext(dispatchers.io) { + val body = CreatePromotionRegistrationBody( + campaignId = campaign.slug, + walletIds = walletIds.map { it.stringValue }, + tokenReward = tokenReward.toDto(), + ) + when (val response = tangemApi.createPromotionRegistration(body)) { + is ApiResponse.Success -> { + val saved = response.data.data.tokenReward.toDomain() + enrollmentStore.store(campaign, saved) + EnrollResult.Success(saved) + } + is ApiResponse.Error -> { + val cause = response.cause + val conflict = (cause as? ApiResponseError.HttpException) + ?.takeIf { it.code == ApiResponseError.HttpException.Code.CONFLICT } + if (conflict != null) { + val existing = parseConflict(conflict.errorBody)?.data?.tokenReward?.toDomain() ?: tokenReward + enrollmentStore.store(campaign, existing) + EnrollResult.AlreadyEnrolled(existing) + } else { + throw cause + } + } + } + } + + private fun parseConflict(body: String?): PromotionRegistrationResponse? { + if (body.isNullOrBlank()) return null + return runCatching { + moshi.adapter(PromotionRegistrationResponse::class.java).fromJson(body) + }.getOrNull() + } + + private fun TokenReward.toDto() = CreatePromotionRegistrationBody.TokenRewardDto( + tokenAddress = tokenAddress, + networkId = networkId, + ) + + private fun CreatePromotionRegistrationBody.TokenRewardDto.toDomain() = TokenReward( + tokenAddress = tokenAddress, + networkId = networkId, + ) + + private companion object { + const val ACTIVE_STATUS = "active" + } +} \ No newline at end of file diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/converter/PromoCampaignConverter.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/converter/PromoCampaignConverter.kt new file mode 100644 index 0000000000..4db59a14f2 --- /dev/null +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/converter/PromoCampaignConverter.kt @@ -0,0 +1,29 @@ +package com.tangem.data.promo.converter + +import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.All +import com.tangem.domain.promo.models.PromoCampaignId +import com.tangem.domain.promo.models.PromoCampaignState +import com.tangem.domain.promo.models.PromoPayoutToken +import com.tangem.domain.promo.models.PromoTimeline +import kotlinx.datetime.Instant + +internal object PromoCampaignConverter { + + fun toAvailable(campaign: PromoCampaignId, all: All): PromoCampaignState.Available { + return PromoCampaignState.Available( + campaign = campaign, + payoutTokens = all.tokens.orEmpty().map { token -> + PromoPayoutToken( + tokenAddress = token.tokenAddress, + tokenSymbol = token.tokenSymbol, + tokenName = token.tokenName, + networkId = token.networkId, + ) + }, + timeline = PromoTimeline( + start = Instant.parse(all.timeline.start), + end = Instant.parse(all.timeline.end), + ), + ) + } +} \ No newline at end of file diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/di/PromoDataModule.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/di/PromoDataModule.kt new file mode 100644 index 0000000000..86123f8e4a --- /dev/null +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/di/PromoDataModule.kt @@ -0,0 +1,46 @@ +package com.tangem.data.promo.di + +import com.squareup.moshi.Moshi +import com.tangem.data.promo.DefaultPromoRepository +import com.tangem.data.promo.store.DefaultPromoEnrollmentStore +import com.tangem.data.promo.store.PromoEnrollmentStore +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.promotion.PromotionsSupplier +import com.tangem.domain.promo.PromoRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object PromoDataModule { + + @Provides + @Singleton + fun providePromoEnrollmentStore(appPreferencesStore: AppPreferencesStore): PromoEnrollmentStore { + return DefaultPromoEnrollmentStore(appPreferencesStore) + } + + @Provides + @Singleton + fun providePromoRepository( + promotionsSupplier: PromotionsSupplier, + tangemApi: TangemTechApi, + enrollmentStore: PromoEnrollmentStore, + @NetworkMoshi moshi: Moshi, + dispatchers: CoroutineDispatcherProvider, + ): PromoRepository { + return DefaultPromoRepository( + promotionsSupplier = promotionsSupplier, + tangemApi = tangemApi, + enrollmentStore = enrollmentStore, + moshi = moshi, + dispatchers = dispatchers, + ) + } +} \ No newline at end of file diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/store/DefaultPromoEnrollmentStore.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/store/DefaultPromoEnrollmentStore.kt new file mode 100644 index 0000000000..9266118a8b --- /dev/null +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/store/DefaultPromoEnrollmentStore.kt @@ -0,0 +1,27 @@ +package com.tangem.data.promo.store + +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getObjectMapSync +import com.tangem.domain.promo.models.PromoCampaignId +import com.tangem.domain.promo.models.TokenReward + +internal class DefaultPromoEnrollmentStore( + private val appPreferencesStore: AppPreferencesStore, +) : PromoEnrollmentStore { + + override suspend fun getSyncOrNull(campaign: PromoCampaignId): TokenReward? { + return appPreferencesStore + .getObjectMapSync(PreferencesKeys.PROMO_ENROLLMENTS_KEY)[campaign.slug] + } + + override suspend fun store(campaign: PromoCampaignId, tokenReward: TokenReward) { + appPreferencesStore.editData { mutablePreferences -> + val current = mutablePreferences.getObjectMap(PreferencesKeys.PROMO_ENROLLMENTS_KEY) + mutablePreferences.setObjectMap( + key = PreferencesKeys.PROMO_ENROLLMENTS_KEY, + value = current + (campaign.slug to tokenReward), + ) + } + } +} \ No newline at end of file diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/store/PromoEnrollmentStore.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/store/PromoEnrollmentStore.kt new file mode 100644 index 0000000000..2f770ef638 --- /dev/null +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/store/PromoEnrollmentStore.kt @@ -0,0 +1,11 @@ +package com.tangem.data.promo.store + +import com.tangem.domain.promo.models.PromoCampaignId +import com.tangem.domain.promo.models.TokenReward + +interface PromoEnrollmentStore { + + suspend fun getSyncOrNull(campaign: PromoCampaignId): TokenReward? + + suspend fun store(campaign: PromoCampaignId, tokenReward: TokenReward) +} \ No newline at end of file diff --git a/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt b/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt new file mode 100644 index 0000000000..061e43c88c --- /dev/null +++ b/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt @@ -0,0 +1,208 @@ +package com.tangem.data.promo + +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.tangem.data.promo.store.PromoEnrollmentStore +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody +import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse +import com.tangem.datasource.api.promotion.models.PromotionsResponse +import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto +import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.All +import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.PromoToken +import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.Timeline +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.promotion.PromotionsSupplier +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.promo.models.EnrollResult +import com.tangem.domain.promo.models.PromoCampaignId +import com.tangem.domain.promo.models.PromoCampaignState +import com.tangem.domain.promo.models.TokenReward +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultPromoRepositoryTest { + + private val promotionsSupplier: PromotionsSupplier = mockk() + private val tangemApi: TangemTechApi = mockk() + private val enrollmentStore: PromoEnrollmentStore = mockk(relaxed = true) + private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + + private val repository = DefaultPromoRepository( + promotionsSupplier = promotionsSupplier, + tangemApi = tangemApi, + enrollmentStore = enrollmentStore, + moshi = moshi, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val campaign = PromoCampaignId.WhaleSwapCashback + private val userWalletId = UserWalletId("abcdef012345") + private val tokenReward = TokenReward("0xToken", "ethereum") + + private fun activeDto() = PromotionDto( + name = campaign.slug, + all = All( + timeline = Timeline("2026-06-23T00:00:00.000Z", "2026-08-31T20:59:59.000Z"), + tokens = listOf(PromoToken("0xToken", "USDT", "Tether USD", "ethereum")), + status = "active", + link = "", + ), + ) + + @BeforeEach + fun setUp() = clearMocks(promotionsSupplier, tangemApi, enrollmentStore) + + @Test + fun `GIVEN locally enrolled WHEN getCampaignState THEN Enrolled without api`() = runTest { + // Arrange + coEvery { enrollmentStore.getSyncOrNull(campaign) } returns tokenReward + + // Act + val result = repository.getCampaignState(campaign, userWalletId) + + // Assert + assertThat(result).isEqualTo(PromoCampaignState.Enrolled(campaign, tokenReward)) + coVerify(exactly = 0) { promotionsSupplier.getPromotions(any(), any()) } + } + + @Test + fun `GIVEN active campaign present and not enrolled WHEN getCampaignState THEN Available`() = runTest { + // Arrange + coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null + coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns + PromotionsResponse(promotions = listOf(activeDto())) + + // Act + val result = repository.getCampaignState(campaign, userWalletId) + + // Assert + assertThat(result).isInstanceOf(PromoCampaignState.Available::class.java) + } + + @Test + fun `GIVEN campaign absent WHEN getCampaignState THEN NotActive`() = runTest { + // Arrange + coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null + coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns + PromotionsResponse(promotions = emptyList()) + + // Act + val result = repository.getCampaignState(campaign, userWalletId) + + // Assert + assertThat(result).isEqualTo(PromoCampaignState.NotActive(campaign)) + } + + @Test + fun `GIVEN campaign present but finished WHEN getCampaignState THEN NotActive`() = runTest { + // Arrange + coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null + val finished = activeDto().copy(all = activeDto().all!!.copy(status = "finished")) + coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns + PromotionsResponse(promotions = listOf(finished)) + + // Act + val result = repository.getCampaignState(campaign, userWalletId) + + // Assert + assertThat(result).isEqualTo(PromoCampaignState.NotActive(campaign)) + } + + @Test + fun `GIVEN api returns 201 with canonical token WHEN enroll THEN Success and persists backend token`() = runTest { + // Arrange + val data = PromotionRegistrationResponse.RegistrationData( + campaignId = campaign.slug, + registeredAt = "2026-07-06T09:27:13.363Z", + tokenReward = CreatePromotionRegistrationBody.TokenRewardDto("0xCanonical", "ethereum"), + ) + coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Success( + PromotionRegistrationResponse(status = "saved", message = null, data = data), + ) + + // Act + val result = repository.enroll(campaign, tokenReward, listOf(userWalletId)) + + // Assert + val backendToken = TokenReward("0xCanonical", "ethereum") + assertThat(result).isEqualTo(EnrollResult.Success(backendToken)) + coVerify(exactly = 1) { enrollmentStore.store(campaign, backendToken) } + } + + @Test + fun `GIVEN api returns 409 WHEN enroll THEN AlreadyEnrolled with existing token`() = runTest { + // Arrange + val existing = """ + {"status":"already_exists","message":"exists","data":{"campaignId":"${campaign.slug}", + "registeredAt":"2026-07-01T10:00:00.000Z","tokenReward":{"tokenAddress":"0xOther", + "networkId":"base","userAddress":"0xExisting"}}} + """.trimIndent() + @Suppress("UNCHECKED_CAST") + coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Error( + ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.CONFLICT, + message = "conflict", + errorBody = existing, + ), + ) as ApiResponse + + // Act + val result = repository.enroll(campaign, tokenReward, listOf(userWalletId)) + + // Assert + val expectedToken = TokenReward("0xOther", "base") + assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(expectedToken)) + coVerify(exactly = 1) { enrollmentStore.store(campaign, expectedToken) } + } + + @Test + fun `GIVEN 409 with null errorBody WHEN enroll THEN AlreadyEnrolled with submitted token`() = runTest { + // Arrange + @Suppress("UNCHECKED_CAST") + coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Error( + ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.CONFLICT, + message = "conflict", + errorBody = null, + ), + ) as ApiResponse + + // Act + val result = repository.enroll(campaign, tokenReward, listOf(userWalletId)) + + // Assert + assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(tokenReward)) + coVerify(exactly = 1) { enrollmentStore.store(campaign, tokenReward) } + } + + @Test + fun `GIVEN api returns 500 WHEN enroll THEN throws`() = runTest { + // Arrange + @Suppress("UNCHECKED_CAST") + coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Error( + ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.INTERNAL_SERVER_ERROR, + message = "server", + errorBody = null, + ), + ) as ApiResponse + + // Act + val error = runCatching { repository.enroll(campaign, tokenReward, listOf(userWalletId)) }.exceptionOrNull() + + // Assert + assertThat(error).isNotNull() + coVerify(exactly = 0) { enrollmentStore.store(any(), any()) } + } +} \ No newline at end of file diff --git a/data/promo/src/test/kotlin/com/tangem/data/promo/converter/PromoCampaignConverterTest.kt b/data/promo/src/test/kotlin/com/tangem/data/promo/converter/PromoCampaignConverterTest.kt new file mode 100644 index 0000000000..522ff2a3cf --- /dev/null +++ b/data/promo/src/test/kotlin/com/tangem/data/promo/converter/PromoCampaignConverterTest.kt @@ -0,0 +1,54 @@ +package com.tangem.data.promo.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.All +import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.PromoToken +import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.Timeline +import com.tangem.domain.promo.models.PromoCampaignId +import com.tangem.domain.promo.models.PromoPayoutToken +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Test + +internal class PromoCampaignConverterTest { + + private val campaign = PromoCampaignId.WhaleSwapCashback + + @Test + fun `GIVEN dto with tokens WHEN toAvailable THEN maps tokens and timeline`() { + // Arrange + val all = All( + timeline = Timeline(start = "2026-06-23T00:00:00.000Z", end = "2026-08-31T20:59:59.000Z"), + tokens = listOf(PromoToken("0xdac1", "USDT", "Tether USD", "ethereum")), + status = "active", + link = "", + ) + + // Act + val result = PromoCampaignConverter.toAvailable(campaign, all) + + // Assert + assertThat(result.campaign).isEqualTo(campaign) + assertThat(result.payoutTokens).containsExactly( + PromoPayoutToken("0xdac1", "USDT", "Tether USD", "ethereum"), + ) + assertThat(result.timeline.start).isEqualTo(Instant.parse("2026-06-23T00:00:00.000Z")) + assertThat(result.timeline.end).isEqualTo(Instant.parse("2026-08-31T20:59:59.000Z")) + } + + @Test + fun `GIVEN dto with null tokens WHEN toAvailable THEN empty payout list`() { + // Arrange + val all = All( + timeline = Timeline(start = "2026-06-23T00:00:00.000Z", end = "2026-08-31T20:59:59.000Z"), + tokens = null, + status = "active", + link = null, + ) + + // Act + val result = PromoCampaignConverter.toAvailable(campaign, all) + + // Assert + assertThat(result.payoutTokens).isEmpty() + } +} \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt index 38ff5bba43..97b4b09ff1 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt @@ -9,6 +9,7 @@ import com.tangem.data.yield.supply.promo.DefaultYieldPromoRepository import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.YieldSupplyApi import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.promotion.PromotionsSupplier import com.tangem.datasource.local.yieldsupply.YieldMarketsStore import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore @@ -83,12 +84,14 @@ internal object YieldSupplyDataModule { @Singleton fun provideYieldPromoRepository( tangemApi: TangemTechApi, + promotionsSupplier: PromotionsSupplier, promoStore: YieldBoostPromoStore, statusStore: YieldBoostStatusStore, dispatchers: CoroutineDispatcherProvider, ): YieldPromoRepository { return DefaultYieldPromoRepository( tangemApi = tangemApi, + promotionsSupplier = promotionsSupplier, promoStore = promoStore, statusStore = statusStore, dispatchers = dispatchers, diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepository.kt index f9463dd69e..2e5548e4e8 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepository.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepository.kt @@ -4,6 +4,7 @@ import com.tangem.data.yield.supply.promo.converter.YieldBoostPromoConverter import com.tangem.data.yield.supply.promo.converter.YieldBoostStatusConverter import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.promotion.PromotionsSupplier import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore import com.tangem.domain.models.wallet.UserWalletId @@ -15,6 +16,7 @@ import kotlinx.coroutines.withContext internal class DefaultYieldPromoRepository( private val tangemApi: TangemTechApi, + private val promotionsSupplier: PromotionsSupplier, private val promoStore: YieldBoostPromoStore, private val statusStore: YieldBoostStatusStore, private val dispatchers: CoroutineDispatcherProvider, @@ -25,7 +27,7 @@ internal class DefaultYieldPromoRepository( promoStore.getSyncOrNull(userWalletId)?.let { return it } } return try { - val fresh = fetchPromo(userWalletId) + val fresh = fetchPromo(userWalletId, forceRefresh) promoStore.store(userWalletId, fresh) fresh } catch (e: Exception) { @@ -46,11 +48,13 @@ internal class DefaultYieldPromoRepository( } } - private suspend fun fetchPromo(userWalletId: UserWalletId): YieldBoostPromo = withContext(dispatchers.io) { - val response = tangemApi.getPromotions(walletId = userWalletId.stringValue).getOrThrow() - val dto = response.promotions.firstOrNull { it.name == PROMO_NAME } ?: return@withContext YieldBoostPromo.None - YieldBoostPromoConverter.convert(dto) - } + private suspend fun fetchPromo(userWalletId: UserWalletId, forceRefresh: Boolean): YieldBoostPromo = + withContext(dispatchers.io) { + val response = promotionsSupplier.getPromotions(userWalletId, forceRefresh) + val dto = response.promotions.firstOrNull { it.name == PROMO_NAME } + ?: return@withContext YieldBoostPromo.None + YieldBoostPromoConverter.convert(dto) + } private suspend fun fetchStatus(userWalletId: UserWalletId): YieldBoostStatus = withContext(dispatchers.io) { val response = tangemApi.getYieldBoostStatus(walletId = userWalletId.stringValue).getOrThrow() diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt new file mode 100644 index 0000000000..4b1562c0c8 --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt @@ -0,0 +1,248 @@ +package com.tangem.data.yield.supply.promo + +import com.google.common.truth.Truth.assertThat +import com.tangem.data.yield.supply.promo.converter.YieldBoostPromoConverter +import com.tangem.data.yield.supply.promo.converter.YieldBoostStatusConverter +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.promotion.models.PromotionsResponse +import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.promotion.PromotionsSupplier +import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore +import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.models.YieldBoostPromo +import com.tangem.domain.yield.supply.models.YieldBoostStatus +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.io.IOException + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultYieldPromoRepositoryTest { + + private val tangemApi: TangemTechApi = mockk() + private val promotionsSupplier: PromotionsSupplier = mockk() + private val promoStore: YieldBoostPromoStore = mockk(relaxed = true) + private val statusStore: YieldBoostStatusStore = mockk(relaxed = true) + + private val repository = DefaultYieldPromoRepository( + tangemApi = tangemApi, + promotionsSupplier = promotionsSupplier, + promoStore = promoStore, + statusStore = statusStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val userWalletId = UserWalletId("abcdef012345") + + @BeforeEach + fun setUp() { + clearMocks(tangemApi, promotionsSupplier, promoStore, statusStore) + } + + // region getYieldBoostPromo + @Test + fun `GIVEN cached promo and no refresh WHEN getYieldBoostPromo THEN returns cache without api`() = runTest { + // Arrange + val cached = YieldBoostPromo.None + coEvery { promoStore.getSyncOrNull(userWalletId) } returns cached + + // Act + val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = false) + + // Assert + assertThat(result).isEqualTo(cached) + coVerify(exactly = 0) { promotionsSupplier.getPromotions(any(), any()) } + } + + @Test + fun `GIVEN no cache WHEN getYieldBoostPromo THEN fetches stores and returns converted`() = runTest { + // Arrange + val dto = matchingPromoDto() + coEvery { promoStore.getSyncOrNull(userWalletId) } returns null + coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns PromotionsResponse( + promotions = listOf(dto), + ) + val expected = YieldBoostPromoConverter.convert(dto) + + // Act + val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = false) + + // Assert + assertThat(result).isEqualTo(expected) + coVerify(exactly = 1) { promoStore.store(userWalletId, expected) } + } + + @Test + fun `GIVEN cached promo and force refresh WHEN getYieldBoostPromo THEN fetches anyway`() = runTest { + // Arrange + coEvery { promoStore.getSyncOrNull(userWalletId) } returns YieldBoostPromo.None + coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns PromotionsResponse( + promotions = listOf(matchingPromoDto()), + ) + + // Act + repository.getYieldBoostPromo(userWalletId, forceRefresh = true) + + // Assert + coVerify(exactly = 1) { promotionsSupplier.getPromotions(any(), any()) } + } + + @Test + fun `GIVEN no matching promo name WHEN getYieldBoostPromo THEN returns None`() = runTest { + // Arrange + coEvery { promoStore.getSyncOrNull(userWalletId) } returns null + coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns PromotionsResponse( + promotions = listOf(PromotionsResponse.PromotionDto(name = "other", all = null)), + ) + + // Act + val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = false) + + // Assert + assertThat(result).isEqualTo(YieldBoostPromo.None) + coVerify(exactly = 1) { promoStore.store(userWalletId, YieldBoostPromo.None) } + } + + @Test + fun `GIVEN fetch fails and cache present WHEN getYieldBoostPromo THEN falls back to cache`() = runTest { + // Arrange — force refresh so the initial cache check is skipped and the fetch is attempted + val cached = YieldBoostPromo.None + coEvery { promotionsSupplier.getPromotions(any(), any()) } throws IOException("network") + coEvery { promoStore.getSyncOrNull(userWalletId) } returns cached + + // Act + val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = true) + + // Assert + assertThat(result).isEqualTo(cached) + coVerify(exactly = 0) { promoStore.store(any(), any()) } + } + + @Test + fun `GIVEN fetch fails and no cache WHEN getYieldBoostPromo THEN rethrows`() = runTest { + // Arrange + coEvery { promotionsSupplier.getPromotions(any(), any()) } throws IOException("network") + coEvery { promoStore.getSyncOrNull(userWalletId) } returns null + + // Act + val error = runCatching { repository.getYieldBoostPromo(userWalletId, forceRefresh = true) } + .exceptionOrNull() + + // Assert + assertThat(error).isInstanceOf(IOException::class.java) + } + // endregion + + // region getYieldBoostStatus + @Test + fun `GIVEN cached status and no refresh WHEN getYieldBoostStatus THEN returns cache without api`() = runTest { + // Arrange + val cached = YieldBoostStatus.NotStarted + coEvery { statusStore.getSyncOrNull(userWalletId) } returns cached + + // Act + val result = repository.getYieldBoostStatus(userWalletId, forceRefresh = false) + + // Assert + assertThat(result).isEqualTo(cached) + coVerify(exactly = 0) { tangemApi.getYieldBoostStatus(any()) } + } + + @Test + fun `GIVEN no cache WHEN getYieldBoostStatus THEN fetches stores and returns converted`() = runTest { + // Arrange + val response = statusResponse() + coEvery { statusStore.getSyncOrNull(userWalletId) } returns null + coEvery { tangemApi.getYieldBoostStatus(any()) } returns ApiResponse.Success(response) + val expected = YieldBoostStatusConverter.convert(response) + + // Act + val result = repository.getYieldBoostStatus(userWalletId, forceRefresh = false) + + // Assert + assertThat(result).isEqualTo(expected) + coVerify(exactly = 1) { statusStore.store(userWalletId, expected) } + } + + @Test + fun `GIVEN cached status and force refresh WHEN getYieldBoostStatus THEN fetches anyway`() = runTest { + // Arrange + coEvery { statusStore.getSyncOrNull(userWalletId) } returns YieldBoostStatus.NotStarted + coEvery { tangemApi.getYieldBoostStatus(any()) } returns ApiResponse.Success(statusResponse()) + + // Act + repository.getYieldBoostStatus(userWalletId, forceRefresh = true) + + // Assert + coVerify(exactly = 1) { tangemApi.getYieldBoostStatus(any()) } + } + + @Test + fun `GIVEN fetch fails and cache present WHEN getYieldBoostStatus THEN falls back to cache`() = runTest { + // Arrange — force refresh so the initial cache check is skipped and the fetch is attempted + val cached = YieldBoostStatus.NotStarted + coEvery { tangemApi.getYieldBoostStatus(any()) } throws IOException("network") + coEvery { statusStore.getSyncOrNull(userWalletId) } returns cached + + // Act + val result = repository.getYieldBoostStatus(userWalletId, forceRefresh = true) + + // Assert + assertThat(result).isEqualTo(cached) + coVerify(exactly = 0) { statusStore.store(any(), any()) } + } + + @Test + fun `GIVEN fetch fails and no cache WHEN getYieldBoostStatus THEN rethrows`() = runTest { + // Arrange + coEvery { tangemApi.getYieldBoostStatus(any()) } throws IOException("network") + coEvery { statusStore.getSyncOrNull(userWalletId) } returns null + + // Act + val error = runCatching { repository.getYieldBoostStatus(userWalletId, forceRefresh = true) } + .exceptionOrNull() + + // Assert + assertThat(error).isInstanceOf(IOException::class.java) + } + // endregion + + private fun matchingPromoDto() = PromotionsResponse.PromotionDto( + name = "yield-apr-boost", + all = PromotionsResponse.PromotionDto.All( + timeline = PromotionsResponse.PromotionDto.Timeline( + start = "2026-06-15T00:00:00.000Z", + end = "2027-06-15T22:00:00.000Z", + ), + tokens = listOf( + PromotionsResponse.PromotionDto.PromoToken( + tokenAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + tokenSymbol = "USDC", + tokenName = "USD Coin", + networkId = "ethereum", + ), + ), + status = "active", + link = "https://example.com/terms", + ), + ) + + private fun statusResponse() = YieldBoostStatusResponse( + tokenName = "USD Coin", + networkId = "ethereum", + moduleAddress = "0xModule", + userAddress = "0xUser", + contractAddress = "0xContract", + promoEnrollmentStatus = "NOT_STARTED", + qualificationEndDate = null, + disqualificationReason = null, + ) +} \ No newline at end of file diff --git a/domain/promo/build.gradle.kts b/domain/promo/build.gradle.kts new file mode 100644 index 0000000000..ae01bc51ba --- /dev/null +++ b/domain/promo/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.domain.promo" +} + +dependencies { + + // region Kotlin + api(deps.kotlin.coroutines) + api(deps.arrow.core) + // endregion + + // region Core modules + api(projects.core.utils) + // endregion + + // region Domain models + api(projects.domain.models) + api(projects.domain.promo.models) + // endregion + + // region Tests + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit5) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(projects.test.core) + // endregion +} \ No newline at end of file diff --git a/domain/promo/models/build.gradle.kts b/domain/promo/models/build.gradle.kts new file mode 100644 index 0000000000..fe5476f27b --- /dev/null +++ b/domain/promo/models/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} + +dependencies { + + // region Kotlin + api(deps.kotlin.datetime) + // endregion + + // region Domain models + api(projects.domain.models) + // endregion + + // region Tests + testImplementation(deps.test.junit5) + testImplementation(deps.test.truth) + // endregion +} \ No newline at end of file diff --git a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignId.kt b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignId.kt new file mode 100644 index 0000000000..81269512e0 --- /dev/null +++ b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignId.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.promo.models + +enum class PromoCampaignId(val deeplinkId: Int, val slug: String) { + WhaleSwapCashback(deeplinkId = 1, slug = "whale-swap-cashback"), + ReactivationCashback(deeplinkId = 2, slug = "reactivation-cashback"), + ; + + companion object { + fun fromDeeplinkId(id: Int): PromoCampaignId? = entries.firstOrNull { it.deeplinkId == id } + fun fromSlug(slug: String): PromoCampaignId? = entries.firstOrNull { it.slug == slug } + } +} \ No newline at end of file diff --git a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignState.kt b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignState.kt new file mode 100644 index 0000000000..6e580f4fcd --- /dev/null +++ b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignState.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.promo.models + +sealed interface PromoCampaignState { + + val campaign: PromoCampaignId + + data class Available( + override val campaign: PromoCampaignId, + val payoutTokens: List, + val timeline: PromoTimeline, + ) : PromoCampaignState + + data class Enrolled( + override val campaign: PromoCampaignId, + val tokenReward: TokenReward, + ) : PromoCampaignState + + data class NotActive( + override val campaign: PromoCampaignId, + ) : PromoCampaignState +} \ No newline at end of file diff --git a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt new file mode 100644 index 0000000000..8d72a7c755 --- /dev/null +++ b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt @@ -0,0 +1,27 @@ +package com.tangem.domain.promo.models + +import kotlinx.datetime.Instant + +data class PromoPayoutToken( + val tokenAddress: String, + val tokenSymbol: String, + val tokenName: String, + val networkId: String, +) + +data class PromoTimeline( + val start: Instant, + val end: Instant, +) + +data class TokenReward( + val tokenAddress: String, + val networkId: String, +) + +sealed interface EnrollResult { + val tokenReward: TokenReward + + data class Success(override val tokenReward: TokenReward) : EnrollResult + data class AlreadyEnrolled(override val tokenReward: TokenReward) : EnrollResult +} \ No newline at end of file diff --git a/domain/promo/models/src/test/kotlin/com/tangem/domain/promo/models/PromoCampaignIdTest.kt b/domain/promo/models/src/test/kotlin/com/tangem/domain/promo/models/PromoCampaignIdTest.kt new file mode 100644 index 0000000000..acd7d9425d --- /dev/null +++ b/domain/promo/models/src/test/kotlin/com/tangem/domain/promo/models/PromoCampaignIdTest.kt @@ -0,0 +1,29 @@ +package com.tangem.domain.promo.models + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +internal class PromoCampaignIdTest { + + @Test + fun `GIVEN known deeplink id WHEN fromDeeplinkId THEN returns campaign`() { + assertThat(PromoCampaignId.fromDeeplinkId(1)).isEqualTo(PromoCampaignId.WhaleSwapCashback) + assertThat(PromoCampaignId.fromDeeplinkId(2)).isEqualTo(PromoCampaignId.ReactivationCashback) + } + + @Test + fun `GIVEN unknown deeplink id WHEN fromDeeplinkId THEN returns null`() { + assertThat(PromoCampaignId.fromDeeplinkId(99)).isNull() + } + + @Test + fun `GIVEN known slug WHEN fromSlug THEN returns campaign`() { + assertThat(PromoCampaignId.fromSlug("whale-swap-cashback")).isEqualTo(PromoCampaignId.WhaleSwapCashback) + assertThat(PromoCampaignId.fromSlug("reactivation-cashback")).isEqualTo(PromoCampaignId.ReactivationCashback) + } + + @Test + fun `GIVEN unknown slug WHEN fromSlug THEN returns null`() { + assertThat(PromoCampaignId.fromSlug("nope")).isNull() + } +} \ No newline at end of file diff --git a/domain/promo/src/main/kotlin/com/tangem/domain/promo/PromoRepository.kt b/domain/promo/src/main/kotlin/com/tangem/domain/promo/PromoRepository.kt new file mode 100644 index 0000000000..21de8bea8e --- /dev/null +++ b/domain/promo/src/main/kotlin/com/tangem/domain/promo/PromoRepository.kt @@ -0,0 +1,38 @@ +package com.tangem.domain.promo + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.promo.models.EnrollResult +import com.tangem.domain.promo.models.PromoCampaignId +import com.tangem.domain.promo.models.PromoCampaignState +import com.tangem.domain.promo.models.TokenReward + +/** + * Backend promo-campaign plumbing (enrollment state and registration). + * + * Both methods propagate the underlying error (network, parsing, etc.) by throwing rather than + * returning an error type — callers (use cases) are expected to wrap the call, e.g. with `Either.catch`. + */ +interface PromoRepository { + + /** + * Resolves the state of [campaign] for [userWalletId]: locally enrolled, available, or not active. + * Throws if the campaign list can't be fetched and no cached/local data is available. + */ + @Throws(Exception::class) + suspend fun getCampaignState( + campaign: PromoCampaignId, + userWalletId: UserWalletId, + forceRefresh: Boolean = false, + ): PromoCampaignState + + /** + * Registers [walletIds] for [campaign] with the given [tokenReward]. Throws on any non-conflict + * API error; a 409 conflict resolves to [EnrollResult.AlreadyEnrolled] instead of throwing. + */ + @Throws(Exception::class) + suspend fun enroll( + campaign: PromoCampaignId, + tokenReward: TokenReward, + walletIds: List, + ): EnrollResult +} \ No newline at end of file diff --git a/domain/promo/src/main/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCase.kt b/domain/promo/src/main/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCase.kt new file mode 100644 index 0000000000..93cdcfbb18 --- /dev/null +++ b/domain/promo/src/main/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCase.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.promo.usecase + +import arrow.core.Either +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.promo.PromoRepository +import com.tangem.domain.promo.models.EnrollResult +import com.tangem.domain.promo.models.PromoCampaignId +import com.tangem.domain.promo.models.TokenReward + +class EnrollPromoCampaignUseCase( + private val repository: PromoRepository, +) { + + suspend operator fun invoke( + campaign: PromoCampaignId, + tokenReward: TokenReward, + walletIds: List, + ): Either = Either.catch { + repository.enroll(campaign, tokenReward, walletIds) + } +} \ No newline at end of file diff --git a/domain/promo/src/main/kotlin/com/tangem/domain/promo/usecase/GetPromoCampaignStateUseCase.kt b/domain/promo/src/main/kotlin/com/tangem/domain/promo/usecase/GetPromoCampaignStateUseCase.kt new file mode 100644 index 0000000000..9a920219f5 --- /dev/null +++ b/domain/promo/src/main/kotlin/com/tangem/domain/promo/usecase/GetPromoCampaignStateUseCase.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.promo.usecase + +import arrow.core.Either +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.promo.PromoRepository +import com.tangem.domain.promo.models.PromoCampaignId +import com.tangem.domain.promo.models.PromoCampaignState + +class GetPromoCampaignStateUseCase( + private val repository: PromoRepository, +) { + + suspend operator fun invoke( + campaign: PromoCampaignId, + userWalletId: UserWalletId, + forceRefresh: Boolean = false, + ): Either = Either.catch { + repository.getCampaignState(campaign, userWalletId, forceRefresh) + } +} \ No newline at end of file diff --git a/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCaseTest.kt b/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCaseTest.kt new file mode 100644 index 0000000000..a308dc7335 --- /dev/null +++ b/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCaseTest.kt @@ -0,0 +1,57 @@ +package com.tangem.domain.promo.usecase + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.promo.PromoRepository +import com.tangem.domain.promo.models.EnrollResult +import com.tangem.domain.promo.models.PromoCampaignId +import com.tangem.domain.promo.models.TokenReward +import com.tangem.test.core.assertEitherLeft +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.io.IOException + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class EnrollPromoCampaignUseCaseTest { + + private val repository: PromoRepository = mockk() + private val useCase = EnrollPromoCampaignUseCase(repository) + + private val campaign = PromoCampaignId.WhaleSwapCashback + private val walletIds = listOf(UserWalletId("abcdef012345")) + private val tokenReward = TokenReward("0xToken", "ethereum") + + @BeforeEach + fun setUp() = clearMocks(repository) + + @Test + fun `GIVEN repo returns Success WHEN invoke THEN Right Success`() = runTest { + // Arrange + val expected = EnrollResult.Success(tokenReward) + coEvery { repository.enroll(campaign, tokenReward, walletIds) } returns expected + + // Act + val result = useCase(campaign, tokenReward, walletIds) + + // Assert + assertThat(result.getOrNull()).isEqualTo(expected) + } + + @Test + fun `GIVEN repo throws WHEN invoke THEN Left`() = runTest { + // Arrange + val error = IOException("x") + coEvery { repository.enroll(campaign, tokenReward, walletIds) } throws error + + // Act + val result = useCase(campaign, tokenReward, walletIds) + + // Assert + assertEitherLeft(result, error) + } +} \ No newline at end of file diff --git a/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/GetPromoCampaignStateUseCaseTest.kt b/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/GetPromoCampaignStateUseCaseTest.kt new file mode 100644 index 0000000000..6140437e2a --- /dev/null +++ b/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/GetPromoCampaignStateUseCaseTest.kt @@ -0,0 +1,55 @@ +package com.tangem.domain.promo.usecase + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.promo.PromoRepository +import com.tangem.domain.promo.models.PromoCampaignId +import com.tangem.domain.promo.models.PromoCampaignState +import com.tangem.test.core.assertEitherLeft +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.io.IOException + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class GetPromoCampaignStateUseCaseTest { + + private val repository: PromoRepository = mockk() + private val useCase = GetPromoCampaignStateUseCase(repository) + + private val campaign = PromoCampaignId.WhaleSwapCashback + private val userWalletId = UserWalletId("abcdef012345") + + @BeforeEach + fun setUp() = clearMocks(repository) + + @Test + fun `GIVEN repo returns state WHEN invoke THEN Right of state`() = runTest { + // Arrange + val expected = PromoCampaignState.NotActive(campaign) + coEvery { repository.getCampaignState(campaign, userWalletId, false) } returns expected + + // Act + val result = useCase(campaign, userWalletId) + + // Assert + assertThat(result.getOrNull()).isEqualTo(expected) + } + + @Test + fun `GIVEN repo throws WHEN invoke THEN Left`() = runTest { + // Arrange + val error = IOException("x") + coEvery { repository.getCampaignState(campaign, userWalletId, false) } throws error + + // Act + val result = useCase(campaign, userWalletId) + + // Assert + assertEitherLeft(result, error) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index b61afb33b1..aac87637cc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -432,6 +432,8 @@ include(":domain:wallet-manager") include(":domain:wallet-manager:models") include(":domain:yield-supply") include(":domain:yield-supply:models") +include(":domain:promo") +include(":domain:promo:models") include(":domain:news") include(":domain:earn") include(":domain:search") @@ -476,6 +478,7 @@ include(":data:swap") include(":data:express") include(":data:wallet-manager") include(":data:yield-supply") +include(":data:promo") include(":data:news") include(":data:earn") include(":data:search") From cf6143972925e64287dc9e599709ad700d43bf6e Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Jul 2026 16:37:19 +0200 Subject: [PATCH 28/59] Updated on 2026-08-14 --- .../message/MessageBottomSheetUM.kt | 4 +- .../message/MessageBottomSheetV2.kt | 2 + features/promo-banners/impl/build.gradle.kts | 2 + .../ActivateCampaignBottomSheetComponent.kt | 56 ++++--- ...ignAlreadyActivatedBottomSheetComponent.kt | 54 +++++++ .../CampaignEnrolledBottomSheetComponent.kt | 66 ++++---- .../component/CampaignsModularComponent.kt | 10 ++ .../component/DefaultCampaignsComponent.kt | 91 ++++++++++- .../NotActiveCampaignBottomSheetComponent.kt | 64 ++++---- .../impl/campaigns/di/CampaignsModule.kt | 6 + .../campaigns/entity/ActivateCampaignUM.kt | 32 +++- .../entity/CampaignAlreadyActivatedUM.kt | 10 ++ .../impl/campaigns/entity/CampaignType.kt | 11 +- .../entity/CampaignsBottomSheetConfig.kt | 11 ++ .../campaigns/model/ActivateCampaignsModel.kt | 113 +++++++++++-- .../model/CampaignAlreadyActivatedModel.kt | 73 +++++++++ .../impl/campaigns/model/CampaignsModel.kt | 19 +++ .../ui/ActivateCampaignBottomSheet.kt | 50 ------ .../campaigns/ui/ActivateCampaignContent.kt | 152 +++++++++--------- .../campaigns/ui/ActivateCampaignFooter.kt | 105 ++++++++++++ .../ui/AlreadyActivatedCampaignContent.kt | 104 ++++++++++++ .../ui/CampaignEnrolledMessageContent.kt | 61 +++++++ .../impl/campaigns/ui/CampaignPreviewData.kt | 70 ++++++++ .../ui/NotActiveCampaignMessageContent.kt | 70 ++++++++ .../campaigns/ui/PromoCampaignTokenItem.kt | 126 +++++++++++++++ 25 files changed, 1127 insertions(+), 235 deletions(-) create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignAlreadyActivatedBottomSheetComponent.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignsModularComponent.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignAlreadyActivatedUM.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignAlreadyActivatedModel.kt delete mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignBottomSheet.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignFooter.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/AlreadyActivatedCampaignContent.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/CampaignEnrolledMessageContent.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/CampaignPreviewData.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/NotActiveCampaignMessageContent.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/PromoCampaignTokenItem.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetUM.kt index 6ac0d9d8ca..5a79c753b7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetUM.kt @@ -49,11 +49,11 @@ data class MessageBottomSheetUM( var backgroundType: BackgroundType = BackgroundType.Unspecified, ) : Element { enum class Type { - Unspecified, Accent, Informative, Attention, Warning, + Unspecified, Accent, Informative, Attention, Warning, Success, } enum class BackgroundType { - Unspecified, SameAsTint, Accent, Informative, Attention, Warning, + Unspecified, SameAsTint, Accent, Informative, Attention, Warning, Success, } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt index baad8cca0a..cf02bcd878 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt @@ -214,6 +214,7 @@ private fun BottomSheetVector(vector: MessageBottomSheetUM.Vector, modifier: Mod MessageBottomSheetUM.Vector.Type.Informative -> TangemTheme.colors3.icon.status.info MessageBottomSheetUM.Vector.Type.Attention -> TangemTheme.colors3.icon.status.warning MessageBottomSheetUM.Vector.Type.Warning -> TangemTheme.colors3.icon.status.error + MessageBottomSheetUM.Vector.Type.Success -> TangemTheme.colors3.icon.status.success } val backgroundColor = when (vector.backgroundType) { @@ -223,6 +224,7 @@ private fun BottomSheetVector(vector: MessageBottomSheetUM.Vector, modifier: Mod MessageBottomSheetUM.Vector.BackgroundType.Informative -> TangemTheme.colors3.bg.status.infoSubtle MessageBottomSheetUM.Vector.BackgroundType.Attention -> TangemTheme.colors3.bg.status.warningSubtle MessageBottomSheetUM.Vector.BackgroundType.Warning -> TangemTheme.colors3.bg.status.errorSubtle + MessageBottomSheetUM.Vector.BackgroundType.Success -> TangemTheme.colors3.bg.status.successSubtle } Box( diff --git a/features/promo-banners/impl/build.gradle.kts b/features/promo-banners/impl/build.gradle.kts index f1a7fb8504..5b7f1e1e76 100644 --- a/features/promo-banners/impl/build.gradle.kts +++ b/features/promo-banners/impl/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { api(projects.domain.common) implementation(projects.domain.models) implementation(projects.domain.appCurrency) + implementation(projects.domain.account.status) /** Core */ api(projects.core.configToggles) @@ -56,4 +57,5 @@ dependencies { /** Tests */ testImplementation(deps.test.junit5) testImplementation(deps.test.truth) + testImplementation(deps.kotlin.serialization) } \ No newline at end of file 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 97ffbc4835..7e8f99ece9 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 @@ -1,26 +1,33 @@ package com.tangem.features.promobanners.impl.campaigns.component +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel +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.sheet.TangemBottomSheet -import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.ds2.button.Close +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel import com.tangem.features.promobanners.impl.campaigns.ui.ActivateCampaignContent +import com.tangem.features.promobanners.impl.campaigns.ui.ActivateCampaignFooter internal class ActivateCampaignBottomSheetComponent( appComponentContext: AppComponentContext, chooseTokenComponentFactory: ChooseTokenComponent.Factory, - params: ActivateCampaignsModel.Params, -) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + private val params: ActivateCampaignsModel.Params, +) : CampaignsModularComponent, AppComponentContext by appComponentContext { private val model: ActivateCampaignsModel = getOrCreateModel(params) @@ -29,36 +36,45 @@ internal class ActivateCampaignBottomSheetComponent( params = ChooseTokenComponent.Params(bridge = model.bridge), ) - override fun dismiss() = model.onDismiss() + @Composable + override fun Title(bottomSheetState: State) { + TangemTopNavigation( + windowInsets = WindowInsets(0), + blurBackground = false, + endButton = { TangemButton.Close(onClick = params.onDismiss) }, + ) + } @Composable - override fun BottomSheet() { + override fun Content(bottomSheetState: State, contentPadding: PaddingValues, modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() - ActivateCampaignContent( - state = state, - onSelectTokenClick = model::onSelectTokenClick, - onEnrollClick = model::onEnrollClick, - onLearnMoreClick = { /* [REDACTED_TODO_COMMENT] */ }, - onDismiss = ::dismiss, - ) + ActivateCampaignContent(um = state) if (state.isChoosingToken) { - ChooseTokenBottomSheet() + ChooseTokenBottomSheet(state.onChooseTokenDismiss) } } @Composable - private fun ChooseTokenBottomSheet() { + override fun Footer() { + val state by model.uiState.collectAsStateWithLifecycle() + + ActivateCampaignFooter( + footerUM = state.footerUM, + ) + } + + @Composable + private fun ChooseTokenBottomSheet(onChooseTokenDismiss: () -> Unit) { TangemBottomSheet( config = TangemBottomSheetConfig( isShown = true, - onDismissRequest = model::onChooseTokenDismiss, + onDismissRequest = onChooseTokenDismiss, content = TangemBottomSheetConfigContent.Empty, ), - onBack = model::onChooseTokenDismiss, - ) { - chooseTokenComponent.Content(modifier = Modifier.fillMaxWidth()) - } + onBack = onChooseTokenDismiss, + content = { chooseTokenComponent.Content(modifier = Modifier.fillMaxWidth()) }, + ) } } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignAlreadyActivatedBottomSheetComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignAlreadyActivatedBottomSheetComponent.kt new file mode 100644 index 0000000000..7a6becffa4 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignAlreadyActivatedBottomSheetComponent.kt @@ -0,0 +1,54 @@ +package com.tangem.features.promobanners.impl.campaigns.component + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.ds2.button.Close +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.features.promobanners.impl.R +import com.tangem.features.promobanners.impl.campaigns.model.CampaignAlreadyActivatedModel +import com.tangem.features.promobanners.impl.campaigns.ui.ActivateCampaignContent + +internal class CampaignAlreadyActivatedBottomSheetComponent( + appComponentContext: AppComponentContext, + private val params: CampaignAlreadyActivatedModel.Params, +) : CampaignsModularComponent, AppComponentContext by appComponentContext { + + private val model: CampaignAlreadyActivatedModel = getOrCreateModel(params) + + @Composable + override fun Title(bottomSheetState: State) { + TangemTopNavigation( + windowInsets = WindowInsets(0), + blurBackground = false, + endButton = { TangemButton.Close(onClick = params.onDismiss) }, + ) + } + + @Composable + override fun Content(bottomSheetState: State, contentPadding: PaddingValues, modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + + ActivateCampaignContent(um = state) + } + + @Composable + override fun Footer() { + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.common_close), + onClick = params.onDismiss, + ) + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignEnrolledBottomSheetComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignEnrolledBottomSheetComponent.kt index 8bdbfb6ac7..8853bca703 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignEnrolledBottomSheetComponent.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignEnrolledBottomSheetComponent.kt @@ -1,50 +1,50 @@ package com.tangem.features.promobanners.impl.campaigns.component +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheet -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM -import com.tangem.core.ui.components.bottomsheets.message.infoBlock -import com.tangem.core.ui.components.bottomsheets.message.messageBottomSheetUM -import com.tangem.core.ui.components.bottomsheets.message.onClick -import com.tangem.core.ui.components.bottomsheets.message.onDismiss -import com.tangem.core.ui.components.bottomsheets.message.primaryButton -import com.tangem.core.ui.components.bottomsheets.message.vector -import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.core.ui.extensions.resourceReference +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.ds2.button.Close +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.res.generated.icons.Icons -import com.tangem.core.ui.res.generated.icons.ic_success_24 +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.features.promobanners.impl.R import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType import com.tangem.features.promobanners.impl.campaigns.entity.campaignName +import com.tangem.features.promobanners.impl.campaigns.ui.CampaignEnrolledMessageContent internal class CampaignEnrolledBottomSheetComponent( private val campaignType: CampaignType, private val onDismissRequest: () -> Unit, -) : ComposableBottomSheetComponent { - - override fun dismiss() = onDismissRequest() +) : CampaignsModularComponent { @Composable - override fun BottomSheet() { - MessageBottomSheet( - state = messageBottomSheetUM { - onDismiss(onDismissRequest) - infoBlock { - vector(Icons.ic_success_24) { - type = MessageBottomSheetUM.Vector.Type.Accent - backgroundType = MessageBottomSheetUM.Vector.BackgroundType.SameAsTint - } + override fun Title(bottomSheetState: State) { + TangemTopNavigation( + windowInsets = WindowInsets(0), + blurBackground = false, + endButton = { TangemButton.Close(onClick = onDismissRequest) }, + ) + } - title = stringReference("You're successfully enrolled in ${campaignType.campaignName()}") - body = stringReference("Your cashback will be applied to eligible swaps automatically.") - } - primaryButton { - text = resourceReference(R.string.common_close) - onClick { closeBs() } - } - }, - onDismissRequest = onDismissRequest, + @Composable + override fun Content(bottomSheetState: State, contentPadding: PaddingValues, modifier: Modifier) { + CampaignEnrolledMessageContent( + message = stringReference("You're successfully enrolled in ${campaignType.campaignName()}"), + ) + } + + @Composable + override fun Footer() { + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.common_close), + onClick = { onDismissRequest() }, ) } } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignsModularComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignsModularComponent.kt new file mode 100644 index 0000000000..bb2368cd7e --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignsModularComponent.kt @@ -0,0 +1,10 @@ +package com.tangem.features.promobanners.impl.campaigns.component + +import androidx.compose.runtime.Composable +import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent + +interface CampaignsModularComponent : ComposableModularBottomSheetContentComponent { + + @Composable + fun Footer() +} \ No newline at end of file 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 e815a741c6..61ee9f8197 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 @@ -1,19 +1,36 @@ package com.tangem.features.promobanners.impl.campaigns.component +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp 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.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.components.bottomsheets.LocalBottomSheetContentScrollable +import com.tangem.core.ui.components.bottomsheets.LocalTangemBottomSheetContentBottomInset +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.extensions.rememberLastNonNull import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent import com.tangem.features.promobanners.api.swapcashback.CampaignsComponent import com.tangem.features.promobanners.impl.campaigns.entity.CampaignsBottomSheetConfig import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel +import com.tangem.features.promobanners.impl.campaigns.model.CampaignAlreadyActivatedModel import com.tangem.features.promobanners.impl.campaigns.model.CampaignsModel import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -29,7 +46,7 @@ internal class DefaultCampaignsComponent @AssistedInject constructor( private val bottomSheetSlot = childSlot( source = model.bottomSheetNavigation, - serializer = CampaignsBottomSheetConfig.serializer(), + serializer = null, handleBackButton = false, childFactory = ::bottomSheetChild, ) @@ -37,13 +54,61 @@ internal class DefaultCampaignsComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val bottomSheet by bottomSheetSlot.subscribeAsState() - bottomSheet.child?.instance?.BottomSheet() + val activeChild = bottomSheet.child?.instance + val displayedChild = rememberLastNonNull(activeChild) + val bottomSheetState = remember { mutableStateOf(BottomSheetState.EXPANDED) } + + TangemBottomSheet( + config = TangemBottomSheetConfig( + isShown = activeChild != null, + onDismissRequest = model::onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + onBack = model::onDismiss, + title = { + displayedChild?.Title(bottomSheetState) + }, + content = { + val bottomInset = LocalTangemBottomSheetContentBottomInset.current + val scrollableSignal = LocalBottomSheetContentScrollable.current + + if (scrollableSignal != null) { + LaunchedEffect(Unit) { + scrollableSignal.value = false + } + DisposableEffect(scrollableSignal) { + onDispose { scrollableSignal.value = true } + } + } + + Box( + modifier = Modifier + .padding(bottom = bottomInset) + .animateContentSize(), + ) { + displayedChild?.Content( + bottomSheetState = bottomSheetState, + contentPadding = PaddingValues(), + modifier = Modifier, + ) + } + }, + footer = { + Box( + modifier = Modifier + .navigationBarsPadding() + .padding(12.dp), + ) { + displayedChild?.Footer() + } + }, + ) } private fun bottomSheetChild( config: CampaignsBottomSheetConfig, componentContext: ComponentContext, - ): ComposableBottomSheetComponent { + ): CampaignsModularComponent { val context = childByContext(componentContext) return when (config) { CampaignsBottomSheetConfig.NotActive -> NotActiveCampaignBottomSheetComponent( @@ -60,6 +125,24 @@ internal class DefaultCampaignsComponent @AssistedInject constructor( campaignType = config.campaignType, onDismiss = model::onDismiss, onActivated = model::onActivated, + onAlreadyActivated = { campaignType, appCurrency, account, currency -> + model.onAlreadyActivated( + campaignType = campaignType, + appCurrency = appCurrency, + account = account, + currency = currency, + ) + }, + ), + ) + is CampaignsBottomSheetConfig.AlreadyActivated -> CampaignAlreadyActivatedBottomSheetComponent( + appComponentContext = context, + params = CampaignAlreadyActivatedModel.Params( + campaignType = config.campaignType, + appCurrency = config.appCurrency, + account = config.account, + currency = config.currency, + onDismiss = model::onDismiss, ), ) } diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/NotActiveCampaignBottomSheetComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/NotActiveCampaignBottomSheetComponent.kt index 005d3c057e..05dd624b48 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/NotActiveCampaignBottomSheetComponent.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/NotActiveCampaignBottomSheetComponent.kt @@ -1,44 +1,44 @@ package com.tangem.features.promobanners.impl.campaigns.component +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheet -import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM -import com.tangem.core.ui.components.bottomsheets.message.icon -import com.tangem.core.ui.components.bottomsheets.message.infoBlock -import com.tangem.core.ui.components.bottomsheets.message.messageBottomSheetUM -import com.tangem.core.ui.components.bottomsheets.message.onClick -import com.tangem.core.ui.components.bottomsheets.message.onDismiss -import com.tangem.core.ui.components.bottomsheets.message.primaryButton -import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.ds2.button.Close +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.features.promobanners.impl.R +import com.tangem.features.promobanners.impl.campaigns.ui.NotActiveCampaignMessageContent internal class NotActiveCampaignBottomSheetComponent( private val onDismissRequest: () -> Unit, -) : ComposableBottomSheetComponent { - - override fun dismiss() = onDismissRequest() +) : CampaignsModularComponent { @Composable - override fun BottomSheet() { - MessageBottomSheet( - state = messageBottomSheetUM { - onDismiss(onDismissRequest) - infoBlock { - icon(R.drawable.ic_alert_circle_24) { - type = MessageBottomSheetUM.Icon.Type.Warning - backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint - } - title = stringReference("Campaign not active") // TODO localization - body = stringReference("This campaign no longer exists or has expired.") // TODO localization - } - primaryButton { - text = resourceReference(R.string.common_close) - onClick { closeBs() } - } - }, - onDismissRequest = onDismissRequest, + override fun Title(bottomSheetState: State) { + TangemTopNavigation( + windowInsets = WindowInsets(0), + blurBackground = false, + endButton = { TangemButton.Close(onClick = onDismissRequest) }, + ) + } + + @Composable + override fun Content(bottomSheetState: State, contentPadding: PaddingValues, modifier: Modifier) { + NotActiveCampaignMessageContent() + } + + @Composable + override fun Footer() { + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.common_close), + onClick = { onDismissRequest() }, ) } } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt index a3ad2a34b4..981656f1da 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt @@ -7,6 +7,7 @@ import com.tangem.features.promobanners.api.swapcashback.CampaignsComponent import com.tangem.features.promobanners.impl.campaigns.component.DefaultCampaignsComponent import com.tangem.features.promobanners.impl.campaigns.deeplink.DefaultCampaignsDeepLinkHandler import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel +import com.tangem.features.promobanners.impl.campaigns.model.CampaignAlreadyActivatedModel import com.tangem.features.promobanners.impl.campaigns.model.CampaignsModel import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService import com.tangem.features.promobanners.impl.campaigns.service.DefaultCampaignsService @@ -50,4 +51,9 @@ internal interface CampaignsModelModule { @IntoMap @ClassKey(ActivateCampaignsModel::class) fun bindCampaignActivateModel(model: ActivateCampaignsModel): Model + + @Binds + @IntoMap + @ClassKey(CampaignAlreadyActivatedModel::class) + fun bindCampaignAlreadyActivatedModel(model: CampaignAlreadyActivatedModel): Model } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/ActivateCampaignUM.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/ActivateCampaignUM.kt index 40e312763e..4481ece80f 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/ActivateCampaignUM.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/ActivateCampaignUM.kt @@ -1,5 +1,7 @@ package com.tangem.features.promobanners.impl.campaigns.entity +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.TextReference @@ -10,11 +12,39 @@ import com.tangem.core.ui.extensions.TextReference * the footer shows "Select token"; once a token is chosen it shows the account block, the terms agreement * and the "Enroll" button. When [isChoosingToken] is `true` the token selector is shown on top of the * intro (as a stacked bottom sheet), not instead of it. + * + * [selectedAccount] is shown above the token only in accounts (multi-account) mode — it names the account + * the asset was picked from. It is `null` in single-account mode or before a token is chosen. */ + +@Immutable internal data class ActivateCampaignUM( - val campaignName: String, val title: TextReference, val description: TextReference, val selectedToken: TokenItemState?, + val selectedAccount: SelectedAccountUM?, val isChoosingToken: Boolean, + val footerUM: FooterUM, + val onChooseTokenDismiss: () -> Unit, + val onLearnMoreClick: () -> Unit, +) + +@Immutable +internal data class FooterUM( + val label: TextReference, + val onPrimaryButtonClick: () -> Unit, + val terms: TermsUM? = null, +) + +@Immutable +data class TermsUM( + val text: TextReference, + val linkText: TextReference, + val onTermsClick: () -> Unit, +) + +@Immutable +internal data class SelectedAccountUM( + val iconState: CurrencyIconState, + val name: TextReference, ) \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignAlreadyActivatedUM.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignAlreadyActivatedUM.kt new file mode 100644 index 0000000000..eff722c71a --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignAlreadyActivatedUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.promobanners.impl.campaigns.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.token.state.TokenItemState + +@Immutable +internal data class CampaignAlreadyActivatedUM( + val selectedToken: TokenItemState, + val selectedAccount: SelectedAccountUM?, +) \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignType.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignType.kt index f3d6574f6d..bd8106428e 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignType.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignType.kt @@ -3,10 +3,13 @@ package com.tangem.features.promobanners.impl.campaigns.entity import kotlinx.serialization.Serializable @Serializable -sealed interface CampaignType { +internal sealed class CampaignType { - val campaignId: String + abstract val campaignId: String - data class ReactivationCashback(override val campaignId: String) : CampaignType - data class WhaleSwapCashback(override val campaignId: String) : CampaignType + @Serializable + data class ReactivationCashback(override val campaignId: String) : CampaignType() + + @Serializable + data class WhaleSwapCashback(override val campaignId: String) : CampaignType() } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt index 50bf5c6cca..8970c31429 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt @@ -1,5 +1,8 @@ package com.tangem.features.promobanners.impl.campaigns.entity +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrencyStatus import kotlinx.serialization.Serializable @Serializable @@ -17,4 +20,12 @@ internal sealed class CampaignsBottomSheetConfig { data class Activate( val campaignType: CampaignType, ) : CampaignsBottomSheetConfig() + + @Serializable + data class AlreadyActivated( + val campaignType: CampaignType, + val appCurrency: AppCurrency, + val account: Account?, + val currency: CryptoCurrencyStatus, + ) : CampaignsBottomSheetConfig() } \ No newline at end of file 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 a4774195bf..fad53de9ca 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 @@ -1,18 +1,29 @@ package com.tangem.features.promobanners.impl.campaigns.model +import com.tangem.common.ui.account.AccountIconItemStateConverter +import com.tangem.common.ui.account.toUM import com.tangem.common.ui.tokens.TokenItemStateConverter 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.components.account.AccountIconSize +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult import com.tangem.features.promobanners.impl.R import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import com.tangem.features.promobanners.impl.campaigns.entity.FooterUM +import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM +import com.tangem.features.promobanners.impl.campaigns.entity.TermsUM import com.tangem.features.promobanners.impl.campaigns.entity.campaignName import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow @@ -21,6 +32,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @ModelScoped @@ -29,11 +41,15 @@ internal class ActivateCampaignsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, chooseTokenBridgeFactory: ChooseTokenBridge.Factory, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val urlOpener: UrlOpener, ) : Model() { private val params = paramsContainer.require() - + private val accountIconConverter = AccountIconItemStateConverter(size = AccountIconSize.ExtraSmall) private var appCurrency: AppCurrency = AppCurrency.Default + private var selectedAccount: Account? = null + private var selectedCurrency: CryptoCurrencyStatus? = null val uiState: StateFlow field = MutableStateFlow(getInitialState()) @@ -62,43 +78,104 @@ internal class ActivateCampaignsModel @Inject constructor( .launchIn(modelScope) } - fun onSelectTokenClick() { + private fun onSelectTokenClick() { uiState.update { it.copy(isChoosingToken = true) } } - fun onChooseTokenDismiss() { + private fun onChooseTokenDismiss() { uiState.update { it.copy(isChoosingToken = false) } } - fun onEnrollClick() { - // TODO([REDACTED_TASK_KEY]): call the real campaign enrollment use case with the chosen token before proceeding. - params.onActivated(params.campaignType) + private fun onEnrollClick() { + modelScope.launch { + // TODO([REDACTED_TASK_KEY]): call the real campaign enrollment use case; its result decides the next sheet. + // Success -> the "enrolled" sheet; "already activated" error -> hand the chosen token/account + // over to the "already activated" sheet. + if (enrollInCampaign()) { + params.onActivated(params.campaignType) + } else { + selectedCurrency?.let { + params.onAlreadyActivated(params.campaignType, appCurrency, selectedAccount, it) + } + } + } } - fun onDismiss() = params.onDismiss() + @Suppress("FunctionOnlyReturningConstant") // TODO([REDACTED_TASK_KEY]): stub until the real enrollment use case exists. + private fun enrollInCampaign(): Boolean { + // TODO([REDACTED_TASK_KEY]): replace with the real enrollment use case call; `true` = enrolled, `false` = already active. + return true + } + + private fun onTermsClick() { + urlOpener.openUrl(CAMPAIGN_TERMS_URL) + } + + private fun onLearnMoreClick() { + urlOpener.openUrl(CAMPAIGN_TERMS_URL) + } private fun onTokenChosen(result: ChooseTokenResult) { - val tokenItem = TokenItemStateConverter(appCurrency = appCurrency).convert(result.currency) + modelScope.launch { + val selectedAccountUM = if (isAccountsModeEnabledUseCase.invokeSync()) { + val account = result.account.account + selectedAccount = account - uiState.update { state -> - state.copy( - isChoosingToken = false, - selectedToken = tokenItem, - ) + when (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 + } + } else { + null + } + + selectedCurrency = result.currency + val tokenItem = TokenItemStateConverter(appCurrency = appCurrency).convert(result.currency) + + uiState.update { state -> + state.copy( + isChoosingToken = false, + selectedToken = tokenItem, + selectedAccount = selectedAccountUM, + footerUM = FooterUM( + label = stringReference("Enroll"), + onPrimaryButtonClick = ::onEnrollClick, + terms = TermsUM( + // TODO([REDACTED_TASK_KEY]): localize + text = stringReference("I agree with"), + linkText = stringReference("${params.campaignType.campaignName()} Terms"), + onTermsClick = ::onTermsClick, + ), + ), + ) + } } } private fun getInitialState(): ActivateCampaignUM { return ActivateCampaignUM( - campaignName = params.campaignType.campaignName(), // TODO([REDACTED_TASK_KEY]): source real campaign copy. title = stringReference("Enroll in ${params.campaignType.campaignName()}"), description = stringReference( "Earn cashback on every swap from \$10K until the end of July.\n\n" + - "Rates step up with size: 0.10% from \$10K, 0.20% from \$20K, 0.50% from \$100K.\n\n", + "Rates step up with size: 0.10% from \$10K, 0.20% from \$20K, 0.50% from \$100K.", ), selectedToken = null, + selectedAccount = null, isChoosingToken = false, + footerUM = FooterUM( + label = stringReference("Select token"), + onPrimaryButtonClick = ::onSelectTokenClick, + ), + onChooseTokenDismiss = ::onChooseTokenDismiss, + onLearnMoreClick = ::onLearnMoreClick, ) } @@ -106,5 +183,11 @@ internal class ActivateCampaignsModel @Inject constructor( val campaignType: CampaignType, val onDismiss: () -> Unit, val onActivated: (CampaignType) -> Unit, + val onAlreadyActivated: (CampaignType, AppCurrency, Account?, CryptoCurrencyStatus) -> Unit, ) + + private companion object { + // TODO([REDACTED_TASK_KEY]): replace with the real campaign terms URL. + const val CAMPAIGN_TERMS_URL = "https://tangem.com/en/" + } } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignAlreadyActivatedModel.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignAlreadyActivatedModel.kt new file mode 100644 index 0000000000..4c25b2c515 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignAlreadyActivatedModel.kt @@ -0,0 +1,73 @@ +package com.tangem.features.promobanners.impl.campaigns.model + +import com.tangem.common.ui.account.AccountIconItemStateConverter +import com.tangem.common.ui.account.toUM +import com.tangem.common.ui.tokens.TokenItemStateConverter +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.ui.components.account.AccountIconSize +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignAlreadyActivatedUM +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +/** + * Thin model for the "campaign already activated" bottom sheet. The screen is read-only: it just renders + * the token/account the cashback is paid out to. + * + * The state is the selection the user made in the activate flow, handed over via [Params] when enrollment + * reports the campaign is already active. That selection is in-memory only (it carries non-serializable UI + * state), so after process death it is `null` and the model assembles a placeholder state instead + * (see [REDACTED_TASK_KEY]). + */ +@ModelScoped +internal class CampaignAlreadyActivatedModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params = paramsContainer.require() + private val accountIconConverter = AccountIconItemStateConverter(size = AccountIconSize.ExtraSmall) + + val uiState: StateFlow + field = MutableStateFlow(buildState()) + + private fun buildState(): CampaignAlreadyActivatedUM { + val selectedAccountUM = params.account?.let { account -> + when (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 + } + } + + val tokenItem = TokenItemStateConverter(appCurrency = params.appCurrency).convert(params.currency) + + return CampaignAlreadyActivatedUM( + selectedToken = tokenItem, + selectedAccount = selectedAccountUM, + ) + } + + data class Params( + val campaignType: CampaignType, + val account: Account?, + val appCurrency: AppCurrency, + val currency: CryptoCurrencyStatus, + val onDismiss: () -> Unit, + ) +} \ No newline at end of file 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 ab5bba4e11..36c5a3f459 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 @@ -5,6 +5,9 @@ import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.promobanners.impl.campaigns.converters.CampaignIdConverter import com.tangem.features.promobanners.impl.campaigns.entity.CampaignsBottomSheetConfig import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType @@ -57,4 +60,20 @@ internal class CampaignsModel @Inject constructor( fun onActivated(campaignType: CampaignType) { bottomSheetNavigation.activate(CampaignsBottomSheetConfig.Enrolled(campaignType)) } + + fun onAlreadyActivated( + campaignType: CampaignType, + appCurrency: AppCurrency, + account: Account?, + currency: CryptoCurrencyStatus, + ) { + bottomSheetNavigation.activate( + CampaignsBottomSheetConfig.AlreadyActivated( + campaignType = campaignType, + appCurrency = appCurrency, + account = account, + currency = currency, + ), + ) + } } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignBottomSheet.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignBottomSheet.kt deleted file mode 100644 index 4d3c39aa1c..0000000000 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignBottomSheet.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.tangem.features.promobanners.impl.campaigns.ui - -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.runtime.Composable -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet -import com.tangem.core.ui.ds2.button.Close -import com.tangem.core.ui.ds2.button.TangemButton -import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation -import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM - -@Composable -internal fun ActivateCampaignContent( - state: ActivateCampaignUM, - onSelectTokenClick: () -> Unit, - onEnrollClick: () -> Unit, - onLearnMoreClick: () -> Unit, - onDismiss: () -> Unit, -) { - TangemBottomSheet( - config = TangemBottomSheetConfig( - isShown = true, - onDismissRequest = onDismiss, - content = TangemBottomSheetConfigContent.Empty, - ), - containerColor = TangemTheme.colors.background.tertiary, - onBack = onDismiss, - title = { - TangemTopNavigation( - windowInsets = WindowInsets(0), - blurBackground = false, - endButton = { - TangemButton.Close( - onClick = onDismiss, - ) - }, - ) - }, - content = { - ActivateCampaignContent( - state = state, - onSelectTokenClick = onSelectTokenClick, - onEnrollClick = onEnrollClick, - onLearnMoreClick = onLearnMoreClick, - ) - }, - ) -} \ 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 4ad65c2d5f..7c9337d187 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 @@ -1,5 +1,6 @@ package com.tangem.features.promobanners.impl.campaigns.ui +import android.content.res.Configuration import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -13,29 +14,26 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign -import com.tangem.core.ui.components.PrimaryButton +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.SpacerH12 -import com.tangem.core.ui.components.SpacerH16 import com.tangem.core.ui.components.SpacerH24 +import com.tangem.core.ui.components.SpacerH32 import com.tangem.core.ui.components.SpacerH8 -import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.features.promobanners.impl.R import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM +import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM @Composable -internal fun ActivateCampaignContent( - state: ActivateCampaignUM, - onSelectTokenClick: () -> Unit, - onEnrollClick: () -> Unit, - onLearnMoreClick: () -> Unit, -) { +internal fun ActivateCampaignContent(um: ActivateCampaignUM) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = TangemTheme.dimens.spacing16) - .navigationBarsPadding(), + .padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Image( @@ -43,93 +41,99 @@ internal fun ActivateCampaignContent( painter = painterResource(R.drawable.ill_businessman_3d), contentDescription = null, modifier = Modifier - .size(TangemTheme.dimens.size96) + .size(80.dp) .clip(CircleShape), ) - SpacerH16() + SpacerH32() + Text( - text = state.title.resolveReference(), - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, + text = um.title.resolveReference(), + style = TangemTheme.typography3.heading.small, + color = TangemTheme.colors3.text.primary, textAlign = TextAlign.Start, modifier = Modifier.fillMaxWidth(), ) + SpacerH8() + Text( - text = state.description.resolveReference(), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, + text = um.description.resolveReference(), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, modifier = Modifier.fillMaxWidth(), ) + SpacerH12() + Text( // TODO([REDACTED_TASK_KEY]): localize text = "Learn more", - style = TangemTheme.typography.button, - color = TangemTheme.colors.text.accent, + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.primary, modifier = Modifier .fillMaxWidth() - .clickable(onClick = onLearnMoreClick), + .clickable(onClick = um.onLearnMoreClick), ) - val selectedToken = state.selectedToken - if (selectedToken != null) { - SpacerH24() - Text( - text = "Select cashback account", // TODO([REDACTED_TASK_KEY]): localize - style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.primary1, - modifier = Modifier.fillMaxWidth(), - ) - SpacerH12() - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(TangemTheme.dimens.radius16)) - .background(TangemTheme.colors.background.primary) - .padding(vertical = TangemTheme.dimens.spacing12), - ) { - TokenItem(state = selectedToken, isBalanceHidden = false) - } - } - - SpacerH24() - - Footer( - campaignName = state.campaignName, - hasSelectedToken = selectedToken != null, - onSelectTokenClick = onSelectTokenClick, - onEnrollClick = onEnrollClick, + SelectedTokenContent( + selectedToken = um.selectedToken, + selectedAccount = um.selectedAccount, ) + + SpacerH32() } } @Composable -private fun Footer( - campaignName: String, - hasSelectedToken: Boolean, - onSelectTokenClick: () -> Unit, - onEnrollClick: () -> Unit, -) { - if (hasSelectedToken) { +private fun SelectedTokenContent(selectedToken: TokenItemState?, selectedAccount: SelectedAccountUM?) { + if (selectedToken != null) { + SpacerH24() + Text( - text = "I agree with $campaignName Terms", // TODO([REDACTED_TASK_KEY]): localize + clickable terms - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.secondary, - textAlign = TextAlign.Center, + text = "Select cashback account", // TODO([REDACTED_TASK_KEY]): localize + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, modifier = Modifier.fillMaxWidth(), ) - SpacerH12() - } - PrimaryButton( - modifier = Modifier.fillMaxWidth(), - text = if (hasSelectedToken) { - "Enroll" // TODO([REDACTED_TASK_KEY]): localize - } else { - "Select token" // TODO([REDACTED_TASK_KEY]): localize - }, - onClick = if (hasSelectedToken) onEnrollClick else onSelectTokenClick, - ) - SpacerH16() -} \ No newline at end of file + SpacerH12() + + PromoCampaignTokenItem( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(TangemTheme.colors.background.primary), + selectedToken = selectedToken, + selectedAccount = selectedAccount, + ) + } +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_ActivateCampaignContent_WithToken() { + TangemThemePreviewRedesign { + Box(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) { + ActivateCampaignContent(um = CampaignPreviewData.activateCampaign) + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_ActivateCampaignContent_NoToken() { + TangemThemePreviewRedesign { + Box(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) { + ActivateCampaignContent( + um = CampaignPreviewData.activateCampaign.copy( + selectedToken = null, + selectedAccount = null, + ), + ) + } + } +} +// endregion \ No newline at end of file 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 new file mode 100644 index 0000000000..ce1d2879f4 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignFooter.kt @@ -0,0 +1,105 @@ +package com.tangem.features.promobanners.impl.campaigns.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +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.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.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 com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.promobanners.impl.campaigns.entity.FooterUM +import com.tangem.features.promobanners.impl.campaigns.entity.TermsUM + +@Composable +internal fun ActivateCampaignFooter(footerUM: FooterUM, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + val terms = footerUM.terms + + if (terms != null) { + Text( + text = termsAnnotatedString(terms), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + + SpacerH12() + + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + text = footerUM.label.resolveReference(), + onClick = footerUM.onPrimaryButtonClick, + ) + } +} + +@Composable +private fun termsAnnotatedString(terms: TermsUM) = buildAnnotatedString { + val startText = terms.text.resolveReference() + val linkText = terms.linkText.resolveReference() + + append(startText) + append(" ") + withLink( + link = LinkAnnotation.Clickable( + tag = "CAMPAIGN_TERMS", + linkInteractionListener = { terms.onTermsClick() }, + ), + ) { + withStyle( + SpanStyle( + color = TangemTheme.colors3.text.primary, + textDecoration = TextDecoration.None, + ), + ) { + append(linkText) + } + } +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_ActivateCampaignFooter_WithTerms() { + TangemThemePreviewRedesign { + ActivateCampaignFooter( + footerUM = CampaignPreviewData.footer, + modifier = Modifier + .background(TangemTheme.colors3.bg.primary) + .padding(16.dp), + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_ActivateCampaignFooter_NoTerms() { + TangemThemePreviewRedesign { + ActivateCampaignFooter( + footerUM = CampaignPreviewData.footer.copy(terms = null), + modifier = Modifier + .background(TangemTheme.colors3.bg.primary) + .padding(16.dp), + ) + } +} +// endregion \ No newline at end of file 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 new file mode 100644 index 0000000000..fa2367418d --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/AlreadyActivatedCampaignContent.kt @@ -0,0 +1,104 @@ +package com.tangem.features.promobanners.impl.campaigns.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +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.unit.dp +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerH24 +import com.tangem.core.ui.components.SpacerH32 +import com.tangem.core.ui.components.token.state.TokenItemState +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.features.promobanners.impl.campaigns.entity.CampaignAlreadyActivatedUM +import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM + +@Composable +internal fun ActivateCampaignContent(um: CampaignAlreadyActivatedUM) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(TangemTheme.colors3.bg.status.infoSubtle), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = Icons.ic_info_24, + contentDescription = null, + tint = TangemTheme.colors3.icon.status.info, + ) + } + + SpacerH32() + + Text( + text = "You’re already enrolled in Whale Swap Cashback", // TODO localization + style = TangemTheme.typography3.heading.small, + color = TangemTheme.colors3.text.primary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + SpacerH12() + + SelectedTokenContent( + selectedToken = um.selectedToken, + selectedAccount = um.selectedAccount, + ) + + SpacerH32() + } +} + +@Composable +private fun SelectedTokenContent(selectedToken: TokenItemState, selectedAccount: SelectedAccountUM?) { + SpacerH24() + + Text( + text = "Eligible cashback will be distributed to:", // TODO([REDACTED_TASK_KEY]): localize + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + SpacerH12() + + PromoCampaignTokenItem( + modifier = Modifier.fillMaxWidth(), + selectedToken = selectedToken, + selectedAccount = selectedAccount, + ) +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_AlreadyActivatedCampaignContent() { + TangemThemePreviewRedesign { + Box(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) { + ActivateCampaignContent(um = CampaignPreviewData.alreadyActivated) + } + } +} +// endregion \ No newline at end of file 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 new file mode 100644 index 0000000000..ef064a4a4d --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/CampaignEnrolledMessageContent.kt @@ -0,0 +1,61 @@ +package com.tangem.features.promobanners.impl.campaigns.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.unit.dp +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerH32 +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +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_success_24 + +@Composable +fun CampaignEnrolledMessageContent(message: TextReference, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(TangemTheme.colors3.bg.status.successSubtle), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = Icons.ic_success_24, + contentDescription = null, + tint = TangemTheme.colors3.icon.status.success, + ) + } + + SpacerH32() + + Text( + text = message.resolveReference(), + style = TangemTheme.typography3.heading.small, + color = TangemTheme.colors3.text.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + SpacerH(48.dp) + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/CampaignPreviewData.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/CampaignPreviewData.kt new file mode 100644 index 0000000000..0aa7391b16 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/CampaignPreviewData.kt @@ -0,0 +1,70 @@ +package com.tangem.features.promobanners.impl.campaigns.ui + +import androidx.compose.ui.graphics.Color +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignAlreadyActivatedUM +import com.tangem.features.promobanners.impl.campaigns.entity.FooterUM +import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM +import com.tangem.features.promobanners.impl.campaigns.entity.TermsUM + +/** + * Shared preview fixtures for the campaign UI `@Preview`s. Not used in production code. + */ +internal object CampaignPreviewData { + + val tokenItem: TokenItemState.Content = TokenItemState.Content( + id = "preview-token", + iconState = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = com.tangem.core.ui.R.drawable.img_polygon_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + titleState = TokenItemState.TitleState.Content(text = stringReference("Polygon")), + subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference("MATIC")), + fiatAmountState = TokenItemState.FiatAmountState.Content(text = "321 $"), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "5,412 MATIC"), + onItemClick = {}, + onItemLongClick = {}, + ) + + val selectedAccount: SelectedAccountUM = SelectedAccountUM( + iconState = CurrencyIconState.CryptoPortfolio.Icon( + resId = com.tangem.core.ui.R.drawable.ic_rounded_star_24, + color = Color(color = 0xFF0099FF), + isGrayscale = false, + ), + name = stringReference("Main account"), + ) + + val footer: FooterUM = FooterUM( + label = stringReference("Enroll"), + onPrimaryButtonClick = {}, + terms = TermsUM( + text = stringReference("By enrolling you agree to the"), + linkText = stringReference("Terms & Conditions"), + onTermsClick = {}, + ), + ) + + val activateCampaign: ActivateCampaignUM = ActivateCampaignUM( + title = stringReference("Whale Swap Cashback"), + description = stringReference( + "Get cashback on every swap. Pick a token and the account where your rewards will be paid out.", + ), + selectedToken = tokenItem, + selectedAccount = selectedAccount, + isChoosingToken = false, + footerUM = footer, + onChooseTokenDismiss = {}, + onLearnMoreClick = {}, + ) + + val alreadyActivated: CampaignAlreadyActivatedUM = CampaignAlreadyActivatedUM( + selectedToken = tokenItem, + selectedAccount = selectedAccount, + ) +} \ No newline at end of file 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 new file mode 100644 index 0000000000..9557228a22 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/NotActiveCampaignMessageContent.kt @@ -0,0 +1,70 @@ +package com.tangem.features.promobanners.impl.campaigns.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.unit.dp +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerH32 +import com.tangem.core.ui.components.SpacerH8 +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_warning_24 + +@Composable +fun NotActiveCampaignMessageContent(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(TangemTheme.colors3.bg.status.warningSubtle), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = Icons.ic_warning_24, + contentDescription = null, + tint = TangemTheme.colors3.icon.status.warning, + ) + } + + SpacerH32() + + Text( + text = " Campaign not active", // TODO localization + style = TangemTheme.typography3.heading.small, + color = TangemTheme.colors3.text.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + SpacerH8() + + Text( + text = "This campaign no longer exists or has expired", // TODO localization + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.secondary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + SpacerH(48.dp) + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/PromoCampaignTokenItem.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/PromoCampaignTokenItem.kt new file mode 100644 index 0000000000..df75c8e68b --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/PromoCampaignTokenItem.kt @@ -0,0 +1,126 @@ +package com.tangem.features.promobanners.impl.campaigns.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +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.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.SpacerW4 +import com.tangem.core.ui.components.account.AccountCharIcon +import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.components.account.AccountResIcon +import com.tangem.core.ui.components.account.PaymentAccountIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM + +@Composable +internal fun PromoCampaignTokenItem( + selectedToken: TokenItemState, + selectedAccount: SelectedAccountUM?, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + if (selectedAccount != null) { + Row( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + when (val icon = selectedAccount.iconState) { + is CurrencyIconState.PaymentAccount -> PaymentAccountIcon(size = icon.size) + is CurrencyIconState.CryptoPortfolio.Icon -> AccountResIcon( + resId = icon.resId, + color = icon.color, + size = AccountIconSize.ExtraSmall, + ) + is CurrencyIconState.CryptoPortfolio.Letter -> AccountCharIcon( + char = icon.char.resolveReference().first(), + color = icon.color, + size = AccountIconSize.ExtraSmall, + ) + is CurrencyIconState.CoinIcon, + is CurrencyIconState.CustomTokenIcon, + is CurrencyIconState.Empty, + is CurrencyIconState.FiatIcon, + CurrencyIconState.Loading, + CurrencyIconState.Locked, + is CurrencyIconState.TokenIcon, + -> Unit + } + + SpacerW4() + + Text( + modifier = Modifier + .padding(vertical = 2.dp) + .alignByBaseline(), + text = selectedAccount.name.resolveReference(), + color = TangemTheme.colors.text.primary1, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = TangemTheme.typography.caption1, + ) + } + + SpacerH8() + } + + TokenItem( + state = selectedToken, + isBalanceHidden = false, + itemPaddingValues = PaddingValues(horizontal = 16.dp), + ) + } +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_PromoCampaignTokenItem_WithAccount() { + TangemThemePreviewRedesign { + PromoCampaignTokenItem( + modifier = Modifier + .background(TangemTheme.colors3.bg.primary) + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(TangemTheme.colors.background.primary), + selectedToken = CampaignPreviewData.tokenItem, + selectedAccount = CampaignPreviewData.selectedAccount, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_PromoCampaignTokenItem_NoAccount() { + TangemThemePreviewRedesign { + PromoCampaignTokenItem( + modifier = Modifier + .background(TangemTheme.colors3.bg.primary) + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(TangemTheme.colors.background.primary), + selectedToken = CampaignPreviewData.tokenItem, + selectedAccount = null, + ) + } +} +// endregion \ No newline at end of file From 6b2728ba7ccff7d265fe2ec68deb09ec7e5932c2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 8 Jul 2026 15:43:39 +0200 Subject: [PATCH 29/59] Updated on 2026-08-14 --- features/promo-banners/impl/.gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 features/promo-banners/impl/.gitignore diff --git a/features/promo-banners/impl/.gitignore b/features/promo-banners/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/promo-banners/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file From f692cd4788ab25f1eb15c6f4c7febf1d9bddbf68 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 8 Jul 2026 15:56:19 +0200 Subject: [PATCH 30/59] Updated on 2026-08-14 --- .../promobanners/impl/campaigns/ui/ActivateCampaignFooter.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ce1d2879f4..237962daee 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 @@ -38,9 +38,9 @@ internal fun ActivateCampaignFooter(footerUM: FooterUM, modifier: Modifier = Mod textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) - } - SpacerH12() + SpacerH12() + } PrimaryButton( modifier = Modifier.fillMaxWidth(), From 3e250db77c4e63d13b4cc4e81b9eac569d534164 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Jul 2026 09:01:19 +0200 Subject: [PATCH 31/59] Updated on 2026-08-14 --- .../ActivateCampaignBottomSheetComponent.kt | 19 ++++++++++-- ...ignAlreadyActivatedBottomSheetComponent.kt | 18 +++++++++-- .../component/DefaultCampaignsComponent.kt | 31 ++++++++++--------- .../campaigns/model/ActivateCampaignsModel.kt | 15 +++------ .../model/CampaignAlreadyActivatedModel.kt | 14 ++------- 5 files changed, 55 insertions(+), 42 deletions(-) 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 7e8f99ece9..6137d3f58a 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 @@ -18,7 +18,11 @@ import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.ds2.button.Close import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel import com.tangem.features.promobanners.impl.campaigns.ui.ActivateCampaignContent import com.tangem.features.promobanners.impl.campaigns.ui.ActivateCampaignFooter @@ -26,7 +30,8 @@ import com.tangem.features.promobanners.impl.campaigns.ui.ActivateCampaignFooter internal class ActivateCampaignBottomSheetComponent( appComponentContext: AppComponentContext, chooseTokenComponentFactory: ChooseTokenComponent.Factory, - private val params: ActivateCampaignsModel.Params, + private val params: Params, + val onDismiss: () -> Unit, ) : CampaignsModularComponent, AppComponentContext by appComponentContext { private val model: ActivateCampaignsModel = getOrCreateModel(params) @@ -41,7 +46,7 @@ internal class ActivateCampaignBottomSheetComponent( TangemTopNavigation( windowInsets = WindowInsets(0), blurBackground = false, - endButton = { TangemButton.Close(onClick = params.onDismiss) }, + endButton = { TangemButton.Close(onClick = onDismiss) }, ) } @@ -77,4 +82,14 @@ internal class ActivateCampaignBottomSheetComponent( content = { chooseTokenComponent.Content(modifier = Modifier.fillMaxWidth()) }, ) } + + data class Params( + val campaignType: CampaignType, + val modelCallbacks: ActivateCampaignModelCallbacks, + ) + + interface ActivateCampaignModelCallbacks { + val onActivated: (CampaignType) -> Unit + val onAlreadyActivated: (CampaignType, AppCurrency, Account?, CryptoCurrencyStatus) -> Unit + } } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignAlreadyActivatedBottomSheetComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignAlreadyActivatedBottomSheetComponent.kt index 7a6becffa4..bbc6504059 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignAlreadyActivatedBottomSheetComponent.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignAlreadyActivatedBottomSheetComponent.kt @@ -16,13 +16,18 @@ import com.tangem.core.ui.ds2.button.Close import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.promobanners.impl.R +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType import com.tangem.features.promobanners.impl.campaigns.model.CampaignAlreadyActivatedModel import com.tangem.features.promobanners.impl.campaigns.ui.ActivateCampaignContent internal class CampaignAlreadyActivatedBottomSheetComponent( appComponentContext: AppComponentContext, - private val params: CampaignAlreadyActivatedModel.Params, + params: Params, + val onDismiss: () -> Unit, ) : CampaignsModularComponent, AppComponentContext by appComponentContext { private val model: CampaignAlreadyActivatedModel = getOrCreateModel(params) @@ -32,7 +37,7 @@ internal class CampaignAlreadyActivatedBottomSheetComponent( TangemTopNavigation( windowInsets = WindowInsets(0), blurBackground = false, - endButton = { TangemButton.Close(onClick = params.onDismiss) }, + endButton = { TangemButton.Close(onClick = onDismiss) }, ) } @@ -48,7 +53,14 @@ internal class CampaignAlreadyActivatedBottomSheetComponent( PrimaryButton( modifier = Modifier.fillMaxWidth(), text = stringResourceSafe(R.string.common_close), - onClick = params.onDismiss, + onClick = onDismiss, ) } + + data class Params( + val campaignType: CampaignType, + val account: Account?, + val appCurrency: AppCurrency, + val currency: CryptoCurrencyStatus, + ) } \ No newline at end of file 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 61ee9f8197..74c1e17c55 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 @@ -26,11 +26,14 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.extensions.rememberLastNonNull +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent import com.tangem.features.promobanners.api.swapcashback.CampaignsComponent +import com.tangem.features.promobanners.impl.campaigns.component.ActivateCampaignBottomSheetComponent.ActivateCampaignModelCallbacks +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType import com.tangem.features.promobanners.impl.campaigns.entity.CampaignsBottomSheetConfig -import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel -import com.tangem.features.promobanners.impl.campaigns.model.CampaignAlreadyActivatedModel import com.tangem.features.promobanners.impl.campaigns.model.CampaignsModel import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -121,28 +124,28 @@ internal class DefaultCampaignsComponent @AssistedInject constructor( is CampaignsBottomSheetConfig.Activate -> ActivateCampaignBottomSheetComponent( appComponentContext = context, chooseTokenComponentFactory = chooseTokenComponentFactory, - params = ActivateCampaignsModel.Params( + onDismiss = model::onDismiss, + params = ActivateCampaignBottomSheetComponent.Params( campaignType = config.campaignType, - onDismiss = model::onDismiss, - onActivated = model::onActivated, - onAlreadyActivated = { campaignType, appCurrency, account, currency -> - model.onAlreadyActivated( - campaignType = campaignType, - appCurrency = appCurrency, - account = account, - currency = currency, - ) + modelCallbacks = object : ActivateCampaignModelCallbacks { + override val onActivated: (CampaignType) -> Unit = model::onActivated + override val onAlreadyActivated: ( + CampaignType, + AppCurrency, + Account?, + CryptoCurrencyStatus, + ) -> Unit = model::onAlreadyActivated }, ), ) is CampaignsBottomSheetConfig.AlreadyActivated -> CampaignAlreadyActivatedBottomSheetComponent( appComponentContext = context, - params = CampaignAlreadyActivatedModel.Params( + onDismiss = model::onDismiss, + params = CampaignAlreadyActivatedBottomSheetComponent.Params( campaignType = config.campaignType, appCurrency = config.appCurrency, account = config.account, currency = config.currency, - onDismiss = model::onDismiss, ), ) } 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 fad53de9ca..25e8f6cb9f 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 @@ -19,8 +19,8 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult import com.tangem.features.promobanners.impl.R +import com.tangem.features.promobanners.impl.campaigns.component.ActivateCampaignBottomSheetComponent import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM -import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType import com.tangem.features.promobanners.impl.campaigns.entity.FooterUM import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM import com.tangem.features.promobanners.impl.campaigns.entity.TermsUM @@ -45,7 +45,7 @@ internal class ActivateCampaignsModel @Inject constructor( private val urlOpener: UrlOpener, ) : Model() { - private val params = paramsContainer.require() + private val params = paramsContainer.require() private val accountIconConverter = AccountIconItemStateConverter(size = AccountIconSize.ExtraSmall) private var appCurrency: AppCurrency = AppCurrency.Default private var selectedAccount: Account? = null @@ -92,10 +92,10 @@ internal class ActivateCampaignsModel @Inject constructor( // Success -> the "enrolled" sheet; "already activated" error -> hand the chosen token/account // over to the "already activated" sheet. if (enrollInCampaign()) { - params.onActivated(params.campaignType) + params.modelCallbacks.onActivated(params.campaignType) } else { selectedCurrency?.let { - params.onAlreadyActivated(params.campaignType, appCurrency, selectedAccount, it) + params.modelCallbacks.onAlreadyActivated(params.campaignType, appCurrency, selectedAccount, it) } } } @@ -179,13 +179,6 @@ internal class ActivateCampaignsModel @Inject constructor( ) } - data class Params( - val campaignType: CampaignType, - val onDismiss: () -> Unit, - val onActivated: (CampaignType) -> Unit, - val onAlreadyActivated: (CampaignType, AppCurrency, Account?, CryptoCurrencyStatus) -> Unit, - ) - private companion object { // TODO([REDACTED_TASK_KEY]): replace with the real campaign terms URL. const val CAMPAIGN_TERMS_URL = "https://tangem.com/en/" diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignAlreadyActivatedModel.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignAlreadyActivatedModel.kt index 4c25b2c515..83af0ce6ba 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignAlreadyActivatedModel.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignAlreadyActivatedModel.kt @@ -8,11 +8,9 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account -import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.promobanners.impl.campaigns.component.CampaignAlreadyActivatedBottomSheetComponent import com.tangem.features.promobanners.impl.campaigns.entity.CampaignAlreadyActivatedUM -import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow @@ -34,7 +32,7 @@ internal class CampaignAlreadyActivatedModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, ) : Model() { - private val params = paramsContainer.require() + private val params = paramsContainer.require() private val accountIconConverter = AccountIconItemStateConverter(size = AccountIconSize.ExtraSmall) val uiState: StateFlow @@ -62,12 +60,4 @@ internal class CampaignAlreadyActivatedModel @Inject constructor( selectedAccount = selectedAccountUM, ) } - - data class Params( - val campaignType: CampaignType, - val account: Account?, - val appCurrency: AppCurrency, - val currency: CryptoCurrencyStatus, - val onDismiss: () -> Unit, - ) } \ No newline at end of file From f899b889e6a7ce4ae845b2500f365da7e39ce4c3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Jul 2026 09:44:21 +0200 Subject: [PATCH 32/59] Updated on 2026-08-14 --- .../modal/TangemModalBottomSheetWithFooter.kt | 24 +++- features/promo-banners/impl/build.gradle.kts | 1 + .../ActivateCampaignBottomSheetComponent.kt | 20 ++- ...ignAlreadyActivatedBottomSheetComponent.kt | 37 +++-- .../CampaignEnrolledBottomSheetComponent.kt | 29 ++-- .../component/CampaignsModularComponent.kt | 10 -- .../component/DefaultCampaignsComponent.kt | 68 +++------ .../NotActiveCampaignBottomSheetComponent.kt | 12 +- .../DefaultCampaignsDeepLinkHandler.kt | 18 ++- .../impl/campaigns/di/CampaignsModule.kt | 6 - .../campaigns/entity/ActivateCampaignUM.kt | 3 + .../entity/CampaignAlreadyActivatedUM.kt | 10 -- .../impl/campaigns/entity/CampaignTypeExt.kt | 40 +++++- .../entity/CampaignsBottomSheetConfig.kt | 6 - .../campaigns/model/ActivateCampaignsModel.kt | 132 ++++++++++-------- .../model/CampaignAlreadyActivatedModel.kt | 63 --------- .../impl/campaigns/model/CampaignContent.kt | 12 ++ .../impl/campaigns/model/CampaignsModel.kt | 95 ++++++++----- .../campaigns/service/CampaignsService.kt | 17 ++- .../service/DefaultCampaignsService.kt | 9 +- .../campaigns/ui/ActivateCampaignContent.kt | 34 +++-- .../campaigns/ui/ActivateCampaignFooter.kt | 20 ++- .../ui/AlreadyActivatedCampaignContent.kt | 45 +----- .../ui/CampaignEnrolledMessageContent.kt | 24 +++- .../impl/campaigns/ui/CampaignPreviewData.kt | 10 +- .../ui/NotActiveCampaignMessageContent.kt | 9 +- 26 files changed, 383 insertions(+), 371 deletions(-) delete mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignsModularComponent.kt delete mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignAlreadyActivatedUM.kt delete mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignAlreadyActivatedModel.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignContent.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt index e79062c4c7..c5609bbad4 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.components.* @@ -38,6 +39,9 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.WindowInsetsZero import com.tangem.core.ui.utils.toPx +/** Default reserved height for the [TangemModalBottomSheetWithFooter] footer slot. */ +val DEFAULT_FOOTER_HEIGHT: Dp = 80.dp + /** * Modal bottom sheet with [content], [footer] and optional [title]. * @@ -50,6 +54,12 @@ inline fun TangemModalBottomSheetWi config: TangemBottomSheetConfig, containerColor: Color = TangemTheme.colors.background.primary, skipPartiallyExpanded: Boolean = true, + // FIXME([REDACTED_TASK_KEY]): temp workaround + // The footer slot reserves a fixed [DEFAULT_FOOTER_HEIGHT]; + // callers whose footer differs must pass the real height explicitly. Exposed as an opt-in + // parameter so existing usages keep the previous behavior and nothing else is affected. + // Rework so the sheet measures the actual footer height internally and drops this parameter. + footerHeight: Dp = DEFAULT_FOOTER_HEIGHT, noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit = {}, crossinline content: @Composable (T) -> Unit, @@ -65,6 +75,7 @@ inline fun TangemModalBottomSheetWi content = content, footer = footer, skipPartiallyExpanded = skipPartiallyExpanded, + footerHeight = footerHeight, ) } else { DefaultModalBottomSheetWithFooter( @@ -75,6 +86,7 @@ inline fun TangemModalBottomSheetWi footer = footer, onBack = onBack, skipPartiallyExpanded = skipPartiallyExpanded, + footerHeight = footerHeight, ) } } @@ -85,6 +97,7 @@ inline fun DefaultModalBottomSheetW config: TangemBottomSheetConfig, containerColor: Color, skipPartiallyExpanded: Boolean = true, + footerHeight: Dp = DEFAULT_FOOTER_HEIGHT, noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit, crossinline content: @Composable (T) -> Unit, @@ -117,6 +130,7 @@ inline fun DefaultModalBottomSheetW onBack = onBack, content = content, footer = footer, + footerHeight = footerHeight, ) } @@ -135,6 +149,7 @@ inline fun PreviewModalBottomSheetW config: TangemBottomSheetConfig, containerColor: Color, skipPartiallyExpanded: Boolean = true, + footerHeight: Dp = DEFAULT_FOOTER_HEIGHT, crossinline title: @Composable BoxScope.(T) -> Unit, crossinline content: @Composable (T) -> Unit, noinline footer: @Composable (BoxScope.(T) -> Unit)?, @@ -150,6 +165,7 @@ inline fun PreviewModalBottomSheetW title = title, content = content, footer = footer, + footerHeight = footerHeight, ) } @@ -161,6 +177,7 @@ inline fun BasicModalBottomSheetWit sheetState: TangemSheetState, containerColor: Color, modifier: Modifier = Modifier, + footerHeight: Dp = DEFAULT_FOOTER_HEIGHT, noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit, crossinline content: @Composable (T) -> Unit, @@ -178,11 +195,8 @@ inline fun BasicModalBottomSheetWit val isKeyboardOpen by rememberIsKeyboardVisible() val buttonHeight by animateDpAsState( - if (footer != null) { - 80.dp - } else { - 0.dp - }, + targetValue = if (footer != null) footerHeight else 0.dp, + label = "FooterHeight", ) // Offset calculation for keyboard scroll adjustment: // 1) Button height (footer) diff --git a/features/promo-banners/impl/build.gradle.kts b/features/promo-banners/impl/build.gradle.kts index 5b7f1e1e76..8d084ddf38 100644 --- a/features/promo-banners/impl/build.gradle.kts +++ b/features/promo-banners/impl/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(projects.domain.models) implementation(projects.domain.appCurrency) implementation(projects.domain.account.status) + implementation(projects.domain.promo) /** Core */ api(projects.core.configToggles) 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 6137d3f58a..c9068a7b07 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 @@ -1,12 +1,11 @@ package com.tangem.features.promobanners.impl.campaigns.component -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.State 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 @@ -14,13 +13,10 @@ import com.tangem.core.decompose.model.getOrCreateModel 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.state.BottomSheetState +import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.core.ui.ds2.button.Close import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel @@ -32,7 +28,8 @@ internal class ActivateCampaignBottomSheetComponent( chooseTokenComponentFactory: ChooseTokenComponent.Factory, private val params: Params, val onDismiss: () -> Unit, -) : CampaignsModularComponent, AppComponentContext by appComponentContext { + val onFooterExtraHeightReady: (Dp) -> Unit, +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: ActivateCampaignsModel = getOrCreateModel(params) @@ -42,7 +39,7 @@ internal class ActivateCampaignBottomSheetComponent( ) @Composable - override fun Title(bottomSheetState: State) { + override fun Title() { TangemTopNavigation( windowInsets = WindowInsets(0), blurBackground = false, @@ -51,10 +48,10 @@ internal class ActivateCampaignBottomSheetComponent( } @Composable - override fun Content(bottomSheetState: State, contentPadding: PaddingValues, modifier: Modifier) { + override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() - ActivateCampaignContent(um = state) + ActivateCampaignContent(um = state, modifier = modifier) if (state.isChoosingToken) { ChooseTokenBottomSheet(state.onChooseTokenDismiss) @@ -67,6 +64,7 @@ internal class ActivateCampaignBottomSheetComponent( ActivateCampaignFooter( footerUM = state.footerUM, + onFooterTextHeightReady = onFooterExtraHeightReady, ) } @@ -90,6 +88,6 @@ internal class ActivateCampaignBottomSheetComponent( interface ActivateCampaignModelCallbacks { val onActivated: (CampaignType) -> Unit - val onAlreadyActivated: (CampaignType, AppCurrency, Account?, CryptoCurrencyStatus) -> Unit + val onAlreadyActivated: (CampaignType) -> Unit } } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignAlreadyActivatedBottomSheetComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignAlreadyActivatedBottomSheetComponent.kt index bbc6504059..398bc5d891 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignAlreadyActivatedBottomSheetComponent.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignAlreadyActivatedBottomSheetComponent.kt @@ -1,39 +1,33 @@ package com.tangem.features.promobanners.impl.campaigns.component -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.components.PrimaryButton -import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.core.ui.ds2.button.Close import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.core.ui.extensions.wrappedList import com.tangem.features.promobanners.impl.R import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType -import com.tangem.features.promobanners.impl.campaigns.model.CampaignAlreadyActivatedModel -import com.tangem.features.promobanners.impl.campaigns.ui.ActivateCampaignContent +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignTypeToContentConverter +import com.tangem.features.promobanners.impl.campaigns.ui.AlreadyActivatedCampaignContent internal class CampaignAlreadyActivatedBottomSheetComponent( appComponentContext: AppComponentContext, params: Params, val onDismiss: () -> Unit, -) : CampaignsModularComponent, AppComponentContext by appComponentContext { +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { - private val model: CampaignAlreadyActivatedModel = getOrCreateModel(params) + private val campaignName = CampaignTypeToContentConverter().convert(params.campaignType).name @Composable - override fun Title(bottomSheetState: State) { + override fun Title() { TangemTopNavigation( windowInsets = WindowInsets(0), blurBackground = false, @@ -42,10 +36,14 @@ internal class CampaignAlreadyActivatedBottomSheetComponent( } @Composable - override fun Content(bottomSheetState: State, contentPadding: PaddingValues, modifier: Modifier) { - val state by model.uiState.collectAsStateWithLifecycle() - - ActivateCampaignContent(um = state) + override fun Content(modifier: Modifier) { + AlreadyActivatedCampaignContent( + message = resourceReference( + R.string.promo_campaign_already_activated_title, + wrappedList(campaignName), + ), + modifier = modifier, + ) } @Composable @@ -59,8 +57,5 @@ internal class CampaignAlreadyActivatedBottomSheetComponent( data class Params( val campaignType: CampaignType, - val account: Account?, - val appCurrency: AppCurrency, - val currency: CryptoCurrencyStatus, ) } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignEnrolledBottomSheetComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignEnrolledBottomSheetComponent.kt index 8853bca703..03c1c5b5e3 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignEnrolledBottomSheetComponent.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignEnrolledBottomSheetComponent.kt @@ -1,30 +1,31 @@ package com.tangem.features.promobanners.impl.campaigns.component -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.State import androidx.compose.ui.Modifier import com.tangem.core.ui.components.PrimaryButton -import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.core.ui.ds2.button.Close import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation -import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.extensions.wrappedList import com.tangem.features.promobanners.impl.R import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType -import com.tangem.features.promobanners.impl.campaigns.entity.campaignName +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignTypeToContentConverter import com.tangem.features.promobanners.impl.campaigns.ui.CampaignEnrolledMessageContent internal class CampaignEnrolledBottomSheetComponent( - private val campaignType: CampaignType, + params: Params, private val onDismissRequest: () -> Unit, -) : CampaignsModularComponent { +) : ComposableModularContentComponent { + + private val campaignName = CampaignTypeToContentConverter().convert(params.campaignType).name @Composable - override fun Title(bottomSheetState: State) { + override fun Title() { TangemTopNavigation( windowInsets = WindowInsets(0), blurBackground = false, @@ -33,9 +34,13 @@ internal class CampaignEnrolledBottomSheetComponent( } @Composable - override fun Content(bottomSheetState: State, contentPadding: PaddingValues, modifier: Modifier) { + override fun Content(modifier: Modifier) { CampaignEnrolledMessageContent( - message = stringReference("You're successfully enrolled in ${campaignType.campaignName()}"), + message = resourceReference( + R.string.promo_campaign_enroll_success_title, + wrappedList(campaignName), + ), + modifier = modifier, ) } @@ -47,4 +52,8 @@ internal class CampaignEnrolledBottomSheetComponent( onClick = { onDismissRequest() }, ) } + + data class Params( + val campaignType: CampaignType, + ) } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignsModularComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignsModularComponent.kt deleted file mode 100644 index bb2368cd7e..0000000000 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignsModularComponent.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.features.promobanners.impl.campaigns.component - -import androidx.compose.runtime.Composable -import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent - -interface CampaignsModularComponent : ComposableModularBottomSheetContentComponent { - - @Composable - fun Footer() -} \ No newline at end of file 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 74c1e17c55..15ab2f6054 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,33 +2,26 @@ package com.tangem.features.promobanners.impl.campaigns.component import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember 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.bottomsheets.LocalBottomSheetContentScrollable -import com.tangem.core.ui.components.bottomsheets.LocalTangemBottomSheetContentBottomInset import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet -import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +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.decompose.ComposableModularContentComponent import com.tangem.core.ui.extensions.rememberLastNonNull -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.core.ui.res.TangemTheme import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent import com.tangem.features.promobanners.api.swapcashback.CampaignsComponent import com.tangem.features.promobanners.impl.campaigns.component.ActivateCampaignBottomSheetComponent.ActivateCampaignModelCallbacks @@ -59,41 +52,23 @@ internal class DefaultCampaignsComponent @AssistedInject constructor( val bottomSheet by bottomSheetSlot.subscribeAsState() val activeChild = bottomSheet.child?.instance val displayedChild = rememberLastNonNull(activeChild) - val bottomSheetState = remember { mutableStateOf(BottomSheetState.EXPANDED) } + val footerExtraHeight by model.footerExtraHeightState.collectAsStateWithLifecycle() - TangemBottomSheet( + TangemModalBottomSheetWithFooter( config = TangemBottomSheetConfig( isShown = activeChild != null, onDismissRequest = model::onDismiss, content = TangemBottomSheetConfigContent.Empty, ), + containerColor = TangemTheme.colors3.bg.primary, + footerHeight = DEFAULT_FOOTER_HEIGHT + footerExtraHeight, onBack = model::onDismiss, title = { - displayedChild?.Title(bottomSheetState) + displayedChild?.Title() }, content = { - val bottomInset = LocalTangemBottomSheetContentBottomInset.current - val scrollableSignal = LocalBottomSheetContentScrollable.current - - if (scrollableSignal != null) { - LaunchedEffect(Unit) { - scrollableSignal.value = false - } - DisposableEffect(scrollableSignal) { - onDispose { scrollableSignal.value = true } - } - } - - Box( - modifier = Modifier - .padding(bottom = bottomInset) - .animateContentSize(), - ) { - displayedChild?.Content( - bottomSheetState = bottomSheetState, - contentPadding = PaddingValues(), - modifier = Modifier, - ) + Box(modifier = Modifier.animateContentSize()) { + displayedChild?.Content(modifier = Modifier) } }, footer = { @@ -111,42 +86,37 @@ internal class DefaultCampaignsComponent @AssistedInject constructor( private fun bottomSheetChild( config: CampaignsBottomSheetConfig, componentContext: ComponentContext, - ): CampaignsModularComponent { + ): ComposableModularContentComponent { val context = childByContext(componentContext) return when (config) { CampaignsBottomSheetConfig.NotActive -> NotActiveCampaignBottomSheetComponent( onDismissRequest = model::onDismiss, ) is CampaignsBottomSheetConfig.Enrolled -> CampaignEnrolledBottomSheetComponent( - campaignType = config.campaignType, + params = CampaignEnrolledBottomSheetComponent.Params( + campaignType = config.campaignType, + ), onDismissRequest = model::onDismiss, ) is CampaignsBottomSheetConfig.Activate -> ActivateCampaignBottomSheetComponent( appComponentContext = context, chooseTokenComponentFactory = chooseTokenComponentFactory, onDismiss = model::onDismiss, + onFooterExtraHeightReady = model::onFooterExtraHeightReady, params = ActivateCampaignBottomSheetComponent.Params( campaignType = config.campaignType, modelCallbacks = object : ActivateCampaignModelCallbacks { override val onActivated: (CampaignType) -> Unit = model::onActivated - override val onAlreadyActivated: ( - CampaignType, - AppCurrency, - Account?, - CryptoCurrencyStatus, - ) -> Unit = model::onAlreadyActivated + override val onAlreadyActivated: (CampaignType) -> Unit = model::onAlreadyActivated }, ), ) is CampaignsBottomSheetConfig.AlreadyActivated -> CampaignAlreadyActivatedBottomSheetComponent( appComponentContext = context, - onDismiss = model::onDismiss, params = CampaignAlreadyActivatedBottomSheetComponent.Params( campaignType = config.campaignType, - appCurrency = config.appCurrency, - account = config.account, - currency = config.currency, ), + onDismiss = model::onDismiss, ) } } diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/NotActiveCampaignBottomSheetComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/NotActiveCampaignBottomSheetComponent.kt index 05dd624b48..8e90b9da88 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/NotActiveCampaignBottomSheetComponent.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/NotActiveCampaignBottomSheetComponent.kt @@ -1,13 +1,11 @@ package com.tangem.features.promobanners.impl.campaigns.component -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.State import androidx.compose.ui.Modifier import com.tangem.core.ui.components.PrimaryButton -import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.core.ui.ds2.button.Close import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation @@ -17,10 +15,10 @@ import com.tangem.features.promobanners.impl.campaigns.ui.NotActiveCampaignMessa internal class NotActiveCampaignBottomSheetComponent( private val onDismissRequest: () -> Unit, -) : CampaignsModularComponent { +) : ComposableModularContentComponent { @Composable - override fun Title(bottomSheetState: State) { + override fun Title() { TangemTopNavigation( windowInsets = WindowInsets(0), blurBackground = false, @@ -29,8 +27,8 @@ internal class NotActiveCampaignBottomSheetComponent( } @Composable - override fun Content(bottomSheetState: State, contentPadding: PaddingValues, modifier: Modifier) { - NotActiveCampaignMessageContent() + override fun Content(modifier: Modifier) { + NotActiveCampaignMessageContent(modifier = modifier) } @Composable diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/deeplink/DefaultCampaignsDeepLinkHandler.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/deeplink/DefaultCampaignsDeepLinkHandler.kt index 4d9c7cdac9..1a6fb4a0bc 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/deeplink/DefaultCampaignsDeepLinkHandler.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/deeplink/DefaultCampaignsDeepLinkHandler.kt @@ -1,6 +1,7 @@ package com.tangem.features.promobanners.impl.campaigns.deeplink import com.tangem.common.routing.deeplink.DeeplinkConst +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.features.promobanners.api.deeplink.CampaignsDeepLinkHandler import com.tangem.features.promobanners.api.toggles.PromoBannersFeatureToggles import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService @@ -18,13 +19,24 @@ import dagger.assisted.AssistedInject internal class DefaultCampaignsDeepLinkHandler @AssistedInject constructor( @Assisted private val queryParams: Map, campaignsService: CampaignsService, - private val promoBannersFeatureToggles: PromoBannersFeatureToggles, + getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + promoBannersFeatureToggles: PromoBannersFeatureToggles, ) : CampaignsDeepLinkHandler { init { if (promoBannersFeatureToggles.isCampaignsToggleEnabled) { - val campaignId = queryParams[DeeplinkConst.CAMPAIGN_ID_KEY].orEmpty() - campaignsService.show(campaignId) + // It is okay here, we are navigating from outside, and there is no other way to getting UserWallet + getSelectedWalletSyncUseCase().fold( + ifLeft = { + TangemLogger.e("Error on getting user wallet") + }, + ifRight = { userWallet -> + val campaignId = queryParams[DeeplinkConst.CAMPAIGN_ID_KEY].orEmpty() + val userWalletId = userWallet.walletId + + campaignsService.show(campaignId = campaignId, userWalletId = userWalletId) + }, + ) } else { TangemLogger.i("Campaigns feature is disabled") } diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt index 981656f1da..a3ad2a34b4 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt @@ -7,7 +7,6 @@ import com.tangem.features.promobanners.api.swapcashback.CampaignsComponent import com.tangem.features.promobanners.impl.campaigns.component.DefaultCampaignsComponent import com.tangem.features.promobanners.impl.campaigns.deeplink.DefaultCampaignsDeepLinkHandler import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel -import com.tangem.features.promobanners.impl.campaigns.model.CampaignAlreadyActivatedModel import com.tangem.features.promobanners.impl.campaigns.model.CampaignsModel import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService import com.tangem.features.promobanners.impl.campaigns.service.DefaultCampaignsService @@ -51,9 +50,4 @@ internal interface CampaignsModelModule { @IntoMap @ClassKey(ActivateCampaignsModel::class) fun bindCampaignActivateModel(model: ActivateCampaignsModel): Model - - @Binds - @IntoMap - @ClassKey(CampaignAlreadyActivatedModel::class) - fun bindCampaignAlreadyActivatedModel(model: CampaignAlreadyActivatedModel): Model } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/ActivateCampaignUM.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/ActivateCampaignUM.kt index 4481ece80f..458103a638 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/ActivateCampaignUM.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/ActivateCampaignUM.kt @@ -3,6 +3,7 @@ package com.tangem.features.promobanners.impl.campaigns.entity import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.TextReference /** @@ -19,6 +20,7 @@ import com.tangem.core.ui.extensions.TextReference @Immutable internal data class ActivateCampaignUM( + val logo: TangemIconUM, val title: TextReference, val description: TextReference, val selectedToken: TokenItemState?, @@ -27,6 +29,7 @@ internal data class ActivateCampaignUM( val footerUM: FooterUM, val onChooseTokenDismiss: () -> Unit, val onLearnMoreClick: () -> Unit, + val onChooseTokenClick: () -> Unit, ) @Immutable diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignAlreadyActivatedUM.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignAlreadyActivatedUM.kt deleted file mode 100644 index eff722c71a..0000000000 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignAlreadyActivatedUM.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.features.promobanners.impl.campaigns.entity - -import androidx.compose.runtime.Immutable -import com.tangem.core.ui.components.token.state.TokenItemState - -@Immutable -internal data class CampaignAlreadyActivatedUM( - val selectedToken: TokenItemState, - val selectedAccount: SelectedAccountUM?, -) \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignTypeExt.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignTypeExt.kt index 3c202361c9..607977d1a3 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignTypeExt.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignTypeExt.kt @@ -1,7 +1,39 @@ package com.tangem.features.promobanners.impl.campaigns.entity -internal fun CampaignType.campaignName(): String = when (this) { - // TODO([REDACTED_TASK_KEY]): source real campaign display names. - is CampaignType.ReactivationCashback -> "Reactivation Cashback" - is CampaignType.WhaleSwapCashback -> "Whale Swap Cashback" +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.promo.models.PromoCampaignId +import com.tangem.features.promobanners.impl.R +import com.tangem.features.promobanners.impl.campaigns.model.CampaignContent +import com.tangem.utils.converter.Converter + +internal class CampaignTypeToContentConverter : Converter { + + override fun convert(value: CampaignType): CampaignContent = when (value) { + is CampaignType.ReactivationCashback -> CampaignContent( + name = "Summer Swap Cashback", + logo = TangemIconUM.Url( + url = "https://s3.dualstack.eu-central-1.amazonaws.com/tangem.api/stories/Reactivation_Cashback.webp", + fallbackRes = R.drawable.ic_alert_24, + ), + description = resourceReference(R.string.promo_campaign_reactivation_summary_description), + termsUrl = "https://tangem.com/docs/en/summer-swap-cashback-terms.pdf", + learnMoreUrl = "https://tangem.com/en/blog/post/summer-swap", + ) + is CampaignType.WhaleSwapCashback -> CampaignContent( + name = "Whale Swap Cashback", + logo = TangemIconUM.Url( + url = "https://s3.dualstack.eu-central-1.amazonaws.com/tangem.api/stories/Whale_Swap_Cashback.webp", + fallbackRes = R.drawable.ic_alert_24, + ), + description = resourceReference(R.string.promo_campaign_whale_swap_summary_description), + termsUrl = "https://tangem.com/docs/en/whale-swap-cashback-terms.pdf", + learnMoreUrl = "https://tangem.com/en/blog/post/whale-swap", + ) + } +} + +internal fun CampaignType.toPromoCampaignId(): PromoCampaignId = when (this) { + is CampaignType.ReactivationCashback -> PromoCampaignId.ReactivationCashback + is CampaignType.WhaleSwapCashback -> PromoCampaignId.WhaleSwapCashback } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt index 8970c31429..3bc363ef98 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt @@ -1,8 +1,5 @@ package com.tangem.features.promobanners.impl.campaigns.entity -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.currency.CryptoCurrencyStatus import kotlinx.serialization.Serializable @Serializable @@ -24,8 +21,5 @@ internal sealed class CampaignsBottomSheetConfig { @Serializable data class AlreadyActivated( val campaignType: CampaignType, - val appCurrency: AppCurrency, - val account: Account?, - val currency: CryptoCurrencyStatus, ) : CampaignsBottomSheetConfig() } \ No newline at end of file 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 25e8f6cb9f..a4be2f2a3b 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 @@ -3,29 +3,40 @@ package com.tangem.features.promobanners.impl.campaigns.model import com.tangem.common.ui.account.AccountIconItemStateConverter import com.tangem.common.ui.account.toUM import com.tangem.common.ui.tokens.TokenItemStateConverter +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.url.UrlOpener import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.promo.models.EnrollResult +import com.tangem.domain.promo.models.PromoCampaignId +import com.tangem.domain.promo.models.TokenReward +import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult import com.tangem.features.promobanners.impl.R import com.tangem.features.promobanners.impl.campaigns.component.ActivateCampaignBottomSheetComponent import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignTypeToContentConverter import com.tangem.features.promobanners.impl.campaigns.entity.FooterUM import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM import com.tangem.features.promobanners.impl.campaigns.entity.TermsUM -import com.tangem.features.promobanners.impl.campaigns.entity.campaignName +import com.tangem.features.promobanners.impl.campaigns.entity.toPromoCampaignId 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 @@ -35,6 +46,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("LongParameterList") @ModelScoped internal class ActivateCampaignsModel @Inject constructor( paramsContainer: ParamsContainer, @@ -42,17 +54,20 @@ internal class ActivateCampaignsModel @Inject constructor( chooseTokenBridgeFactory: ChooseTokenBridge.Factory, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase, private val urlOpener: UrlOpener, + @GlobalUiMessageSender private val messageSender: UiMessageSender, ) : Model() { private val params = paramsContainer.require() + private val campaignType = params.campaignType private val accountIconConverter = AccountIconItemStateConverter(size = AccountIconSize.ExtraSmall) private var appCurrency: AppCurrency = AppCurrency.Default - private var selectedAccount: Account? = null - private var selectedCurrency: CryptoCurrencyStatus? = null + private val campaignId: PromoCampaignId = params.campaignType.toPromoCampaignId() + private val campaignContent = CampaignTypeToContentConverter().convert(campaignType) val uiState: StateFlow - field = MutableStateFlow(getInitialState()) + field = MutableStateFlow(buildInitialModel()) val bridge: ChooseTokenBridge = chooseTokenBridgeFactory.create( modelScope = modelScope, @@ -78,7 +93,26 @@ internal class ActivateCampaignsModel @Inject constructor( .launchIn(modelScope) } - private fun onSelectTokenClick() { + private fun buildInitialModel(): ActivateCampaignUM = ActivateCampaignUM( + logo = campaignContent.logo, + title = resourceReference( + R.string.promo_campaign_summary_title, + wrappedList(campaignContent.name), + ), + description = campaignContent.description, + selectedToken = null, + selectedAccount = null, + isChoosingToken = false, + footerUM = FooterUM( + label = resourceReference(R.string.promo_campaign_select_token), + onPrimaryButtonClick = ::onChooseTokenClick, + ), + onChooseTokenDismiss = ::onChooseTokenDismiss, + onLearnMoreClick = ::onLearnMoreClick, + onChooseTokenClick = ::onChooseTokenClick, + ) + + private fun onChooseTokenClick() { uiState.update { it.copy(isChoosingToken = true) } } @@ -86,42 +120,45 @@ internal class ActivateCampaignsModel @Inject constructor( uiState.update { it.copy(isChoosingToken = false) } } - private fun onEnrollClick() { + private fun onEnrollClick(selectedWalletId: UserWalletId, selectedCurrencyStatus: CryptoCurrencyStatus) { + val token = selectedCurrencyStatus.currency as? CryptoCurrency.Token ?: return + modelScope.launch { - // TODO([REDACTED_TASK_KEY]): call the real campaign enrollment use case; its result decides the next sheet. - // Success -> the "enrolled" sheet; "already activated" error -> hand the chosen token/account - // over to the "already activated" sheet. - if (enrollInCampaign()) { - params.modelCallbacks.onActivated(params.campaignType) - } else { - selectedCurrency?.let { - params.modelCallbacks.onAlreadyActivated(params.campaignType, appCurrency, selectedAccount, it) - } + enrollPromoCampaignUseCase.invoke( + campaign = campaignId, + tokenReward = TokenReward( + tokenAddress = token.contractAddress, + networkId = token.network.rawId, + ), + walletIds = listOf(selectedWalletId), + ).onLeft { error -> + TangemLogger.e("Error enrolling campaign ${campaignType.campaignId}", error) + messageSender.send(SnackbarMessage(message = resourceReference(R.string.common_unknown_error))) + }.onRight { + handleEnrollResponse(it) } } } - @Suppress("FunctionOnlyReturningConstant") // TODO([REDACTED_TASK_KEY]): stub until the real enrollment use case exists. - private fun enrollInCampaign(): Boolean { - // TODO([REDACTED_TASK_KEY]): replace with the real enrollment use case call; `true` = enrolled, `false` = already active. - return true + private fun handleEnrollResponse(enrollResult: EnrollResult) { + when (enrollResult) { + is EnrollResult.AlreadyEnrolled -> params.modelCallbacks.onAlreadyActivated(campaignType) + is EnrollResult.Success -> params.modelCallbacks.onActivated(campaignType) + } } private fun onTermsClick() { - urlOpener.openUrl(CAMPAIGN_TERMS_URL) + urlOpener.openUrl(campaignContent.termsUrl) } private fun onLearnMoreClick() { - urlOpener.openUrl(CAMPAIGN_TERMS_URL) + urlOpener.openUrl(campaignContent.learnMoreUrl) } private fun onTokenChosen(result: ChooseTokenResult) { modelScope.launch { val selectedAccountUM = if (isAccountsModeEnabledUseCase.invokeSync()) { - val account = result.account.account - selectedAccount = account - - when (account) { + when (val account = result.account.account) { is Account.CryptoPortfolio -> SelectedAccountUM( iconState = accountIconConverter.convert(account), name = account.accountName.toUM().value, @@ -136,7 +173,6 @@ internal class ActivateCampaignsModel @Inject constructor( null } - selectedCurrency = result.currency val tokenItem = TokenItemStateConverter(appCurrency = appCurrency).convert(result.currency) uiState.update { state -> @@ -145,12 +181,19 @@ internal class ActivateCampaignsModel @Inject constructor( selectedToken = tokenItem, selectedAccount = selectedAccountUM, footerUM = FooterUM( - label = stringReference("Enroll"), - onPrimaryButtonClick = ::onEnrollClick, + label = resourceReference(R.string.promo_campaign_enroll), + onPrimaryButtonClick = { + onEnrollClick( + selectedWalletId = result.walletId, + selectedCurrencyStatus = result.currency, + ) + }, terms = TermsUM( - // TODO([REDACTED_TASK_KEY]): localize - text = stringReference("I agree with"), - linkText = stringReference("${params.campaignType.campaignName()} Terms"), + text = resourceReference(R.string.promo_campaign_terms_agreement_android), + linkText = resourceReference( + R.string.promo_campaign_terms_link, + wrappedList(campaignContent.name), + ), onTermsClick = ::onTermsClick, ), ), @@ -158,29 +201,4 @@ internal class ActivateCampaignsModel @Inject constructor( } } } - - private fun getInitialState(): ActivateCampaignUM { - return ActivateCampaignUM( - // TODO([REDACTED_TASK_KEY]): source real campaign copy. - title = stringReference("Enroll in ${params.campaignType.campaignName()}"), - description = stringReference( - "Earn cashback on every swap from \$10K until the end of July.\n\n" + - "Rates step up with size: 0.10% from \$10K, 0.20% from \$20K, 0.50% from \$100K.", - ), - selectedToken = null, - selectedAccount = null, - isChoosingToken = false, - footerUM = FooterUM( - label = stringReference("Select token"), - onPrimaryButtonClick = ::onSelectTokenClick, - ), - onChooseTokenDismiss = ::onChooseTokenDismiss, - onLearnMoreClick = ::onLearnMoreClick, - ) - } - - private companion object { - // TODO([REDACTED_TASK_KEY]): replace with the real campaign terms URL. - const val CAMPAIGN_TERMS_URL = "https://tangem.com/en/" - } } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignAlreadyActivatedModel.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignAlreadyActivatedModel.kt deleted file mode 100644 index 83af0ce6ba..0000000000 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignAlreadyActivatedModel.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.tangem.features.promobanners.impl.campaigns.model - -import com.tangem.common.ui.account.AccountIconItemStateConverter -import com.tangem.common.ui.account.toUM -import com.tangem.common.ui.tokens.TokenItemStateConverter -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.ui.components.account.AccountIconSize -import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.domain.models.account.Account -import com.tangem.features.promobanners.impl.campaigns.component.CampaignAlreadyActivatedBottomSheetComponent -import com.tangem.features.promobanners.impl.campaigns.entity.CampaignAlreadyActivatedUM -import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import javax.inject.Inject - -/** - * Thin model for the "campaign already activated" bottom sheet. The screen is read-only: it just renders - * the token/account the cashback is paid out to. - * - * The state is the selection the user made in the activate flow, handed over via [Params] when enrollment - * reports the campaign is already active. That selection is in-memory only (it carries non-serializable UI - * state), so after process death it is `null` and the model assembles a placeholder state instead - * (see [REDACTED_TASK_KEY]). - */ -@ModelScoped -internal class CampaignAlreadyActivatedModel @Inject constructor( - paramsContainer: ParamsContainer, - override val dispatchers: CoroutineDispatcherProvider, -) : Model() { - - private val params = paramsContainer.require() - private val accountIconConverter = AccountIconItemStateConverter(size = AccountIconSize.ExtraSmall) - - val uiState: StateFlow - field = MutableStateFlow(buildState()) - - private fun buildState(): CampaignAlreadyActivatedUM { - val selectedAccountUM = params.account?.let { account -> - when (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 - } - } - - val tokenItem = TokenItemStateConverter(appCurrency = params.appCurrency).convert(params.currency) - - return CampaignAlreadyActivatedUM( - selectedToken = tokenItem, - selectedAccount = selectedAccountUM, - ) - } -} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignContent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignContent.kt new file mode 100644 index 0000000000..792aced5e2 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignContent.kt @@ -0,0 +1,12 @@ +package com.tangem.features.promobanners.impl.campaigns.model + +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.TextReference + +internal data class CampaignContent( + val name: String, + val logo: TangemIconUM, + val description: TextReference, + val termsUrl: String, + val learnMoreUrl: String, +) \ No newline at end of file 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 36c5a3f459..9bef5d07e1 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,20 +1,32 @@ 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 +import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.promo.models.PromoCampaignState +import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase +import com.tangem.features.promobanners.impl.R import com.tangem.features.promobanners.impl.campaigns.converters.CampaignIdConverter import com.tangem.features.promobanners.impl.campaigns.entity.CampaignsBottomSheetConfig import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +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 import javax.inject.Inject @ModelScoped @@ -22,35 +34,62 @@ internal class CampaignsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val campaignIdConverter: CampaignIdConverter, campaignsService: CampaignsService, + private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase, + @GlobalUiMessageSender private val messageSender: UiMessageSender, ) : Model() { val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val footerExtraHeightState: StateFlow + field = MutableStateFlow(0.dp) + init { campaignsService.campaignFlow - .onEach { campaignId -> resolveStartNavigation(campaignIdConverter.convert(campaignId)) } + .onEach { request -> + resolveStartNavigation( + campaignType = campaignIdConverter.convert(request.campaignId), + userWalletId = request.userWalletId, + ) + } .launchIn(modelScope) } - @Suppress("UnusedPrivateMember") - private fun resolveStartNavigation(campaignType: CampaignType?) { - val config = when (campaignType) { - is CampaignType.ReactivationCashback -> checkReactivationCashbackCampaignState(campaignType) - is CampaignType.WhaleSwapCashback -> checkWhaleSwapCashbackCampaignState(campaignType) - null -> CampaignsBottomSheetConfig.NotActive + private fun resolveStartNavigation(campaignType: CampaignType?, userWalletId: UserWalletId) { + modelScope.launch { + val config = if (campaignType == null) { + CampaignsBottomSheetConfig.NotActive + } else { + checkCampaignState(campaignType, userWalletId) + } + + config?.let { bottomSheetNavigation.activate(it) } } - - bottomSheetNavigation.activate(config) } - // TODO - private fun checkReactivationCashbackCampaignState(campaignType: CampaignType): CampaignsBottomSheetConfig { - return CampaignsBottomSheetConfig.Activate(campaignType) - } + private suspend fun checkCampaignState( + campaignType: CampaignType, + userWalletId: UserWalletId, + ): CampaignsBottomSheetConfig? = getPromoCampaignStateUseCase.invoke( + campaign = campaignType.toPromoCampaignId(), + userWalletId = userWalletId, + ).fold( + ifLeft = { error -> + TangemLogger.e("Error getting campaign ${campaignType.campaignId} state", error) + messageSender.send(SnackbarMessage(message = resourceReference(R.string.common_unknown_error))) + null + }, + ifRight = { campaignState -> + when (campaignState) { + is PromoCampaignState.Enrolled, + is PromoCampaignState.Available, + -> CampaignsBottomSheetConfig.Activate(campaignType) + is PromoCampaignState.NotActive -> CampaignsBottomSheetConfig.NotActive + } + }, + ) - // TODO - private fun checkWhaleSwapCashbackCampaignState(campaignType: CampaignType): CampaignsBottomSheetConfig { - return CampaignsBottomSheetConfig.Activate(campaignType) + fun onFooterExtraHeightReady(height: Dp) { + footerExtraHeightState.value = height } fun onDismiss() { @@ -58,22 +97,12 @@ internal class CampaignsModel @Inject constructor( } fun onActivated(campaignType: CampaignType) { + footerExtraHeightState.value = 0.dp bottomSheetNavigation.activate(CampaignsBottomSheetConfig.Enrolled(campaignType)) } - fun onAlreadyActivated( - campaignType: CampaignType, - appCurrency: AppCurrency, - account: Account?, - currency: CryptoCurrencyStatus, - ) { - bottomSheetNavigation.activate( - CampaignsBottomSheetConfig.AlreadyActivated( - campaignType = campaignType, - appCurrency = appCurrency, - account = account, - currency = currency, - ), - ) + fun onAlreadyActivated(campaignType: CampaignType) { + 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/service/CampaignsService.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/service/CampaignsService.kt index a05d5ccbb7..eaa186f620 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/service/CampaignsService.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/service/CampaignsService.kt @@ -1,5 +1,6 @@ package com.tangem.features.promobanners.impl.campaigns.service +import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.flow.Flow /** @@ -9,9 +10,15 @@ import kotlinx.coroutines.flow.Flow */ internal interface CampaignsService { - /** Emits the campaignId requested via [show]. */ - val campaignFlow: Flow + /** Emits the campaign requested via [show]. */ + val campaignFlow: Flow - /** Requests showing the campaign identified by [campaignId]. */ - fun show(campaignId: String) -} \ No newline at end of file + /** Requests showing the campaign identified by [campaignId] for the given [userWalletId]. */ + fun show(campaignId: String, userWalletId: UserWalletId) +} + +/** Payload of the campaigns bus: the campaign id and the wallet the campaign should be activated for. */ +internal data class CampaignRequest( + val campaignId: String, + val userWalletId: UserWalletId, +) \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/service/DefaultCampaignsService.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/service/DefaultCampaignsService.kt index cc51f81c6f..fda538fd32 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/service/DefaultCampaignsService.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/service/DefaultCampaignsService.kt @@ -1,5 +1,6 @@ package com.tangem.features.promobanners.impl.campaigns.service +import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.receiveAsFlow @@ -9,10 +10,10 @@ import javax.inject.Singleton @Singleton internal class DefaultCampaignsService @Inject constructor() : CampaignsService { - private val _campaignFlow: Channel = Channel(Channel.BUFFERED) - override val campaignFlow: Flow = _campaignFlow.receiveAsFlow() + private val _campaignFlow: Channel = Channel(Channel.BUFFERED) + override val campaignFlow: Flow = _campaignFlow.receiveAsFlow() - override fun show(campaignId: String) { - _campaignFlow.trySend(campaignId) + override fun show(campaignId: String, userWalletId: UserWalletId) { + _campaignFlow.trySend(CampaignRequest(campaignId = campaignId, userWalletId = userWalletId)) } } \ 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 7c9337d187..80472880e0 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 @@ -1,7 +1,6 @@ package com.tangem.features.promobanners.impl.campaigns.ui import android.content.res.Configuration -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -12,7 +11,6 @@ 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.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -21,7 +19,9 @@ import com.tangem.core.ui.components.SpacerH24 import com.tangem.core.ui.components.SpacerH32 import com.tangem.core.ui.components.SpacerH8 import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.ds.image.TangemIcon import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.features.promobanners.impl.R @@ -29,21 +29,20 @@ import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM @Composable -internal fun ActivateCampaignContent(um: ActivateCampaignUM) { +internal fun ActivateCampaignContent(um: ActivateCampaignUM, modifier: Modifier = Modifier) { Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Image( - // TODO([REDACTED_TASK_KEY]): replace placeholder with the real campaign illustration. - painter = painterResource(R.drawable.ill_businessman_3d), - contentDescription = null, + TangemIcon( + tangemIconUM = um.logo, modifier = Modifier .size(80.dp) .clip(CircleShape), ) + SpacerH32() Text( @@ -66,18 +65,18 @@ internal fun ActivateCampaignContent(um: ActivateCampaignUM) { SpacerH12() Text( - // TODO([REDACTED_TASK_KEY]): localize - text = "Learn more", + text = stringResourceSafe(R.string.common_learn_more), style = TangemTheme.typography3.caption.medium, color = TangemTheme.colors3.text.primary, modifier = Modifier - .fillMaxWidth() + .align(Alignment.Start) .clickable(onClick = um.onLearnMoreClick), ) SelectedTokenContent( selectedToken = um.selectedToken, selectedAccount = um.selectedAccount, + onChooseTokenClick = um.onChooseTokenClick, ) SpacerH32() @@ -85,12 +84,16 @@ internal fun ActivateCampaignContent(um: ActivateCampaignUM) { } @Composable -private fun SelectedTokenContent(selectedToken: TokenItemState?, selectedAccount: SelectedAccountUM?) { +private fun SelectedTokenContent( + selectedToken: TokenItemState?, + selectedAccount: SelectedAccountUM?, + onChooseTokenClick: () -> Unit, +) { if (selectedToken != null) { SpacerH24() Text( - text = "Select cashback account", // TODO([REDACTED_TASK_KEY]): localize + text = stringResourceSafe(R.string.promo_campaign_select_cashback_account), style = TangemTheme.typography.subtitle1, color = TangemTheme.colors.text.primary1, modifier = Modifier.fillMaxWidth(), @@ -102,7 +105,10 @@ private fun SelectedTokenContent(selectedToken: TokenItemState?, selectedAccount modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(24.dp)) - .background(TangemTheme.colors.background.primary), + .background(TangemTheme.colors3.bg.tertiary) + .clickable { + onChooseTokenClick.invoke() + }, selectedToken = selectedToken, selectedAccount = selectedAccount, ) 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 237962daee..92f9e7688d 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,6 +8,8 @@ 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 @@ -16,6 +18,7 @@ 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 @@ -26,17 +29,28 @@ import com.tangem.features.promobanners.impl.campaigns.entity.FooterUM import com.tangem.features.promobanners.impl.campaigns.entity.TermsUM @Composable -internal fun ActivateCampaignFooter(footerUM: FooterUM, modifier: Modifier = Modifier) { +internal fun ActivateCampaignFooter( + footerUM: FooterUM, + onFooterTextHeightReady: (Dp) -> Unit, + 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, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .onSizeChanged { + val termsBlockHeight = with(density) { it.height.toDp() } + 12.dp + onFooterTextHeightReady.invoke(termsBlockHeight) + }, ) SpacerH12() @@ -85,6 +99,7 @@ private fun Preview_ActivateCampaignFooter_WithTerms() { modifier = Modifier .background(TangemTheme.colors3.bg.primary) .padding(16.dp), + onFooterTextHeightReady = {}, ) } } @@ -99,6 +114,7 @@ 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 fa2367418d..d654226708 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 @@ -13,21 +13,19 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.SpacerH12 -import com.tangem.core.ui.components.SpacerH24 import com.tangem.core.ui.components.SpacerH32 -import com.tangem.core.ui.components.token.state.TokenItemState +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.features.promobanners.impl.campaigns.entity.CampaignAlreadyActivatedUM -import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM @Composable -internal fun ActivateCampaignContent(um: CampaignAlreadyActivatedUM) { +internal fun AlreadyActivatedCampaignContent(message: TextReference, modifier: Modifier = Modifier) { Column( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { Box( @@ -48,7 +46,7 @@ internal fun ActivateCampaignContent(um: CampaignAlreadyActivatedUM) { SpacerH32() Text( - text = "You’re already enrolled in Whale Swap Cashback", // TODO localization + text = message.resolveReference(), style = TangemTheme.typography3.heading.small, color = TangemTheme.colors3.text.primary, textAlign = TextAlign.Center, @@ -57,39 +55,10 @@ internal fun ActivateCampaignContent(um: CampaignAlreadyActivatedUM) { .padding(horizontal = 16.dp), ) - SpacerH12() - - SelectedTokenContent( - selectedToken = um.selectedToken, - selectedAccount = um.selectedAccount, - ) - SpacerH32() } } -@Composable -private fun SelectedTokenContent(selectedToken: TokenItemState, selectedAccount: SelectedAccountUM?) { - SpacerH24() - - Text( - text = "Eligible cashback will be distributed to:", // TODO([REDACTED_TASK_KEY]): localize - style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.primary1, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) - - SpacerH12() - - PromoCampaignTokenItem( - modifier = Modifier.fillMaxWidth(), - selectedToken = selectedToken, - selectedAccount = selectedAccount, - ) -} - // region Preview @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @@ -97,7 +66,7 @@ private fun SelectedTokenContent(selectedToken: TokenItemState, selectedAccount: private fun Preview_AlreadyActivatedCampaignContent() { TangemThemePreviewRedesign { Box(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) { - ActivateCampaignContent(um = CampaignPreviewData.alreadyActivated) + AlreadyActivatedCampaignContent(message = stringReference("You’re already enrolled in Whale Swap Cashback")) } } } 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 ef064a4a4d..c55a819bbf 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 @@ -1,5 +1,6 @@ package com.tangem.features.promobanners.impl.campaigns.ui +import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,12 +15,14 @@ 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.unit.dp -import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH32 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_success_24 @@ -56,6 +59,21 @@ fun CampaignEnrolledMessageContent(message: TextReference, modifier: Modifier = modifier = Modifier.fillMaxWidth(), ) - SpacerH(48.dp) + SpacerH32() } -} \ No newline at end of file +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_CampaignEnrolledMessageContent() { + TangemThemePreviewRedesign { + Box(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) { + CampaignEnrolledMessageContent( + message = stringReference("You’re successfully enrolled in Enroll in Whale Swap Cashback"), + ) + } + } +} +// endregion \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/CampaignPreviewData.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/CampaignPreviewData.kt index 0aa7391b16..6589fd0136 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/CampaignPreviewData.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/CampaignPreviewData.kt @@ -3,9 +3,10 @@ package com.tangem.features.promobanners.impl.campaigns.ui import androidx.compose.ui.graphics.Color import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.promobanners.impl.R import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM -import com.tangem.features.promobanners.impl.campaigns.entity.CampaignAlreadyActivatedUM import com.tangem.features.promobanners.impl.campaigns.entity.FooterUM import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM import com.tangem.features.promobanners.impl.campaigns.entity.TermsUM @@ -51,6 +52,7 @@ internal object CampaignPreviewData { ) val activateCampaign: ActivateCampaignUM = ActivateCampaignUM( + logo = TangemIconUM.Icon(R.drawable.ic_alert_24), title = stringReference("Whale Swap Cashback"), description = stringReference( "Get cashback on every swap. Pick a token and the account where your rewards will be paid out.", @@ -61,10 +63,6 @@ internal object CampaignPreviewData { footerUM = footer, onChooseTokenDismiss = {}, onLearnMoreClick = {}, - ) - - val alreadyActivated: CampaignAlreadyActivatedUM = CampaignAlreadyActivatedUM( - selectedToken = tokenItem, - selectedAccount = selectedAccount, + onChooseTokenClick = {}, ) } \ No newline at end of file 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 9557228a22..9a30f5d86d 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 @@ -15,9 +15,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.SpacerH +import com.tangem.features.promobanners.impl.R import com.tangem.core.ui.components.SpacerH32 import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.extensions.stringResourceSafe 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_warning_24 @@ -48,7 +49,7 @@ fun NotActiveCampaignMessageContent(modifier: Modifier = Modifier) { SpacerH32() Text( - text = " Campaign not active", // TODO localization + text = stringResourceSafe(R.string.promo_campaign_not_active_title), style = TangemTheme.typography3.heading.small, color = TangemTheme.colors3.text.primary, textAlign = TextAlign.Center, @@ -58,13 +59,13 @@ fun NotActiveCampaignMessageContent(modifier: Modifier = Modifier) { SpacerH8() Text( - text = "This campaign no longer exists or has expired", // TODO localization + text = stringResourceSafe(R.string.promo_campaign_not_active_subtitle), style = TangemTheme.typography3.subheading.medium, color = TangemTheme.colors3.text.secondary, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) - SpacerH(48.dp) + SpacerH32() } } \ No newline at end of file From c0092847b7c0deca9880717d51741f2167c6460a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Jul 2026 17:10:31 +0200 Subject: [PATCH 33/59] Updated on 2026-08-14 --- features/promo-banners/impl/build.gradle.kts | 5 +- .../analytics/PromoCampaignsAnalyticsEvent.kt | 36 +++ .../campaigns/model/ActivateCampaignsModel.kt | 28 +- .../impl/campaigns/model/CampaignsModel.kt | 4 + .../PromoCampaignsAnalyticsEventTest.kt | 90 ++++++ .../converters/CampaignIdConverterTest.kt | 38 +++ .../model/ActivateCampaignsModelTest.kt | 266 ++++++++++++++++++ .../campaigns/model/CampaignsModelTest.kt | 197 +++++++++++++ 8 files changed, 653 insertions(+), 11 deletions(-) create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/analytics/PromoCampaignsAnalyticsEvent.kt create mode 100644 features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/analytics/PromoCampaignsAnalyticsEventTest.kt create mode 100644 features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverterTest.kt create mode 100644 features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModelTest.kt create mode 100644 features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModelTest.kt diff --git a/features/promo-banners/impl/build.gradle.kts b/features/promo-banners/impl/build.gradle.kts index 8d084ddf38..9dbea1af14 100644 --- a/features/promo-banners/impl/build.gradle.kts +++ b/features/promo-banners/impl/build.gradle.kts @@ -56,7 +56,6 @@ dependencies { kapt(deps.hilt.kapt) /** Tests */ - testImplementation(deps.test.junit5) - testImplementation(deps.test.truth) - testImplementation(deps.kotlin.serialization) + testImplementation(projects.test.core) + testImplementation(projects.common.test) } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/analytics/PromoCampaignsAnalyticsEvent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/analytics/PromoCampaignsAnalyticsEvent.kt new file mode 100644 index 0000000000..95be1d4c64 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/analytics/PromoCampaignsAnalyticsEvent.kt @@ -0,0 +1,36 @@ +package com.tangem.features.promobanners.impl.campaigns.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType + +internal sealed class PromoCampaignsAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Promotion", event = event, params = params) { + + class PromotionScreenOpened(campaignType: CampaignType) : PromoCampaignsAnalyticsEvent( + event = "Promotion Screen Opened", + params = mapOf("Screen" to campaignType.analyticsName), + ) + + class EnrollButtonClicked( + campaignType: CampaignType, + token: String, + blockchain: String, + ) : PromoCampaignsAnalyticsEvent( + event = "Enroll Button Clicked", + params = mapOf( + "Campaign" to campaignType.analyticsName, + "Token" to token, + "Blockchain" to blockchain, + ), + ) + + class AlreadyEnrolledScreenOpened : PromoCampaignsAnalyticsEvent(event = "Already Enrolled Screen Opened") +} + +private val CampaignType.analyticsName: String + get() = when (this) { + is CampaignType.WhaleSwapCashback -> "Cashback" + is CampaignType.ReactivationCashback -> "Reactivation" + } \ No newline at end of file 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 a4be2f2a3b..2a1f4b9dfc 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 @@ -3,6 +3,7 @@ package com.tangem.features.promobanners.impl.campaigns.model import com.tangem.common.ui.account.AccountIconItemStateConverter import com.tangem.common.ui.account.toUM import com.tangem.common.ui.tokens.TokenItemStateConverter +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 @@ -13,13 +14,12 @@ import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.promo.models.EnrollResult import com.tangem.domain.promo.models.PromoCampaignId @@ -28,6 +28,7 @@ import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult import com.tangem.features.promobanners.impl.R +import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent import com.tangem.features.promobanners.impl.campaigns.component.ActivateCampaignBottomSheetComponent import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM import com.tangem.features.promobanners.impl.campaigns.entity.CampaignTypeToContentConverter @@ -57,6 +58,7 @@ internal class ActivateCampaignsModel @Inject constructor( private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase, private val urlOpener: UrlOpener, @GlobalUiMessageSender private val messageSender: UiMessageSender, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private val params = paramsContainer.require() @@ -80,6 +82,8 @@ internal class ActivateCampaignsModel @Inject constructor( ) init { + analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.PromotionScreenOpened(campaignType)) + getSelectedAppCurrencyUseCase.invokeOrDefault() .onEach { appCurrency = it } .launchIn(modelScope) @@ -120,20 +124,26 @@ internal class ActivateCampaignsModel @Inject constructor( uiState.update { it.copy(isChoosingToken = false) } } - private fun onEnrollClick(selectedWalletId: UserWalletId, selectedCurrencyStatus: CryptoCurrencyStatus) { - val token = selectedCurrencyStatus.currency as? CryptoCurrency.Token ?: return + private fun onEnrollClick(selectedWalletId: UserWalletId, selectedToken: CryptoCurrency.Token) { + analyticsEventHandler.send( + PromoCampaignsAnalyticsEvent.EnrollButtonClicked( + campaignType = campaignType, + token = selectedToken.symbol, + blockchain = selectedToken.network.name, + ), + ) modelScope.launch { enrollPromoCampaignUseCase.invoke( campaign = campaignId, tokenReward = TokenReward( - tokenAddress = token.contractAddress, - networkId = token.network.rawId, + tokenAddress = selectedToken.contractAddress, + networkId = selectedToken.network.rawId, ), walletIds = listOf(selectedWalletId), ).onLeft { error -> TangemLogger.e("Error enrolling campaign ${campaignType.campaignId}", error) - messageSender.send(SnackbarMessage(message = resourceReference(R.string.common_unknown_error))) + messageSender.send(ToastMessage(message = resourceReference(R.string.common_unknown_error))) }.onRight { handleEnrollResponse(it) } @@ -156,6 +166,8 @@ internal class ActivateCampaignsModel @Inject constructor( } private fun onTokenChosen(result: ChooseTokenResult) { + val selectedToken = result.currency.currency as? CryptoCurrency.Token ?: return + modelScope.launch { val selectedAccountUM = if (isAccountsModeEnabledUseCase.invokeSync()) { when (val account = result.account.account) { @@ -185,7 +197,7 @@ internal class ActivateCampaignsModel @Inject constructor( onPrimaryButtonClick = { onEnrollClick( selectedWalletId = result.walletId, - selectedCurrencyStatus = result.currency, + selectedToken = selectedToken, ) }, terms = TermsUM( 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 9bef5d07e1..e309a9d8f1 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 @@ -5,6 +5,7 @@ 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 +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 @@ -15,6 +16,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.promo.models.PromoCampaignState import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase import com.tangem.features.promobanners.impl.R +import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent import com.tangem.features.promobanners.impl.campaigns.converters.CampaignIdConverter import com.tangem.features.promobanners.impl.campaigns.entity.CampaignsBottomSheetConfig import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType @@ -36,6 +38,7 @@ internal class CampaignsModel @Inject constructor( campaignsService: CampaignsService, private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase, @GlobalUiMessageSender private val messageSender: UiMessageSender, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { val bottomSheetNavigation: SlotNavigation = SlotNavigation() @@ -102,6 +105,7 @@ internal class CampaignsModel @Inject constructor( } fun onAlreadyActivated(campaignType: CampaignType) { + analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened()) footerExtraHeightState.value = 0.dp bottomSheetNavigation.activate(CampaignsBottomSheetConfig.AlreadyActivated(campaignType = campaignType)) } diff --git a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/analytics/PromoCampaignsAnalyticsEventTest.kt b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/analytics/PromoCampaignsAnalyticsEventTest.kt new file mode 100644 index 0000000000..6e27185205 --- /dev/null +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/analytics/PromoCampaignsAnalyticsEventTest.kt @@ -0,0 +1,90 @@ +package com.tangem.features.promobanners.impl.campaigns.analytics + +import com.google.common.truth.Truth.assertThat +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class PromoCampaignsAnalyticsEventTest { + + @Test + fun `GIVEN any event WHEN created THEN category is Promotion`() { + // Arrange + val events = listOf( + PromoCampaignsAnalyticsEvent.PromotionScreenOpened(campaignType = whaleSwap), + PromoCampaignsAnalyticsEvent.EnrollButtonClicked( + campaignType = whaleSwap, + token = "USDT", + blockchain = "Ethereum", + ), + PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened(), + ) + + // Assert + assertThat(events.map { it.category }).containsExactly("Promotion", "Promotion", "Promotion") + } + + @ParameterizedTest + @MethodSource("provideCampaignTypes") + fun `GIVEN campaign type WHEN PromotionScreenOpened THEN event name and Screen param are correct`( + model: CampaignNameModel, + ) { + // Act + val event = PromoCampaignsAnalyticsEvent.PromotionScreenOpened(campaignType = model.campaignType) + + // Assert + assertThat(event.event).isEqualTo("Promotion Screen Opened") + assertThat(event.params).containsExactly("Screen", model.expectedAnalyticsName) + } + + @ParameterizedTest + @MethodSource("provideCampaignTypes") + fun `GIVEN campaign type WHEN EnrollButtonClicked THEN event name and params are correct`( + model: CampaignNameModel, + ) { + // Act + val event = PromoCampaignsAnalyticsEvent.EnrollButtonClicked( + campaignType = model.campaignType, + token = "USDT", + blockchain = "Ethereum", + ) + + // Assert + assertThat(event.event).isEqualTo("Enroll Button Clicked") + assertThat(event.params).containsExactly( + "Campaign", model.expectedAnalyticsName, + "Token", "USDT", + "Blockchain", "Ethereum", + ) + } + + @Test + fun `GIVEN AlreadyEnrolledScreenOpened WHEN created THEN event name is correct and no params`() { + // Act + val event = PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened() + + // Assert + assertThat(event.event).isEqualTo("Already Enrolled Screen Opened") + assertThat(event.params).isEmpty() + } + + private fun provideCampaignTypes() = listOf( + CampaignNameModel(campaignType = whaleSwap, expectedAnalyticsName = "Cashback"), + CampaignNameModel(campaignType = reactivation, expectedAnalyticsName = "Reactivation"), + ) + + internal data class CampaignNameModel( + val campaignType: CampaignType, + val expectedAnalyticsName: String, + ) { + override fun toString(): String = "${campaignType::class.simpleName} -> $expectedAnalyticsName" + } + + private companion object { + val whaleSwap = CampaignType.WhaleSwapCashback(campaignId = "whale") + val reactivation = CampaignType.ReactivationCashback(campaignId = "reactivation") + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverterTest.kt b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverterTest.kt new file mode 100644 index 0000000000..b80b33fbbd --- /dev/null +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverterTest.kt @@ -0,0 +1,38 @@ +package com.tangem.features.promobanners.impl.campaigns.converters + +import com.google.common.truth.Truth.assertThat +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class CampaignIdConverterTest { + + private val converter = CampaignIdConverter() + + @ParameterizedTest + @MethodSource("provideConvertModels") + fun `GIVEN campaign id WHEN convert THEN correct campaign type is returned`(model: ConvertModel) { + // Act + val actual = converter.convert(model.id) + + // Assert + assertThat(actual).isEqualTo(model.expected) + } + + private fun provideConvertModels() = listOf( + ConvertModel(id = "1", expected = CampaignType.WhaleSwapCashback(campaignId = "1")), + ConvertModel(id = "2", expected = CampaignType.ReactivationCashback(campaignId = "2")), + ConvertModel(id = "0", expected = null), + ConvertModel(id = "unknown", expected = null), + ConvertModel(id = "", expected = null), + ) + + internal data class ConvertModel( + val id: String, + val expected: CampaignType?, + ) { + override fun toString(): String = "\"$id\" -> ${expected?.let { it::class.simpleName } ?: "null"}" + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..0c3f8c94c0 --- /dev/null +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModelTest.kt @@ -0,0 +1,266 @@ +package com.tangem.features.promobanners.impl.campaigns.model + +import arrow.core.Either +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.core.analytics.api.AnalyticsEventHandler +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.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.promo.models.EnrollResult +import com.tangem.domain.promo.models.PromoCampaignId +import com.tangem.domain.promo.models.TokenReward +import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult +import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent +import com.tangem.features.promobanners.impl.campaigns.component.ActivateCampaignBottomSheetComponent +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +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.channels.Channel +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 +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class ActivateCampaignsModelTest { + + private val chooseTokenBridgeFactory: ChooseTokenBridge.Factory = mockk(relaxed = true) + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk() + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk() + private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase = mockk() + private val urlOpener: UrlOpener = mockk(relaxed = true) + private val messageSender: UiMessageSender = mockk(relaxed = true) + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val modelCallbacks: ActivateCampaignBottomSheetComponent.ActivateCampaignModelCallbacks = + mockk(relaxed = true) + + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + + private lateinit var onCurrencyChosen: Channel + + @BeforeEach + fun setup() { + clearMocks( + getSelectedAppCurrencyUseCase, + isAccountsModeEnabledUseCase, + enrollPromoCampaignUseCase, + messageSender, + analyticsEventHandler, + modelCallbacks, + ) + } + + @ParameterizedTest + @MethodSource("provideCampaignTypes") + fun `GIVEN campaign type WHEN model created THEN PromotionScreenOpened is sent`(campaignType: CampaignType) = + runTest { + // Act + val model = createModel(campaignType) + advanceUntilIdle() + + // Assert + verify(exactly = 1) { + analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.PromotionScreenOpened(campaignType)) + } + model.onDestroy() + } + + @Test + fun `WHEN choose token clicked and dismissed THEN isChoosingToken toggles`() = runTest { + // Arrange + val model = createModel(CampaignType.WhaleSwapCashback(campaignId = "1")) + advanceUntilIdle() + + // Act & Assert + model.uiState.value.onChooseTokenClick() + assertThat(model.uiState.value.isChoosingToken).isTrue() + + model.uiState.value.onChooseTokenDismiss() + assertThat(model.uiState.value.isChoosingToken).isFalse() + model.onDestroy() + } + + @Test + fun `GIVEN a coin is chosen WHEN currency chosen THEN it is ignored`() = runTest { + // Arrange + val model = createModel(CampaignType.WhaleSwapCashback(campaignId = "1")) + advanceUntilIdle() + + // Act + onCurrencyChosen.send(chooseTokenResult(currency = coin())) + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.selectedToken).isNull() + model.onDestroy() + } + + @Test + fun `GIVEN a token chosen and enroll succeeds WHEN enroll clicked THEN event carries symbol and blockchain`() = + runTest { + // Arrange + val token = token() + val campaignType = CampaignType.WhaleSwapCashback(campaignId = "1") + coEvery { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } returns + Either.Right(EnrollResult.Success(TokenReward(tokenAddress = "a", networkId = "b"))) + val model = createModel(campaignType) + advanceUntilIdle() + + // Act + onCurrencyChosen.send(chooseTokenResult(currency = token)) + advanceUntilIdle() + model.uiState.value.footerUM.onPrimaryButtonClick() + advanceUntilIdle() + + // Assert — Token param is the symbol (TTK), Blockchain param is the network name (Ethereum) + val events = mutableListOf() + verify { analyticsEventHandler.send(capture(events)) } + val enrollEvent = events.filterIsInstance().single() + assertThat(enrollEvent.event).isEqualTo("Enroll Button Clicked") + assertThat(enrollEvent.params).containsExactly( + "Campaign", "Cashback", + "Token", "TTK", + "Blockchain", "Ethereum", + ) + + coVerify(exactly = 1) { + enrollPromoCampaignUseCase.invoke( + campaign = PromoCampaignId.WhaleSwapCashback, + tokenReward = TokenReward(tokenAddress = token.contractAddress, networkId = token.network.rawId), + walletIds = listOf(userWalletId), + ) + } + verify(exactly = 1) { modelCallbacks.onActivated(campaignType) } + model.onDestroy() + } + + @Test + fun `GIVEN enroll returns AlreadyEnrolled WHEN enroll clicked THEN onAlreadyActivated called`() = runTest { + // Arrange + val campaignType = CampaignType.WhaleSwapCashback(campaignId = "1") + coEvery { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } returns + Either.Right(EnrollResult.AlreadyEnrolled(TokenReward(tokenAddress = "a", networkId = "b"))) + val model = createModel(campaignType) + advanceUntilIdle() + + // Act + onCurrencyChosen.send(chooseTokenResult(currency = token())) + advanceUntilIdle() + model.uiState.value.footerUM.onPrimaryButtonClick() + advanceUntilIdle() + + // Assert + verify(exactly = 1) { modelCallbacks.onAlreadyActivated(campaignType) } + verify(exactly = 0) { modelCallbacks.onActivated(any()) } + model.onDestroy() + } + + @Test + fun `GIVEN enroll fails WHEN enroll clicked THEN error message is sent and no callback`() = runTest { + // Arrange + coEvery { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } returns + Either.Left(RuntimeException("network")) + val model = createModel(CampaignType.WhaleSwapCashback(campaignId = "1")) + advanceUntilIdle() + + // Act + onCurrencyChosen.send(chooseTokenResult(currency = token())) + advanceUntilIdle() + model.uiState.value.footerUM.onPrimaryButtonClick() + advanceUntilIdle() + + // Assert + verify(exactly = 1) { messageSender.send(any()) } + verify(exactly = 0) { modelCallbacks.onActivated(any()) } + verify(exactly = 0) { modelCallbacks.onAlreadyActivated(any()) } + model.onDestroy() + } + + private fun chooseTokenResult(currency: CryptoCurrency): ChooseTokenResult { + val status = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.MissedDerivation(priceChange = null, fiatRate = null), + ) + val wallet: UserWallet = mockk { + every { walletId } returns userWalletId + } + return ChooseTokenResult(currency = status, account = mockk(relaxed = true), wallet = wallet) + } + + private fun TestScope.createModel(campaignType: CampaignType): ActivateCampaignsModel { + onCurrencyChosen = Channel(capacity = Channel.UNLIMITED) + val bridge = mockk(relaxed = true) { + every { onCurrencyChosen } returns this@ActivateCampaignsModelTest.onCurrencyChosen + every { onClose } returns Channel() + } + every { chooseTokenBridgeFactory.create(any(), any(), any()) } returns bridge + every { getSelectedAppCurrencyUseCase.invokeOrDefault() } returns flowOf(AppCurrency.Default) + coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false + return ActivateCampaignsModel( + paramsContainer = MutableParamsContainer( + ActivateCampaignBottomSheetComponent.Params( + campaignType = campaignType, + modelCallbacks = modelCallbacks, + ), + ), + dispatchers = createTestingCoroutineDispatcherProvider(), + chooseTokenBridgeFactory = chooseTokenBridgeFactory, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase, + enrollPromoCampaignUseCase = enrollPromoCampaignUseCase, + urlOpener = urlOpener, + messageSender = messageSender, + analyticsEventHandler = analyticsEventHandler, + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + // Override name/symbol so analytics assertions pin the symbol ("TTK"), not the name ("TEST_TOKEN"). + private fun token(): CryptoCurrency.Token = cryptoCurrencyFactory + .createToken(blockchain = Blockchain.Ethereum, contractAddress = "0xToken") + .copy(name = "TEST_TOKEN", symbol = "TTK") + + private fun coin(): CryptoCurrency.Coin = cryptoCurrencyFactory.ethereum + + private fun provideCampaignTypes() = listOf( + CampaignType.WhaleSwapCashback(campaignId = "1"), + CampaignType.ReactivationCashback(campaignId = "2"), + ) + + private companion object { + val userWalletId = UserWalletId("0011223344556677") + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..27e2b8cb00 --- /dev/null +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModelTest.kt @@ -0,0 +1,197 @@ +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 +import com.tangem.domain.promo.models.PromoCampaignId +import com.tangem.domain.promo.models.PromoCampaignState +import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase +import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent +import com.tangem.features.promobanners.impl.campaigns.converters.CampaignIdConverter +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import com.tangem.features.promobanners.impl.campaigns.service.CampaignRequest +import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService +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.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +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 +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class CampaignsModelTest { + + private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase = mockk() + private val messageSender: UiMessageSender = mockk(relaxed = true) + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + + // Real converter: "1" -> WhaleSwapCashback, "2" -> ReactivationCashback, else null. + private val campaignIdConverter = CampaignIdConverter() + + @BeforeEach + fun setup() { + clearMocks(getPromoCampaignStateUseCase, messageSender, analyticsEventHandler) + } + + @ParameterizedTest + @MethodSource("provideKnownCampaignModels") + fun `GIVEN known campaign request WHEN emitted THEN campaign state is checked with mapped id`( + model: KnownCampaignModel, + ) = runTest { + // Arrange + coEvery { getPromoCampaignStateUseCase.invoke(any(), any(), any()) } returns + Either.Right(PromoCampaignState.NotActive(model.expectedPromoId)) + val campaignsModel = createModel( + campaignFlow = flowOf(CampaignRequest(campaignId = model.campaignId, userWalletId = userWalletId)), + ) + + // Act + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { + getPromoCampaignStateUseCase.invoke( + campaign = model.expectedPromoId, + userWalletId = userWalletId, + forceRefresh = any(), + ) + } + verify { messageSender wasNot Called } + campaignsModel.onDestroy() + } + + @Test + fun `GIVEN campaign state fails WHEN emitted THEN error message is sent`() = runTest { + // Arrange + coEvery { getPromoCampaignStateUseCase.invoke(any(), any(), any()) } returns + Either.Left(RuntimeException("network")) + val model = createModel(campaignFlow = flowOf(CampaignRequest(campaignId = "1", userWalletId = userWalletId))) + + // Act + advanceUntilIdle() + + // Assert + verify(exactly = 1) { messageSender.send(any()) } + model.onDestroy() + } + + @Test + fun `GIVEN unknown campaign id WHEN emitted THEN campaign state is not checked`() = runTest { + // Arrange + val model = createModel(campaignFlow = flowOf(CampaignRequest(campaignId = "unknown", userWalletId = userWalletId))) + + // Act + advanceUntilIdle() + + // Assert + coVerify(exactly = 0) { getPromoCampaignStateUseCase.invoke(any(), any(), any()) } + verify { messageSender wasNot Called } + model.onDestroy() + } + + @Test + fun `GIVEN footer height set WHEN onAlreadyActivated THEN analytics sent and height reset`() = runTest { + // Arrange + val model = createModel(campaignFlow = emptyFlow()) + advanceUntilIdle() + model.onFooterExtraHeightReady(100.dp) + + // Act + model.onAlreadyActivated(CampaignType.WhaleSwapCashback(campaignId = "1")) + + // Assert + 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 { + // 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() + } + + private fun TestScope.createModel(campaignFlow: Flow): CampaignsModel { + val campaignsService: CampaignsService = mockk { + every { this@mockk.campaignFlow } returns campaignFlow + } + return CampaignsModel( + dispatchers = createTestingCoroutineDispatcherProvider(), + campaignIdConverter = campaignIdConverter, + campaignsService = campaignsService, + getPromoCampaignStateUseCase = getPromoCampaignStateUseCase, + messageSender = messageSender, + analyticsEventHandler = analyticsEventHandler, + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + private fun provideKnownCampaignModels() = listOf( + KnownCampaignModel(campaignId = "1", expectedPromoId = PromoCampaignId.WhaleSwapCashback), + KnownCampaignModel(campaignId = "2", expectedPromoId = PromoCampaignId.ReactivationCashback), + ) + + internal data class KnownCampaignModel( + val campaignId: String, + val expectedPromoId: PromoCampaignId, + ) { + override fun toString(): String = "\"$campaignId\" -> $expectedPromoId" + } + + private companion object { + val userWalletId = UserWalletId("0011223344556677") + } +} \ No newline at end of file From 503106376cbe932c404a6e2e54bd64b2ab029ef6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Jul 2026 12:53:59 +0400 Subject: [PATCH 34/59] Updated on 2026-08-14 --- .../promotion/models/PromotionsResponse.kt | 2 + .../promo/converter/PromoCampaignConverter.kt | 2 + .../data/promo/DefaultPromoRepositoryTest.kt | 11 +- .../converter/PromoCampaignConverterTest.kt | 20 +- .../promo/DefaultYieldPromoRepositoryTest.kt | 2 + .../converter/YieldBoostPromoConverterTest.kt | 4 + .../tangem/domain/promo/models/PromoModels.kt | 2 + .../api/choosetoken/ChooseTokenBridge.kt | 24 +- .../api/choosetoken/PredefinedTokenToAdd.kt | 19 ++ .../choosetoken/model/ChooseTokenModel.kt | 75 +++-- .../choosetoken/model/MarketBlockDelegate.kt | 14 +- .../PredefinedTokensBlockDelegate.kt | 109 +++++++ .../predefined/state/PredefinedTokensUM.kt | 17 + .../impl/choosetoken/ui/ChooseTokenScreen.kt | 157 +++++++++- .../impl/choosetoken/ui/ChooseTokenUM.kt | 4 +- .../choosetoken/ui/state/ChooserBlockUM.kt | 15 + .../model/MarketBlockDelegateTest.kt | 9 +- .../PredefinedTokensBlockDelegateTest.kt | 290 ++++++++++++++++++ features/promo-banners/impl/build.gradle.kts | 4 + .../ActivateCampaignBottomSheetComponent.kt | 2 + .../component/DefaultCampaignsComponent.kt | 1 + .../entity/CampaignsBottomSheetConfig.kt | 2 + .../campaigns/model/ActivateCampaignsModel.kt | 22 +- .../impl/campaigns/model/CampaignsModel.kt | 2 +- .../model/PredefinedTokenResolver.kt | 41 +++ .../model/ActivateCampaignsModelTest.kt | 7 + .../model/PredefinedTokenResolverTest.kt | 88 ++++++ 27 files changed, 887 insertions(+), 58 deletions(-) create mode 100644 features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/PredefinedTokenToAdd.kt create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/PredefinedTokensBlockDelegate.kt create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/state/PredefinedTokensUM.kt create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/state/ChooserBlockUM.kt create mode 100644 features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/PredefinedTokensBlockDelegateTest.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/PredefinedTokenResolver.kt create mode 100644 features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/PredefinedTokenResolverTest.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionsResponse.kt index 6d9dea62b9..0558cfce9e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionsResponse.kt @@ -30,10 +30,12 @@ data class PromotionsResponse( @JsonClass(generateAdapter = true) data class PromoToken( + @Json(name = "tokenId") val tokenId: String, @Json(name = "tokenAddress") val tokenAddress: String, @Json(name = "tokenSymbol") val tokenSymbol: String, @Json(name = "tokenName") val tokenName: String, @Json(name = "networkId") val networkId: String, + @Json(name = "decimals") val decimals: Int, ) } } \ No newline at end of file diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/converter/PromoCampaignConverter.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/converter/PromoCampaignConverter.kt index 4db59a14f2..91aec47f55 100644 --- a/data/promo/src/main/kotlin/com/tangem/data/promo/converter/PromoCampaignConverter.kt +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/converter/PromoCampaignConverter.kt @@ -14,10 +14,12 @@ internal object PromoCampaignConverter { campaign = campaign, payoutTokens = all.tokens.orEmpty().map { token -> PromoPayoutToken( + tokenId = token.tokenId, tokenAddress = token.tokenAddress, tokenSymbol = token.tokenSymbol, tokenName = token.tokenName, networkId = token.networkId, + decimals = token.decimals, ) }, timeline = PromoTimeline( diff --git a/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt b/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt index 061e43c88c..f2586d9b50 100644 --- a/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt +++ b/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt @@ -54,7 +54,16 @@ internal class DefaultPromoRepositoryTest { name = campaign.slug, all = All( timeline = Timeline("2026-06-23T00:00:00.000Z", "2026-08-31T20:59:59.000Z"), - tokens = listOf(PromoToken("0xToken", "USDT", "Tether USD", "ethereum")), + tokens = listOf( + PromoToken( + tokenId = "tether", + tokenAddress = "0xToken", + tokenSymbol = "USDT", + tokenName = "Tether USD", + networkId = "ethereum", + decimals = 6, + ), + ), status = "active", link = "", ), diff --git a/data/promo/src/test/kotlin/com/tangem/data/promo/converter/PromoCampaignConverterTest.kt b/data/promo/src/test/kotlin/com/tangem/data/promo/converter/PromoCampaignConverterTest.kt index 522ff2a3cf..ab330fd49b 100644 --- a/data/promo/src/test/kotlin/com/tangem/data/promo/converter/PromoCampaignConverterTest.kt +++ b/data/promo/src/test/kotlin/com/tangem/data/promo/converter/PromoCampaignConverterTest.kt @@ -18,7 +18,16 @@ internal class PromoCampaignConverterTest { // Arrange val all = All( timeline = Timeline(start = "2026-06-23T00:00:00.000Z", end = "2026-08-31T20:59:59.000Z"), - tokens = listOf(PromoToken("0xdac1", "USDT", "Tether USD", "ethereum")), + tokens = listOf( + PromoToken( + tokenId = "tether", + tokenAddress = "0xdac1", + tokenSymbol = "USDT", + tokenName = "Tether USD", + networkId = "ethereum", + decimals = 6, + ), + ), status = "active", link = "", ) @@ -29,7 +38,14 @@ internal class PromoCampaignConverterTest { // Assert assertThat(result.campaign).isEqualTo(campaign) assertThat(result.payoutTokens).containsExactly( - PromoPayoutToken("0xdac1", "USDT", "Tether USD", "ethereum"), + PromoPayoutToken( + tokenId = "tether", + tokenAddress = "0xdac1", + tokenSymbol = "USDT", + tokenName = "Tether USD", + networkId = "ethereum", + decimals = 6, + ), ) assertThat(result.timeline.start).isEqualTo(Instant.parse("2026-06-23T00:00:00.000Z")) assertThat(result.timeline.end).isEqualTo(Instant.parse("2026-08-31T20:59:59.000Z")) diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt index 4b1562c0c8..091069392f 100644 --- a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt @@ -224,10 +224,12 @@ internal class DefaultYieldPromoRepositoryTest { ), tokens = listOf( PromotionsResponse.PromotionDto.PromoToken( + tokenId = "usd-coin", tokenAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", tokenSymbol = "USDC", tokenName = "USD Coin", networkId = "ethereum", + decimals = 6, ), ), status = "active", diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostPromoConverterTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostPromoConverterTest.kt index 8d1c034fba..f6f2c99542 100644 --- a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostPromoConverterTest.kt +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostPromoConverterTest.kt @@ -100,16 +100,20 @@ class YieldBoostPromoConverterTest { ), tokens = listOf( PromotionsResponse.PromotionDto.PromoToken( + tokenId = "usd-coin", tokenAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", tokenSymbol = "USDC", tokenName = "USD Coin", networkId = "ethereum", + decimals = 6, ), PromotionsResponse.PromotionDto.PromoToken( + tokenId = "tether", tokenAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7", tokenSymbol = "USDT", tokenName = "Tether USD", networkId = "ethereum", + decimals = 6, ), ), status = "active", diff --git a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt index 8d72a7c755..e2a3013893 100644 --- a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt +++ b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt @@ -3,10 +3,12 @@ package com.tangem.domain.promo.models import kotlinx.datetime.Instant data class PromoPayoutToken( + val tokenId: String, val tokenAddress: String, val tokenSymbol: String, val tokenName: String, val networkId: String, + val decimals: Int, ) data class PromoTimeline( diff --git a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenBridge.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenBridge.kt index 0ba2e187cf..68a1de2d6e 100644 --- a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenBridge.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenBridge.kt @@ -28,7 +28,7 @@ interface ChooseTokenBridge : ChooseTokenBridgeInternal { data class Settings( val title: TextReference, - val isShowMarketBlock: Boolean, + val chooserBlock: ChooserBlock, val isShowPaymentAccount: Boolean, val isAppBarShown: Boolean = true, /** @@ -40,24 +40,24 @@ interface ChooseTokenBridge : ChooseTokenBridgeInternal { companion object { val SwapFrom = Settings( title = resourceReference(R.string.swapping_from_title), - isShowMarketBlock = true, + chooserBlock = ChooserBlock.Market, isShowPaymentAccount = true, ) val SwapTo = Settings( title = resourceReference(R.string.swapping_to_title), - isShowMarketBlock = true, + chooserBlock = ChooserBlock.Market, isShowPaymentAccount = true, ) val AddFunds = Settings( title = resourceReference(R.string.swapping_to_title), - isShowMarketBlock = true, + chooserBlock = ChooserBlock.Market, isShowPaymentAccount = false, isAppBarShown = false, isShowSingleCurrencyWallets = true, ) val Transfer = Settings( title = resourceReference(R.string.common_transfer), - isShowMarketBlock = false, + chooserBlock = ChooserBlock.None, isShowPaymentAccount = false, isAppBarShown = false, isShowSingleCurrencyWallets = true, @@ -116,6 +116,20 @@ data class ChooseTokenResult( .any { it.value } } +/** + * Which "add a token" block the chooser shows. Mutually exclusive by construction. + */ +sealed interface ChooserBlock { + data object None : ChooserBlock + data object Market : ChooserBlock + + /** + * Shows an "add these tokens" block (e.g. Onramp promo payout). The caller owns [predefinedTokens] + * and pushes into it; the chooser derives the "add" cells and a network filter from it. + */ + data class Predefined(val predefinedTokens: StateFlow>) : ChooserBlock +} + sealed interface ChooseTokenAnalyticsPayload { @Suppress("BooleanPropertyNaming") diff --git a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/PredefinedTokenToAdd.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/PredefinedTokenToAdd.kt new file mode 100644 index 0000000000..ec88e8bd8c --- /dev/null +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/PredefinedTokenToAdd.kt @@ -0,0 +1,19 @@ +package com.tangem.features.commonfeatures.api.choosetoken + +import com.tangem.domain.markets.RawMarketToken +import com.tangem.domain.markets.TokenMarketInfo + +/** + * A token offered for adding to the portfolio inside the token chooser, independent of any + * campaign/promo model. Callers convert their own models (e.g. promo payout tokens) into this type, + * so the chooser stays agnostic of feature-specific sources. + * + * Exactly one [network] per token — the chooser renders one "add" row per [PredefinedTokenToAdd]. + * [TokenMarketInfo.Network.decimalCount] must be resolved by the caller — a network without decimals + * cannot be added and must be dropped before reaching the chooser. + */ +data class PredefinedTokenToAdd( + val token: RawMarketToken, + val network: TokenMarketInfo.Network, + val iconUrl: String? = null, +) \ No newline at end of file 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 4b0097ba4b..aefb466348 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 @@ -1,5 +1,6 @@ package com.tangem.features.commonfeatures.impl.choosetoken.model +import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -10,15 +11,18 @@ import com.tangem.features.commonfeatures.api.R import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge +import com.tangem.features.commonfeatures.api.choosetoken.ChooserBlock 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.ChooseTokenComponent import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult +import com.tangem.features.commonfeatures.impl.choosetoken.AddToPortfolioRoute import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarToggleTransformer import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarUpdateQueryTransformer -import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState +import com.tangem.features.commonfeatures.impl.choosetoken.predefined.PredefinedTokensBlockDelegate 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.delay import kotlinx.coroutines.flow.* @@ -30,6 +34,8 @@ import javax.inject.Inject internal class ChooseTokenModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, marketBlockDelegateFactory: MarketBlockDelegate.Factory, + predefinedTokensBlockDelegateFactory: PredefinedTokensBlockDelegate.Factory, + addToPortfolioManagerFactory: AddToPortfolioManager.Factory, paramsContainer: ParamsContainer, ) : Model() { @@ -38,34 +44,57 @@ internal class ChooseTokenModel @Inject constructor( private val searchQueryState: StateFlow = bridge.searchQueryState private val isSearchingState: Boolean get() = bridge.searchQueryState.isSearchingState - private val marketBlockDelegate: MarketBlockDelegate = marketBlockDelegateFactory.create( - modelScope = modelScope, - searchQueryState = searchQueryState, - screensSourcesName = bridge.analyticsPayload - .filterIsInstance() - .firstOrNull()?.value.orEmpty(), - selectedWalletFlow = bridge.selectedWalletFlow, - shouldShowSingleCurrencyWallets = bridge.settings.isShowSingleCurrencyWallets, + private val screensSourcesName: String = bridge.analyticsPayload + .filterIsInstance() + .firstOrNull()?.value.orEmpty() + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( + scope = modelScope, + settings = AddToPortfolioManager.Settings.ChooseToken, + analyticsParams = AddToPortfolioManager.AnalyticsParams(source = screensSourcesName), ) - val bottomSheetNavigation get() = marketBlockDelegate.addToPortfolioSlot - val addToPortfolioManager get() = marketBlockDelegate.addToPortfolioManager - private val marketsStateFlow: Flow = if (bridge.settings.isShowMarketBlock) { - marketBlockDelegate.marketsStateFlow - } else { - flowOf(null) + private val marketBlockDelegate: MarketBlockDelegate by lazy { + marketBlockDelegateFactory.create( + modelScope = modelScope, + searchQueryState = searchQueryState, + selectedWalletFlow = bridge.selectedWalletFlow, + shouldShowSingleCurrencyWallets = bridge.settings.isShowSingleCurrencyWallets, + addToPortfolioManager = addToPortfolioManager, + addToPortfolioSlot = bottomSheetNavigation, + ) + } + + private val predefinedTokensBlockDelegate: PredefinedTokensBlockDelegate by lazy { + val block = bridge.settings.chooserBlock as ChooserBlock.Predefined + predefinedTokensBlockDelegateFactory.create( + predefinedTokens = block.predefinedTokens, + searchQueryState = searchQueryState, + addToPortfolioManager = addToPortfolioManager, + addToPortfolioSlot = bottomSheetNavigation, + modelScope = modelScope, + tokenFilter = bridge.tokenFilter, + ) + } + + private val chooserBlockFlow: Flow = when (bridge.settings.chooserBlock) { + ChooserBlock.Market -> marketBlockDelegate.marketsStateFlow.map { it?.let(ChooserBlockUM::Market) } + is ChooserBlock.Predefined -> + predefinedTokensBlockDelegate.stateFlow.map { it?.let(ChooserBlockUM::Predefined) } + ChooserBlock.None -> flowOf(null) } private val initialState: MutableStateFlow = MutableStateFlow(getInitState()) val state: StateFlow = combine( flow = initialState, flow2 = bridge.fullPortfolioBlock, - flow3 = marketsStateFlow, - transform = { initial, content, marketBlock -> + flow3 = chooserBlockFlow, + transform = { initial, content, chooserBlock -> ChooseTokenFullUM( initialUM = initial, portfolioBlock = content, - marketsBlock = marketBlock, + chooserBlock = chooserBlock, ) }, ).stateIn( @@ -74,7 +103,7 @@ internal class ChooseTokenModel @Inject constructor( initialValue = ChooseTokenFullUM( initialUM = initialState.value, portfolioBlock = bridge.fullPortfolioBlock.value, - marketsBlock = null, + chooserBlock = null, ), ) @@ -87,10 +116,12 @@ internal class ChooseTokenModel @Inject constructor( } addToPortfolioManager.onDismiss.receiveAsFlow() - .onEach { marketBlockDelegate.addToPortfolioSlot.dismiss() } + .onEach { bottomSheetNavigation.dismiss() } .launchIn(modelScope) addToPortfolioManager.onSuccessAdded.receiveAsFlow() - .onEach { notifyCurrencyChosen(it, isMarketTokenSelected = true) } + .onEach { + notifyCurrencyChosen(it, isMarketTokenSelected = bridge.settings.chooserBlock == ChooserBlock.Market) + } .launchIn(modelScope) addToPortfolioManager.onAddedTokenClick.receiveAsFlow() .onEach { notifyCurrencyChosen(it, isMarketTokenSelected = false) } @@ -112,7 +143,7 @@ internal class ChooseTokenModel @Inject constructor( ), ) bridge.onCurrencyChosen(chooseTokenResult) - marketBlockDelegate.addToPortfolioSlot.dismiss() + bottomSheetNavigation.dismiss() } private fun getInitialSearchBar(): SearchBarUM = SearchBarUM( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt index 667760f723..ac3f875a2e 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt @@ -41,13 +41,13 @@ internal class MarketBlockDelegate @AssistedInject constructor( private val marketsListBatchFlowManagerFactory: MarketsListBatchFlowManager.Factory, private val excludedBlockchains: ExcludedBlockchains, private val getUserWalletsUseCase: GetWalletsUseCase, - private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, @Assisted private val modelScope: CoroutineScope, @Assisted private val searchQueryState: StateFlow, - @Assisted private val screensSourcesName: String, @Assisted private val selectedWalletFlow: SharedFlow, @Assisted private val shouldShowSingleCurrencyWallets: Boolean, + @Assisted private val addToPortfolioManager: AddToPortfolioManager, + @Assisted private val addToPortfolioSlot: SlotNavigation, ) { private val visibleMarketItemIds = MutableStateFlow>(emptyList()) @@ -55,13 +55,6 @@ internal class MarketBlockDelegate @AssistedInject constructor( private val selectedCategoryFlow = MutableStateFlow(SwapMarketCategory.MarketCap) - val addToPortfolioSlot: SlotNavigation = SlotNavigation() - val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( - scope = modelScope, - settings = AddToPortfolioManager.Settings.ChooseToken, - analyticsParams = AddToPortfolioManager.AnalyticsParams(source = screensSourcesName), - ) - private val baseMarketsStateFlow: Flow = searchQueryState // Switch between default and search market flows .map { it.value.isEmpty() } @@ -319,9 +312,10 @@ internal class MarketBlockDelegate @AssistedInject constructor( fun create( searchQueryState: StateFlow, modelScope: CoroutineScope, - screensSourcesName: String, selectedWalletFlow: SharedFlow, shouldShowSingleCurrencyWallets: Boolean, + addToPortfolioManager: AddToPortfolioManager, + addToPortfolioSlot: SlotNavigation, ): MarketBlockDelegate } } \ No newline at end of file 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 new file mode 100644 index 0000000000..2b69771852 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/PredefinedTokensBlockDelegate.kt @@ -0,0 +1,109 @@ +package com.tangem.features.commonfeatures.impl.choosetoken.predefined + +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.features.commonfeatures.api.choosetoken.PredefinedTokenToAdd +import com.tangem.features.commonfeatures.impl.choosetoken.AddToPortfolioRoute +import com.tangem.features.commonfeatures.impl.choosetoken.predefined.state.PredefinedTokenItemUM +import com.tangem.features.commonfeatures.impl.choosetoken.predefined.state.PredefinedTokensUM +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@Suppress("LongParameterList") +internal class PredefinedTokensBlockDelegate @AssistedInject constructor( + @Assisted private val predefinedTokens: StateFlow>, + @Assisted private val searchQueryState: StateFlow, + @Assisted private val addToPortfolioManager: AddToPortfolioManager, + @Assisted private val addToPortfolioSlot: SlotNavigation, + @Assisted private val modelScope: CoroutineScope, + @Assisted private val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>, +) { + + init { + predefinedTokens + .onEach { tokens -> tokenFilter.value = buildTokenFilter(tokens) } + .launchIn(modelScope) + } + + val stateFlow: Flow = combine( + predefinedTokens, + searchQueryState, + ) { tokens, query -> + val filtered = tokens.filter { it.hasValidNetwork() && it.matchesQuery(query.value) } + if (filtered.isEmpty()) { + null + } else { + PredefinedTokensUM(items = filtered.map { it.toItemUM() }.toImmutableList()) + } + } + + private fun buildTokenFilter( + tokens: List, + ): (AccountStatus, CryptoCurrencyStatus) -> Boolean { + val tokenKeys = tokens + .filter { it.hasValidNetwork() } + .mapTo(hashSetOf()) { it.token.id.value to it.network.networkId } + if (tokenKeys.isEmpty()) return { _, _ -> true } + return filter@{ _, currencyStatus -> + val rawId = currencyStatus.currency.id.rawCurrencyId?.value ?: return@filter false + tokenKeys.contains(rawId to currencyStatus.currency.network.rawId) + } + } + + private fun PredefinedTokenToAdd.hasValidNetwork(): Boolean = + network.networkId.isNotBlank() && network.decimalCount != null + + private fun PredefinedTokenToAdd.matchesQuery(query: String): Boolean { + if (query.isBlank()) return true + return token.symbol.contains(query, ignoreCase = true) || + token.name.contains(query, ignoreCase = true) + } + + private fun PredefinedTokenToAdd.toItemUM(): PredefinedTokenItemUM { + val item = this + val networkId = network.networkId + val networkName = Blockchain.fromNetworkId(networkId)?.fullName?.takeIf { it.isNotBlank() } ?: networkId + return PredefinedTokenItemUM( + id = "${token.id.value}_$networkId", + symbol = token.symbol, + networkName = TextReference.Str(networkName), + networkId = networkId, + iconUrl = iconUrl, + onAddClick = { onAddClick(item) }, + ) + } + + private fun onAddClick(item: PredefinedTokenToAdd) { + addToPortfolioManager.setTokenParams(item.token) + addToPortfolioManager.setTokenNetworks(listOf(item.network)) + addToPortfolioSlot.activate(AddToPortfolioRoute) + } + + @AssistedFactory + interface Factory { + fun create( + predefinedTokens: StateFlow>, + searchQueryState: StateFlow, + addToPortfolioManager: AddToPortfolioManager, + addToPortfolioSlot: SlotNavigation, + modelScope: CoroutineScope, + tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>, + ): PredefinedTokensBlockDelegate + } +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/state/PredefinedTokensUM.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/state/PredefinedTokensUM.kt new file mode 100644 index 0000000000..fd9ed6a18e --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/predefined/state/PredefinedTokensUM.kt @@ -0,0 +1,17 @@ +package com.tangem.features.commonfeatures.impl.choosetoken.predefined.state + +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +internal data class PredefinedTokensUM( + val items: ImmutableList, +) + +internal data class PredefinedTokenItemUM( + val id: String, + val symbol: String, + val networkName: TextReference, + val networkId: String, + val iconUrl: String?, + val onAddClick: () -> Unit, +) \ 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 2175d7f0aa..ae03a82318 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 @@ -38,6 +38,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.common.ui.extensions.getActiveIconRes import com.tangem.common.ui.tokens.NonContentItemContent import com.tangem.common.ui.tokens.SlideInItemVisibility import com.tangem.core.ui.components.SpacerH @@ -59,6 +62,9 @@ import com.tangem.core.ui.components.tokenlist.state.PortfolioItemContentUM import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.ds.button.SecondaryTangemButton +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize import com.tangem.core.ui.ds.image.DeviceIconUM import com.tangem.core.ui.ds.image.TangemDeviceIcon import com.tangem.core.ui.ds.image.TangemIcon @@ -70,6 +76,7 @@ import com.tangem.core.ui.ds.row.token.TangemTokenRowUM import com.tangem.core.ui.ds.row.token.internal.TokenRowTitle import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.BuyTokenScreenTestTags @@ -84,6 +91,9 @@ import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM import com.tangem.features.commonfeatures.impl.R import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState +import com.tangem.features.commonfeatures.impl.choosetoken.predefined.state.PredefinedTokenItemUM +import com.tangem.features.commonfeatures.impl.choosetoken.predefined.state.PredefinedTokensUM +import com.tangem.features.commonfeatures.impl.choosetoken.ui.state.ChooserBlockUM import com.tangem.utils.StringsSigns.DOT import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -99,10 +109,13 @@ private const val ACCOUNT_CONTENT_ANIM_MS = 350 private const val ACCOUNT_CONTENT_ANIM_DELAY_MS = 90 private const val ACCOUNT_BOUNDS_ANIM_MS = 250 +private val ChooseTokenFullUM.marketState: SwapMarketState? + get() = (chooserBlock as? ChooserBlockUM.Market)?.state + private val ChooseTokenFullUM.isNotFoundState: Boolean get() { if (portfolioBlock == null) return false - if (marketsBlock == null) return false + val marketsBlock = marketState ?: return false return portfolioBlock.tokensListData.tokensList.isEmpty() && portfolioBlock.isSearching && marketsBlock !is SwapMarketState.Content && @@ -112,7 +125,7 @@ private val ChooseTokenFullUM.isNotFoundState: Boolean private val ChooseTokenFullUM.isEmptyState: Boolean get() { if (portfolioBlock == null) return false - if (marketsBlock == null) return false + val marketsBlock = marketState ?: return false return portfolioBlock.tokensListData.tokensList.isEmpty() && !portfolioBlock.isSearching && marketsBlock !is SwapMarketState.Content && @@ -192,17 +205,25 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { isBalanceHidden = state.portfolioBlock.isBalanceHidden, ) - if (state.marketsBlock != null) { - item("markets_title_spacer") { SpacerH(height = 40.dp) } - swapMarketsListItems(state.marketsBlock) + when (val block = state.chooserBlock) { + is ChooserBlockUM.Market -> { + item("markets_title_spacer") { SpacerH(height = 40.dp) } + swapMarketsListItems(block.state) + } + is ChooserBlockUM.Predefined -> { + item("predefined_title_spacer") { SpacerH(height = 40.dp) } + predefinedTokensListItems(block.state) + } + null -> Unit } } } } } } - if (state.marketsBlock != null && !state.isNotFoundState && !state.isEmptyState) { - SetupMarketScrollTracker(state.marketsBlock, lazyListState) + val marketsBlock = state.marketState + if (marketsBlock != null && !state.isNotFoundState && !state.isEmptyState) { + SetupMarketScrollTracker(marketsBlock, lazyListState) } } @@ -700,6 +721,92 @@ private fun buildAccountSubtitle(tokensCount: TextReference?, balance: String?): } } +private fun LazyListScope.predefinedTokensListItems(state: PredefinedTokensUM) { + item(key = "predefined_title") { + Text( + text = stringResourceSafe(R.string.markets_portfolio_eligible_block_title), + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + modifier = Modifier + .fillMaxWidth() + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing8, + ), + ) + } + itemsIndexed( + items = state.items, + key = { _, item -> "predefined_${item.id}" }, + contentType = { _, _ -> PredefinedTokenItemUM::class.java }, + itemContent = { index, item -> + PredefinedTokenItem( + state = item, + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = state.items.lastIndex, + backgroundColor = if (LocalRedesignEnabled.current) { + TangemTheme.colors2.surface.level1 + } else { + TangemTheme.colors.background.primary + }, + ) + .testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) + .semantics { lazyListItemPosition = index }, + ) + }, + ) +} + +@Composable +private fun PredefinedTokenItem(state: PredefinedTokenItemUM, modifier: Modifier = Modifier) { + val tokenRowUM = remember(state) { + val iconState = CurrencyIconState.TokenIcon( + url = state.iconUrl, + topBadgeIconResId = getActiveIconRes(Blockchain.fromNetworkId(state.networkId) ?: Blockchain.Unknown), + isGrayscale = false, + shouldShowCustomBadge = false, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + ) + TangemTokenRowUM.Content( + id = state.id, + headIconUM = TangemIconUM.Currency(currencyIconState = iconState), + titleUM = TangemTokenRowUM.TitleUM.Content(text = stringReference(state.symbol)), + subtitleUM = TangemTokenRowUM.SubtitleUM.Content( + text = resourceReference( + id = R.string.domain_receive_assets_onboarding_network_name, + formatArgs = wrappedList(state.networkName), + ), + ), + topEndContentUM = TangemTokenRowUM.EndContentUM.Empty, + bottomEndContentUM = TangemTokenRowUM.EndContentUM.Empty, + tailUM = TangemRowTailUM.Empty, + onItemClick = null, + onItemLongClick = null, + ) + } + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + TangemTokenRow( + tokenRowUM = tokenRowUM, + isBalanceHidden = false, + modifier = Modifier.weight(1f), + ) + SecondaryTangemButton( + onClick = state.onAddClick, + modifier = Modifier.padding(start = TangemTheme.dimens2.x2, end = TangemTheme.dimens2.x4), + text = resourceReference(R.string.common_add), + size = TangemButtonSize.X9, + shape = TangemButtonShape.Default, + ) + } +} + private fun LazyListScope.emptyTokensList(modifier: Modifier = Modifier) { item("EmptyTokensList") { Box( @@ -865,6 +972,25 @@ private val wallets ), ) +private val predefinedTokens = persistentListOf( + PredefinedTokenItemUM( + id = "usdc-ethereum", + symbol = "USDC", + networkName = stringReference("Ethereum"), + networkId = "ethereum", + iconUrl = null, + onAddClick = {}, + ), + PredefinedTokenItemUM( + id = "usdt-tron", + symbol = "USDT", + networkName = stringReference("Tron"), + networkId = "tron", + iconUrl = null, + onAddClick = {}, + ), +) + private val initialUM = ChooseTokenInitialUM( screenTitle = stringReference("Choose token"), isAppBarShown = true, @@ -885,7 +1011,7 @@ private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider = mockk(relaxUnitFun = true) + private val account: AccountStatus = mockk() + + @BeforeEach + fun setup() { + clearMocks(addToPortfolioManager, addToPortfolioSlot) + } + + @Test + fun `GIVEN single token AND blank query WHEN state emitted THEN token mapped to item`() = runTest { + // Arrange + val token = createPredefinedToken( + id = "bitcoin", + symbol = "BTC", + networkId = ETHEREUM_NETWORK_ID, + iconUrl = "https://icon/btc.png", + ) + val delegate = createDelegate(predefinedTokens = MutableStateFlow(listOf(token))) + + // Act + val actual = lastState(delegate) + + // Assert + val actualItem = requireNotNull(actual).items.single() + val expected = PredefinedTokenItemUM( + id = "bitcoin_$ETHEREUM_NETWORK_ID", + symbol = "BTC", + networkName = TextReference.Str("Ethereum"), + networkId = ETHEREUM_NETWORK_ID, + iconUrl = "https://icon/btc.png", + onAddClick = actualItem.onAddClick, + ) + assertThat(actualItem).isEqualTo(expected) + } + + @Test + fun `GIVEN same token on two networks WHEN state emitted THEN item ids are unique`() = runTest { + // Arrange + val tokens = listOf( + createPredefinedToken(id = "usd-coin", symbol = "USDC", networkId = "ethereum"), + createPredefinedToken(id = "usd-coin", symbol = "USDC", networkId = "polygon-pos"), + ) + val delegate = createDelegate(predefinedTokens = MutableStateFlow(tokens)) + + // Act + val actualIds = requireNotNull(lastState(delegate)).items.map { it.id } + + // Assert + assertThat(actualIds).containsExactly("usd-coin_ethereum", "usd-coin_polygon-pos").inOrder() + } + + @Test + fun `GIVEN token network without decimals WHEN state emitted THEN token dropped`() = runTest { + // Arrange + val valid = createPredefinedToken(id = "bitcoin", networkId = "ethereum") + val noDecimals = PredefinedTokenToAdd( + token = RawMarketToken(id = CryptoCurrency.RawID("ghost"), name = "Ghost", symbol = "GHOST"), + network = TokenMarketInfo.Network( + networkId = "ethereum", + isExchangeable = false, + contractAddress = null, + decimalCount = null, + ), + iconUrl = null, + ) + val delegate = createDelegate(predefinedTokens = MutableStateFlow(listOf(valid, noDecimals))) + + // Act + val actual = lastState(delegate) + + // Assert + assertThat(actual?.items?.map { it.id }).containsExactly("bitcoin_ethereum") + } + + @Test + fun `GIVEN empty predefined list WHEN state emitted THEN emits null`() = runTest { + // Arrange + val delegate = createDelegate(predefinedTokens = MutableStateFlow(emptyList())) + + // Act + val actual = lastState(delegate) + + // Assert + assertThat(actual).isNull() + } + + @ParameterizedTest + @ProvideTestModels + fun filter(model: FilterModel) = runTest { + // Arrange + val tokens = listOf( + createPredefinedToken(id = "bitcoin", name = "Bitcoin", symbol = "BTC"), + createPredefinedToken(id = "ethereum", name = "Ethereum", symbol = "ETH"), + createPredefinedToken(id = "solana", name = "Solana", symbol = "SOL"), + ) + val delegate = createDelegate( + predefinedTokens = MutableStateFlow(tokens), + searchQueryState = MutableStateFlow(SearchQuery(model.query)), + ) + + // Act + val actual = lastState(delegate) + + // Assert + val expectedIds = model.expectedIds + if (expectedIds == null) { + assertThat(actual).isNull() + } else { + assertThat(actual?.items?.map { it.id }).containsExactlyElementsIn(expectedIds).inOrder() + } + } + + @Test + fun `GIVEN token WHEN onAddClick invoked THEN manager updated AND slot activated`() = runTest { + // Arrange + val rawToken = RawMarketToken(id = CryptoCurrency.RawID("bitcoin"), name = "Bitcoin", symbol = "BTC") + val network = network(ETHEREUM_NETWORK_ID) + val token = PredefinedTokenToAdd(token = rawToken, network = network, iconUrl = null) + val delegate = createDelegate(predefinedTokens = MutableStateFlow(listOf(token))) + val item = requireNotNull(lastState(delegate)).items.single() + + // Act + item.onAddClick() + + // Assert + val transformer: CapturingSlot<(AddToPortfolioRoute?) -> AddToPortfolioRoute?> = slot() + verify(exactly = 1) { addToPortfolioManager.setTokenParams(rawToken) } + verify(exactly = 1) { addToPortfolioManager.setTokenNetworks(listOf(network)) } + verify(exactly = 1) { addToPortfolioSlot.navigate(capture(transformer), any()) } + assertThat(transformer.captured.invoke(null)).isEqualTo(AddToPortfolioRoute) + } + + @Test + fun `GIVEN predefined tokens WHEN emitted THEN tokenFilter matches only those tokens`() = runTest { + // Arrange + val tokenFilter = MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>({ _, _ -> true }) + val predefinedTokens = MutableStateFlow>(emptyList()) + createDelegate(predefinedTokens = predefinedTokens, tokenFilter = tokenFilter) + + // Act + predefinedTokens.value = listOf( + createPredefinedToken(id = "usd-coin", networkId = "ethereum"), + createPredefinedToken(id = "tether", networkId = "polygon-pos"), + ) + advanceUntilIdle() + + // Assert + val predicate = tokenFilter.value + assertThat(predicate(account, currency(rawId = "usd-coin", networkId = "ethereum"))).isTrue() + assertThat(predicate(account, currency(rawId = "tether", networkId = "polygon-pos"))).isTrue() + // same network, different token → excluded + assertThat(predicate(account, currency(rawId = "shiba-inu", networkId = "ethereum"))).isFalse() + // same token, different network → excluded + assertThat(predicate(account, currency(rawId = "usd-coin", networkId = "solana"))).isFalse() + } + + @Test + fun `GIVEN only invalid predefined tokens WHEN emitted THEN tokenFilter stays pass-through`() = runTest { + // Arrange + val tokenFilter = MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>({ _, _ -> false }) + val predefinedTokens = MutableStateFlow>(emptyList()) + createDelegate(predefinedTokens = predefinedTokens, tokenFilter = tokenFilter) + + // Act — a token whose network has no decimals is invalid (not addable), so must not constrain the list + predefinedTokens.value = listOf( + PredefinedTokenToAdd( + token = RawMarketToken(id = CryptoCurrency.RawID("usd-coin"), name = "USD Coin", symbol = "USDC"), + network = TokenMarketInfo.Network( + networkId = "ethereum", + isExchangeable = false, + contractAddress = null, + decimalCount = null, + ), + iconUrl = null, + ), + ) + advanceUntilIdle() + + // Assert — no valid predefined tokens → filter shows everything + assertThat(tokenFilter.value(account, currency(rawId = "shiba-inu", networkId = "ethereum"))).isTrue() + } + + // region Helpers + + private fun TestScope.lastState(delegate: PredefinedTokensBlockDelegate): PredefinedTokensUM? { + val emittedValues = getEmittedValues(delegate.stateFlow) + advanceUntilIdle() + return emittedValues.last() + } + + private fun TestScope.createDelegate( + predefinedTokens: MutableStateFlow>, + searchQueryState: MutableStateFlow = MutableStateFlow(SearchQuery.Empty), + tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> = + MutableStateFlow({ _, _ -> true }), + ): PredefinedTokensBlockDelegate = PredefinedTokensBlockDelegate( + predefinedTokens = predefinedTokens, + searchQueryState = searchQueryState, + addToPortfolioManager = addToPortfolioManager, + addToPortfolioSlot = addToPortfolioSlot, + modelScope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)), + tokenFilter = tokenFilter, + ) + + private fun currency(rawId: String, networkId: String): CryptoCurrencyStatus = + mockk(relaxed = true) { + every { currency.id.rawCurrencyId } returns CryptoCurrency.RawID(rawId) + every { currency.network.rawId } returns networkId + } + + private fun createPredefinedToken( + id: String = "bitcoin", + name: String = "Bitcoin", + symbol: String = "BTC", + networkId: String = ETHEREUM_NETWORK_ID, + iconUrl: String? = null, + ): PredefinedTokenToAdd = PredefinedTokenToAdd( + token = RawMarketToken(id = CryptoCurrency.RawID(id), name = name, symbol = symbol), + network = network(networkId), + iconUrl = iconUrl, + ) + + private fun network(networkId: String): TokenMarketInfo.Network = TokenMarketInfo.Network( + networkId = networkId, + isExchangeable = false, + contractAddress = null, + decimalCount = 6, + ) + + internal data class FilterModel(val query: String, val expectedIds: List?) + + @Suppress("UnusedPrivateMember") + private fun provideTestModels() = listOf( + FilterModel( + query = "", + expectedIds = listOf("bitcoin_$ETHEREUM_NETWORK_ID", "ethereum_$ETHEREUM_NETWORK_ID", "solana_$ETHEREUM_NETWORK_ID"), + ), + FilterModel(query = "btc", expectedIds = listOf("bitcoin_$ETHEREUM_NETWORK_ID")), + FilterModel(query = "SOL", expectedIds = listOf("solana_$ETHEREUM_NETWORK_ID")), + FilterModel(query = "ethereum", expectedIds = listOf("ethereum_$ETHEREUM_NETWORK_ID")), + FilterModel(query = "zzz", expectedIds = null), + ) + + // endregion + + private companion object { + const val ETHEREUM_NETWORK_ID = "ethereum" + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/build.gradle.kts b/features/promo-banners/impl/build.gradle.kts index 9dbea1af14..44b8f28f91 100644 --- a/features/promo-banners/impl/build.gradle.kts +++ b/features/promo-banners/impl/build.gradle.kts @@ -24,6 +24,10 @@ dependencies { implementation(projects.domain.appCurrency) implementation(projects.domain.account.status) implementation(projects.domain.promo) + implementation(projects.domain.promo.models) + + /** Data */ + implementation(projects.data.common) /** Core */ api(projects.core.configToggles) 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 c9068a7b07..0f312d3dba 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 @@ -18,6 +18,7 @@ import com.tangem.core.ui.ds2.button.Close import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel import com.tangem.features.promobanners.impl.campaigns.ui.ActivateCampaignContent @@ -83,6 +84,7 @@ internal class ActivateCampaignBottomSheetComponent( data class Params( val campaignType: CampaignType, + val userWalletId: UserWalletId, val modelCallbacks: ActivateCampaignModelCallbacks, ) 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 15ab2f6054..444542e29e 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 @@ -105,6 +105,7 @@ internal class DefaultCampaignsComponent @AssistedInject constructor( onFooterExtraHeightReady = model::onFooterExtraHeightReady, params = ActivateCampaignBottomSheetComponent.Params( campaignType = config.campaignType, + userWalletId = config.userWalletId, modelCallbacks = object : ActivateCampaignModelCallbacks { override val onActivated: (CampaignType) -> Unit = model::onActivated override val onAlreadyActivated: (CampaignType) -> Unit = model::onAlreadyActivated diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt index 3bc363ef98..cbfec595c7 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt @@ -1,5 +1,6 @@ package com.tangem.features.promobanners.impl.campaigns.entity +import com.tangem.domain.models.wallet.UserWalletId import kotlinx.serialization.Serializable @Serializable @@ -16,6 +17,7 @@ internal sealed class CampaignsBottomSheetConfig { @Serializable data class Activate( val campaignType: CampaignType, + val userWalletId: UserWalletId, ) : CampaignsBottomSheetConfig() @Serializable 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 2a1f4b9dfc..083f621067 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 @@ -23,10 +23,14 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.promo.models.EnrollResult import com.tangem.domain.promo.models.PromoCampaignId +import com.tangem.domain.promo.models.PromoCampaignState import com.tangem.domain.promo.models.TokenReward import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase +import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult +import com.tangem.features.commonfeatures.api.choosetoken.ChooserBlock +import com.tangem.features.commonfeatures.api.choosetoken.PredefinedTokenToAdd import com.tangem.features.promobanners.impl.R import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent import com.tangem.features.promobanners.impl.campaigns.component.ActivateCampaignBottomSheetComponent @@ -59,6 +63,8 @@ internal class ActivateCampaignsModel @Inject constructor( private val urlOpener: UrlOpener, @GlobalUiMessageSender private val messageSender: UiMessageSender, private val analyticsEventHandler: AnalyticsEventHandler, + private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase, + private val predefinedTokenResolver: PredefinedTokenResolver, ) : Model() { private val params = paramsContainer.require() @@ -68,6 +74,8 @@ internal class ActivateCampaignsModel @Inject constructor( private val campaignId: PromoCampaignId = params.campaignType.toPromoCampaignId() private val campaignContent = CampaignTypeToContentConverter().convert(campaignType) + private val predefinedTokensFlow = MutableStateFlow>(emptyList()) + val uiState: StateFlow field = MutableStateFlow(buildInitialModel()) @@ -75,7 +83,7 @@ internal class ActivateCampaignsModel @Inject constructor( modelScope = modelScope, settings = ChooseTokenBridge.Settings( title = resourceReference(R.string.common_choose_token), - isShowMarketBlock = false, + chooserBlock = ChooserBlock.Predefined(predefinedTokensFlow), isShowPaymentAccount = false, isShowSingleCurrencyWallets = true, ), @@ -95,6 +103,18 @@ internal class ActivateCampaignsModel @Inject constructor( bridge.onClose.receiveAsFlow() .onEach { onChooseTokenDismiss() } .launchIn(modelScope) + + modelScope.launch { loadPredefinedTokens() } + } + + private suspend fun loadPredefinedTokens() { + getPromoCampaignStateUseCase(campaignId, params.userWalletId) + .onLeft { error -> TangemLogger.e("Error loading campaign ${campaignType.campaignId} state", error) } + .onRight { state -> + if (state is PromoCampaignState.Available) { + predefinedTokensFlow.value = predefinedTokenResolver.resolve(state.payoutTokens) + } + } } private fun buildInitialModel(): ActivateCampaignUM = ActivateCampaignUM( 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 e309a9d8f1..fd1a8721a9 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 @@ -85,7 +85,7 @@ internal class CampaignsModel @Inject constructor( when (campaignState) { is PromoCampaignState.Enrolled, is PromoCampaignState.Available, - -> CampaignsBottomSheetConfig.Activate(campaignType) + -> CampaignsBottomSheetConfig.Activate(campaignType, userWalletId) is PromoCampaignState.NotActive -> CampaignsBottomSheetConfig.NotActive } }, diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/PredefinedTokenResolver.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/PredefinedTokenResolver.kt new file mode 100644 index 0000000000..d1ca926493 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/PredefinedTokenResolver.kt @@ -0,0 +1,41 @@ +package com.tangem.features.promobanners.impl.campaigns.model + +import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost +import com.tangem.domain.markets.RawMarketToken +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.promo.models.PromoPayoutToken +import com.tangem.features.commonfeatures.api.choosetoken.PredefinedTokenToAdd +import javax.inject.Inject + +/** + * Maps backend promo payout tokens into [PredefinedTokenToAdd] for the token chooser. + * + * The backend payload already carries everything needed — `tokenId` (the market raw id), decimals, + * symbol, name, contract address and network id — so no catalog lookup is required. The icon url is + * derived from the raw id via the canonical host helper, matching what the add-to-portfolio flow uses. + */ +internal class PredefinedTokenResolver @Inject constructor() { + + fun resolve(payoutTokens: List): List = payoutTokens + .map { payoutToken -> payoutToken.toPredefinedToken() } + .distinctBy { it.token.id.value to it.network.networkId } + + private fun PromoPayoutToken.toPredefinedToken(): PredefinedTokenToAdd { + val rawId = CryptoCurrency.RawID(tokenId) + return PredefinedTokenToAdd( + token = RawMarketToken( + id = rawId, + name = tokenName, + symbol = tokenSymbol, + ), + network = TokenMarketInfo.Network( + networkId = networkId, + isExchangeable = false, + contractAddress = tokenAddress, + decimalCount = decimals, + ), + iconUrl = getTokenIconUrlFromDefaultHost(rawId), + ) + } +} \ No newline at end of file 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 0c3f8c94c0..a1c0568648 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 @@ -20,6 +20,7 @@ import com.tangem.domain.promo.models.EnrollResult import com.tangem.domain.promo.models.PromoCampaignId import com.tangem.domain.promo.models.TokenReward import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase +import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent @@ -54,6 +55,8 @@ internal class ActivateCampaignsModelTest { private val urlOpener: UrlOpener = mockk(relaxed = true) private val messageSender: UiMessageSender = mockk(relaxed = true) private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase = mockk() + private val predefinedTokenResolver: PredefinedTokenResolver = mockk(relaxed = true) private val modelCallbacks: ActivateCampaignBottomSheetComponent.ActivateCampaignModelCallbacks = mockk(relaxed = true) @@ -219,10 +222,12 @@ internal class ActivateCampaignsModelTest { every { chooseTokenBridgeFactory.create(any(), any(), any()) } returns bridge every { getSelectedAppCurrencyUseCase.invokeOrDefault() } returns flowOf(AppCurrency.Default) coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false + coEvery { getPromoCampaignStateUseCase(any(), any(), any()) } returns Either.Left(Throwable()) return ActivateCampaignsModel( paramsContainer = MutableParamsContainer( ActivateCampaignBottomSheetComponent.Params( campaignType = campaignType, + userWalletId = userWalletId, modelCallbacks = modelCallbacks, ), ), @@ -234,6 +239,8 @@ internal class ActivateCampaignsModelTest { urlOpener = urlOpener, messageSender = messageSender, analyticsEventHandler = analyticsEventHandler, + getPromoCampaignStateUseCase = getPromoCampaignStateUseCase, + predefinedTokenResolver = predefinedTokenResolver, ) } diff --git a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/PredefinedTokenResolverTest.kt b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/PredefinedTokenResolverTest.kt new file mode 100644 index 0000000000..e11b87ee57 --- /dev/null +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/model/PredefinedTokenResolverTest.kt @@ -0,0 +1,88 @@ +package com.tangem.features.promobanners.impl.campaigns.model + +import com.google.common.truth.Truth.assertThat +import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost +import com.tangem.domain.markets.RawMarketToken +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.promo.models.PromoPayoutToken +import com.tangem.features.commonfeatures.api.choosetoken.PredefinedTokenToAdd +import org.junit.jupiter.api.Test + +internal class PredefinedTokenResolverTest { + + private val resolver = PredefinedTokenResolver() + + @Test + fun `GIVEN payout token WHEN resolve THEN mapped from payload`() { + // Arrange + val payoutToken = createPayoutToken( + tokenId = "cat-token", + tokenAddress = "0xContract", + tokenSymbol = "CAT", + tokenName = "Cat Token", + networkId = "ethereum", + decimals = 6, + ) + + // Act + val actual = resolver.resolve(listOf(payoutToken)) + + // Assert + val rawId = CryptoCurrency.RawID("cat-token") + val expected = PredefinedTokenToAdd( + token = RawMarketToken(id = rawId, name = "Cat Token", symbol = "CAT"), + network = TokenMarketInfo.Network( + networkId = "ethereum", + isExchangeable = false, + contractAddress = "0xContract", + decimalCount = 6, + ), + iconUrl = getTokenIconUrlFromDefaultHost(rawId), + ) + assertThat(actual).containsExactly(expected) + } + + @Test + fun `GIVEN multiple payout tokens WHEN resolve THEN input order preserved`() { + // Arrange + val first = createPayoutToken(tokenId = "first-token", tokenSymbol = "AAA", networkId = "ethereum") + val second = createPayoutToken(tokenId = "second-token", tokenSymbol = "BBB", networkId = "polygon") + + // Act + val actual = resolver.resolve(listOf(first, second)).map { it.token.id.value } + + // Assert + assertThat(actual).containsExactly("first-token", "second-token").inOrder() + } + + @Test + fun `GIVEN two payouts with same id on same network WHEN resolve THEN duplicate removed`() { + // Arrange + val first = createPayoutToken(tokenId = "usd-coin", tokenAddress = "0xFirst", networkId = "ethereum") + val second = createPayoutToken(tokenId = "usd-coin", tokenAddress = "0xSecond", networkId = "ethereum") + + // Act + val actual = resolver.resolve(listOf(first, second)) + + // Assert + assertThat(actual).hasSize(1) + assertThat(actual.single().network.contractAddress).isEqualTo("0xFirst") + } + + private fun createPayoutToken( + tokenId: String = "cat-token", + tokenAddress: String = "0xContract", + tokenSymbol: String = "CAT", + tokenName: String = "Cat Token", + networkId: String = "ethereum", + decimals: Int = 6, + ) = PromoPayoutToken( + tokenId = tokenId, + tokenAddress = tokenAddress, + tokenSymbol = tokenSymbol, + tokenName = tokenName, + networkId = networkId, + decimals = decimals, + ) +} \ No newline at end of file From 2b943f239f98e6c34832e934aa270b3bb41b0610 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 16 Jul 2026 09:36:16 +0200 Subject: [PATCH 35/59] Updated on 2026-08-14 --- .../com/tangem/domain/promo/models/PromoCampaignId.kt | 10 ++++++---- .../tangem/domain/promo/models/PromoCampaignIdTest.kt | 11 ----------- .../impl/campaigns/converters/CampaignIdConverter.kt | 10 +++------- .../campaigns/converters/CampaignIdConverterTest.kt | 10 ++++++++-- .../impl/campaigns/model/CampaignsModelTest.kt | 9 +++++---- 5 files changed, 22 insertions(+), 28 deletions(-) diff --git a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignId.kt b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignId.kt index 81269512e0..bb94ae415d 100644 --- a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignId.kt +++ b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignId.kt @@ -1,12 +1,14 @@ package com.tangem.domain.promo.models -enum class PromoCampaignId(val deeplinkId: Int, val slug: String) { - WhaleSwapCashback(deeplinkId = 1, slug = "whale-swap-cashback"), - ReactivationCashback(deeplinkId = 2, slug = "reactivation-cashback"), +private const val CAMPAIGN_ID_WHALE = "whale-swap-cashback" +private const val CAMPAIGN_ID_REACTIVATION = "reactivation-cashback" + +enum class PromoCampaignId(val slug: String) { + WhaleSwapCashback(slug = CAMPAIGN_ID_WHALE), + ReactivationCashback(slug = CAMPAIGN_ID_REACTIVATION), ; companion object { - fun fromDeeplinkId(id: Int): PromoCampaignId? = entries.firstOrNull { it.deeplinkId == id } fun fromSlug(slug: String): PromoCampaignId? = entries.firstOrNull { it.slug == slug } } } \ No newline at end of file diff --git a/domain/promo/models/src/test/kotlin/com/tangem/domain/promo/models/PromoCampaignIdTest.kt b/domain/promo/models/src/test/kotlin/com/tangem/domain/promo/models/PromoCampaignIdTest.kt index acd7d9425d..414d3bfa06 100644 --- a/domain/promo/models/src/test/kotlin/com/tangem/domain/promo/models/PromoCampaignIdTest.kt +++ b/domain/promo/models/src/test/kotlin/com/tangem/domain/promo/models/PromoCampaignIdTest.kt @@ -5,17 +5,6 @@ import org.junit.jupiter.api.Test internal class PromoCampaignIdTest { - @Test - fun `GIVEN known deeplink id WHEN fromDeeplinkId THEN returns campaign`() { - assertThat(PromoCampaignId.fromDeeplinkId(1)).isEqualTo(PromoCampaignId.WhaleSwapCashback) - assertThat(PromoCampaignId.fromDeeplinkId(2)).isEqualTo(PromoCampaignId.ReactivationCashback) - } - - @Test - fun `GIVEN unknown deeplink id WHEN fromDeeplinkId THEN returns null`() { - assertThat(PromoCampaignId.fromDeeplinkId(99)).isNull() - } - @Test fun `GIVEN known slug WHEN fromSlug THEN returns campaign`() { assertThat(PromoCampaignId.fromSlug("whale-swap-cashback")).isEqualTo(PromoCampaignId.WhaleSwapCashback) diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverter.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverter.kt index bf2a879216..6c5c480f96 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverter.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverter.kt @@ -1,5 +1,6 @@ package com.tangem.features.promobanners.impl.campaigns.converters +import com.tangem.domain.promo.models.PromoCampaignId import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType import com.tangem.utils.converter.Converter import javax.inject.Inject @@ -9,14 +10,9 @@ internal class CampaignIdConverter @Inject constructor() : override fun convert(value: String): CampaignType? { return when (value) { - CAMPAIGN_ID_REACTIVATION -> CampaignType.ReactivationCashback(campaignId = value) - CAMPAIGN_ID_WHALE -> CampaignType.WhaleSwapCashback(campaignId = value) + PromoCampaignId.ReactivationCashback.slug -> CampaignType.ReactivationCashback(campaignId = value) + PromoCampaignId.WhaleSwapCashback.slug -> CampaignType.WhaleSwapCashback(campaignId = value) else -> null } } - - private companion object { - const val CAMPAIGN_ID_WHALE = "1" - const val CAMPAIGN_ID_REACTIVATION = "2" - } } \ No newline at end of file diff --git a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverterTest.kt b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverterTest.kt index b80b33fbbd..9c571d85d0 100644 --- a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverterTest.kt +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverterTest.kt @@ -22,8 +22,14 @@ internal class CampaignIdConverterTest { } private fun provideConvertModels() = listOf( - ConvertModel(id = "1", expected = CampaignType.WhaleSwapCashback(campaignId = "1")), - ConvertModel(id = "2", expected = CampaignType.ReactivationCashback(campaignId = "2")), + ConvertModel( + id = "whale-swap-cashback", + expected = CampaignType.WhaleSwapCashback(campaignId = "whale-swap-cashback"), + ), + ConvertModel( + id = "reactivation-cashback", + expected = CampaignType.ReactivationCashback(campaignId = "reactivation-cashback"), + ), ConvertModel(id = "0", expected = null), ConvertModel(id = "unknown", expected = null), ConvertModel(id = "", expected = null), 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 27e2b8cb00..246510e41f 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 @@ -42,7 +42,6 @@ internal class CampaignsModelTest { private val messageSender: UiMessageSender = mockk(relaxed = true) private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) - // Real converter: "1" -> WhaleSwapCashback, "2" -> ReactivationCashback, else null. private val campaignIdConverter = CampaignIdConverter() @BeforeEach @@ -82,7 +81,9 @@ internal class CampaignsModelTest { // Arrange coEvery { getPromoCampaignStateUseCase.invoke(any(), any(), any()) } returns Either.Left(RuntimeException("network")) - val model = createModel(campaignFlow = flowOf(CampaignRequest(campaignId = "1", userWalletId = userWalletId))) + val model = createModel( + campaignFlow = flowOf(CampaignRequest(campaignId = "whale-swap-cashback", userWalletId = userWalletId)), + ) // Act advanceUntilIdle() @@ -180,8 +181,8 @@ internal class CampaignsModelTest { } private fun provideKnownCampaignModels() = listOf( - KnownCampaignModel(campaignId = "1", expectedPromoId = PromoCampaignId.WhaleSwapCashback), - KnownCampaignModel(campaignId = "2", expectedPromoId = PromoCampaignId.ReactivationCashback), + KnownCampaignModel(campaignId = "whale-swap-cashback", expectedPromoId = PromoCampaignId.WhaleSwapCashback), + KnownCampaignModel(campaignId = "reactivation-cashback", expectedPromoId = PromoCampaignId.ReactivationCashback), ) internal data class KnownCampaignModel( From 49f24cf911e0dd43f3d24e7028e32c010b9424ad Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 15 Jul 2026 11:02:46 +0200 Subject: [PATCH 36/59] Updated on 2026-08-14 --- .../models/CreatePromotionRegistrationBody.kt | 2 + .../models/PromotionRegistrationResponse.kt | 9 ++- .../local/preferences/PreferencesKeys.kt | 2 - .../data/promo/DefaultPromoRepository.kt | 24 +++++--- .../tangem/data/promo/di/PromoDataModule.kt | 11 ---- .../store/DefaultPromoEnrollmentStore.kt | 27 -------- .../data/promo/store/PromoEnrollmentStore.kt | 11 ---- .../data/promo/DefaultPromoRepositoryTest.kt | 49 +++++---------- .../domain/promo/models/PromoCampaignState.kt | 5 -- .../tangem/domain/promo/models/PromoModels.kt | 14 ++++- .../usecase/EnrollPromoCampaignUseCaseTest.kt | 6 +- .../campaigns/model/ActivateCampaignsModel.kt | 24 ++++++-- .../impl/campaigns/model/CampaignsModel.kt | 4 +- .../model/ActivateCampaignsModelTest.kt | 61 +++++++++++++++++-- 14 files changed, 132 insertions(+), 117 deletions(-) delete mode 100644 data/promo/src/main/kotlin/com/tangem/data/promo/store/DefaultPromoEnrollmentStore.kt delete mode 100644 data/promo/src/main/kotlin/com/tangem/data/promo/store/PromoEnrollmentStore.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/CreatePromotionRegistrationBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/CreatePromotionRegistrationBody.kt index dd02a67af8..0b2d9523f2 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/CreatePromotionRegistrationBody.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/CreatePromotionRegistrationBody.kt @@ -14,5 +14,7 @@ data class CreatePromotionRegistrationBody( data class TokenRewardDto( @Json(name = "tokenAddress") val tokenAddress: String, @Json(name = "networkId") val networkId: String, + @Json(name = "userAddress") val userAddress: String, + @Json(name = "tokenId") val tokenId: String, ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionRegistrationResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionRegistrationResponse.kt index 7f6da1f3ec..8909098866 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionRegistrationResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionRegistrationResponse.kt @@ -14,6 +14,13 @@ data class PromotionRegistrationResponse( data class RegistrationData( @Json(name = "campaignId") val campaignId: String, @Json(name = "registeredAt") val registeredAt: String?, - @Json(name = "tokenReward") val tokenReward: CreatePromotionRegistrationBody.TokenRewardDto, + @Json(name = "tokenReward") val tokenReward: RegisteredTokenRewardDto, + ) + + @JsonClass(generateAdapter = true) + data class RegisteredTokenRewardDto( + @Json(name = "tokenAddress") val tokenAddress: String, + @Json(name = "networkId") val networkId: String, + @Json(name = "tokenId") val tokenId: String, ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index f986a34931..eba1fa1049 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -129,8 +129,6 @@ object PreferencesKeys { val PENDING_ASSETS_DISCOVERY_KEY by lazy { stringPreferencesKey(name = "pendingAssetsDiscovery") } - val PROMO_ENROLLMENTS_KEY by lazy { stringPreferencesKey(name = "promoEnrollments") } - // region Notifications val NOTIFICATIONS_APPLICATION_ID_KEY by lazy { stringPreferencesKey(name = "notificationsApplicationId") } diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/DefaultPromoRepository.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/DefaultPromoRepository.kt index f1f3d5d51a..cfe29ea4d4 100644 --- a/data/promo/src/main/kotlin/com/tangem/data/promo/DefaultPromoRepository.kt +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/DefaultPromoRepository.kt @@ -2,7 +2,6 @@ package com.tangem.data.promo import com.squareup.moshi.Moshi import com.tangem.data.promo.converter.PromoCampaignConverter -import com.tangem.data.promo.store.PromoEnrollmentStore import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody @@ -12,6 +11,7 @@ import com.tangem.datasource.local.promotion.PromotionsSupplier import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.promo.PromoRepository import com.tangem.domain.promo.models.EnrollResult +import com.tangem.domain.promo.models.EnrolledTokenReward import com.tangem.domain.promo.models.PromoCampaignId import com.tangem.domain.promo.models.PromoCampaignState import com.tangem.domain.promo.models.TokenReward @@ -21,7 +21,6 @@ import kotlinx.coroutines.withContext internal class DefaultPromoRepository( private val promotionsSupplier: PromotionsSupplier, private val tangemApi: TangemTechApi, - private val enrollmentStore: PromoEnrollmentStore, private val moshi: Moshi, private val dispatchers: CoroutineDispatcherProvider, ) : PromoRepository { @@ -31,9 +30,6 @@ internal class DefaultPromoRepository( userWalletId: UserWalletId, forceRefresh: Boolean, ): PromoCampaignState = withContext(dispatchers.io) { - enrollmentStore.getSyncOrNull(campaign)?.let { - return@withContext PromoCampaignState.Enrolled(campaign, it) - } val all = promotionsSupplier.getPromotions(userWalletId, forceRefresh) .promotions.firstOrNull { it.name == campaign.slug }?.all when { @@ -56,7 +52,6 @@ internal class DefaultPromoRepository( when (val response = tangemApi.createPromotionRegistration(body)) { is ApiResponse.Success -> { val saved = response.data.data.tokenReward.toDomain() - enrollmentStore.store(campaign, saved) EnrollResult.Success(saved) } is ApiResponse.Error -> { @@ -64,8 +59,10 @@ internal class DefaultPromoRepository( val conflict = (cause as? ApiResponseError.HttpException) ?.takeIf { it.code == ApiResponseError.HttpException.Code.CONFLICT } if (conflict != null) { - val existing = parseConflict(conflict.errorBody)?.data?.tokenReward?.toDomain() ?: tokenReward - enrollmentStore.store(campaign, existing) + val existing = parseConflict(conflict.errorBody)?.data + ?.tokenReward + ?.toDomain() + ?: tokenReward.toEnrolledTokenReward() EnrollResult.AlreadyEnrolled(existing) } else { throw cause @@ -84,11 +81,20 @@ internal class DefaultPromoRepository( private fun TokenReward.toDto() = CreatePromotionRegistrationBody.TokenRewardDto( tokenAddress = tokenAddress, networkId = networkId, + userAddress = userAddress, + tokenId = tokenId, ) - private fun CreatePromotionRegistrationBody.TokenRewardDto.toDomain() = TokenReward( + private fun TokenReward.toEnrolledTokenReward() = EnrolledTokenReward( tokenAddress = tokenAddress, networkId = networkId, + tokenId = tokenId, + ) + + private fun PromotionRegistrationResponse.RegisteredTokenRewardDto.toDomain() = EnrolledTokenReward( + tokenAddress = tokenAddress, + networkId = networkId, + tokenId = tokenId, ) private companion object { diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/di/PromoDataModule.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/di/PromoDataModule.kt index 86123f8e4a..905c2597a7 100644 --- a/data/promo/src/main/kotlin/com/tangem/data/promo/di/PromoDataModule.kt +++ b/data/promo/src/main/kotlin/com/tangem/data/promo/di/PromoDataModule.kt @@ -2,11 +2,8 @@ package com.tangem.data.promo.di import com.squareup.moshi.Moshi import com.tangem.data.promo.DefaultPromoRepository -import com.tangem.data.promo.store.DefaultPromoEnrollmentStore -import com.tangem.data.promo.store.PromoEnrollmentStore import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.di.NetworkMoshi -import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.promotion.PromotionsSupplier import com.tangem.domain.promo.PromoRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -20,25 +17,17 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object PromoDataModule { - @Provides - @Singleton - fun providePromoEnrollmentStore(appPreferencesStore: AppPreferencesStore): PromoEnrollmentStore { - return DefaultPromoEnrollmentStore(appPreferencesStore) - } - @Provides @Singleton fun providePromoRepository( promotionsSupplier: PromotionsSupplier, tangemApi: TangemTechApi, - enrollmentStore: PromoEnrollmentStore, @NetworkMoshi moshi: Moshi, dispatchers: CoroutineDispatcherProvider, ): PromoRepository { return DefaultPromoRepository( promotionsSupplier = promotionsSupplier, tangemApi = tangemApi, - enrollmentStore = enrollmentStore, moshi = moshi, dispatchers = dispatchers, ) diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/store/DefaultPromoEnrollmentStore.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/store/DefaultPromoEnrollmentStore.kt deleted file mode 100644 index 9266118a8b..0000000000 --- a/data/promo/src/main/kotlin/com/tangem/data/promo/store/DefaultPromoEnrollmentStore.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.tangem.data.promo.store - -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.preferences.PreferencesKeys -import com.tangem.datasource.local.preferences.utils.getObjectMapSync -import com.tangem.domain.promo.models.PromoCampaignId -import com.tangem.domain.promo.models.TokenReward - -internal class DefaultPromoEnrollmentStore( - private val appPreferencesStore: AppPreferencesStore, -) : PromoEnrollmentStore { - - override suspend fun getSyncOrNull(campaign: PromoCampaignId): TokenReward? { - return appPreferencesStore - .getObjectMapSync(PreferencesKeys.PROMO_ENROLLMENTS_KEY)[campaign.slug] - } - - override suspend fun store(campaign: PromoCampaignId, tokenReward: TokenReward) { - appPreferencesStore.editData { mutablePreferences -> - val current = mutablePreferences.getObjectMap(PreferencesKeys.PROMO_ENROLLMENTS_KEY) - mutablePreferences.setObjectMap( - key = PreferencesKeys.PROMO_ENROLLMENTS_KEY, - value = current + (campaign.slug to tokenReward), - ) - } - } -} \ No newline at end of file diff --git a/data/promo/src/main/kotlin/com/tangem/data/promo/store/PromoEnrollmentStore.kt b/data/promo/src/main/kotlin/com/tangem/data/promo/store/PromoEnrollmentStore.kt deleted file mode 100644 index 2f770ef638..0000000000 --- a/data/promo/src/main/kotlin/com/tangem/data/promo/store/PromoEnrollmentStore.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tangem.data.promo.store - -import com.tangem.domain.promo.models.PromoCampaignId -import com.tangem.domain.promo.models.TokenReward - -interface PromoEnrollmentStore { - - suspend fun getSyncOrNull(campaign: PromoCampaignId): TokenReward? - - suspend fun store(campaign: PromoCampaignId, tokenReward: TokenReward) -} \ No newline at end of file diff --git a/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt b/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt index f2586d9b50..faa0e46217 100644 --- a/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt +++ b/data/promo/src/test/kotlin/com/tangem/data/promo/DefaultPromoRepositoryTest.kt @@ -3,10 +3,8 @@ package com.tangem.data.promo import com.google.common.truth.Truth.assertThat import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory -import com.tangem.data.promo.store.PromoEnrollmentStore import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError -import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse import com.tangem.datasource.api.promotion.models.PromotionsResponse import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto @@ -17,13 +15,13 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.promotion.PromotionsSupplier import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.promo.models.EnrollResult +import com.tangem.domain.promo.models.EnrolledTokenReward import com.tangem.domain.promo.models.PromoCampaignId import com.tangem.domain.promo.models.PromoCampaignState import com.tangem.domain.promo.models.TokenReward import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks import io.mockk.coEvery -import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach @@ -35,20 +33,21 @@ internal class DefaultPromoRepositoryTest { private val promotionsSupplier: PromotionsSupplier = mockk() private val tangemApi: TangemTechApi = mockk() - private val enrollmentStore: PromoEnrollmentStore = mockk(relaxed = true) private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() private val repository = DefaultPromoRepository( promotionsSupplier = promotionsSupplier, tangemApi = tangemApi, - enrollmentStore = enrollmentStore, moshi = moshi, dispatchers = TestingCoroutineDispatcherProvider(), ) private val campaign = PromoCampaignId.WhaleSwapCashback private val userWalletId = UserWalletId("abcdef012345") - private val tokenReward = TokenReward("0xToken", "ethereum") + private val tokenReward = TokenReward("0xToken", "ethereum", "0xUser", "tether") + + // The enroll result drops userAddress — this is what the submitted tokenReward collapses to. + private val resultTokenReward = EnrolledTokenReward("0xToken", "ethereum", "tether") private fun activeDto() = PromotionDto( name = campaign.slug, @@ -70,25 +69,11 @@ internal class DefaultPromoRepositoryTest { ) @BeforeEach - fun setUp() = clearMocks(promotionsSupplier, tangemApi, enrollmentStore) - - @Test - fun `GIVEN locally enrolled WHEN getCampaignState THEN Enrolled without api`() = runTest { - // Arrange - coEvery { enrollmentStore.getSyncOrNull(campaign) } returns tokenReward - - // Act - val result = repository.getCampaignState(campaign, userWalletId) - - // Assert - assertThat(result).isEqualTo(PromoCampaignState.Enrolled(campaign, tokenReward)) - coVerify(exactly = 0) { promotionsSupplier.getPromotions(any(), any()) } - } + fun setUp() = clearMocks(promotionsSupplier, tangemApi) @Test fun `GIVEN active campaign present and not enrolled WHEN getCampaignState THEN Available`() = runTest { // Arrange - coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns PromotionsResponse(promotions = listOf(activeDto())) @@ -102,7 +87,6 @@ internal class DefaultPromoRepositoryTest { @Test fun `GIVEN campaign absent WHEN getCampaignState THEN NotActive`() = runTest { // Arrange - coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns PromotionsResponse(promotions = emptyList()) @@ -116,7 +100,6 @@ internal class DefaultPromoRepositoryTest { @Test fun `GIVEN campaign present but finished WHEN getCampaignState THEN NotActive`() = runTest { // Arrange - coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null val finished = activeDto().copy(all = activeDto().all!!.copy(status = "finished")) coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns PromotionsResponse(promotions = listOf(finished)) @@ -129,12 +112,16 @@ internal class DefaultPromoRepositoryTest { } @Test - fun `GIVEN api returns 201 with canonical token WHEN enroll THEN Success and persists backend token`() = runTest { + fun `GIVEN api returns 201 with canonical token WHEN enroll THEN Success with backend token`() = runTest { // Arrange val data = PromotionRegistrationResponse.RegistrationData( campaignId = campaign.slug, registeredAt = "2026-07-06T09:27:13.363Z", - tokenReward = CreatePromotionRegistrationBody.TokenRewardDto("0xCanonical", "ethereum"), + tokenReward = PromotionRegistrationResponse.RegisteredTokenRewardDto( + tokenAddress = "0xCanonical", + networkId = "ethereum", + tokenId = "tether", + ), ) coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Success( PromotionRegistrationResponse(status = "saved", message = null, data = data), @@ -144,9 +131,8 @@ internal class DefaultPromoRepositoryTest { val result = repository.enroll(campaign, tokenReward, listOf(userWalletId)) // Assert - val backendToken = TokenReward("0xCanonical", "ethereum") + val backendToken = EnrolledTokenReward("0xCanonical", "ethereum", "tether") assertThat(result).isEqualTo(EnrollResult.Success(backendToken)) - coVerify(exactly = 1) { enrollmentStore.store(campaign, backendToken) } } @Test @@ -155,7 +141,7 @@ internal class DefaultPromoRepositoryTest { val existing = """ {"status":"already_exists","message":"exists","data":{"campaignId":"${campaign.slug}", "registeredAt":"2026-07-01T10:00:00.000Z","tokenReward":{"tokenAddress":"0xOther", - "networkId":"base","userAddress":"0xExisting"}}} + "networkId":"base","userAddress":"0xExisting","tokenId":"usd-coin"}}} """.trimIndent() @Suppress("UNCHECKED_CAST") coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Error( @@ -170,9 +156,8 @@ internal class DefaultPromoRepositoryTest { val result = repository.enroll(campaign, tokenReward, listOf(userWalletId)) // Assert - val expectedToken = TokenReward("0xOther", "base") + val expectedToken = EnrolledTokenReward("0xOther", "base", "usd-coin") assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(expectedToken)) - coVerify(exactly = 1) { enrollmentStore.store(campaign, expectedToken) } } @Test @@ -191,8 +176,7 @@ internal class DefaultPromoRepositoryTest { val result = repository.enroll(campaign, tokenReward, listOf(userWalletId)) // Assert - assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(tokenReward)) - coVerify(exactly = 1) { enrollmentStore.store(campaign, tokenReward) } + assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(resultTokenReward)) } @Test @@ -212,6 +196,5 @@ internal class DefaultPromoRepositoryTest { // Assert assertThat(error).isNotNull() - coVerify(exactly = 0) { enrollmentStore.store(any(), any()) } } } \ No newline at end of file diff --git a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignState.kt b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignState.kt index 6e580f4fcd..e741682340 100644 --- a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignState.kt +++ b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoCampaignState.kt @@ -10,11 +10,6 @@ sealed interface PromoCampaignState { val timeline: PromoTimeline, ) : PromoCampaignState - data class Enrolled( - override val campaign: PromoCampaignId, - val tokenReward: TokenReward, - ) : PromoCampaignState - data class NotActive( override val campaign: PromoCampaignId, ) : PromoCampaignState diff --git a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt index e2a3013893..c55f3a92b2 100644 --- a/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt +++ b/domain/promo/models/src/main/kotlin/com/tangem/domain/promo/models/PromoModels.kt @@ -19,11 +19,19 @@ data class PromoTimeline( data class TokenReward( val tokenAddress: String, val networkId: String, + val userAddress: String, + val tokenId: String, +) + +data class EnrolledTokenReward( + val tokenAddress: String, + val networkId: String, + val tokenId: String, ) sealed interface EnrollResult { - val tokenReward: TokenReward + val tokenReward: EnrolledTokenReward - data class Success(override val tokenReward: TokenReward) : EnrollResult - data class AlreadyEnrolled(override val tokenReward: TokenReward) : EnrollResult + data class Success(override val tokenReward: EnrolledTokenReward) : EnrollResult + data class AlreadyEnrolled(override val tokenReward: EnrolledTokenReward) : EnrollResult } \ No newline at end of file diff --git a/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCaseTest.kt b/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCaseTest.kt index a308dc7335..89e8244638 100644 --- a/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCaseTest.kt +++ b/domain/promo/src/test/kotlin/com/tangem/domain/promo/usecase/EnrollPromoCampaignUseCaseTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.promo.PromoRepository import com.tangem.domain.promo.models.EnrollResult +import com.tangem.domain.promo.models.EnrolledTokenReward import com.tangem.domain.promo.models.PromoCampaignId import com.tangem.domain.promo.models.TokenReward import com.tangem.test.core.assertEitherLeft @@ -24,7 +25,8 @@ internal class EnrollPromoCampaignUseCaseTest { private val campaign = PromoCampaignId.WhaleSwapCashback private val walletIds = listOf(UserWalletId("abcdef012345")) - private val tokenReward = TokenReward("0xToken", "ethereum") + private val tokenReward = TokenReward("0xToken", "ethereum", "0xUser", "tether") + private val resultTokenReward = EnrolledTokenReward("0xToken", "ethereum", "tether") @BeforeEach fun setUp() = clearMocks(repository) @@ -32,7 +34,7 @@ internal class EnrollPromoCampaignUseCaseTest { @Test fun `GIVEN repo returns Success WHEN invoke THEN Right Success`() = runTest { // Arrange - val expected = EnrollResult.Success(tokenReward) + val expected = EnrollResult.Success(resultTokenReward) coEvery { repository.enroll(campaign, tokenReward, walletIds) } returns expected // Act 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 083f621067..835a2f1642 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 @@ -20,13 +20,14 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.promo.models.EnrollResult import com.tangem.domain.promo.models.PromoCampaignId import com.tangem.domain.promo.models.PromoCampaignState import com.tangem.domain.promo.models.TokenReward import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult import com.tangem.features.commonfeatures.api.choosetoken.ChooserBlock @@ -42,6 +43,7 @@ import com.tangem.features.promobanners.impl.campaigns.entity.TermsUM import com.tangem.features.promobanners.impl.campaigns.entity.toPromoCampaignId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn @@ -65,6 +67,7 @@ internal class ActivateCampaignsModel @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase, private val predefinedTokenResolver: PredefinedTokenResolver, + private val getWalletsUseCase: GetWalletsUseCase, ) : Model() { private val params = paramsContainer.require() @@ -76,6 +79,8 @@ internal class ActivateCampaignsModel @Inject constructor( private val predefinedTokensFlow = MutableStateFlow>(emptyList()) + private var enrollJob: Job? = null + val uiState: StateFlow field = MutableStateFlow(buildInitialModel()) @@ -144,7 +149,9 @@ internal class ActivateCampaignsModel @Inject constructor( uiState.update { it.copy(isChoosingToken = false) } } - private fun onEnrollClick(selectedWalletId: UserWalletId, selectedToken: CryptoCurrency.Token) { + private fun onEnrollClick(selectedToken: CryptoCurrency.Token, networkAddress: NetworkAddress) { + if (enrollJob?.isActive == true) return + analyticsEventHandler.send( PromoCampaignsAnalyticsEvent.EnrollButtonClicked( campaignType = campaignType, @@ -153,14 +160,16 @@ internal class ActivateCampaignsModel @Inject constructor( ), ) - modelScope.launch { + enrollJob = modelScope.launch { enrollPromoCampaignUseCase.invoke( campaign = campaignId, tokenReward = TokenReward( tokenAddress = selectedToken.contractAddress, networkId = selectedToken.network.rawId, + tokenId = selectedToken.id.rawCurrencyId?.value.orEmpty(), + userAddress = networkAddress.defaultAddress.value, ), - walletIds = listOf(selectedWalletId), + walletIds = getAllUserWalletIds(), ).onLeft { error -> TangemLogger.e("Error enrolling campaign ${campaignType.campaignId}", error) messageSender.send(ToastMessage(message = resourceReference(R.string.common_unknown_error))) @@ -170,6 +179,10 @@ internal class ActivateCampaignsModel @Inject constructor( } } + private fun getAllUserWalletIds() = getWalletsUseCase + .invokeSync() + .map { it.walletId } + private fun handleEnrollResponse(enrollResult: EnrollResult) { when (enrollResult) { is EnrollResult.AlreadyEnrolled -> params.modelCallbacks.onAlreadyActivated(campaignType) @@ -187,6 +200,7 @@ internal class ActivateCampaignsModel @Inject constructor( 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()) { @@ -216,8 +230,8 @@ internal class ActivateCampaignsModel @Inject constructor( label = resourceReference(R.string.promo_campaign_enroll), onPrimaryButtonClick = { onEnrollClick( - selectedWalletId = result.walletId, selectedToken = selectedToken, + networkAddress = networkAddress, ) }, terms = TermsUM( 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 fd1a8721a9..91406341ff 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 @@ -83,9 +83,7 @@ internal class CampaignsModel @Inject constructor( }, ifRight = { campaignState -> when (campaignState) { - is PromoCampaignState.Enrolled, - is PromoCampaignState.Available, - -> CampaignsBottomSheetConfig.Activate(campaignType, userWalletId) + is PromoCampaignState.Available -> CampaignsBottomSheetConfig.Activate(campaignType, userWalletId) is PromoCampaignState.NotActive -> CampaignsBottomSheetConfig.NotActive } }, 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 a1c0568648..ac1a70953b 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 @@ -14,13 +14,16 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.promo.models.EnrollResult +import com.tangem.domain.promo.models.EnrolledTokenReward import com.tangem.domain.promo.models.PromoCampaignId import com.tangem.domain.promo.models.TokenReward import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent @@ -56,6 +59,7 @@ internal class ActivateCampaignsModelTest { private val messageSender: UiMessageSender = mockk(relaxed = true) private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase = mockk() + private val getWalletsUseCase: GetWalletsUseCase = mockk() private val predefinedTokenResolver: PredefinedTokenResolver = mockk(relaxed = true) private val modelCallbacks: ActivateCampaignBottomSheetComponent.ActivateCampaignModelCallbacks = mockk(relaxed = true) @@ -70,6 +74,7 @@ internal class ActivateCampaignsModelTest { getSelectedAppCurrencyUseCase, isAccountsModeEnabledUseCase, enrollPromoCampaignUseCase, + getWalletsUseCase, messageSender, analyticsEventHandler, modelCallbacks, @@ -128,7 +133,7 @@ internal class ActivateCampaignsModelTest { val token = token() val campaignType = CampaignType.WhaleSwapCashback(campaignId = "1") coEvery { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } returns - Either.Right(EnrollResult.Success(TokenReward(tokenAddress = "a", networkId = "b"))) + Either.Right(EnrollResult.Success(EnrolledTokenReward(tokenAddress = "a", networkId = "b", tokenId = "d"))) val model = createModel(campaignType) advanceUntilIdle() @@ -152,20 +157,45 @@ internal class ActivateCampaignsModelTest { coVerify(exactly = 1) { enrollPromoCampaignUseCase.invoke( campaign = PromoCampaignId.WhaleSwapCashback, - tokenReward = TokenReward(tokenAddress = token.contractAddress, networkId = token.network.rawId), - walletIds = listOf(userWalletId), + tokenReward = TokenReward( + tokenAddress = token.contractAddress, + networkId = token.network.rawId, + userAddress = userAddress, + tokenId = token.id.rawCurrencyId?.value.orEmpty(), + ), + walletIds = allWalletIds, ) } verify(exactly = 1) { modelCallbacks.onActivated(campaignType) } model.onDestroy() } + @Test + fun `GIVEN enroll in progress WHEN enroll clicked again THEN use case invoked once`() = runTest { + // Arrange + coEvery { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } returns + Either.Right(EnrollResult.Success(EnrolledTokenReward(tokenAddress = "a", networkId = "b", tokenId = "d"))) + val model = createModel(CampaignType.WhaleSwapCashback(campaignId = "1")) + advanceUntilIdle() + onCurrencyChosen.send(chooseTokenResult(currency = token())) + advanceUntilIdle() + + // Act — click twice before the in-flight enroll coroutine gets a chance to run + model.uiState.value.footerUM.onPrimaryButtonClick() + model.uiState.value.footerUM.onPrimaryButtonClick() + advanceUntilIdle() + + // Assert — the re-entrant click is ignored: enroll is triggered only once + coVerify(exactly = 1) { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } + model.onDestroy() + } + @Test fun `GIVEN enroll returns AlreadyEnrolled WHEN enroll clicked THEN onAlreadyActivated called`() = runTest { // Arrange val campaignType = CampaignType.WhaleSwapCashback(campaignId = "1") coEvery { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } returns - Either.Right(EnrollResult.AlreadyEnrolled(TokenReward(tokenAddress = "a", networkId = "b"))) + Either.Right(EnrollResult.AlreadyEnrolled(EnrolledTokenReward(tokenAddress = "a", networkId = "b", tokenId = "d"))) val model = createModel(campaignType) advanceUntilIdle() @@ -205,7 +235,14 @@ internal class ActivateCampaignsModelTest { private fun chooseTokenResult(currency: CryptoCurrency): ChooseTokenResult { val status = CryptoCurrencyStatus( currency = currency, - value = CryptoCurrencyStatus.MissedDerivation(priceChange = null, fiatRate = null), + // Must carry a networkAddress: the model resolves userAddress from it and otherwise drops the token. + value = CryptoCurrencyStatus.Unreachable( + priceChange = null, + fiatRate = null, + networkAddress = NetworkAddress.Single( + NetworkAddress.Address(value = userAddress, type = NetworkAddress.Address.Type.Primary), + ), + ), ) val wallet: UserWallet = mockk { every { walletId } returns userWalletId @@ -223,6 +260,9 @@ internal class ActivateCampaignsModelTest { every { getSelectedAppCurrencyUseCase.invokeOrDefault() } returns flowOf(AppCurrency.Default) coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false coEvery { getPromoCampaignStateUseCase(any(), any(), any()) } returns Either.Left(Throwable()) + every { getWalletsUseCase.invokeSync() } returns allWalletIds.map { walletId -> + mockk { every { this@mockk.walletId } returns walletId } + } return ActivateCampaignsModel( paramsContainer = MutableParamsContainer( ActivateCampaignBottomSheetComponent.Params( @@ -241,6 +281,7 @@ internal class ActivateCampaignsModelTest { analyticsEventHandler = analyticsEventHandler, getPromoCampaignStateUseCase = getPromoCampaignStateUseCase, predefinedTokenResolver = predefinedTokenResolver, + getWalletsUseCase = getWalletsUseCase, ) } @@ -269,5 +310,15 @@ internal class ActivateCampaignsModelTest { private companion object { val userWalletId = UserWalletId("0011223344556677") + + // The user's payout address the model resolves from the chosen token's networkAddress. + const val userAddress = "0xUserPayoutAddress" + + // Enrollment must target ALL user wallets ([REDACTED_TASK_KEY]), not only the currently selected one. + val allWalletIds = listOf( + userWalletId, + UserWalletId("8899aabbccddeeff"), + UserWalletId("a1b2c3d4e5f60718"), + ) } } \ No newline at end of file From 06b4772826e91fc5f841bad8d10c6c0ecce4bd8c Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Jul 2026 14:25:43 +0500 Subject: [PATCH 37/59] Updated on 2026-08-14 --- .../com/tangem/datasource/api/tangemTech/TangemTechApi.kt | 1 - features/common-features/impl/build.gradle.kts | 1 + .../impl/choosetoken/model/ChooseTokenModel.kt | 8 ++------ features/promo-banners/impl/build.gradle.kts | 4 +++- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index 9fe1bd2211..ba7b268cdf 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -3,7 +3,6 @@ package com.tangem.datasource.api.tangemTech import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse -import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse import com.tangem.datasource.api.promotion.models.PromotionsResponse import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse import com.tangem.datasource.api.stories.models.StoryContentResponse diff --git a/features/common-features/impl/build.gradle.kts b/features/common-features/impl/build.gradle.kts index a44ae83e47..43ff47afbd 100644 --- a/features/common-features/impl/build.gradle.kts +++ b/features/common-features/impl/build.gradle.kts @@ -47,6 +47,7 @@ dependencies { /** Tangem libraries */ implementation(tangemDeps.card.core) + implementation(tangemDeps.blockchain) /** Common */ implementation(projects.common.ui) 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 aefb466348..a824606076 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 @@ -9,13 +9,9 @@ import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.commonfeatures.api.R import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager -import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload -import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge -import com.tangem.features.commonfeatures.api.choosetoken.ChooserBlock +import com.tangem.features.commonfeatures.api.choosetoken.* 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.ChooseTokenComponent -import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult import com.tangem.features.commonfeatures.impl.choosetoken.AddToPortfolioRoute import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarToggleTransformer import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarUpdateQueryTransformer @@ -108,7 +104,7 @@ internal class ChooseTokenModel @Inject constructor( ) init { - if (bridge.settings.isShowMarketBlock) { + if (bridge.settings.chooserBlock is ChooserBlock.Market) { modelScope.launch { delay(MARKETS_INITIAL_LOAD_DELAY) marketBlockDelegate.loadDefaultMarkets() diff --git a/features/promo-banners/impl/build.gradle.kts b/features/promo-banners/impl/build.gradle.kts index 44b8f28f91..983c6465b4 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) @@ -36,7 +38,6 @@ dependencies { api(projects.core.decompose) api(projects.core.navigation) api(projects.core.utils) - implementation(projects.core.analytics) implementation(projects.core.analytics.models) implementation(projects.core.ui) @@ -44,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 */ From 26a48c75a10b81ba9d6db92ddfdc56157eab20f9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Jul 2026 13:29:13 +0300 Subject: [PATCH 38/59] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index f983c6defd..c6dd1a8d38 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit f983c6defd0b2240eb6f134aefe30f7e93696795 +Subproject commit c6dd1a8d384aa4b91ee09a9f99b7cdf8be3f1ab2 From 8d6114d0d09828526b202a3beba2954b0deb77c7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Jul 2026 13:31:33 +0300 Subject: [PATCH 39/59] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index c6dd1a8d38..5559381cd9 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit c6dd1a8d384aa4b91ee09a9f99b7cdf8be3f1ab2 +Subproject commit 5559381cd92d7d8747ca4531462879915499d405 From 3e900b5d5b164afe2a34be0d2645055932902277 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Jul 2026 14:09:48 +0200 Subject: [PATCH 40/59] Updated on 2026-08-14 --- .../impl/model/PromoBannersBlockModel.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 { From 33efe2ae522ff072159d4d01eba3a037c07e6119 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Jul 2026 14:14:24 +0200 Subject: [PATCH 41/59] Updated on 2026-08-14 --- .../bottomsheets/TangemBottomSheet.kt | 6 ++- .../modal/TangemModalBottomSheetWithFooter.kt | 20 +------- .../ActivateCampaignBottomSheetComponent.kt | 3 -- .../component/DefaultCampaignsComponent.kt | 50 +++++++++++++------ .../campaigns/model/ActivateCampaignsModel.kt | 13 ++++- .../impl/campaigns/model/CampaignsModel.kt | 13 ----- .../campaigns/ui/ActivateCampaignContent.kt | 2 - .../campaigns/ui/ActivateCampaignFooter.kt | 20 +------- .../ui/AlreadyActivatedCampaignContent.kt | 2 - .../ui/CampaignEnrolledMessageContent.kt | 2 - .../ui/NotActiveCampaignMessageContent.kt | 2 - .../campaigns/model/CampaignsModelTest.kt | 24 +-------- 12 files changed, 56 insertions(+), 101 deletions(-) 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 b597df325e..b1c7390080 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/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt index c5609bbad4..7d73948c60 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt @@ -21,7 +21,6 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.components.* @@ -39,9 +38,6 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.WindowInsetsZero import com.tangem.core.ui.utils.toPx -/** Default reserved height for the [TangemModalBottomSheetWithFooter] footer slot. */ -val DEFAULT_FOOTER_HEIGHT: Dp = 80.dp - /** * Modal bottom sheet with [content], [footer] and optional [title]. * @@ -54,12 +50,6 @@ inline fun TangemModalBottomSheetWi config: TangemBottomSheetConfig, containerColor: Color = TangemTheme.colors.background.primary, skipPartiallyExpanded: Boolean = true, - // FIXME([REDACTED_TASK_KEY]): temp workaround - // The footer slot reserves a fixed [DEFAULT_FOOTER_HEIGHT]; - // callers whose footer differs must pass the real height explicitly. Exposed as an opt-in - // parameter so existing usages keep the previous behavior and nothing else is affected. - // Rework so the sheet measures the actual footer height internally and drops this parameter. - footerHeight: Dp = DEFAULT_FOOTER_HEIGHT, noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit = {}, crossinline content: @Composable (T) -> Unit, @@ -75,7 +65,6 @@ inline fun TangemModalBottomSheetWi content = content, footer = footer, skipPartiallyExpanded = skipPartiallyExpanded, - footerHeight = footerHeight, ) } else { DefaultModalBottomSheetWithFooter( @@ -86,7 +75,6 @@ inline fun TangemModalBottomSheetWi footer = footer, onBack = onBack, skipPartiallyExpanded = skipPartiallyExpanded, - footerHeight = footerHeight, ) } } @@ -97,7 +85,6 @@ inline fun DefaultModalBottomSheetW config: TangemBottomSheetConfig, containerColor: Color, skipPartiallyExpanded: Boolean = true, - footerHeight: Dp = DEFAULT_FOOTER_HEIGHT, noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit, crossinline content: @Composable (T) -> Unit, @@ -130,7 +117,6 @@ inline fun DefaultModalBottomSheetW onBack = onBack, content = content, footer = footer, - footerHeight = footerHeight, ) } @@ -149,7 +135,6 @@ inline fun PreviewModalBottomSheetW config: TangemBottomSheetConfig, containerColor: Color, skipPartiallyExpanded: Boolean = true, - footerHeight: Dp = DEFAULT_FOOTER_HEIGHT, crossinline title: @Composable BoxScope.(T) -> Unit, crossinline content: @Composable (T) -> Unit, noinline footer: @Composable (BoxScope.(T) -> Unit)?, @@ -165,7 +150,6 @@ inline fun PreviewModalBottomSheetW title = title, content = content, footer = footer, - footerHeight = footerHeight, ) } @@ -177,7 +161,6 @@ inline fun BasicModalBottomSheetWit sheetState: TangemSheetState, containerColor: Color, modifier: Modifier = Modifier, - footerHeight: Dp = DEFAULT_FOOTER_HEIGHT, noinline onBack: (() -> Unit)? = null, crossinline title: @Composable BoxScope.(T) -> Unit, crossinline content: @Composable (T) -> Unit, @@ -195,8 +178,7 @@ inline fun BasicModalBottomSheetWit val isKeyboardOpen by rememberIsKeyboardVisible() val buttonHeight by animateDpAsState( - targetValue = if (footer != null) footerHeight else 0.dp, - label = "FooterHeight", + targetValue = if (footer != null) 80.dp else 0.dp, ) // Offset calculation for keyboard scroll adjustment: // 1) Button height (footer) 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..8762938b78 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, + 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..4d9d11235f 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 @@ -12,6 +12,7 @@ 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 @@ -219,7 +220,17 @@ internal class ActivateCampaignsModel @Inject constructor( 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..bf7da60980 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() } } 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..237962daee 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, 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/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() } From 499b7c04ea30ac8ff565ae92f87d802c332936a0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 8 Jul 2026 19:32:44 +0500 Subject: [PATCH 42/59] Updated on 2026-08-14 --- app/build.gradle.kts | 3 + .../tap/di/domain/MarketingDomainModule.kt | 28 ++ .../configs/feature_toggles_config.json | 4 + .../models/MarketingCampaignsCacheEntry.kt | 11 + .../models/MarketingCampaignsResponse.kt | 42 +++ .../api/tangemTech/TangemTechApi.kt | 15 + .../MarketingCampaignsResponseTest.kt | 75 +++++ data/marketing/build.gradle.kts | 30 ++ .../marketing/DefaultMarketingRepository.kt | 97 ++++++ .../converter/MarketingCampaignConverter.kt | 66 ++++ .../data/marketing/di/MarketingDataModule.kt | 91 ++++++ .../DefaultMarketingFeatureToggles.kt | 13 + .../store/MarketingCampaignsCacheStore.kt | 21 ++ .../marketing/store/MarketingDismissStore.kt | 20 ++ .../DefaultMarketingRepositoryTest.kt | 154 +++++++++ .../MarketingCampaignConverterTest.kt | 125 ++++++++ .../DefaultMarketingFeatureTogglesTest.kt | 32 ++ .../marketing/store/MarketingStoresTest.kt | 64 ++++ domain/marketing/build.gradle.kts | 13 + domain/marketing/models/build.gradle.kts | 8 + .../marketing/models/MarketingBanner.kt | 16 + .../marketing/models/MarketingCampaign.kt | 16 + .../models/MarketingCampaignTarget.kt | 14 + .../marketing/models/MarketingScreen.kt | 43 +++ .../marketing/models/MarketingScreenType.kt | 19 ++ .../models/MarketingScreenTypeTest.kt | 36 +++ .../DismissMarketingBannerUseCase.kt | 12 + .../marketing/GetMarketingBannerUseCase.kt | 85 +++++ .../marketing/MarketingFeatureToggles.kt | 5 + .../domain/marketing/MarketingRepository.kt | 16 + .../DismissMarketingBannerUseCaseTest.kt | 34 ++ .../GetMarketingBannerUseCaseTest.kt | 291 ++++++++++++++++++ settings.gradle.kts | 3 + 33 files changed, 1502 insertions(+) create mode 100644 app/src/main/java/com/tangem/tap/di/domain/MarketingDomainModule.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/marketing/models/MarketingCampaignsCacheEntry.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/marketing/models/MarketingCampaignsResponse.kt create mode 100644 core/datasource/src/test/kotlin/com/tangem/datasource/api/marketing/MarketingCampaignsResponseTest.kt create mode 100644 data/marketing/build.gradle.kts create mode 100644 data/marketing/src/main/kotlin/com/tangem/data/marketing/DefaultMarketingRepository.kt create mode 100644 data/marketing/src/main/kotlin/com/tangem/data/marketing/converter/MarketingCampaignConverter.kt create mode 100644 data/marketing/src/main/kotlin/com/tangem/data/marketing/di/MarketingDataModule.kt create mode 100644 data/marketing/src/main/kotlin/com/tangem/data/marketing/featuretoggle/DefaultMarketingFeatureToggles.kt create mode 100644 data/marketing/src/main/kotlin/com/tangem/data/marketing/store/MarketingCampaignsCacheStore.kt create mode 100644 data/marketing/src/main/kotlin/com/tangem/data/marketing/store/MarketingDismissStore.kt create mode 100644 data/marketing/src/test/kotlin/com/tangem/data/marketing/DefaultMarketingRepositoryTest.kt create mode 100644 data/marketing/src/test/kotlin/com/tangem/data/marketing/converter/MarketingCampaignConverterTest.kt create mode 100644 data/marketing/src/test/kotlin/com/tangem/data/marketing/featuretoggle/DefaultMarketingFeatureTogglesTest.kt create mode 100644 data/marketing/src/test/kotlin/com/tangem/data/marketing/store/MarketingStoresTest.kt create mode 100644 domain/marketing/build.gradle.kts create mode 100644 domain/marketing/models/build.gradle.kts create mode 100644 domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingBanner.kt create mode 100644 domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaign.kt create mode 100644 domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignTarget.kt create mode 100644 domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingScreen.kt create mode 100644 domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingScreenType.kt create mode 100644 domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingScreenTypeTest.kt create mode 100644 domain/marketing/src/main/kotlin/com/tangem/domain/marketing/DismissMarketingBannerUseCase.kt create mode 100644 domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt create mode 100644 domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingFeatureToggles.kt create mode 100644 domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingRepository.kt create mode 100644 domain/marketing/src/test/kotlin/com/tangem/domain/marketing/DismissMarketingBannerUseCaseTest.kt create mode 100644 domain/marketing/src/test/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCaseTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5a4feed03c..06af283694 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -155,6 +155,8 @@ dependencies { implementation(projects.domain.onramp) implementation(projects.domain.stories) implementation(projects.domain.stories.models) + implementation(projects.domain.marketing) + implementation(projects.domain.marketing.models) implementation(projects.domain.networks) implementation(projects.domain.quotes) implementation(projects.domain.notifications) @@ -209,6 +211,7 @@ dependencies { implementation(projects.data.transaction) implementation(projects.data.visa) implementation(projects.data.stories) + implementation(projects.data.marketing) implementation(projects.data.onboarding) implementation(projects.data.dynamicAddresses) implementation(projects.data.feedback) diff --git a/app/src/main/java/com/tangem/tap/di/domain/MarketingDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/MarketingDomainModule.kt new file mode 100644 index 0000000000..7e927f9d61 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/MarketingDomainModule.kt @@ -0,0 +1,28 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.marketing.DismissMarketingBannerUseCase +import com.tangem.domain.marketing.GetMarketingBannerUseCase +import com.tangem.domain.marketing.MarketingFeatureToggles +import com.tangem.domain.marketing.MarketingRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object MarketingDomainModule { + + @Provides + @Singleton + fun provideGetMarketingBannerUseCase( + repository: MarketingRepository, + featureToggles: MarketingFeatureToggles, + ): GetMarketingBannerUseCase = GetMarketingBannerUseCase(repository, featureToggles) + + @Provides + @Singleton + fun provideDismissMarketingBannerUseCase(repository: MarketingRepository): DismissMarketingBannerUseCase = + DismissMarketingBannerUseCase(repository) +} \ 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 0f7b86fa5d..d20c48faa5 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 @@ -178,5 +178,9 @@ { "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/datasource/src/main/java/com/tangem/datasource/api/marketing/models/MarketingCampaignsCacheEntry.kt b/core/datasource/src/main/java/com/tangem/datasource/api/marketing/models/MarketingCampaignsCacheEntry.kt new file mode 100644 index 0000000000..fed65f16a5 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/marketing/models/MarketingCampaignsCacheEntry.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.marketing.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** Cached campaigns response plus its ETag, persisted per [CampaignDto.type] for revalidation. */ +@JsonClass(generateAdapter = true) +data class MarketingCampaignsCacheEntry( + @Json(name = "eTag") val eTag: String?, + @Json(name = "response") val response: MarketingCampaignsResponse, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/marketing/models/MarketingCampaignsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/marketing/models/MarketingCampaignsResponse.kt new file mode 100644 index 0000000000..4340898419 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/marketing/models/MarketingCampaignsResponse.kt @@ -0,0 +1,42 @@ +package com.tangem.datasource.api.marketing.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.math.BigDecimal + +@JsonClass(generateAdapter = true) +data class MarketingCampaignsResponse( + @Json(name = "campaigns") val campaigns: List, +) + +@JsonClass(generateAdapter = true) +data class CampaignDto( + @Json(name = "id") val id: Int, + @Json(name = "type") val type: String, + @Json(name = "priority") val priority: Int, + @Json(name = "startAt") val startAt: String? = null, + @Json(name = "endAt") val endAt: String? = null, + @Json(name = "minAmount") val minAmount: BigDecimal? = null, + @Json(name = "maxAmount") val maxAmount: BigDecimal? = null, + @Json(name = "providerIds") val providerIds: List? = null, + @Json(name = "tokens") val tokens: List? = null, + @Json(name = "banner") val banner: BannerDto, +) + +@JsonClass(generateAdapter = true) +data class CampaignTokenDto( + @Json(name = "networkId") val networkId: String? = null, + @Json(name = "contractAddress") val contractAddress: String? = null, + @Json(name = "id") val id: String? = null, +) + +@JsonClass(generateAdapter = true) +data class BannerDto( + @Json(name = "uiType") val uiType: String, + @Json(name = "text") val text: String? = null, + @Json(name = "icon") val icon: String? = null, + @Json(name = "iconAlign") val iconAlign: String? = null, + @Json(name = "bgColor") val bgColor: String? = null, + @Json(name = "deeplink") val deeplink: String? = null, + @Json(name = "dismissible") val isDismissible: Boolean = false, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index ba7b268cdf..1289a1471c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -3,6 +3,7 @@ package com.tangem.datasource.api.tangemTech import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse +import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse import com.tangem.datasource.api.promotion.models.PromotionsResponse import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse import com.tangem.datasource.api.stories.models.StoryContentResponse @@ -242,4 +243,18 @@ interface TangemTechApi { @GET("v1/earn/networks") suspend fun getEarnNetworks(@Query("type") type: String? = null): ApiResponse // endregion + + // region marketing + @GET("api/v1/marketing/campaigns") + suspend fun getMarketingCampaigns( + @Query("type") type: String, + @Query("language") language: String? = null, + @Query("fromNetwork") fromNetwork: String? = null, + @Query("fromContractAddress") fromContractAddress: String? = null, + @Query("toNetwork") toNetwork: String? = null, + @Query("toContractAddress") toContractAddress: String? = null, + @Query("fromFiat") fromFiat: String? = null, + @Header("If-None-Match") eTag: String? = null, + ): ApiResponse + // endregion } \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/marketing/MarketingCampaignsResponseTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/marketing/MarketingCampaignsResponseTest.kt new file mode 100644 index 0000000000..db902e7e69 --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/marketing/MarketingCampaignsResponseTest.kt @@ -0,0 +1,75 @@ +package com.tangem.datasource.api.marketing + +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.Moshi +import com.tangem.datasource.api.common.adapter.BigDecimalAdapter +import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class MarketingCampaignsResponseTest { + + private val moshi = Moshi.Builder().add(BigDecimalAdapter()).build() + private val adapter = moshi.adapter(MarketingCampaignsResponse::class.java) + + @Test + fun `GIVEN swap response json WHEN parsed THEN fields mapped`() { + // Arrange + val json = """ + {"campaigns":[{"id":12,"type":"swap","priority":1,"minAmount":50,"maxAmount":300, + "providerIds":["provider1"], + "banner":{"uiType":"linked_to_provider","text":"Cashback 4 U","icon":"https://x/star.webp", + "bgColor":"#FF0011","deeplink":"https://tangem.com","dismissible":true}}]} + """.trimIndent() + + // Act + val result = adapter.fromJson(json)!! + + // Assert + val campaign = result.campaigns.single() + assertThat(campaign.id).isEqualTo(12) + assertThat(campaign.type).isEqualTo("swap") + assertThat(campaign.minAmount).isEqualTo(BigDecimal(50)) + assertThat(campaign.providerIds).containsExactly("provider1") + assertThat(campaign.banner.uiType).isEqualTo("linked_to_provider") + assertThat(campaign.banner.isDismissible).isTrue() + assertThat(campaign.tokens).isNull() + } + + @Test + fun `GIVEN token_details response WHEN parsed THEN network targets mapped`() { + // Arrange + val json = """ + {"campaigns":[{"id":12,"type":"token_details","priority":1, + "tokens":[{"networkId":"ethereum","contractAddress":"0xA0b8"}], + "banner":{"uiType":"standalone","dismissible":false}}]} + """.trimIndent() + + // Act + val campaign = adapter.fromJson(json)!!.campaigns.single() + + // Assert + val token = campaign.tokens!!.single() + assertThat(token.networkId).isEqualTo("ethereum") + assertThat(token.contractAddress).isEqualTo("0xA0b8") + assertThat(token.id).isNull() + assertThat(campaign.minAmount).isNull() + } + + @Test + fun `GIVEN markets response WHEN parsed THEN coingecko ids mapped`() { + // Arrange + val json = """ + {"campaigns":[{"id":12,"type":"markets_token","priority":1, + "tokens":[{"id":"1696501400"},{"id":"3296501412"}], + "banner":{"uiType":"standalone","dismissible":true}}]} + """.trimIndent() + + // Act + val tokens = adapter.fromJson(json)!!.campaigns.single().tokens!! + + // Assert + assertThat(tokens.map { it.id }).containsExactly("1696501400", "3296501412") + assertThat(tokens.all { it.networkId == null }).isTrue() + } +} \ No newline at end of file diff --git a/data/marketing/build.gradle.kts b/data/marketing/build.gradle.kts new file mode 100644 index 0000000000..ac7fab8510 --- /dev/null +++ b/data/marketing/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.data.marketing" +} + +dependencies { + implementation(deps.androidx.datastore) + + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + implementation(deps.kotlin.coroutines) + implementation(deps.arrow.core) + + implementation(projects.domain.marketing) + implementation(projects.domain.marketing.models) + + implementation(projects.core.datasource) + implementation(projects.core.utils) + implementation(projects.core.configToggles) + + testImplementation(projects.test.core) +} \ No newline at end of file diff --git a/data/marketing/src/main/kotlin/com/tangem/data/marketing/DefaultMarketingRepository.kt b/data/marketing/src/main/kotlin/com/tangem/data/marketing/DefaultMarketingRepository.kt new file mode 100644 index 0000000000..1c27200732 --- /dev/null +++ b/data/marketing/src/main/kotlin/com/tangem/data/marketing/DefaultMarketingRepository.kt @@ -0,0 +1,97 @@ +package com.tangem.data.marketing + +import arrow.core.Either +import com.tangem.data.marketing.converter.MarketingCampaignConverter +import com.tangem.data.marketing.store.MarketingCampaignsCacheStore +import com.tangem.data.marketing.store.MarketingDismissStore +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ETAG_HEADER +import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry +import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.domain.marketing.MarketingRepository +import com.tangem.domain.marketing.models.MarketingCampaign +import com.tangem.domain.marketing.models.MarketingScreen +import com.tangem.utils.SupportedLanguages +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.withContext + +internal class DefaultMarketingRepository( + private val tangemTechApi: TangemTechApi, + private val cacheStore: MarketingCampaignsCacheStore, + private val dismissStore: MarketingDismissStore, + private val converter: MarketingCampaignConverter, + private val dispatchers: CoroutineDispatcherProvider, +) : MarketingRepository { + + override suspend fun getCampaigns(screen: MarketingScreen): Either> = + withContext(dispatchers.io) { + Either.catch { + val isCacheable = screen.type.isCacheable + val cached = if (isCacheable) cacheStore.get(screen.type.value) else null + + when (val response = requestCampaigns(screen, eTag = cached?.eTag)) { + is ApiResponse.Success -> { + if (isCacheable) { + // eTag may be null if the server omits it; we still cache the body for the 5xx + // fallback path. A null eTag simply means the next request sends no If-None-Match + // (Retrofit omits null headers) and receives a fresh 200. + val eTag = response.headers[ETAG_HEADER]?.firstOrNull() + cacheStore.store(screen.type.value, MarketingCampaignsCacheEntry(eTag, response.data)) + } + convert(response.data) + } + is ApiResponse.Error -> handleError(cached) + } + } + } + + override suspend fun getDismissedBannerIds(): Set = dismissStore.getDismissedIds() + + override suspend fun dismissBanner(campaignId: Int) = dismissStore.dismiss(campaignId) + + private suspend fun requestCampaigns( + screen: MarketingScreen, + eTag: String?, + ): ApiResponse { + val language = SupportedLanguages.getCurrentSupportedLanguageCode() + return when (screen) { + is MarketingScreen.Swap -> tangemTechApi.getMarketingCampaigns( + type = screen.type.value, + language = language, + fromNetwork = screen.fromNetwork, + // Omit the contract for a native coin (blank) — Retrofit drops null query params. + fromContractAddress = screen.fromContractAddress.ifBlank { null }, + toNetwork = screen.toNetwork, + toContractAddress = screen.toContractAddress.ifBlank { null }, + ) + is MarketingScreen.Onramp -> tangemTechApi.getMarketingCampaigns( + type = screen.type.value, + language = language, + fromFiat = screen.fromFiat, + toNetwork = screen.toNetwork, + toContractAddress = screen.toContractAddress.ifBlank { null }, + ) + is MarketingScreen.TokenDetails, + is MarketingScreen.TokenMarkets, + is MarketingScreen.Staking, + is MarketingScreen.Yield, + -> tangemTechApi.getMarketingCampaigns(type = screen.type.value, language = language, eTag = eTag) + } + } + + /** + * All error cases (304 not-modified, 5xx, network failure) degrade gracefully to the cached + * response. Returning an empty list when there is no cache is intentional — "no banner" is a + * normal state, not an error the caller needs to handle. + */ + private fun handleError(cached: MarketingCampaignsCacheEntry?): List { + return cached?.response?.let(::convert).orEmpty() + } + + private fun convert(response: MarketingCampaignsResponse): List = + converter.convertListIgnoreErrors(response.campaigns) { throwable -> + TangemLogger.w("Skipped invalid marketing campaign: ${throwable.message}") + } +} \ No newline at end of file diff --git a/data/marketing/src/main/kotlin/com/tangem/data/marketing/converter/MarketingCampaignConverter.kt b/data/marketing/src/main/kotlin/com/tangem/data/marketing/converter/MarketingCampaignConverter.kt new file mode 100644 index 0000000000..9a4ec1e436 --- /dev/null +++ b/data/marketing/src/main/kotlin/com/tangem/data/marketing/converter/MarketingCampaignConverter.kt @@ -0,0 +1,66 @@ +package com.tangem.data.marketing.converter + +import com.tangem.datasource.api.marketing.models.BannerDto +import com.tangem.datasource.api.marketing.models.CampaignDto +import com.tangem.datasource.api.marketing.models.CampaignTokenDto +import com.tangem.domain.marketing.models.MarketingBanner +import com.tangem.domain.marketing.models.MarketingCampaign +import com.tangem.domain.marketing.models.MarketingCampaignTarget +import com.tangem.domain.marketing.models.MarketingScreenType +import com.tangem.utils.converter.Converter + +internal class MarketingCampaignConverter : Converter { + + override fun convert(value: CampaignDto): MarketingCampaign { + val type = requireNotNull(MarketingScreenType.fromValue(value.type)) { "Unknown campaign type: ${value.type}" } + val banner = convertBanner(value.banner) + + require(banner.uiType != MarketingBanner.UiType.LINKED_TO_PROVIDER || !value.providerIds.isNullOrEmpty()) { + "linked_to_provider campaign ${value.id} has no providerIds" + } + + return MarketingCampaign( + id = value.id, + type = type, + priority = value.priority, + startAt = value.startAt, + endAt = value.endAt, + minAmount = value.minAmount, + maxAmount = value.maxAmount, + providerIds = value.providerIds, + banner = banner, + targets = value.tokens.orEmpty().mapNotNull(::convertTarget), + ) + } + + private fun convertBanner(dto: BannerDto) = MarketingBanner( + uiType = when (dto.uiType) { + "linked_to_provider" -> MarketingBanner.UiType.LINKED_TO_PROVIDER + else -> MarketingBanner.UiType.STANDALONE + }, + text = dto.text, + iconUrl = dto.icon, + iconAlign = when (dto.iconAlign) { + "left" -> MarketingBanner.IconAlign.LEFT + "right" -> MarketingBanner.IconAlign.RIGHT + else -> null + }, + bgColor = dto.bgColor, + deeplink = dto.deeplink, + isDismissible = dto.isDismissible, + ) + + private fun convertTarget(dto: CampaignTokenDto): MarketingCampaignTarget? { + val coingeckoId = dto.id + val networkId = dto.networkId + return when { + coingeckoId != null -> MarketingCampaignTarget.CoingeckoId(id = coingeckoId) + // contractAddress may be null — that's a native coin of [networkId], not an invalid target. + networkId != null -> MarketingCampaignTarget.NetworkContract( + networkId = networkId, + contractAddress = dto.contractAddress, + ) + else -> null + } + } +} \ No newline at end of file diff --git a/data/marketing/src/main/kotlin/com/tangem/data/marketing/di/MarketingDataModule.kt b/data/marketing/src/main/kotlin/com/tangem/data/marketing/di/MarketingDataModule.kt new file mode 100644 index 0000000000..a2b4987850 --- /dev/null +++ b/data/marketing/src/main/kotlin/com/tangem/data/marketing/di/MarketingDataModule.kt @@ -0,0 +1,91 @@ +package com.tangem.data.marketing.di + +import android.content.Context +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.squareup.moshi.Moshi +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.data.marketing.DefaultMarketingRepository +import com.tangem.data.marketing.converter.MarketingCampaignConverter +import com.tangem.data.marketing.featuretoggle.DefaultMarketingFeatureToggles +import com.tangem.data.marketing.store.DefaultMarketingCampaignsCacheStore +import com.tangem.data.marketing.store.DefaultMarketingDismissStore +import com.tangem.data.marketing.store.MarketingCampaignsCacheStore +import com.tangem.data.marketing.store.MarketingDismissStore +import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.utils.MoshiDataStoreSerializer +import com.tangem.datasource.utils.mapWithStringKeyTypes +import com.tangem.datasource.utils.setTypes +import com.tangem.domain.marketing.MarketingFeatureToggles +import com.tangem.domain.marketing.MarketingRepository +import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object MarketingDataModule { + + @Provides + @Singleton + fun provideMarketingCampaignsCacheStore( + @NetworkMoshi moshi: Moshi, + @ApplicationContext context: Context, + appScope: AppCoroutineScope, + ): MarketingCampaignsCacheStore = DefaultMarketingCampaignsCacheStore( + dataStore = DataStoreFactory.create( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = mapWithStringKeyTypes(), + defaultValue = emptyMap(), + ), + produceFile = { context.dataStoreFile(fileName = "marketing_campaigns_cache") }, + scope = appScope, + ), + ) + + @Provides + @Singleton + fun provideMarketingDismissStore( + @NetworkMoshi moshi: Moshi, + @ApplicationContext context: Context, + appScope: AppCoroutineScope, + ): MarketingDismissStore = DefaultMarketingDismissStore( + dataStore = DataStoreFactory.create( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = setTypes(), + defaultValue = emptySet(), + ), + produceFile = { context.dataStoreFile(fileName = "marketing_dismissed_banner_ids") }, + scope = appScope, + ), + ) + + @Provides + @Singleton + fun provideMarketingFeatureToggles(featureTogglesManager: FeatureTogglesManager): MarketingFeatureToggles = + DefaultMarketingFeatureToggles(featureTogglesManager) + + @Provides + @Singleton + fun provideMarketingRepository( + tangemTechApi: TangemTechApi, + cacheStore: MarketingCampaignsCacheStore, + dismissStore: MarketingDismissStore, + dispatchers: CoroutineDispatcherProvider, + ): MarketingRepository = DefaultMarketingRepository( + tangemTechApi = tangemTechApi, + cacheStore = cacheStore, + dismissStore = dismissStore, + converter = MarketingCampaignConverter(), + dispatchers = dispatchers, + ) +} \ No newline at end of file diff --git a/data/marketing/src/main/kotlin/com/tangem/data/marketing/featuretoggle/DefaultMarketingFeatureToggles.kt b/data/marketing/src/main/kotlin/com/tangem/data/marketing/featuretoggle/DefaultMarketingFeatureToggles.kt new file mode 100644 index 0000000000..80beb5b373 --- /dev/null +++ b/data/marketing/src/main/kotlin/com/tangem/data/marketing/featuretoggle/DefaultMarketingFeatureToggles.kt @@ -0,0 +1,13 @@ +package com.tangem.data.marketing.featuretoggle + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.domain.marketing.MarketingFeatureToggles + +internal class DefaultMarketingFeatureToggles( + private val featureTogglesManager: FeatureTogglesManager, +) : MarketingFeatureToggles { + + override val isMarketingBannersEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1522_MARKETING_BANNERS_ENABLED) +} \ No newline at end of file diff --git a/data/marketing/src/main/kotlin/com/tangem/data/marketing/store/MarketingCampaignsCacheStore.kt b/data/marketing/src/main/kotlin/com/tangem/data/marketing/store/MarketingCampaignsCacheStore.kt new file mode 100644 index 0000000000..86dc96da65 --- /dev/null +++ b/data/marketing/src/main/kotlin/com/tangem/data/marketing/store/MarketingCampaignsCacheStore.kt @@ -0,0 +1,21 @@ +package com.tangem.data.marketing.store + +import androidx.datastore.core.DataStore +import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry +import kotlinx.coroutines.flow.first + +interface MarketingCampaignsCacheStore { + suspend fun get(type: String): MarketingCampaignsCacheEntry? + suspend fun store(type: String, entry: MarketingCampaignsCacheEntry) +} + +internal class DefaultMarketingCampaignsCacheStore( + private val dataStore: DataStore>, +) : MarketingCampaignsCacheStore { + + override suspend fun get(type: String): MarketingCampaignsCacheEntry? = dataStore.data.first()[type] + + override suspend fun store(type: String, entry: MarketingCampaignsCacheEntry) { + dataStore.updateData { it + (type to entry) } + } +} \ No newline at end of file diff --git a/data/marketing/src/main/kotlin/com/tangem/data/marketing/store/MarketingDismissStore.kt b/data/marketing/src/main/kotlin/com/tangem/data/marketing/store/MarketingDismissStore.kt new file mode 100644 index 0000000000..71665fd706 --- /dev/null +++ b/data/marketing/src/main/kotlin/com/tangem/data/marketing/store/MarketingDismissStore.kt @@ -0,0 +1,20 @@ +package com.tangem.data.marketing.store + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.flow.first + +interface MarketingDismissStore { + suspend fun getDismissedIds(): Set + suspend fun dismiss(id: Int) +} + +internal class DefaultMarketingDismissStore( + private val dataStore: DataStore>, +) : MarketingDismissStore { + + override suspend fun getDismissedIds(): Set = dataStore.data.first() + + override suspend fun dismiss(id: Int) { + dataStore.updateData { it + id } + } +} \ No newline at end of file diff --git a/data/marketing/src/test/kotlin/com/tangem/data/marketing/DefaultMarketingRepositoryTest.kt b/data/marketing/src/test/kotlin/com/tangem/data/marketing/DefaultMarketingRepositoryTest.kt new file mode 100644 index 0000000000..e752efa9e5 --- /dev/null +++ b/data/marketing/src/test/kotlin/com/tangem/data/marketing/DefaultMarketingRepositoryTest.kt @@ -0,0 +1,154 @@ +package com.tangem.data.marketing + +import com.google.common.truth.Truth.assertThat +import com.tangem.data.marketing.converter.MarketingCampaignConverter +import com.tangem.data.marketing.store.MarketingCampaignsCacheStore +import com.tangem.data.marketing.store.MarketingDismissStore +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code +import com.tangem.datasource.api.common.response.ETAG_HEADER +import com.tangem.datasource.api.marketing.models.BannerDto +import com.tangem.datasource.api.marketing.models.CampaignDto +import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry +import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.domain.marketing.models.MarketingScreen +import com.tangem.utils.SupportedLanguages +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultMarketingRepositoryTest { + + private val tangemTechApi: TangemTechApi = mockk() + private val cacheStore: MarketingCampaignsCacheStore = mockk(relaxed = true) + private val dismissStore: MarketingDismissStore = mockk(relaxed = true) + + private val language = SupportedLanguages.getCurrentSupportedLanguageCode() + + private val repository = DefaultMarketingRepository( + tangemTechApi = tangemTechApi, + cacheStore = cacheStore, + dismissStore = dismissStore, + converter = MarketingCampaignConverter(), + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @BeforeEach + fun reset() { + clearMocks(tangemTechApi, cacheStore, dismissStore) + } + + private fun response(id: Int) = MarketingCampaignsResponse( + campaigns = listOf(CampaignDto(id = id, type = "token_details", priority = 1, banner = BannerDto(uiType = "standalone"))), + ) + + @Suppress("UNCHECKED_CAST") + private fun httpError(code: Code): ApiResponse = ApiResponse.Error( + cause = ApiResponseError.HttpException(code = code, message = null, errorBody = null), + ) as ApiResponse + + @Test + fun `GIVEN 200 for background type WHEN getCampaigns THEN stores etag and returns campaigns`() = runTest { + // Arrange + coEvery { cacheStore.get("token_details") } returns null + coEvery { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) } returns + ApiResponse.Success(data = response(id = 7), headers = mapOf(ETAG_HEADER to listOf("new-etag"))) + + // Act + val result = repository.getCampaigns(MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0x")) + + // Assert + assertThat(result.getOrNull()!!.map { it.id }).containsExactly(7) + coVerify(exactly = 1) { + cacheStore.store("token_details", MarketingCampaignsCacheEntry(eTag = "new-etag", response = response(id = 7))) + } + } + + @Test + fun `GIVEN 304 for background type WHEN getCampaigns THEN returns cached campaigns`() = runTest { + // Arrange + coEvery { cacheStore.get("token_details") } returns + MarketingCampaignsCacheEntry(eTag = "etag", response = response(id = 9)) + coEvery { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = "etag") } returns + httpError(Code.NOT_MODIFIED) + + // Act + val result = repository.getCampaigns(MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0x")) + + // Assert + assertThat(result.getOrNull()!!.map { it.id }).containsExactly(9) + coVerify(exactly = 0) { cacheStore.store(any(), any()) } + } + + @Test + fun `GIVEN 5xx with cache WHEN getCampaigns THEN returns cached`() = runTest { + // Arrange + coEvery { cacheStore.get("staking") } returns + MarketingCampaignsCacheEntry(eTag = "etag", response = response(id = 5)) + coEvery { tangemTechApi.getMarketingCampaigns(type = "staking", language = language, eTag = "etag") } returns + httpError(Code.SERVICE_UNAVAILABLE) + + // Act + val result = repository.getCampaigns(MarketingScreen.Staking(networkId = "ethereum", contractAddress = "0x")) + + // Assert + assertThat(result.getOrNull()!!.map { it.id }).containsExactly(5) + } + + @Test + fun `GIVEN 5xx without cache WHEN getCampaigns THEN returns empty`() = runTest { + // Arrange + coEvery { cacheStore.get("staking") } returns null + coEvery { tangemTechApi.getMarketingCampaigns(type = "staking", language = language, eTag = null) } returns + httpError(Code.INTERNAL_SERVER_ERROR) + + // Act + val result = repository.getCampaigns(MarketingScreen.Staking(networkId = "ethereum", contractAddress = "0x")) + + // Assert + assertThat(result.getOrNull()).isEmpty() + } + + @Test + fun `GIVEN swap screen WHEN getCampaigns THEN sends pair params and does not touch cache`() = runTest { + // Arrange + coEvery { + tangemTechApi.getMarketingCampaigns( + type = "swap", language = language, + fromNetwork = "ethereum", fromContractAddress = "0xFrom", + toNetwork = "bitcoin", toContractAddress = "0xTo", + ) + } returns ApiResponse.Success(data = response(id = 3)) + + // Act + val result = repository.getCampaigns( + MarketingScreen.Swap( + fromNetwork = "ethereum", fromContractAddress = "0xFrom", + toNetwork = "bitcoin", toContractAddress = "0xTo", + ), + ) + + // Assert + assertThat(result.getOrNull()!!.map { it.id }).containsExactly(3) + coVerify(exactly = 0) { cacheStore.get(any()) } + coVerify(exactly = 0) { cacheStore.store(any(), any()) } + } + + @Test + fun `GIVEN dismiss WHEN dismissBanner THEN delegates to dismiss store`() = runTest { + // Act + repository.dismissBanner(42) + + // Assert + coVerify(exactly = 1) { dismissStore.dismiss(42) } + } +} \ No newline at end of file diff --git a/data/marketing/src/test/kotlin/com/tangem/data/marketing/converter/MarketingCampaignConverterTest.kt b/data/marketing/src/test/kotlin/com/tangem/data/marketing/converter/MarketingCampaignConverterTest.kt new file mode 100644 index 0000000000..dd865ecad0 --- /dev/null +++ b/data/marketing/src/test/kotlin/com/tangem/data/marketing/converter/MarketingCampaignConverterTest.kt @@ -0,0 +1,125 @@ +package com.tangem.data.marketing.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.marketing.models.BannerDto +import com.tangem.datasource.api.marketing.models.CampaignDto +import com.tangem.datasource.api.marketing.models.CampaignTokenDto +import com.tangem.domain.marketing.models.MarketingBanner +import com.tangem.domain.marketing.models.MarketingCampaignTarget +import com.tangem.domain.marketing.models.MarketingScreenType +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class MarketingCampaignConverterTest { + + private val converter = MarketingCampaignConverter() + + private fun banner(uiType: String = "standalone", isDismissible: Boolean = true) = BannerDto( + uiType = uiType, + text = "Cashback", + icon = "https://x/star.webp", + iconAlign = "left", + bgColor = "#FF0011", + deeplink = "https://tangem.com", + isDismissible = isDismissible, + ) + + @Test + fun `GIVEN swap campaign WHEN convert THEN mapped with amounts and no targets`() { + // Arrange + val dto = CampaignDto( + id = 12, type = "swap", priority = 1, + minAmount = BigDecimal(50), maxAmount = BigDecimal(300), + providerIds = listOf("provider1"), tokens = null, + banner = banner(uiType = "linked_to_provider"), + ) + + // Act + val result = converter.convert(dto) + + // Assert + assertThat(result.id).isEqualTo(12) + assertThat(result.type).isEqualTo(MarketingScreenType.SWAP) + assertThat(result.minAmount).isEqualTo(BigDecimal(50)) + assertThat(result.banner.uiType).isEqualTo(MarketingBanner.UiType.LINKED_TO_PROVIDER) + assertThat(result.banner.iconAlign).isEqualTo(MarketingBanner.IconAlign.LEFT) + assertThat(result.targets).isEmpty() + } + + @Test + fun `GIVEN token_details campaign WHEN convert THEN network targets mapped`() { + // Arrange + val dto = CampaignDto( + id = 1, type = "token_details", priority = 2, banner = banner(), + tokens = listOf(CampaignTokenDto(networkId = "ethereum", contractAddress = "0xA0b8")), + ) + + // Act + val targets = converter.convert(dto).targets + + // Assert + assertThat(targets).containsExactly( + MarketingCampaignTarget.NetworkContract(networkId = "ethereum", contractAddress = "0xA0b8"), + ) + } + + @Test + fun `GIVEN native coin token (null contract) WHEN convert THEN network target with null contract`() { + // Arrange + val dto = CampaignDto( + id = 1, type = "yield", priority = 1, banner = banner(), + tokens = listOf(CampaignTokenDto(networkId = "bitcoin", contractAddress = null)), + ) + + // Act + val targets = converter.convert(dto).targets + + // Assert + assertThat(targets).containsExactly( + MarketingCampaignTarget.NetworkContract(networkId = "bitcoin", contractAddress = null), + ) + } + + @Test + fun `GIVEN markets campaign WHEN convert THEN coingecko targets mapped`() { + // Arrange + val dto = CampaignDto( + id = 1, type = "markets_token", priority = 1, banner = banner(), + tokens = listOf(CampaignTokenDto(id = "1696501400")), + ) + + // Act + val targets = converter.convert(dto).targets + + // Assert + assertThat(targets).containsExactly(MarketingCampaignTarget.CoingeckoId(id = "1696501400")) + } + + @Test + fun `GIVEN linked_to_provider without providerIds WHEN convertListIgnoreErrors THEN dropped`() { + // Arrange + val invalid = CampaignDto( + id = 1, type = "onramp", priority = 1, providerIds = emptyList(), + banner = banner(uiType = "linked_to_provider"), + ) + val valid = CampaignDto(id = 2, type = "onramp", priority = 2, banner = banner()) + + // Act + val result = converter.convertListIgnoreErrors(listOf(invalid, valid)) + + // Assert + assertThat(result.map { it.id }).containsExactly(2) + } + + @Test + fun `GIVEN unknown type WHEN convertListIgnoreErrors THEN dropped`() { + // Arrange + val dto = CampaignDto(id = 1, type = "carousel", priority = 1, banner = banner()) + + // Act + val result = converter.convertListIgnoreErrors(listOf(dto)) + + // Assert + assertThat(result).isEmpty() + } +} \ No newline at end of file diff --git a/data/marketing/src/test/kotlin/com/tangem/data/marketing/featuretoggle/DefaultMarketingFeatureTogglesTest.kt b/data/marketing/src/test/kotlin/com/tangem/data/marketing/featuretoggle/DefaultMarketingFeatureTogglesTest.kt new file mode 100644 index 0000000000..9f241091d2 --- /dev/null +++ b/data/marketing/src/test/kotlin/com/tangem/data/marketing/featuretoggle/DefaultMarketingFeatureTogglesTest.kt @@ -0,0 +1,32 @@ +package com.tangem.data.marketing.featuretoggle + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test + +internal class DefaultMarketingFeatureTogglesTest { + + private val featureTogglesManager: FeatureTogglesManager = mockk() + private val featureToggles = DefaultMarketingFeatureToggles(featureTogglesManager) + + @Test + fun `GIVEN toggle enabled WHEN isMarketingBannersEnabled THEN true`() { + // Arrange + every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1522_MARKETING_BANNERS_ENABLED) } returns true + + // Assert + assertThat(featureToggles.isMarketingBannersEnabled).isTrue() + } + + @Test + fun `GIVEN toggle disabled WHEN isMarketingBannersEnabled THEN false`() { + // Arrange + every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1522_MARKETING_BANNERS_ENABLED) } returns false + + // Assert + assertThat(featureToggles.isMarketingBannersEnabled).isFalse() + } +} \ No newline at end of file diff --git a/data/marketing/src/test/kotlin/com/tangem/data/marketing/store/MarketingStoresTest.kt b/data/marketing/src/test/kotlin/com/tangem/data/marketing/store/MarketingStoresTest.kt new file mode 100644 index 0000000000..eb5458f533 --- /dev/null +++ b/data/marketing/src/test/kotlin/com/tangem/data/marketing/store/MarketingStoresTest.kt @@ -0,0 +1,64 @@ +package com.tangem.data.marketing.store + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.marketing.models.BannerDto +import com.tangem.datasource.api.marketing.models.CampaignDto +import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry +import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse +import com.tangem.test.core.datastore.MockStateDataStore +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +internal class MarketingStoresTest { + + private val cacheStore = DefaultMarketingCampaignsCacheStore( + dataStore = MockStateDataStore>(default = emptyMap()), + ) + private val dismissStore = DefaultMarketingDismissStore( + dataStore = MockStateDataStore>(default = emptySet()), + ) + + private fun entry(eTag: String?) = MarketingCampaignsCacheEntry( + eTag = eTag, + response = MarketingCampaignsResponse( + campaigns = listOf( + CampaignDto(id = 1, type = "token_details", priority = 1, banner = BannerDto(uiType = "standalone")), + ), + ), + ) + + @Test + fun `GIVEN no cache WHEN get THEN null`() = runTest { + assertThat(cacheStore.get("token_details")).isNull() + } + + @Test + fun `GIVEN stored entry WHEN get same type THEN returns it`() = runTest { + // Arrange + cacheStore.store("token_details", entry(eTag = "abc")) + + // Act + val result = cacheStore.get("token_details") + + // Assert + assertThat(result?.eTag).isEqualTo("abc") + assertThat(result?.response?.campaigns).hasSize(1) + assertThat(cacheStore.get("staking")).isNull() + } + + @Test + fun `GIVEN no dismissed WHEN getDismissedIds THEN empty`() = runTest { + assertThat(dismissStore.getDismissedIds()).isEmpty() + } + + @Test + fun `GIVEN dismissed ids WHEN dismiss again THEN accumulates without duplicates`() = runTest { + // Act + dismissStore.dismiss(12) + dismissStore.dismiss(12) + dismissStore.dismiss(34) + + // Assert + assertThat(dismissStore.getDismissedIds()).containsExactly(12, 34) + } +} \ No newline at end of file diff --git a/domain/marketing/build.gradle.kts b/domain/marketing/build.gradle.kts new file mode 100644 index 0000000000..7492f40f48 --- /dev/null +++ b/domain/marketing/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} + +dependencies { + implementation(projects.domain.marketing.models) + + implementation(deps.kotlin.coroutines) + implementation(deps.arrow.core) + + testImplementation(projects.test.core) +} \ No newline at end of file diff --git a/domain/marketing/models/build.gradle.kts b/domain/marketing/models/build.gradle.kts new file mode 100644 index 0000000000..166cd65bb7 --- /dev/null +++ b/domain/marketing/models/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} + +dependencies { + testImplementation(projects.test.core) +} \ No newline at end of file diff --git a/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingBanner.kt b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingBanner.kt new file mode 100644 index 0000000000..c5ba29beb2 --- /dev/null +++ b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingBanner.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.marketing.models + +data class MarketingBanner( + val uiType: UiType, + val text: String?, + val iconUrl: String?, + val iconAlign: IconAlign?, + val bgColor: String?, + val deeplink: String?, + val isDismissible: Boolean, +) { + + enum class UiType { STANDALONE, LINKED_TO_PROVIDER } + + enum class IconAlign { LEFT, RIGHT } +} \ No newline at end of file diff --git a/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaign.kt b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaign.kt new file mode 100644 index 0000000000..2465834ecc --- /dev/null +++ b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaign.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.marketing.models + +import java.math.BigDecimal + +data class MarketingCampaign( + val id: Int, + val type: MarketingScreenType, + val priority: Int, + val startAt: String?, + val endAt: String?, + val minAmount: BigDecimal?, + val maxAmount: BigDecimal?, + val providerIds: List?, + val banner: MarketingBanner, + val targets: List, +) \ No newline at end of file diff --git a/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignTarget.kt b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignTarget.kt new file mode 100644 index 0000000000..a3dab4ee6e --- /dev/null +++ b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignTarget.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.marketing.models + +sealed interface MarketingCampaignTarget { + + /** + * token_details / staking / yield campaigns target a network + contract address. + * [contractAddress] is `null` for native coins (the backend omits it), so a `null`/blank contract + * matches the coin of that network. + */ + data class NetworkContract(val networkId: String, val contractAddress: String?) : MarketingCampaignTarget + + /** markets_token campaigns target a CoinGecko token id. */ + data class CoingeckoId(val id: String) : MarketingCampaignTarget +} \ No newline at end of file diff --git a/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingScreen.kt b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingScreen.kt new file mode 100644 index 0000000000..f01e2bf695 --- /dev/null +++ b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingScreen.kt @@ -0,0 +1,43 @@ +package com.tangem.domain.marketing.models + +/** + * Screen-specific request context. swap/onramp carry the pair params sent to the backend; background types + * carry the on-screen token identity used for client-side target matching (the request itself sends only [type]). + */ +sealed interface MarketingScreen { + + val type: MarketingScreenType + + data class Swap( + val fromNetwork: String, + val fromContractAddress: String, + val toNetwork: String, + val toContractAddress: String, + ) : MarketingScreen { + override val type: MarketingScreenType = MarketingScreenType.SWAP + } + + data class Onramp( + val fromFiat: String, + val toNetwork: String, + val toContractAddress: String, + ) : MarketingScreen { + override val type: MarketingScreenType = MarketingScreenType.ONRAMP + } + + data class TokenDetails(val networkId: String, val contractAddress: String) : MarketingScreen { + override val type: MarketingScreenType = MarketingScreenType.TOKEN_DETAILS + } + + data class TokenMarkets(val coingeckoId: String) : MarketingScreen { + override val type: MarketingScreenType = MarketingScreenType.TOKEN_MARKETS + } + + data class Staking(val networkId: String, val contractAddress: String) : MarketingScreen { + override val type: MarketingScreenType = MarketingScreenType.STAKING + } + + data class Yield(val networkId: String, val contractAddress: String) : MarketingScreen { + override val type: MarketingScreenType = MarketingScreenType.YIELD + } +} \ No newline at end of file diff --git a/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingScreenType.kt b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingScreenType.kt new file mode 100644 index 0000000000..80c38779bf --- /dev/null +++ b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingScreenType.kt @@ -0,0 +1,19 @@ +package com.tangem.domain.marketing.models + +enum class MarketingScreenType(val value: String) { + SWAP("swap"), + ONRAMP("onramp"), + TOKEN_DETAILS("token_details"), + TOKEN_MARKETS("markets_token"), + STAKING("staking"), + YIELD("yield"), + ; + + /** Background types are ETag-cached; swap/onramp are always re-requested per pair selection. */ + val isCacheable: Boolean + get() = this != SWAP && this != ONRAMP + + companion object { + fun fromValue(value: String): MarketingScreenType? = entries.firstOrNull { it.value == value } + } +} \ No newline at end of file diff --git a/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingScreenTypeTest.kt b/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingScreenTypeTest.kt new file mode 100644 index 0000000000..cb6f375027 --- /dev/null +++ b/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingScreenTypeTest.kt @@ -0,0 +1,36 @@ +package com.tangem.domain.marketing.models + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +internal class MarketingScreenTypeTest { + + @Test + fun `GIVEN screen types WHEN read value THEN matches backend snake_case contract`() { + // Assert + assertThat(MarketingScreenType.SWAP.value).isEqualTo("swap") + assertThat(MarketingScreenType.ONRAMP.value).isEqualTo("onramp") + assertThat(MarketingScreenType.TOKEN_DETAILS.value).isEqualTo("token_details") + assertThat(MarketingScreenType.TOKEN_MARKETS.value).isEqualTo("markets_token") + assertThat(MarketingScreenType.STAKING.value).isEqualTo("staking") + assertThat(MarketingScreenType.YIELD.value).isEqualTo("yield") + } + + @Test + fun `GIVEN known value WHEN fromValue THEN returns type ELSE null`() { + // Assert + assertThat(MarketingScreenType.fromValue("token_details")).isEqualTo(MarketingScreenType.TOKEN_DETAILS) + assertThat(MarketingScreenType.fromValue("unknown")).isNull() + } + + @Test + fun `GIVEN screen type WHEN isCacheable THEN only background types cached`() { + // Assert + assertThat(MarketingScreenType.SWAP.isCacheable).isFalse() + assertThat(MarketingScreenType.ONRAMP.isCacheable).isFalse() + assertThat(MarketingScreenType.TOKEN_DETAILS.isCacheable).isTrue() + assertThat(MarketingScreenType.TOKEN_MARKETS.isCacheable).isTrue() + assertThat(MarketingScreenType.STAKING.isCacheable).isTrue() + assertThat(MarketingScreenType.YIELD.isCacheable).isTrue() + } +} \ No newline at end of file diff --git a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/DismissMarketingBannerUseCase.kt b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/DismissMarketingBannerUseCase.kt new file mode 100644 index 0000000000..7acde6814a --- /dev/null +++ b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/DismissMarketingBannerUseCase.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.marketing + +import arrow.core.Either + +class DismissMarketingBannerUseCase( + private val repository: MarketingRepository, +) { + + suspend operator fun invoke(campaignId: Int): Either = Either.catch { + repository.dismissBanner(campaignId) + } +} \ No newline at end of file diff --git a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt new file mode 100644 index 0000000000..29107a7c3f --- /dev/null +++ b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt @@ -0,0 +1,85 @@ +package com.tangem.domain.marketing + +import arrow.core.Either +import com.tangem.domain.marketing.models.MarketingCampaign +import com.tangem.domain.marketing.models.MarketingCampaignTarget +import com.tangem.domain.marketing.models.MarketingScreen +import com.tangem.domain.marketing.models.MarketingScreenType +import java.math.BigDecimal + +class GetMarketingBannerUseCase( + private val repository: MarketingRepository, + private val featureToggles: MarketingFeatureToggles, +) { + + /** + * Returns campaigns for [screen], filtered (dismissed, target match, USD amount range) and sorted by priority. + * + * @param amountUsd USD equivalent of the entered amount (swap/onramp only). When null, the amount filter is skipped. + */ + suspend operator fun invoke( + screen: MarketingScreen, + amountUsd: BigDecimal? = null, + ): Either> { + if (!featureToggles.isMarketingBannersEnabled) return Either.Right(emptyList()) + + return repository.getCampaigns(screen).map { campaigns -> + val dismissed = repository.getDismissedBannerIds() + campaigns.asSequence() + .filterNot { it.id in dismissed } + .filter { matchesTarget(it, screen) } + .filter { matchesAmount(it, amountUsd) } + .sortedBy { it.priority } + .toList() + } + } + + private fun matchesTarget(campaign: MarketingCampaign, screen: MarketingScreen): Boolean = when (screen) { + is MarketingScreen.Swap, is MarketingScreen.Onramp -> true // matched server-side by pair params + is MarketingScreen.TokenDetails -> matchesNetworkContract(campaign, screen.networkId, screen.contractAddress) + is MarketingScreen.Staking -> matchesNetworkContract(campaign, screen.networkId, screen.contractAddress) + is MarketingScreen.Yield -> matchesNetworkContract(campaign, screen.networkId, screen.contractAddress) + is MarketingScreen.TokenMarkets -> campaign.targets.any { target -> + target is MarketingCampaignTarget.CoingeckoId && target.id == screen.coingeckoId + } + } + + private fun matchesNetworkContract( + campaign: MarketingCampaign, + networkId: String, + contractAddress: String, + ): Boolean { + return campaign.targets.any { target -> + target is MarketingCampaignTarget.NetworkContract && + target.networkId == networkId && + contractAddressMatches(target = target.contractAddress, screen = contractAddress) + } + } + + /** + * Native coins have no contract address: the backend sends `contractAddress: null` and the screen + * passes an empty string, so blank/null on both sides is a native-coin match. Otherwise the + * contracts must match case-insensitively. + */ + private fun contractAddressMatches(target: String?, screen: String): Boolean { + val normalizedTarget = target?.takeIf { it.isNotBlank() } + val normalizedScreen = screen.takeIf { it.isNotBlank() } + return when { + normalizedTarget == null && normalizedScreen == null -> true + normalizedTarget != null && normalizedScreen != null -> + normalizedTarget.equals(normalizedScreen, ignoreCase = true) + else -> false + } + } + + private fun matchesAmount(campaign: MarketingCampaign, amountUsd: BigDecimal?): Boolean { + val isAmountScreen = campaign.type == MarketingScreenType.SWAP || campaign.type == MarketingScreenType.ONRAMP + if (!isAmountScreen || amountUsd == null) return true + + val min = campaign.minAmount + val max = campaign.maxAmount + if (min != null && amountUsd < min) return false + if (max != null && amountUsd > max) return false + return true + } +} \ No newline at end of file diff --git a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingFeatureToggles.kt b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingFeatureToggles.kt new file mode 100644 index 0000000000..03f3ea2efb --- /dev/null +++ b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingFeatureToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.domain.marketing + +interface MarketingFeatureToggles { + val isMarketingBannersEnabled: Boolean +} \ No newline at end of file diff --git a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingRepository.kt b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingRepository.kt new file mode 100644 index 0000000000..77476650b3 --- /dev/null +++ b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingRepository.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.marketing + +import arrow.core.Either +import com.tangem.domain.marketing.models.MarketingCampaign +import com.tangem.domain.marketing.models.MarketingScreen + +interface MarketingRepository { + + /** Fetches campaigns for [screen]. Returns Right(emptyList()) when there is nothing to show (incl. 5xx without cache). */ + suspend fun getCampaigns(screen: MarketingScreen): Either> + + /** Ids of campaigns whose banner the user has dismissed (stored client-side). */ + suspend fun getDismissedBannerIds(): Set + + suspend fun dismissBanner(campaignId: Int) +} \ No newline at end of file diff --git a/domain/marketing/src/test/kotlin/com/tangem/domain/marketing/DismissMarketingBannerUseCaseTest.kt b/domain/marketing/src/test/kotlin/com/tangem/domain/marketing/DismissMarketingBannerUseCaseTest.kt new file mode 100644 index 0000000000..eb8d00610c --- /dev/null +++ b/domain/marketing/src/test/kotlin/com/tangem/domain/marketing/DismissMarketingBannerUseCaseTest.kt @@ -0,0 +1,34 @@ +package com.tangem.domain.marketing + +import com.google.common.truth.Truth.assertThat +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DismissMarketingBannerUseCaseTest { + + private val repository: MarketingRepository = mockk() + private val useCase = DismissMarketingBannerUseCase(repository) + + @BeforeEach + fun reset() = clearMocks(repository) + + @Test + fun `GIVEN campaign id WHEN invoke THEN repository dismiss called and Right returned`() = runTest { + // Arrange + coEvery { repository.dismissBanner(7) } returns Unit + + // Act + val result = useCase(7) + + // Assert + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { repository.dismissBanner(7) } + } +} \ No newline at end of file diff --git a/domain/marketing/src/test/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCaseTest.kt b/domain/marketing/src/test/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCaseTest.kt new file mode 100644 index 0000000000..ddef65713a --- /dev/null +++ b/domain/marketing/src/test/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCaseTest.kt @@ -0,0 +1,291 @@ +package com.tangem.domain.marketing + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.marketing.models.MarketingBanner +import com.tangem.domain.marketing.models.MarketingCampaign +import com.tangem.domain.marketing.models.MarketingCampaignTarget +import com.tangem.domain.marketing.models.MarketingScreen +import com.tangem.domain.marketing.models.MarketingScreenType +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class GetMarketingBannerUseCaseTest { + + private val repository: MarketingRepository = mockk() + private val featureToggles: MarketingFeatureToggles = mockk() + private val useCase = GetMarketingBannerUseCase(repository, featureToggles) + + @BeforeEach + fun reset() { + clearMocks(repository, featureToggles) + every { featureToggles.isMarketingBannersEnabled } returns true + coEvery { repository.getDismissedBannerIds() } returns emptySet() + } + + private fun banner() = MarketingBanner( + uiType = MarketingBanner.UiType.STANDALONE, text = null, iconUrl = null, + iconAlign = null, bgColor = null, deeplink = null, isDismissible = true, + ) + + private fun campaign( + id: Int, + type: MarketingScreenType, + priority: Int, + minAmount: BigDecimal? = null, + maxAmount: BigDecimal? = null, + targets: List = emptyList(), + ) = MarketingCampaign( + id = id, + type = type, + priority = priority, + startAt = null, + endAt = null, + minAmount = minAmount, + maxAmount = maxAmount, + providerIds = null, + banner = banner(), + targets = targets, + ) + + private val swapScreen = MarketingScreen.Swap("eth", "0xF", "btc", "0xT") + private val tokenScreen = MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0xA0b8") + private val stakingScreen = MarketingScreen.Staking(networkId = "ethereum", contractAddress = "0xA0b8") + private val yieldScreen = MarketingScreen.Yield(networkId = "ethereum", contractAddress = "0xA0b8") + + @Test + fun `GIVEN toggle disabled WHEN invoke THEN empty without touching repository`() = runTest { + // Arrange + every { featureToggles.isMarketingBannersEnabled } returns false + + // Act + val result = useCase(swapScreen) + + // Assert + assertThat(result.getOrNull()).isEmpty() + coVerify(exactly = 0) { repository.getCampaigns(any()) } + coVerify(exactly = 0) { repository.getDismissedBannerIds() } + } + + @Test + fun `GIVEN several campaigns WHEN invoke THEN sorted by priority ascending`() = runTest { + // Arrange + coEvery { repository.getCampaigns(swapScreen) } returns listOf( + campaign(id = 1, type = MarketingScreenType.SWAP, priority = 3), + campaign(id = 2, type = MarketingScreenType.SWAP, priority = 1), + campaign(id = 3, type = MarketingScreenType.SWAP, priority = 2), + ).right() + + // Act + val result = useCase(swapScreen) + + // Assert + assertThat(result.getOrNull()?.map { it.id }).containsExactly(2, 3, 1).inOrder() + } + + @Test + fun `GIVEN dismissed id WHEN invoke THEN dismissed campaign filtered out`() = runTest { + // Arrange + coEvery { repository.getDismissedBannerIds() } returns setOf(2) + coEvery { repository.getCampaigns(swapScreen) } returns listOf( + campaign(id = 1, type = MarketingScreenType.SWAP, priority = 1), + campaign(id = 2, type = MarketingScreenType.SWAP, priority = 2), + ).right() + + // Act + val result = useCase(swapScreen) + + // Assert + assertThat(result.getOrNull()?.map { it.id }).containsExactly(1) + } + + @Test + fun `GIVEN amount below min WHEN invoke swap THEN campaign filtered out`() = runTest { + // Arrange + coEvery { repository.getCampaigns(swapScreen) } returns listOf( + campaign(id = 1, type = MarketingScreenType.SWAP, priority = 1, minAmount = BigDecimal(50), maxAmount = BigDecimal(300)), + ).right() + + // Act + val result = useCase(swapScreen, amountUsd = BigDecimal(25)) + + // Assert + assertThat(result.getOrNull()).isEmpty() + } + + @Test + fun `GIVEN amount within range WHEN invoke swap THEN campaign kept`() = runTest { + // Arrange + coEvery { repository.getCampaigns(swapScreen) } returns listOf( + campaign(id = 1, type = MarketingScreenType.SWAP, priority = 1, minAmount = BigDecimal(50), maxAmount = BigDecimal(300)), + ).right() + + // Act + val result = useCase(swapScreen, amountUsd = BigDecimal(100)) + + // Assert + assertThat(result.getOrNull()?.map { it.id }).containsExactly(1) + } + + @Test + fun `GIVEN amount above max WHEN invoke swap THEN campaign filtered out`() = runTest { + // Arrange + coEvery { repository.getCampaigns(swapScreen) } returns listOf( + campaign(id = 1, type = MarketingScreenType.SWAP, priority = 1, minAmount = BigDecimal(50), maxAmount = BigDecimal(300)), + ).right() + + // Act + val result = useCase(swapScreen, amountUsd = BigDecimal(500)) + + // Assert + assertThat(result.getOrNull()).isEmpty() + } + + @Test + fun `GIVEN null amount WHEN invoke swap THEN amount filter skipped`() = runTest { + // Arrange + coEvery { repository.getCampaigns(swapScreen) } returns listOf( + campaign(id = 1, type = MarketingScreenType.SWAP, priority = 1, minAmount = BigDecimal(50)), + ).right() + + // Act + val result = useCase(swapScreen, amountUsd = null) + + // Assert + assertThat(result.getOrNull()?.map { it.id }).containsExactly(1) + } + + @Test + fun `GIVEN background type WHEN invoke THEN only campaigns matching the on-screen token kept`() = runTest { + // Arrange + coEvery { repository.getCampaigns(tokenScreen) } returns listOf( + campaign( + id = 1, type = MarketingScreenType.TOKEN_DETAILS, priority = 1, + targets = listOf(MarketingCampaignTarget.NetworkContract("ethereum", "0xA0b8")), + ), + campaign( + id = 2, type = MarketingScreenType.TOKEN_DETAILS, priority = 2, + targets = listOf(MarketingCampaignTarget.NetworkContract("bitcoin", "0xOther")), + ), + ).right() + + // Act + val result = useCase(tokenScreen) + + // Assert + assertThat(result.getOrNull()?.map { it.id }).containsExactly(1) + } + + @Test + fun `GIVEN token markets screen WHEN invoke THEN only campaigns matching the coingecko id kept`() = runTest { + // Arrange + val marketsScreen = MarketingScreen.TokenMarkets(coingeckoId = "1696501400") + coEvery { repository.getCampaigns(marketsScreen) } returns listOf( + campaign( + id = 1, type = MarketingScreenType.TOKEN_MARKETS, priority = 1, + targets = listOf(MarketingCampaignTarget.CoingeckoId("1696501400")), + ), + campaign( + id = 2, type = MarketingScreenType.TOKEN_MARKETS, priority = 2, + targets = listOf(MarketingCampaignTarget.CoingeckoId("other")), + ), + ).right() + + // Act + val result = useCase(marketsScreen) + + // Assert + assertThat(result.getOrNull()?.map { it.id }).containsExactly(1) + } + + @Test + fun `GIVEN staking screen WHEN invoke THEN only campaigns matching the on-screen token kept`() = runTest { + // Arrange + coEvery { repository.getCampaigns(stakingScreen) } returns listOf( + campaign( + id = 1, type = MarketingScreenType.STAKING, priority = 1, + targets = listOf(MarketingCampaignTarget.NetworkContract("ethereum", "0xA0b8")), + ), + campaign( + id = 2, type = MarketingScreenType.STAKING, priority = 2, + targets = listOf(MarketingCampaignTarget.NetworkContract("bitcoin", "0xOther")), + ), + ).right() + + // Act + val result = useCase(stakingScreen) + + // Assert + assertThat(result.getOrNull()?.map { it.id }).containsExactly(1) + } + + @Test + fun `GIVEN yield screen WHEN invoke THEN only campaigns matching the on-screen token kept`() = runTest { + // Arrange + coEvery { repository.getCampaigns(yieldScreen) } returns listOf( + campaign( + id = 1, type = MarketingScreenType.YIELD, priority = 1, + targets = listOf(MarketingCampaignTarget.NetworkContract("ethereum", "0xA0b8")), + ), + campaign( + id = 2, type = MarketingScreenType.YIELD, priority = 2, + targets = listOf(MarketingCampaignTarget.NetworkContract("bitcoin", "0xOther")), + ), + ).right() + + // Act + val result = useCase(yieldScreen) + + // Assert + assertThat(result.getOrNull()?.map { it.id }).containsExactly(1) + } + + @Test + fun `GIVEN contract address differing only in case WHEN invoke THEN campaign matched`() = runTest { + // Arrange + val screen = MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0xA0B8") + coEvery { repository.getCampaigns(screen) } returns listOf( + campaign( + id = 1, type = MarketingScreenType.TOKEN_DETAILS, priority = 1, + targets = listOf(MarketingCampaignTarget.NetworkContract("ethereum", "0xa0b8")), + ), + ).right() + + // Act + val result = useCase(screen) + + // Assert + assertThat(result.getOrNull()?.map { it.id }).containsExactly(1) + } + + @Test + fun `GIVEN native coin screen WHEN campaign targets that coin with null contract THEN matched`() = runTest { + // Arrange — native coin: screen contract is blank, target contract is null + val screen = MarketingScreen.Yield(networkId = "bitcoin", contractAddress = "") + coEvery { repository.getCampaigns(screen) } returns listOf( + campaign( + id = 1, type = MarketingScreenType.YIELD, priority = 1, + targets = listOf(MarketingCampaignTarget.NetworkContract("bitcoin", contractAddress = null)), + ), + campaign( + id = 2, type = MarketingScreenType.YIELD, priority = 2, + targets = listOf(MarketingCampaignTarget.NetworkContract("ethereum", contractAddress = null)), + ), + ).right() + + // Act + val result = useCase(screen) + + // Assert — only the native coin of the on-screen network matches + assertThat(result.getOrNull()?.map { it.id }).containsExactly(1) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index aac87637cc..11a94fe444 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -414,6 +414,8 @@ include(":domain:onramp:models") include(":domain:offramp") include(":domain:stories") include(":domain:stories:models") +include(":domain:marketing") +include(":domain:marketing:models") include(":domain:nft") include(":domain:nft:models") include(":domain:hot-wallet") @@ -459,6 +461,7 @@ include(":data:visa") include(":data:payment") include(":data:virtual-account") include(":data:stories") +include(":data:marketing") include(":data:onboarding") include(":data:dynamic-addresses") include(":data:feedback") From 1e25b19264718aa187047a4484157e16484ecc85 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Jul 2026 13:02:00 +0500 Subject: [PATCH 43/59] Updated on 2026-08-14 --- .../tap/di/domain/MarketingDomainModule.kt | 8 ++ .../marketing/DefaultMarketingRepository.kt | 95 ++++++++++---- .../DefaultMarketingRepositoryTest.kt | 122 +++++++++++++++++- .../models/MarketingCampaignAmount.kt | 16 +++ .../models/MarketingCampaignAmountTest.kt | 64 +++++++++ .../marketing/GetMarketingBannerUseCase.kt | 15 +-- .../domain/marketing/MarketingRepository.kt | 4 + .../WarmUpMarketingCampaignsUseCase.kt | 31 +++++ .../WarmUpMarketingCampaignsUseCaseTest.kt | 58 +++++++++ features/wallet/impl/build.gradle.kts | 1 + .../wallet/child/wallet/model/WalletModel.kt | 9 ++ 11 files changed, 379 insertions(+), 44 deletions(-) create mode 100644 domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmount.kt create mode 100644 domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmountTest.kt create mode 100644 domain/marketing/src/main/kotlin/com/tangem/domain/marketing/WarmUpMarketingCampaignsUseCase.kt create mode 100644 domain/marketing/src/test/kotlin/com/tangem/domain/marketing/WarmUpMarketingCampaignsUseCaseTest.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/MarketingDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/MarketingDomainModule.kt index 7e927f9d61..16b6359490 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/MarketingDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/MarketingDomainModule.kt @@ -4,6 +4,7 @@ import com.tangem.domain.marketing.DismissMarketingBannerUseCase import com.tangem.domain.marketing.GetMarketingBannerUseCase import com.tangem.domain.marketing.MarketingFeatureToggles import com.tangem.domain.marketing.MarketingRepository +import com.tangem.domain.marketing.WarmUpMarketingCampaignsUseCase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -25,4 +26,11 @@ internal object MarketingDomainModule { @Singleton fun provideDismissMarketingBannerUseCase(repository: MarketingRepository): DismissMarketingBannerUseCase = DismissMarketingBannerUseCase(repository) + + @Provides + @Singleton + fun provideWarmUpMarketingCampaignsUseCase( + repository: MarketingRepository, + featureToggles: MarketingFeatureToggles, + ): WarmUpMarketingCampaignsUseCase = WarmUpMarketingCampaignsUseCase(repository, featureToggles) } \ No newline at end of file diff --git a/data/marketing/src/main/kotlin/com/tangem/data/marketing/DefaultMarketingRepository.kt b/data/marketing/src/main/kotlin/com/tangem/data/marketing/DefaultMarketingRepository.kt index 1c27200732..547f9bbc60 100644 --- a/data/marketing/src/main/kotlin/com/tangem/data/marketing/DefaultMarketingRepository.kt +++ b/data/marketing/src/main/kotlin/com/tangem/data/marketing/DefaultMarketingRepository.kt @@ -12,9 +12,15 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.domain.marketing.MarketingRepository import com.tangem.domain.marketing.models.MarketingCampaign import com.tangem.domain.marketing.models.MarketingScreen +import com.tangem.domain.marketing.models.MarketingScreenType import com.tangem.utils.SupportedLanguages import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext internal class DefaultMarketingRepository( @@ -25,32 +31,82 @@ internal class DefaultMarketingRepository( private val dispatchers: CoroutineDispatcherProvider, ) : MarketingRepository { + // In-memory per-session cache for background (cacheable) types. Serves repeated reads within a session + // without hitting the network; DataStore ETag cache remains the cross-session layer inside fetchAndCacheByType. + private val sessionCache = MutableStateFlow>>(emptyMap()) + private val cacheMutex = Mutex() + override suspend fun getCampaigns(screen: MarketingScreen): Either> = withContext(dispatchers.io) { Either.catch { - val isCacheable = screen.type.isCacheable - val cached = if (isCacheable) cacheStore.get(screen.type.value) else null - - when (val response = requestCampaigns(screen, eTag = cached?.eTag)) { - is ApiResponse.Success -> { - if (isCacheable) { - // eTag may be null if the server omits it; we still cache the body for the 5xx - // fallback path. A null eTag simply means the next request sends no If-None-Match - // (Retrofit omits null headers) and receives a fresh 200. - val eTag = response.headers[ETAG_HEADER]?.firstOrNull() - cacheStore.store(screen.type.value, MarketingCampaignsCacheEntry(eTag, response.data)) - } - convert(response.data) + if (screen.type.isCacheable) { + loadCacheableByType(screen.type) + } else { + // swap/onramp — always fresh, never cached + when (val response = requestCampaigns(screen, eTag = null)) { + is ApiResponse.Success -> convert(response.data) + is ApiResponse.Error -> emptyList() } - is ApiResponse.Error -> handleError(cached) } } } + override suspend fun prefetchBackgroundCampaigns(type: MarketingScreenType) { + if (!type.isCacheable) return + try { + loadCacheableByType(type) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Fire-and-forget warm-up: failures are non-fatal, the next getCampaigns() call will retry. + } + } + override suspend fun getDismissedBannerIds(): Set = dismissStore.getDismissedIds() override suspend fun dismissBanner(campaignId: Int) = dismissStore.dismiss(campaignId) + private suspend fun loadCacheableByType(type: MarketingScreenType): List { + sessionCache.value[type]?.let { return it } + return cacheMutex.withLock { + sessionCache.value[type]?.let { return@withLock it } // double-check under lock + val result = fetchAndCacheByType(type) + // Only cache authoritative results. A pure error fallback (error + no DataStore cache -> null) + // must NOT poison the session cache, so a later screen open still retries the network. + if (result != null) { + sessionCache.update { it + (type to result) } + } + result.orEmpty() + } + } + + private suspend fun fetchAndCacheByType(type: MarketingScreenType): List? { + val cached = cacheStore.get(type.value) + return when (val response = requestByType(type, eTag = cached?.eTag)) { + is ApiResponse.Success -> { + // eTag may be null if the server omits it; we still cache the body for the 5xx + // fallback path. A null eTag simply means the next request sends no If-None-Match + // (Retrofit omits null headers) and receives a fresh 200. + val eTag = response.headers[ETAG_HEADER]?.firstOrNull() + cacheStore.store(type.value, MarketingCampaignsCacheEntry(eTag, response.data)) + convert(response.data) // authoritative (may be empty = real "no banners") + } + // Cached fallback is authoritative-ish; null when there is nothing cached (do not session-cache). + is ApiResponse.Error -> cached?.response?.let(::convert) + } + } + + private suspend fun requestByType( + type: MarketingScreenType, + eTag: String?, + ): ApiResponse { + return tangemTechApi.getMarketingCampaigns( + type = type.value, + language = SupportedLanguages.getCurrentSupportedLanguageCode(), + eTag = eTag, + ) + } + private suspend fun requestCampaigns( screen: MarketingScreen, eTag: String?, @@ -77,19 +133,10 @@ internal class DefaultMarketingRepository( is MarketingScreen.TokenMarkets, is MarketingScreen.Staking, is MarketingScreen.Yield, - -> tangemTechApi.getMarketingCampaigns(type = screen.type.value, language = language, eTag = eTag) + -> requestByType(screen.type, eTag) } } - /** - * All error cases (304 not-modified, 5xx, network failure) degrade gracefully to the cached - * response. Returning an empty list when there is no cache is intentional — "no banner" is a - * normal state, not an error the caller needs to handle. - */ - private fun handleError(cached: MarketingCampaignsCacheEntry?): List { - return cached?.response?.let(::convert).orEmpty() - } - private fun convert(response: MarketingCampaignsResponse): List = converter.convertListIgnoreErrors(response.campaigns) { throwable -> TangemLogger.w("Skipped invalid marketing campaign: ${throwable.message}") diff --git a/data/marketing/src/test/kotlin/com/tangem/data/marketing/DefaultMarketingRepositoryTest.kt b/data/marketing/src/test/kotlin/com/tangem/data/marketing/DefaultMarketingRepositoryTest.kt index e752efa9e5..40895fc9a5 100644 --- a/data/marketing/src/test/kotlin/com/tangem/data/marketing/DefaultMarketingRepositoryTest.kt +++ b/data/marketing/src/test/kotlin/com/tangem/data/marketing/DefaultMarketingRepositoryTest.kt @@ -14,12 +14,16 @@ import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.domain.marketing.models.MarketingScreen +import com.tangem.domain.marketing.models.MarketingScreenType import com.tangem.utils.SupportedLanguages import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -34,17 +38,20 @@ internal class DefaultMarketingRepositoryTest { private val language = SupportedLanguages.getCurrentSupportedLanguageCode() - private val repository = DefaultMarketingRepository( - tangemTechApi = tangemTechApi, - cacheStore = cacheStore, - dismissStore = dismissStore, - converter = MarketingCampaignConverter(), - dispatchers = TestingCoroutineDispatcherProvider(), - ) + // Recreated per test (not a val): DefaultMarketingRepository now holds mutable in-memory session-cache + // state, which would otherwise leak between tests sharing this PER_CLASS instance. + private lateinit var repository: DefaultMarketingRepository @BeforeEach fun reset() { clearMocks(tangemTechApi, cacheStore, dismissStore) + repository = DefaultMarketingRepository( + tangemTechApi = tangemTechApi, + cacheStore = cacheStore, + dismissStore = dismissStore, + converter = MarketingCampaignConverter(), + dispatchers = TestingCoroutineDispatcherProvider(), + ) } private fun response(id: Int) = MarketingCampaignsResponse( @@ -118,6 +125,24 @@ internal class DefaultMarketingRepositoryTest { assertThat(result.getOrNull()).isEmpty() } + @Test + fun `GIVEN 5xx without cache WHEN getCampaigns twice THEN not session-cached and retried`() = runTest { + // Arrange + coEvery { cacheStore.get("staking") } returns null + coEvery { tangemTechApi.getMarketingCampaigns(type = "staking", language = language, eTag = null) } returns + httpError(Code.SERVICE_UNAVAILABLE) + val screen = MarketingScreen.Staking(networkId = "ethereum", contractAddress = "0x") + + // Act + val first = repository.getCampaigns(screen) + val second = repository.getCampaigns(screen) + + // Assert + assertThat(first.getOrNull()).isEmpty() + assertThat(second.getOrNull()).isEmpty() + coVerify(exactly = 2) { tangemTechApi.getMarketingCampaigns(type = "staking", language = language, eTag = null) } + } + @Test fun `GIVEN swap screen WHEN getCampaigns THEN sends pair params and does not touch cache`() = runTest { // Arrange @@ -143,6 +168,89 @@ internal class DefaultMarketingRepositoryTest { coVerify(exactly = 0) { cacheStore.store(any(), any()) } } + @Test + fun `GIVEN cached in session WHEN getCampaigns twice THEN api called once`() = runTest { + // Arrange + coEvery { cacheStore.get("token_details") } returns null + coEvery { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) } returns + ApiResponse.Success(data = response(id = 7)) + + // Act + val screen = MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0x") + val first = repository.getCampaigns(screen) + val second = repository.getCampaigns(screen) + + // Assert + assertThat(first.getOrNull()!!.map { it.id }).containsExactly(7) + assertThat(second.getOrNull()!!.map { it.id }).containsExactly(7) + coVerify(exactly = 1) { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) } + } + + @Test + fun `GIVEN two concurrent getCampaigns for same type WHEN both in flight THEN api called once`() = runTest { + // Arrange + val gate = CompletableDeferred() + coEvery { cacheStore.get("token_details") } returns null + coEvery { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) } coAnswers { + gate.await() // first caller suspends inside the lock, second blocks on the mutex + ApiResponse.Success(data = response(id = 7)) + } + val screen = MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0x") + + // Act — launch both before either completes, then release the API + val a = async { repository.getCampaigns(screen) } + val b = async { repository.getCampaigns(screen) } + runCurrent() + gate.complete(Unit) + val first = a.await() + val second = b.await() + + // Assert + assertThat(first.getOrNull()!!.map { it.id }).containsExactly(7) + assertThat(second.getOrNull()!!.map { it.id }).containsExactly(7) + coVerify(exactly = 1) { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) } + } + + @Test + fun `GIVEN prefetch WHEN getCampaigns THEN served from session cache without extra api call`() = runTest { + // Arrange + coEvery { cacheStore.get("markets_token") } returns null + coEvery { tangemTechApi.getMarketingCampaigns(type = "markets_token", language = language, eTag = null) } returns + ApiResponse.Success(data = response(id = 3)) + + // Act + repository.prefetchBackgroundCampaigns(MarketingScreenType.TOKEN_MARKETS) + val result = repository.getCampaigns(MarketingScreen.TokenMarkets(coingeckoId = "id")) + + // Assert + assertThat(result.getOrNull()!!.map { it.id }).containsExactly(3) + coVerify(exactly = 1) { tangemTechApi.getMarketingCampaigns(type = "markets_token", language = language, eTag = null) } + } + + @Test + fun `GIVEN swap WHEN getCampaigns twice THEN never session-cached (api called each time)`() = runTest { + // Arrange + val swap = MarketingScreen.Swap("eth", "0xF", "btc", "0xT") + coEvery { + tangemTechApi.getMarketingCampaigns( + type = "swap", language = language, + fromNetwork = "eth", fromContractAddress = "0xF", toNetwork = "btc", toContractAddress = "0xT", + ) + } returns ApiResponse.Success(data = response(id = 1)) + + // Act + repository.getCampaigns(swap) + repository.getCampaigns(swap) + + // Assert + coVerify(exactly = 2) { + tangemTechApi.getMarketingCampaigns( + type = "swap", language = language, + fromNetwork = "eth", fromContractAddress = "0xF", toNetwork = "btc", toContractAddress = "0xT", + ) + } + } + @Test fun `GIVEN dismiss WHEN dismissBanner THEN delegates to dismiss store`() = runTest { // Act diff --git a/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmount.kt b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmount.kt new file mode 100644 index 0000000000..5fffde6c7d --- /dev/null +++ b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmount.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.marketing.models + +import java.math.BigDecimal + +/** + * USD min/max eligibility gate. Applies only to swap/onramp campaigns and only when [amountUsd] is known; + * otherwise the campaign passes (non-amount screens and the "amount unknown" case are not gated). + */ +fun MarketingCampaign.matchesUsdAmount(amountUsd: BigDecimal?): Boolean { + val isAmountScreen = type == MarketingScreenType.SWAP || type == MarketingScreenType.ONRAMP + if (!isAmountScreen || amountUsd == null) return true + + if (minAmount != null && amountUsd < minAmount) return false + if (maxAmount != null && amountUsd > maxAmount) return false + return true +} \ No newline at end of file diff --git a/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmountTest.kt b/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmountTest.kt new file mode 100644 index 0000000000..2e8fa7ebef --- /dev/null +++ b/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmountTest.kt @@ -0,0 +1,64 @@ +package com.tangem.domain.marketing.models + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class MarketingCampaignAmountTest { + + private fun campaign( + type: MarketingScreenType, + minAmount: BigDecimal? = null, + maxAmount: BigDecimal? = null, + ) = MarketingCampaign( + id = 1, type = type, priority = 1, startAt = null, endAt = null, + minAmount = minAmount, maxAmount = maxAmount, providerIds = null, + banner = MarketingBanner( + uiType = MarketingBanner.UiType.STANDALONE, text = "t", iconUrl = null, + iconAlign = null, bgColor = null, deeplink = null, isDismissible = false, + ), + targets = emptyList(), + ) + + @Test + fun `GIVEN non swap-onramp type WHEN matchesUsdAmount THEN always true`() { + val c = campaign(MarketingScreenType.TOKEN_DETAILS, minAmount = BigDecimal(50), maxAmount = BigDecimal(300)) + assertThat(c.matchesUsdAmount(BigDecimal(10))).isTrue() + assertThat(c.matchesUsdAmount(null)).isTrue() + } + + @Test + fun `GIVEN swap with null amount WHEN matchesUsdAmount THEN true`() { + val c = campaign(MarketingScreenType.SWAP, minAmount = BigDecimal(50)) + assertThat(c.matchesUsdAmount(null)).isTrue() + } + + @Test + fun `GIVEN swap amount below min WHEN matchesUsdAmount THEN false`() { + val c = campaign(MarketingScreenType.SWAP, minAmount = BigDecimal(50), maxAmount = BigDecimal(300)) + assertThat(c.matchesUsdAmount(BigDecimal(49))).isFalse() + } + + @Test + fun `GIVEN swap amount above max WHEN matchesUsdAmount THEN false`() { + val c = campaign(MarketingScreenType.ONRAMP, minAmount = BigDecimal(50), maxAmount = BigDecimal(300)) + assertThat(c.matchesUsdAmount(BigDecimal(301))).isFalse() + } + + @Test + fun `GIVEN amount on boundaries WHEN matchesUsdAmount THEN true`() { + val c = campaign(MarketingScreenType.SWAP, minAmount = BigDecimal(50), maxAmount = BigDecimal(300)) + assertThat(c.matchesUsdAmount(BigDecimal(50))).isTrue() + assertThat(c.matchesUsdAmount(BigDecimal(300))).isTrue() + } + + @Test + fun `GIVEN nullable bounds WHEN matchesUsdAmount THEN only present bound applies`() { + val onlyMin = campaign(MarketingScreenType.SWAP, minAmount = BigDecimal(50), maxAmount = null) + assertThat(onlyMin.matchesUsdAmount(BigDecimal(10_000))).isTrue() + assertThat(onlyMin.matchesUsdAmount(BigDecimal(10))).isFalse() + val onlyMax = campaign(MarketingScreenType.SWAP, minAmount = null, maxAmount = BigDecimal(300)) + assertThat(onlyMax.matchesUsdAmount(BigDecimal(1))).isTrue() + assertThat(onlyMax.matchesUsdAmount(BigDecimal(301))).isFalse() + } +} \ No newline at end of file diff --git a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt index 29107a7c3f..387553c39a 100644 --- a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt +++ b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt @@ -4,7 +4,7 @@ import arrow.core.Either import com.tangem.domain.marketing.models.MarketingCampaign import com.tangem.domain.marketing.models.MarketingCampaignTarget import com.tangem.domain.marketing.models.MarketingScreen -import com.tangem.domain.marketing.models.MarketingScreenType +import com.tangem.domain.marketing.models.matchesUsdAmount import java.math.BigDecimal class GetMarketingBannerUseCase( @@ -28,7 +28,7 @@ class GetMarketingBannerUseCase( campaigns.asSequence() .filterNot { it.id in dismissed } .filter { matchesTarget(it, screen) } - .filter { matchesAmount(it, amountUsd) } + .filter { it.matchesUsdAmount(amountUsd) } .sortedBy { it.priority } .toList() } @@ -71,15 +71,4 @@ class GetMarketingBannerUseCase( else -> false } } - - private fun matchesAmount(campaign: MarketingCampaign, amountUsd: BigDecimal?): Boolean { - val isAmountScreen = campaign.type == MarketingScreenType.SWAP || campaign.type == MarketingScreenType.ONRAMP - if (!isAmountScreen || amountUsd == null) return true - - val min = campaign.minAmount - val max = campaign.maxAmount - if (min != null && amountUsd < min) return false - if (max != null && amountUsd > max) return false - return true - } } \ No newline at end of file diff --git a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingRepository.kt b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingRepository.kt index 77476650b3..15c9f0cf35 100644 --- a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingRepository.kt +++ b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/MarketingRepository.kt @@ -3,12 +3,16 @@ package com.tangem.domain.marketing import arrow.core.Either import com.tangem.domain.marketing.models.MarketingCampaign import com.tangem.domain.marketing.models.MarketingScreen +import com.tangem.domain.marketing.models.MarketingScreenType interface MarketingRepository { /** Fetches campaigns for [screen]. Returns Right(emptyList()) when there is nothing to show (incl. 5xx without cache). */ suspend fun getCampaigns(screen: MarketingScreen): Either> + /** Loads and caches campaigns for a background [type] into the in-memory session cache (fire-and-forget warm-up). */ + suspend fun prefetchBackgroundCampaigns(type: MarketingScreenType) + /** Ids of campaigns whose banner the user has dismissed (stored client-side). */ suspend fun getDismissedBannerIds(): Set diff --git a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/WarmUpMarketingCampaignsUseCase.kt b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/WarmUpMarketingCampaignsUseCase.kt new file mode 100644 index 0000000000..7e4c061bf3 --- /dev/null +++ b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/WarmUpMarketingCampaignsUseCase.kt @@ -0,0 +1,31 @@ +package com.tangem.domain.marketing + +import com.tangem.domain.marketing.models.MarketingScreenType +import kotlinx.coroutines.CancellationException + +/** + * Warms the session cache for background campaign types shown outside a dedicated screen entry + * (token details & markets). Toggle-gated; failures are swallowed (fire-and-forget from the main screen). + */ +class WarmUpMarketingCampaignsUseCase( + private val repository: MarketingRepository, + private val featureToggles: MarketingFeatureToggles, +) { + + suspend operator fun invoke() { + if (!featureToggles.isMarketingBannersEnabled) return + WARMED_TYPES.forEach { type -> + try { + repository.prefetchBackgroundCampaigns(type) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // fire-and-forget warm-up: ignore, next screen open retries + } + } + } + + private companion object { + val WARMED_TYPES = listOf(MarketingScreenType.TOKEN_DETAILS, MarketingScreenType.TOKEN_MARKETS) + } +} \ No newline at end of file diff --git a/domain/marketing/src/test/kotlin/com/tangem/domain/marketing/WarmUpMarketingCampaignsUseCaseTest.kt b/domain/marketing/src/test/kotlin/com/tangem/domain/marketing/WarmUpMarketingCampaignsUseCaseTest.kt new file mode 100644 index 0000000000..5bcfb32997 --- /dev/null +++ b/domain/marketing/src/test/kotlin/com/tangem/domain/marketing/WarmUpMarketingCampaignsUseCaseTest.kt @@ -0,0 +1,58 @@ +package com.tangem.domain.marketing + +import com.tangem.domain.marketing.models.MarketingScreenType +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class WarmUpMarketingCampaignsUseCaseTest { + + private val repository: MarketingRepository = mockk(relaxed = true) + private val featureToggles: MarketingFeatureToggles = mockk() + private val useCase = WarmUpMarketingCampaignsUseCase(repository, featureToggles) + + @BeforeEach + fun reset() = clearMocks(repository, featureToggles) + + @Test + fun `GIVEN toggle off WHEN invoke THEN no prefetch`() = runTest { + // Arrange + every { featureToggles.isMarketingBannersEnabled } returns false + + // Act + useCase() + + // Assert + coVerify(exactly = 0) { repository.prefetchBackgroundCampaigns(any()) } + } + + @Test + fun `GIVEN toggle on WHEN invoke THEN prefetch token_details and markets`() = runTest { + // Arrange + every { featureToggles.isMarketingBannersEnabled } returns true + + // Act + useCase() + + // Assert + coVerify(exactly = 1) { repository.prefetchBackgroundCampaigns(MarketingScreenType.TOKEN_DETAILS) } + coVerify(exactly = 1) { repository.prefetchBackgroundCampaigns(MarketingScreenType.TOKEN_MARKETS) } + } + + @Test + fun `GIVEN prefetch throws WHEN invoke THEN swallowed`() = runTest { + // Arrange + every { featureToggles.isMarketingBannersEnabled } returns true + coEvery { repository.prefetchBackgroundCampaigns(any()) } throws RuntimeException("boom") + + // Act + Assert (does not throw) + useCase() + } +} \ No newline at end of file diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index ede24c9be3..579d588463 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -93,6 +93,7 @@ dependencies { implementation(projects.domain.feedback) implementation(projects.domain.feedback.models) implementation(projects.domain.legacy) + implementation(projects.domain.marketing) implementation(projects.domain.markets.models) implementation(projects.domain.models) implementation(projects.domain.networks) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 8a0506515f..15549d3933 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -23,6 +23,7 @@ import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.marketing.WarmUpMarketingCampaignsUseCase import com.tangem.domain.models.wallet.* import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase import com.tangem.domain.notifications.repository.NotificationsRepository @@ -127,6 +128,7 @@ internal class WalletModel @Inject constructor( private val walletFeatureToggles: WalletFeatureToggles, private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles, private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, + private val warmUpMarketingCampaignsUseCase: WarmUpMarketingCampaignsUseCase, val screenLifecycleProvider: ScreenLifecycleProvider, val innerWalletRouter: InnerWalletRouter, ) : Model() { @@ -151,6 +153,7 @@ internal class WalletModel @Inject constructor( maybeMigrateNames() maybeSetWalletFirstTimeUsage() preloadPushNotificationPreferences() + warmUpMarketingCampaigns() updateYieldSupplyApy() subscribeToUserWalletsUpdates() subscribeOnBalanceHiding() @@ -198,6 +201,12 @@ internal class WalletModel @Inject constructor( } } + private fun warmUpMarketingCampaigns() { + modelScope.launch(dispatchers.io) { + warmUpMarketingCampaignsUseCase() + } + } + private fun preloadPushNotificationPreferences() { if (!pushNotificationSettingsFeatureToggles.isPushNotificationSettingsEnabled) return getWalletsUseCase() From 82038ab5148c01987acb57d699de5a5dd1f2ec83 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Jul 2026 10:02:33 +0200 Subject: [PATCH 44/59] Updated on 2026-08-14 --- .../routing/deeplink/MarketingDeeplink.kt | 75 +++++++++++++++++++ .../routing/deeplink/MarketingDeeplinkTest.kt | 42 +++++++++++ .../domain/onramp/model/OnrampSource.kt | 1 + 3 files changed, 118 insertions(+) create mode 100644 common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/MarketingDeeplink.kt create mode 100644 common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/MarketingDeeplinkTest.kt diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/MarketingDeeplink.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/MarketingDeeplink.kt new file mode 100644 index 0000000000..6436422a27 --- /dev/null +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/MarketingDeeplink.kt @@ -0,0 +1,75 @@ +package com.tangem.common.routing.deeplink + +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.DeepLinkRoute +import com.tangem.common.routing.DeepLinkScheme +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.onramp.model.OnrampSource +import java.net.URI + +/** + * Classification of a marketing-banner deeplink used to decide how a banner tap is routed. + * + * Only the `tangem://` scheme with a swap/buy host triggers contextual in-app routing; everything + * else (external `https://` T&S links, unknown hosts, malformed input) is [EXTERNAL] and handed off + * to the generic deeplink launcher. This mirrors iOS `DefaultIncomingLinkParser`, where + * `https://tangem.com/...` links always resolve to an external link, not an in-app destination. + */ +enum class MarketingDeeplink { + /** `tangem://swap` — open swap for the current token. */ + SWAP, + + /** `tangem://buy` — open onramp for the current token. */ + BUY, + + /** External or unrecognized link — route through the generic deeplink launcher. */ + EXTERNAL, +} + +// TODO: [temporary] Banner taps are intercepted in-host and mapped to an AppRoute directly because the +// shared tangem://swap and tangem://buy deeplinks open context-less screens (generic swap / BuyCrypto +// hub) with no current-token prefill. Replace with targeted swap/onramp deeplinks and drop this +// interception: [REDACTED_JIRA] +/** + * Resolves a marketing-banner [link] into a [MarketingDeeplink]. Never throws: malformed input + * degrades to [MarketingDeeplink.EXTERNAL]. + */ +fun resolveMarketingDeeplink(link: String): MarketingDeeplink { + val uri = runCatching { URI(link) }.getOrNull() ?: return MarketingDeeplink.EXTERNAL + + if (!uri.scheme.equals(DeepLinkScheme.Tangem.scheme, ignoreCase = true)) { + return MarketingDeeplink.EXTERNAL + } + + return when (uri.host) { + DeepLinkRoute.Swap.host -> MarketingDeeplink.SWAP + DeepLinkRoute.Buy.host -> MarketingDeeplink.BUY + else -> MarketingDeeplink.EXTERNAL + } +} + +/** + * Builds the contextual in-app route for a marketing-banner deeplink on a token-scoped screen (staking, + * yield, swap, onramp): swap for the current token, or onramp to buy it. Returns `null` for + * [MarketingDeeplink.EXTERNAL] so the caller falls back to the generic deeplink launcher. + */ +fun MarketingDeeplink.toContextualRoute( + userWalletId: UserWalletId, + currency: CryptoCurrency, + screenSource: AnalyticsParam.ScreensSources, + onrampSource: OnrampSource = OnrampSource.MARKETING_BANNER, +): AppRoute? = when (this) { + MarketingDeeplink.SWAP -> AppRoute.Swap( + userWalletId = userWalletId, + fromCryptoCurrency = currency, + screenSource = screenSource.value, + ) + MarketingDeeplink.BUY -> AppRoute.Onramp( + userWalletId = userWalletId, + currency = currency, + source = onrampSource, + ) + MarketingDeeplink.EXTERNAL -> null +} \ No newline at end of file diff --git a/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/MarketingDeeplinkTest.kt b/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/MarketingDeeplinkTest.kt new file mode 100644 index 0000000000..1035d3123f --- /dev/null +++ b/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/MarketingDeeplinkTest.kt @@ -0,0 +1,42 @@ +package com.tangem.common.routing.deeplink + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class MarketingDeeplinkTest { + + @ParameterizedTest + @MethodSource("provideTestModels") + fun resolveMarketingDeeplink(model: ResolveModel) { + // Act + val actual = resolveMarketingDeeplink(model.link) + + // Assert + assertThat(actual).isEqualTo(model.expected) + } + + internal data class ResolveModel(val link: String, val expected: MarketingDeeplink) + + private fun provideTestModels() = listOf( + // tangem:// swap/buy -> contextual + ResolveModel(link = "tangem://swap", expected = MarketingDeeplink.SWAP), + ResolveModel(link = "tangem://buy", expected = MarketingDeeplink.BUY), + ResolveModel(link = "tangem://swap?foo=bar", expected = MarketingDeeplink.SWAP), + ResolveModel(link = "tangem://buy/extra", expected = MarketingDeeplink.BUY), + ResolveModel(link = "TANGEM://swap", expected = MarketingDeeplink.SWAP), + // tangem:// other hosts -> external + ResolveModel(link = "tangem://token", expected = MarketingDeeplink.EXTERNAL), + ResolveModel(link = "tangem://promo", expected = MarketingDeeplink.EXTERNAL), + // https T&S links -> external (iOS treats these as .link too) + ResolveModel(link = "https://tangem.com/swap", expected = MarketingDeeplink.EXTERNAL), + ResolveModel(link = "https://tangem.com/buy", expected = MarketingDeeplink.EXTERNAL), + ResolveModel(link = "https://tangem.com/promo/summer", expected = MarketingDeeplink.EXTERNAL), + // other schemes / garbage -> external + ResolveModel(link = "wc://connect", expected = MarketingDeeplink.EXTERNAL), + ResolveModel(link = "not a uri", expected = MarketingDeeplink.EXTERNAL), + ResolveModel(link = "", expected = MarketingDeeplink.EXTERNAL), + ) +} \ No newline at end of file diff --git a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampSource.kt b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampSource.kt index 86b72907ad..3e74b2884e 100644 --- a/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampSource.kt +++ b/domain/onramp/models/src/main/kotlin/com/tangem/domain/onramp/model/OnrampSource.kt @@ -6,4 +6,5 @@ enum class OnrampSource(val analyticsName: String) { TOKEN_LONG_TAP("Long Tap"), TOKEN_DETAILS("Token"), MARKETS("Markets"), + MARKETING_BANNER("Marketing Banner"), } \ No newline at end of file From 85158f31dc51cfa8286603077510129b996e145a Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Jul 2026 13:25:18 +0200 Subject: [PATCH 45/59] Updated on 2026-08-14 --- app/build.gradle.kts | 2 + features/marketing/api/build.gradle.kts | 17 ++ .../marketing/api/MarketingBannerComponent.kt | 28 ++ .../marketing/api/MarketingBannerRequest.kt | 17 ++ features/marketing/impl/build.gradle.kts | 50 ++++ .../impl/DefaultMarketingBannerComponent.kt | 41 +++ .../impl/di/MarketingComponentModule.kt | 35 +++ .../impl/model/MarketingBannerModel.kt | 149 ++++++++++ .../marketing/impl/ui/MarketingBanner.kt | 145 +++++++++ .../impl/ui/MarketingBannerCarousel.kt | 45 +++ .../impl/ui/MarketingBannerContent.kt | 36 +++ .../impl/ui/state/MarketingBannerListUM.kt | 12 + .../impl/ui/state/MarketingBannerUM.kt | 15 + .../impl/model/MarketingBannerModelTest.kt | 279 ++++++++++++++++++ settings.gradle.kts | 3 + 15 files changed, 874 insertions(+) create mode 100644 features/marketing/api/build.gradle.kts create mode 100644 features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerComponent.kt create mode 100644 features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerRequest.kt create mode 100644 features/marketing/impl/build.gradle.kts create mode 100644 features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/DefaultMarketingBannerComponent.kt create mode 100644 features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/di/MarketingComponentModule.kt create mode 100644 features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModel.kt create mode 100644 features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBanner.kt create mode 100644 features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBannerCarousel.kt create mode 100644 features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBannerContent.kt create mode 100644 features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerListUM.kt create mode 100644 features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerUM.kt create mode 100644 features/marketing/impl/src/test/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 06af283694..6e701b940a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -240,6 +240,8 @@ dependencies { /** Features */ implementation(projects.features.addressBook.api) implementation(projects.features.addressBook.impl) + implementation(projects.features.marketing.api) + implementation(projects.features.marketing.impl) implementation(projects.features.rating.impl) implementation(projects.features.referral.impl) implementation(projects.features.referral.domain) diff --git a/features/marketing/api/build.gradle.kts b/features/marketing/api/build.gradle.kts new file mode 100644 index 0000000000..3cfd5dcf11 --- /dev/null +++ b/features/marketing/api/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.marketing.api" +} + +dependencies { + implementation(projects.core.decompose) + implementation(projects.core.ui) + implementation(projects.domain.marketing.models) + + implementation(deps.kotlin.coroutines) +} \ No newline at end of file diff --git a/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerComponent.kt b/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerComponent.kt new file mode 100644 index 0000000000..9cd5052a66 --- /dev/null +++ b/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerComponent.kt @@ -0,0 +1,28 @@ +package com.tangem.features.marketing.api + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import kotlinx.coroutines.flow.Flow + +interface MarketingBannerComponent : ComposableContentComponent { + + sealed interface Params { + + /** + * STANDALONE carousel; hosted on all 6 screens. `null` in the flow hides the banner. + * + * @param onDeeplinkClick optional interceptor for a tapped banner deeplink. Return `true` when + * the host routed it contextually (e.g. `tangem://swap`/`tangem://buy` for the current token); + * `false`/`null` lets the banner fall back to the generic deeplink launcher (external links). + */ + data class Standalone( + val requestFlow: Flow, + val onDeeplinkClick: ((deeplink: String) -> Boolean)? = null, + ) : Params + + /** LINKED_TO_PROVIDER single banner rendered inline next to an onramp provider offer. */ + data class LinkedToProvider(val requestFlow: Flow) : Params + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerRequest.kt b/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerRequest.kt new file mode 100644 index 0000000000..3c64911cd0 --- /dev/null +++ b/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerRequest.kt @@ -0,0 +1,17 @@ +package com.tangem.features.marketing.api + +import com.tangem.domain.marketing.models.MarketingScreen +import java.math.BigDecimal + +/** Context for a STANDALONE banner request on any of the 6 surfaces. */ +data class MarketingBannerRequest( + val screen: MarketingScreen, + val amountUsd: BigDecimal? = null, +) + +/** Context for a LINKED_TO_PROVIDER banner request (onramp only), matched against the shown provider. */ +data class LinkedBannerRequest( + val screen: MarketingScreen.Onramp, + val amountUsd: BigDecimal?, + val currentProviderId: String, +) \ No newline at end of file diff --git a/features/marketing/impl/build.gradle.kts b/features/marketing/impl/build.gradle.kts new file mode 100644 index 0000000000..242266738e --- /dev/null +++ b/features/marketing/impl/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.marketing.impl" +} + +dependencies { + /** Project - API */ + implementation(projects.features.marketing.api) + + /** Domain */ + implementation(projects.domain.marketing) + implementation(projects.domain.marketing.models) + + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.navigation) + implementation(projects.core.ui) + implementation(projects.core.utils) + + /** Compose */ + implementation(deps.compose.foundation) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.compose.coil) + implementation(deps.lifecycle.compose) + + /** Other */ + implementation(deps.arrow.core) + implementation(deps.kotlin.immutable.collections) + implementation(deps.kotlin.coroutines) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + /** Tests */ + testImplementation(projects.test.core) + testImplementation(deps.test.junit5) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(deps.test.turbine) + testImplementation(deps.test.coroutine) +} \ No newline at end of file diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/DefaultMarketingBannerComponent.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/DefaultMarketingBannerComponent.kt new file mode 100644 index 0000000000..3de80d23c6 --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/DefaultMarketingBannerComponent.kt @@ -0,0 +1,41 @@ +package com.tangem.features.marketing.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.marketing.api.MarketingBannerComponent +import com.tangem.features.marketing.impl.model.MarketingBannerModel +import com.tangem.features.marketing.impl.ui.MarketingBannerContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultMarketingBannerComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: MarketingBannerComponent.Params, +) : MarketingBannerComponent, AppComponentContext by appComponentContext { + + private val model: MarketingBannerModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + MarketingBannerContent( + state = state, + onBannerClick = model::onBannerClick, + onDismiss = model::onDismiss, + modifier = modifier, + ) + } + + @AssistedFactory + interface Factory : MarketingBannerComponent.Factory { + override fun create( + context: AppComponentContext, + params: MarketingBannerComponent.Params, + ): DefaultMarketingBannerComponent + } +} \ No newline at end of file diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/di/MarketingComponentModule.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/di/MarketingComponentModule.kt new file mode 100644 index 0000000000..b1caeffec9 --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/di/MarketingComponentModule.kt @@ -0,0 +1,35 @@ +package com.tangem.features.marketing.impl.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.marketing.api.MarketingBannerComponent +import com.tangem.features.marketing.impl.DefaultMarketingBannerComponent +import com.tangem.features.marketing.impl.model.MarketingBannerModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface MarketingComponentModule { + + @Binds + @Singleton + fun bindMarketingBannerComponentFactory( + factory: DefaultMarketingBannerComponent.Factory, + ): MarketingBannerComponent.Factory +} + +@Module +@InstallIn(ModelComponent::class) +internal interface MarketingModelModule { + + @Binds + @IntoMap + @ClassKey(MarketingBannerModel::class) + fun bindMarketingBannerModel(model: MarketingBannerModel): Model +} \ No newline at end of file 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 new file mode 100644 index 0000000000..6844151314 --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModel.kt @@ -0,0 +1,149 @@ +package com.tangem.features.marketing.impl.model + +import arrow.core.getOrElse +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.deeplink.DeeplinkLauncher +import com.tangem.domain.marketing.DismissMarketingBannerUseCase +import com.tangem.domain.marketing.GetMarketingBannerUseCase +import com.tangem.domain.marketing.models.MarketingBanner +import com.tangem.domain.marketing.models.MarketingCampaign +import com.tangem.domain.marketing.models.MarketingScreen +import com.tangem.domain.marketing.models.matchesUsdAmount +import com.tangem.features.marketing.api.MarketingBannerComponent +import com.tangem.features.marketing.impl.ui.state.MarketingBannerListUM +import com.tangem.features.marketing.impl.ui.state.MarketingBannerUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.math.BigDecimal +import javax.inject.Inject + +@OptIn(FlowPreview::class) +@ModelScoped +internal class MarketingBannerModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, + private val getMarketingBanner: GetMarketingBannerUseCase, + private val dismissMarketingBanner: DismissMarketingBannerUseCase, + private val deeplinkLauncher: DeeplinkLauncher, +) : Model() { + + private val params = paramsContainer.require() + private val dismissedIds = MutableStateFlow>(emptySet()) + + val uiState: StateFlow + field = MutableStateFlow(MarketingBannerListUM.Hidden) + + init { + observeBanners() + } + + fun onBannerClick(deeplink: String?) { + if (deeplink.isNullOrBlank()) return + // Let the host route contextual deeplinks (swap/buy for the current token). Fall back to the + // generic launcher for external links and when no interceptor is provided. + val isHandledByHost = (params as? MarketingBannerComponent.Params.Standalone) + ?.onDeeplinkClick?.invoke(deeplink) == true + if (!isHandledByHost) deeplinkLauncher.launch(deeplink) + } + + fun onDismiss(campaignId: Int) { + dismissedIds.update { it + campaignId } + modelScope.launch { dismissMarketingBanner(campaignId) } + } + + private fun observeBanners() { + val requestFlow: Flow = when (val p = params) { + is MarketingBannerComponent.Params.Standalone -> + p.requestFlow.map { request -> + request?.let { MarketingRequest(screen = it.screen, amountUsd = it.amountUsd, providerId = null) } + } + is MarketingBannerComponent.Params.LinkedToProvider -> + p.requestFlow.map { request -> + request?.let { linked -> + MarketingRequest( + screen = linked.screen, + amountUsd = linked.amountUsd, + providerId = linked.currentProviderId, + ) + } + } + } + + val campaigns: Flow> = requestFlow + .map { it?.screen } + .distinctUntilChanged() + .debounce(REQUEST_DEBOUNCE_MS) + .mapLatest { screen -> if (screen != null) fetch(screen) else emptyList() } + + val amountUsd: Flow = requestFlow.map { it?.amountUsd }.distinctUntilChanged() + val providerId: Flow = requestFlow.map { it?.providerId }.distinctUntilChanged() + + modelScope.launch { + combine( + flow = campaigns, + flow2 = amountUsd, + flow3 = providerId, + flow4 = dismissedIds, + ) { list, usd, provider, dismissed -> + list.asSequence() + .filterNot { it.id in dismissed } + .filter { it.matchesUsdAmount(usd) } + .filter { matchesUiTypeAndProvider(it, provider) } + .map { it.toUM() } + .toList() + }.collect { banners -> + uiState.value = if (banners.isEmpty()) { + MarketingBannerListUM.Hidden + } else { + MarketingBannerListUM.Content(banners.toImmutableList()) + } + } + } + } + + private suspend fun fetch(screen: MarketingScreen): List = + getMarketingBanner(screen, amountUsd = null).getOrElse { emptyList() } + + private fun matchesUiTypeAndProvider(campaign: MarketingCampaign, providerId: String?): Boolean = when (params) { + is MarketingBannerComponent.Params.Standalone -> + campaign.banner.uiType == MarketingBanner.UiType.STANDALONE + is MarketingBannerComponent.Params.LinkedToProvider -> + campaign.banner.uiType == MarketingBanner.UiType.LINKED_TO_PROVIDER && + providerId != null && campaign.providerIds?.contains(providerId) == true + } + + private data class MarketingRequest( + val screen: MarketingScreen, + val amountUsd: BigDecimal?, + val providerId: String?, + ) + + private fun MarketingCampaign.toUM() = MarketingBannerUM( + campaignId = id, + text = banner.text, + iconUrl = banner.iconUrl, + iconAlign = when (banner.iconAlign) { + MarketingBanner.IconAlign.RIGHT -> MarketingBannerUM.IconAlign.RIGHT + MarketingBanner.IconAlign.LEFT, null -> MarketingBannerUM.IconAlign.LEFT + }, + isDismissible = banner.isDismissible, + deeplink = banner.deeplink, + ) + + private companion object { + const val REQUEST_DEBOUNCE_MS = 300L + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..c24afa21d0 --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBanner.kt @@ -0,0 +1,145 @@ +package com.tangem.features.marketing.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.SubcomposeAsyncImage +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 +import com.tangem.features.marketing.impl.ui.state.MarketingBannerUM + +/** + * Marketing banner rendered with the design-system [TangemMessageBanner] (DS3): default variant with + * the "magic" glow ring, a title, an optional icon slot, and a cross-circle dismiss button. + * + * The whole banner is clickable and launches [onClick] (its deeplink) — the marketing API exposes no + * banner buttons, only a single deeplink. The API's `bgColor` is intentionally not applied here: the DS + * component drives the background via its fixed [TangemMessageBanner.Variant], matching the design. + */ +@Composable +internal fun MarketingBanner( + banner: MarketingBannerUM, + onClick: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val hasDeeplink = !banner.deeplink.isNullOrBlank() + // Hide the icon slot (and its gap) when the image fails to load, so a broken URL leaves no empty gap. + var isIconFailed by remember(banner.iconUrl) { mutableStateOf(false) } + val hasIcon = !banner.iconUrl.isNullOrBlank() && !isIconFailed + val isIconAtStart = hasIcon && banner.iconAlign == MarketingBannerUM.IconAlign.LEFT + val isIconAtEnd = hasIcon && banner.iconAlign == MarketingBannerUM.IconAlign.RIGHT + + TangemMessageBanner( + title = stringReference(banner.text.orEmpty()), + modifier = modifier.then( + if (hasDeeplink) Modifier.clickableSingle(onClick = onClick) else Modifier, + ), + variant = TangemMessageBanner.Variant.Default, + showGlowRing = false, + slotStart = if (isIconAtStart) { + { BannerIcon(banner.iconUrl, onLoadError = { isIconFailed = true }) } + } else { + null + }, + slotEnd = if (isIconAtEnd || banner.isDismissible) { + { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isIconAtEnd) { + BannerIcon(banner.iconUrl, onLoadError = { isIconFailed = true }) + } + if (banner.isDismissible) { + TangemMessageBanner.CloseButton( + onClick = onDismiss, + contentDescription = stringResourceSafe(R.string.common_close), + ) + } + } + } + } else { + null + }, + ) +} + +@Composable +private fun BannerIcon(iconUrl: String?, onLoadError: () -> Unit) { + if (iconUrl.isNullOrBlank()) return + SubcomposeAsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(iconUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + onError = { onLoadError() }, + modifier = Modifier.size(20.dp), + ) +} + +// region Preview + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_MarketingBanner() { + TangemThemePreviewRedesign { + MarketingBanner( + banner = MarketingBannerUM( + campaignId = 1, + text = "1:1 onramp at 0 fees!", + iconUrl = null, + iconAlign = MarketingBannerUM.IconAlign.LEFT, + isDismissible = true, + deeplink = "tangem://promo/1", + ), + onClick = {}, + onDismiss = {}, + modifier = Modifier.padding(16.dp), + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Composable +private fun Preview_MarketingBanner_NotDismissible() { + TangemThemePreviewRedesign { + MarketingBanner( + banner = MarketingBannerUM( + campaignId = 2, + text = "Earn up to 14% APY by staking your crypto directly from the wallet", + iconUrl = null, + iconAlign = MarketingBannerUM.IconAlign.RIGHT, + isDismissible = false, + deeplink = null, + ), + onClick = {}, + onDismiss = {}, + modifier = Modifier.padding(16.dp), + ) + } +} + +// endregion \ No newline at end of file diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBannerCarousel.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBannerCarousel.kt new file mode 100644 index 0000000000..c2ea8ef617 --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBannerCarousel.kt @@ -0,0 +1,45 @@ +package com.tangem.features.marketing.impl.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.pager.PagerIndicator +import com.tangem.features.marketing.impl.ui.state.MarketingBannerUM +import kotlinx.collections.immutable.ImmutableList + +@Composable +internal fun MarketingBannerCarousel( + banners: ImmutableList, + onBannerClick: (String?) -> Unit, + onDismiss: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val pagerState = rememberPagerState(pageCount = { banners.size }) + + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + pageSpacing = 8.dp, + key = { page -> banners[page].campaignId }, + ) { page -> + val banner = banners[page] + MarketingBanner( + banner = banner, + onClick = { onBannerClick(banner.deeplink) }, + onDismiss = { onDismiss(banner.campaignId) }, + ) + } + PagerIndicator(pagerState = pagerState, hasBackground = false) + } +} \ No newline at end of file diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBannerContent.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBannerContent.kt new file mode 100644 index 0000000000..6982742d79 --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/MarketingBannerContent.kt @@ -0,0 +1,36 @@ +package com.tangem.features.marketing.impl.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.features.marketing.impl.ui.state.MarketingBannerListUM + +@Composable +internal fun MarketingBannerContent( + state: MarketingBannerListUM, + onBannerClick: (String?) -> Unit, + onDismiss: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + when (state) { + is MarketingBannerListUM.Hidden -> Unit + is MarketingBannerListUM.Content -> { + val banners = state.banners + if (banners.size == 1) { + val banner = banners.first() + MarketingBanner( + banner = banner, + onClick = { onBannerClick(banner.deeplink) }, + onDismiss = { onDismiss(banner.campaignId) }, + modifier = modifier, + ) + } else { + MarketingBannerCarousel( + banners = banners, + onBannerClick = onBannerClick, + onDismiss = onDismiss, + modifier = modifier, + ) + } + } + } +} \ No newline at end of file diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerListUM.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerListUM.kt new file mode 100644 index 0000000000..02cda2679a --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerListUM.kt @@ -0,0 +1,12 @@ +package com.tangem.features.marketing.impl.ui.state + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed interface MarketingBannerListUM { + + data object Hidden : MarketingBannerListUM + + data class Content(val banners: ImmutableList) : MarketingBannerListUM +} \ No newline at end of file diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerUM.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerUM.kt new file mode 100644 index 0000000000..c4bb2acc88 --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerUM.kt @@ -0,0 +1,15 @@ +package com.tangem.features.marketing.impl.ui.state + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class MarketingBannerUM( + val campaignId: Int, + val text: String?, + val iconUrl: String?, + val iconAlign: IconAlign, + val isDismissible: Boolean, + val deeplink: String?, +) { + enum class IconAlign { LEFT, RIGHT } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..38145f1145 --- /dev/null +++ b/features/marketing/impl/src/test/kotlin/com/tangem/features/marketing/impl/model/MarketingBannerModelTest.kt @@ -0,0 +1,279 @@ +package com.tangem.features.marketing.impl.model + +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.navigation.deeplink.DeeplinkLauncher +import com.tangem.domain.marketing.DismissMarketingBannerUseCase +import com.tangem.domain.marketing.GetMarketingBannerUseCase +import com.tangem.domain.marketing.models.MarketingBanner +import com.tangem.domain.marketing.models.MarketingCampaign +import com.tangem.domain.marketing.models.MarketingScreen +import com.tangem.domain.marketing.models.MarketingScreenType +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.utils.coroutines.CoroutineDispatcherProvider +import io.mockk.Runs +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +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 +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class MarketingBannerModelTest { + + private val getMarketingBanner: GetMarketingBannerUseCase = mockk() + private val dismissMarketingBanner: DismissMarketingBannerUseCase = mockk() + private val deeplinkLauncher: DeeplinkLauncher = mockk(relaxed = true) + + @BeforeEach + fun setup() { + clearMocks(getMarketingBanner, dismissMarketingBanner, deeplinkLauncher) + } + + private fun TestScope.createModel(params: MarketingBannerComponent.Params): MarketingBannerModel { + val dispatcher = StandardTestDispatcher(testScheduler) + val dispatchers = object : CoroutineDispatcherProvider { + override val main = dispatcher + override val mainImmediate = dispatcher + override val io = dispatcher + override val default = dispatcher + override val single = dispatcher + } + return MarketingBannerModel( + dispatchers = dispatchers, + paramsContainer = MutableParamsContainer(params), + getMarketingBanner = getMarketingBanner, + dismissMarketingBanner = dismissMarketingBanner, + deeplinkLauncher = deeplinkLauncher, + ) + } + + private fun campaign(id: Int, uiType: MarketingBanner.UiType, providerIds: List? = null) = + MarketingCampaign( + id = id, + type = MarketingScreenType.ONRAMP, + priority = id, + startAt = null, + endAt = null, + minAmount = null, + maxAmount = null, + providerIds = providerIds, + banner = MarketingBanner( + uiType = uiType, + text = "text-$id", + iconUrl = null, + iconAlign = null, + bgColor = null, + deeplink = "tangem://promo/$id", + isDismissible = true, + ), + targets = emptyList(), + ) + + private val onrampScreen = MarketingScreen.Onramp("USD", "ethereum", "0xabc") + + private fun swapScreen(fromContract: String = "0xF") = + MarketingScreen.Swap(fromNetwork = "eth", fromContractAddress = fromContract, toNetwork = "btc", toContractAddress = "0xT") + + private fun gatedCampaign(id: Int) = MarketingCampaign( + id = id, type = MarketingScreenType.SWAP, priority = id, startAt = null, endAt = null, + minAmount = java.math.BigDecimal(50), maxAmount = java.math.BigDecimal(300), providerIds = null, + banner = MarketingBanner( + uiType = MarketingBanner.UiType.STANDALONE, text = "t$id", iconUrl = null, + iconAlign = null, bgColor = null, deeplink = null, isDismissible = false, + ), + targets = emptyList(), + ) + + @Test + fun `GIVEN amount changes WHEN same pair THEN re-filters locally without re-fetch`() = runTest { + // Arrange + val screen = swapScreen() + coEvery { getMarketingBanner(screen, null) } returns listOf(gatedCampaign(1)).right() + val requests = MutableStateFlow( + MarketingBannerRequest(screen, amountUsd = java.math.BigDecimal(10)), // below min -> hidden + ) + val model = createModel(MarketingBannerComponent.Params.Standalone(requests)) + + // Act + Assert + advanceUntilIdle() + assertThat(model.uiState.value).isEqualTo(MarketingBannerListUM.Hidden) // 10 < 50 + + requests.value = MarketingBannerRequest(screen, amountUsd = java.math.BigDecimal(100)) // in range + advanceUntilIdle() + val content = model.uiState.value as MarketingBannerListUM.Content + assertThat(content.banners.map { it.campaignId }).containsExactly(1) + + // fetched once for the pair, despite two different amounts + coVerify(exactly = 1) { getMarketingBanner(screen, null) } + } + + @Test + fun `GIVEN standalone campaigns WHEN request emitted THEN only STANDALONE banners shown`() = runTest { + // Arrange + coEvery { getMarketingBanner(onrampScreen, null) } returns listOf( + campaign(1, MarketingBanner.UiType.STANDALONE), + campaign(2, MarketingBanner.UiType.LINKED_TO_PROVIDER), + ).right() + val params = MarketingBannerComponent.Params.Standalone( + requestFlow = flowOf(MarketingBannerRequest(onrampScreen, amountUsd = null)), + ) + val model = createModel(params) + + // Act + Assert + model.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state).isInstanceOf(MarketingBannerListUM.Content::class.java) + val content = state as MarketingBannerListUM.Content + assertThat(content.banners.map { it.campaignId }).containsExactly(1) + } + } + + @Test + fun `GIVEN empty result WHEN request emitted THEN Hidden`() = runTest { + // Arrange + coEvery { getMarketingBanner(onrampScreen, null) } returns emptyList().right() + val model = createModel( + MarketingBannerComponent.Params.Standalone(flowOf(MarketingBannerRequest(onrampScreen))), + ) + + // Act + Assert + model.uiState.test { + advanceUntilIdle() + assertThat(expectMostRecentItem()).isEqualTo(MarketingBannerListUM.Hidden) + } + } + + @Test + fun `GIVEN use case fails WHEN request emitted THEN Hidden`() = runTest { + // Arrange + coEvery { getMarketingBanner(onrampScreen, null) } returns RuntimeException("boom").left() + val model = createModel( + MarketingBannerComponent.Params.Standalone(flowOf(MarketingBannerRequest(onrampScreen))), + ) + + // Act + Assert + model.uiState.test { + advanceUntilIdle() + assertThat(expectMostRecentItem()).isEqualTo(MarketingBannerListUM.Hidden) + } + } + + @Test + fun `GIVEN linked campaigns WHEN provider matches THEN only matching LINKED banner shown`() = runTest { + // Arrange + coEvery { getMarketingBanner(onrampScreen, null) } returns listOf( + campaign(1, MarketingBanner.UiType.LINKED_TO_PROVIDER, providerIds = listOf("mercuryo")), + campaign(2, MarketingBanner.UiType.LINKED_TO_PROVIDER, providerIds = listOf("moonpay")), + campaign(3, MarketingBanner.UiType.STANDALONE), + ).right() + val model = createModel( + MarketingBannerComponent.Params.LinkedToProvider( + flowOf(LinkedBannerRequest(onrampScreen, amountUsd = null, currentProviderId = "mercuryo")), + ), + ) + + // Act + Assert + model.uiState.test { + advanceUntilIdle() + val content = expectMostRecentItem() as MarketingBannerListUM.Content + assertThat(content.banners.map { it.campaignId }).containsExactly(1) + } + } + + @Test + fun `GIVEN shown banner WHEN dismissed THEN removed from state and use case called`() = runTest { + // Arrange + coEvery { getMarketingBanner(onrampScreen, null) } returns listOf( + campaign(1, MarketingBanner.UiType.STANDALONE), + ).right() + coEvery { dismissMarketingBanner(1) } returns Unit.right() + val model = createModel( + MarketingBannerComponent.Params.Standalone(flowOf(MarketingBannerRequest(onrampScreen))), + ) + + // Act + advanceUntilIdle() + model.onDismiss(campaignId = 1) + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value).isEqualTo(MarketingBannerListUM.Hidden) + coVerify(exactly = 1) { dismissMarketingBanner(1) } + } + + @Test + fun `GIVEN non-blank deeplink WHEN clicked THEN launcher called`() = runTest { + // Arrange + val model = createModel( + MarketingBannerComponent.Params.Standalone(MutableStateFlow(null)), + ) + + // Act + model.onBannerClick("tangem://promo/1") + + // Assert + verify(exactly = 1) { deeplinkLauncher.launch("tangem://promo/1") } + } + + @Test + fun `GIVEN blank deeplink WHEN clicked THEN launcher not called`() = runTest { + val model = createModel(MarketingBannerComponent.Params.Standalone(MutableStateFlow(null))) + + model.onBannerClick(null) + model.onBannerClick("") + + verify(exactly = 0) { deeplinkLauncher.launch(any()) } + } + + @Test + fun `GIVEN host handles deeplink WHEN clicked THEN launcher not called`() = runTest { + // Arrange + val model = createModel( + MarketingBannerComponent.Params.Standalone( + requestFlow = MutableStateFlow(null), + onDeeplinkClick = { true }, + ), + ) + + // Act + model.onBannerClick("tangem://swap") + + // Assert + verify(exactly = 0) { deeplinkLauncher.launch(any()) } + } + + @Test + fun `GIVEN host does not handle deeplink WHEN clicked THEN launcher called`() = runTest { + // Arrange + val model = createModel( + MarketingBannerComponent.Params.Standalone( + requestFlow = MutableStateFlow(null), + onDeeplinkClick = { false }, + ), + ) + + // Act + model.onBannerClick("https://tangem.com/promo") + + // Assert + verify(exactly = 1) { deeplinkLauncher.launch("https://tangem.com/promo") } + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 11a94fe444..46d255012d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -339,6 +339,9 @@ include(":features:feed:impl") include(":features:promo-banners:api") include(":features:promo-banners:impl") +include(":features:marketing:api") +include(":features:marketing:impl") + include(":features:payment:api") include(":features:payment:impl") From e99ffe39f0b42fa645aa02866bc9f3ee56e81593 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 14 Jul 2026 18:26:55 +0500 Subject: [PATCH 46/59] Updated on 2026-08-14 --- .../models/MarketingCampaignAmount.kt | 13 ++- .../models/MarketingCampaignAmountTest.kt | 16 ++- .../marketing/GetMarketingBannerUseCase.kt | 4 +- features/marketing/api/build.gradle.kts | 5 + .../marketing/api/MarketingBannerComponent.kt | 22 +++- .../marketing/api/MarketingBannerRequest.kt | 6 +- .../impl/DefaultMarketingBannerComponent.kt | 22 ++++ .../impl/model/MarketingBannerModel.kt | 29 ++--- .../impl/ui/LinkedMarketingBanner.kt | 105 ++++++++++++++++++ .../impl/ui/state/MarketingBannerUM.kt | 1 + .../impl/model/MarketingBannerModelTest.kt | 12 +- features/onramp/impl/build.gradle.kts | 4 + .../alloffers/entity/AllOffersStateFactory.kt | 2 + .../alloffers/ui/AllOffersContentSheet.kt | 4 + .../alloffers/ui/PaymentMethodsContent.kt | 2 + .../onramp/main/DefaultOnrampMainComponent.kt | 23 +++- .../onramp/main/entity/OnrampOfferBlockUM.kt | 1 + .../factory/OnrampOffersStateFactory.kt | 1 + .../main/model/OnrampMainComponentModel.kt | 90 +++++++++++++++ .../main/ui/OnrampMainComponentContent.kt | 40 ++++++- .../onramp/main/ui/OnrampOffersContent.kt | 49 ++++++-- features/swap/impl/build.gradle.kts | 4 + .../feature/swap/DefaultSwapComponent.kt | 13 +++ .../tangem/feature/swap/model/SwapModel.kt | 40 +++++++ .../com/tangem/feature/swap/ui/SwapScreen.kt | 17 ++- .../feature/swap/ui/SwapScreenContent.kt | 3 + .../feature/swap/model/SwapModelTestBase.kt | 3 + .../extension/BaseExtensionConfigurations.kt | 1 + 28 files changed, 480 insertions(+), 52 deletions(-) create mode 100644 features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/LinkedMarketingBanner.kt diff --git a/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmount.kt b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmount.kt index 5fffde6c7d..5edd396f2e 100644 --- a/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmount.kt +++ b/domain/marketing/models/src/main/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmount.kt @@ -3,14 +3,15 @@ package com.tangem.domain.marketing.models import java.math.BigDecimal /** - * USD min/max eligibility gate. Applies only to swap/onramp campaigns and only when [amountUsd] is known; - * otherwise the campaign passes (non-amount screens and the "amount unknown" case are not gated). + * USD min/max eligibility gate (mirrors iOS `satisfiesAmount`). A campaign without min/max bounds is + * always eligible. A bounded campaign requires a known [amountUsd] — while the amount is unknown the + * campaign is NOT eligible (hidden until a quote/amount arrives), then it must fall within the bounds. */ fun MarketingCampaign.matchesUsdAmount(amountUsd: BigDecimal?): Boolean { - val isAmountScreen = type == MarketingScreenType.SWAP || type == MarketingScreenType.ONRAMP - if (!isAmountScreen || amountUsd == null) return true + if (minAmount == null && maxAmount == null) return true - if (minAmount != null && amountUsd < minAmount) return false - if (maxAmount != null && amountUsd > maxAmount) return false + val usd = amountUsd ?: return false + if (minAmount != null && usd < minAmount) return false + if (maxAmount != null && usd > maxAmount) return false return true } \ No newline at end of file diff --git a/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmountTest.kt b/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmountTest.kt index 2e8fa7ebef..2ab57b78a4 100644 --- a/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmountTest.kt +++ b/domain/marketing/models/src/test/kotlin/com/tangem/domain/marketing/models/MarketingCampaignAmountTest.kt @@ -21,16 +21,24 @@ internal class MarketingCampaignAmountTest { ) @Test - fun `GIVEN non swap-onramp type WHEN matchesUsdAmount THEN always true`() { - val c = campaign(MarketingScreenType.TOKEN_DETAILS, minAmount = BigDecimal(50), maxAmount = BigDecimal(300)) + fun `GIVEN no min max bounds WHEN matchesUsdAmount THEN always true`() { + val c = campaign(MarketingScreenType.TOKEN_DETAILS, minAmount = null, maxAmount = null) assertThat(c.matchesUsdAmount(BigDecimal(10))).isTrue() assertThat(c.matchesUsdAmount(null)).isTrue() } @Test - fun `GIVEN swap with null amount WHEN matchesUsdAmount THEN true`() { + fun `GIVEN bounded campaign of any type WHEN amount out of range THEN false`() { + // Bounds apply regardless of screen type (iOS parity): type no longer exempts a bounded campaign. + val c = campaign(MarketingScreenType.TOKEN_DETAILS, minAmount = BigDecimal(50), maxAmount = BigDecimal(300)) + assertThat(c.matchesUsdAmount(BigDecimal(10))).isFalse() + assertThat(c.matchesUsdAmount(BigDecimal(100))).isTrue() + } + + @Test + fun `GIVEN bounded campaign with null amount WHEN matchesUsdAmount THEN false`() { val c = campaign(MarketingScreenType.SWAP, minAmount = BigDecimal(50)) - assertThat(c.matchesUsdAmount(null)).isTrue() + assertThat(c.matchesUsdAmount(null)).isFalse() } @Test diff --git a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt index 387553c39a..87d54579f0 100644 --- a/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt +++ b/domain/marketing/src/main/kotlin/com/tangem/domain/marketing/GetMarketingBannerUseCase.kt @@ -28,7 +28,9 @@ class GetMarketingBannerUseCase( campaigns.asSequence() .filterNot { it.id in dismissed } .filter { matchesTarget(it, screen) } - .filter { it.matchesUsdAmount(amountUsd) } + // Amount gating runs reactively in the consumer (with the live amount). Skip it here when + // no amount is provided, so bounded swap/onramp campaigns aren't dropped on the pre-fetch. + .filter { amountUsd == null || it.matchesUsdAmount(amountUsd) } .sortedBy { it.priority } .toList() } diff --git a/features/marketing/api/build.gradle.kts b/features/marketing/api/build.gradle.kts index 3cfd5dcf11..b0d679e453 100644 --- a/features/marketing/api/build.gradle.kts +++ b/features/marketing/api/build.gradle.kts @@ -14,4 +14,9 @@ dependencies { implementation(projects.domain.marketing.models) implementation(deps.kotlin.coroutines) + + // The interface exposes a @Composable LinkedContent function, so the module needs the Compose compiler + // (enabled via the module allowlist in the configuration convention plugin) and these APIs. + api(deps.compose.runtime) + api(deps.compose.ui) } \ No newline at end of file diff --git a/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerComponent.kt b/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerComponent.kt index 9cd5052a66..19dd18fc97 100644 --- a/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerComponent.kt +++ b/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerComponent.kt @@ -1,11 +1,29 @@ package com.tangem.features.marketing.api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent import kotlinx.coroutines.flow.Flow interface MarketingBannerComponent : ComposableContentComponent { + /** + * Renders the LINKED_TO_PROVIDER banner for the offer identified by [providerId] (the row this sits + * next to). Shows nothing when no linked campaign targets that provider. No-op for standalone banners. + */ + @Composable + fun LinkedContent(providerId: String, modifier: Modifier) { + // Default no-op: only the LINKED_TO_PROVIDER implementation renders a banner. + } + + /** + * Whether a LINKED_TO_PROVIDER banner is available for [providerId]. The host uses this to glue the + * banner to the offer (e.g. squaring the offer's bottom corners). Always `false` for standalone banners. + */ + @Composable + fun hasLinkedBanner(providerId: String): Boolean = false + sealed interface Params { /** @@ -20,8 +38,8 @@ interface MarketingBannerComponent : ComposableContentComponent { val onDeeplinkClick: ((deeplink: String) -> Boolean)? = null, ) : Params - /** LINKED_TO_PROVIDER single banner rendered inline next to an onramp provider offer. */ - data class LinkedToProvider(val requestFlow: Flow) : Params + /** LINKED single banner rendered inline next to a host item (currently an onramp provider offer). */ + data class Linked(val requestFlow: Flow) : Params } interface Factory : ComponentFactory diff --git a/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerRequest.kt b/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerRequest.kt index 3c64911cd0..0c517aa39d 100644 --- a/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerRequest.kt +++ b/features/marketing/api/src/main/kotlin/com/tangem/features/marketing/api/MarketingBannerRequest.kt @@ -9,9 +9,11 @@ data class MarketingBannerRequest( val amountUsd: BigDecimal? = null, ) -/** Context for a LINKED_TO_PROVIDER banner request (onramp only), matched against the shown provider. */ +/** + * Context for LINKED_TO_PROVIDER banner requests (onramp only). Provider matching happens per offer at + * render time via [MarketingBannerComponent.LinkedContent], so the request carries no provider id. + */ data class LinkedBannerRequest( val screen: MarketingScreen.Onramp, val amountUsd: BigDecimal?, - val currentProviderId: String, ) \ No newline at end of file diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/DefaultMarketingBannerComponent.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/DefaultMarketingBannerComponent.kt index 3de80d23c6..89acb2c793 100644 --- a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/DefaultMarketingBannerComponent.kt +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/DefaultMarketingBannerComponent.kt @@ -8,7 +8,9 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.marketing.api.MarketingBannerComponent import com.tangem.features.marketing.impl.model.MarketingBannerModel +import com.tangem.features.marketing.impl.ui.LinkedMarketingBanner import com.tangem.features.marketing.impl.ui.MarketingBannerContent +import com.tangem.features.marketing.impl.ui.state.MarketingBannerListUM import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -31,6 +33,26 @@ internal class DefaultMarketingBannerComponent @AssistedInject constructor( ) } + @Composable + override fun LinkedContent(providerId: String, modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + val banner = (state as? MarketingBannerListUM.Content) + ?.banners + ?.firstOrNull { providerId in it.providerIds } + ?: return + LinkedMarketingBanner( + banner = banner, + onClick = { model.onBannerClick(banner.deeplink) }, + modifier = modifier, + ) + } + + @Composable + override fun hasLinkedBanner(providerId: String): Boolean { + val state by model.uiState.collectAsStateWithLifecycle() + return (state as? MarketingBannerListUM.Content)?.banners?.any { providerId in it.providerIds } == true + } + @AssistedFactory interface Factory : MarketingBannerComponent.Factory { override fun create( 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 6844151314..6c03a6d43a 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 @@ -68,17 +68,11 @@ internal class MarketingBannerModel @Inject constructor( val requestFlow: Flow = when (val p = params) { is MarketingBannerComponent.Params.Standalone -> p.requestFlow.map { request -> - request?.let { MarketingRequest(screen = it.screen, amountUsd = it.amountUsd, providerId = null) } + request?.let { MarketingRequest(screen = it.screen, amountUsd = it.amountUsd) } } - is MarketingBannerComponent.Params.LinkedToProvider -> + is MarketingBannerComponent.Params.Linked -> p.requestFlow.map { request -> - request?.let { linked -> - MarketingRequest( - screen = linked.screen, - amountUsd = linked.amountUsd, - providerId = linked.currentProviderId, - ) - } + request?.let { MarketingRequest(screen = it.screen, amountUsd = it.amountUsd) } } } @@ -89,19 +83,17 @@ internal class MarketingBannerModel @Inject constructor( .mapLatest { screen -> if (screen != null) fetch(screen) else emptyList() } val amountUsd: Flow = requestFlow.map { it?.amountUsd }.distinctUntilChanged() - val providerId: Flow = requestFlow.map { it?.providerId }.distinctUntilChanged() modelScope.launch { combine( flow = campaigns, flow2 = amountUsd, - flow3 = providerId, - flow4 = dismissedIds, - ) { list, usd, provider, dismissed -> + flow3 = dismissedIds, + ) { list, usd, dismissed -> list.asSequence() .filterNot { it.id in dismissed } .filter { it.matchesUsdAmount(usd) } - .filter { matchesUiTypeAndProvider(it, provider) } + .filter { matchesUiType(it) } .map { it.toUM() } .toList() }.collect { banners -> @@ -117,18 +109,16 @@ internal class MarketingBannerModel @Inject constructor( private suspend fun fetch(screen: MarketingScreen): List = getMarketingBanner(screen, amountUsd = null).getOrElse { emptyList() } - private fun matchesUiTypeAndProvider(campaign: MarketingCampaign, providerId: String?): Boolean = when (params) { + private fun matchesUiType(campaign: MarketingCampaign): Boolean = when (params) { is MarketingBannerComponent.Params.Standalone -> campaign.banner.uiType == MarketingBanner.UiType.STANDALONE - is MarketingBannerComponent.Params.LinkedToProvider -> - campaign.banner.uiType == MarketingBanner.UiType.LINKED_TO_PROVIDER && - providerId != null && campaign.providerIds?.contains(providerId) == true + is MarketingBannerComponent.Params.Linked -> + campaign.banner.uiType == MarketingBanner.UiType.LINKED_TO_PROVIDER } private data class MarketingRequest( val screen: MarketingScreen, val amountUsd: BigDecimal?, - val providerId: String?, ) private fun MarketingCampaign.toUM() = MarketingBannerUM( @@ -141,6 +131,7 @@ internal class MarketingBannerModel @Inject constructor( }, isDismissible = banner.isDismissible, deeplink = banner.deeplink, + providerIds = providerIds?.toSet().orEmpty(), ) private companion object { diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/LinkedMarketingBanner.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/LinkedMarketingBanner.kt new file mode 100644 index 0000000000..1be64bf57b --- /dev/null +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/LinkedMarketingBanner.kt @@ -0,0 +1,105 @@ +package com.tangem.features.marketing.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.marketing.impl.ui.state.MarketingBannerUM + +private val BOTTOM_CORNER_RADIUS = 20.dp +private val ICON_SIZE = 16.dp + +// DS3 has no dedicated "blue 10%" background token; derive it from the accent blue to match Figma +// (rgba(0,153,255,0.1)). +private const val BACKGROUND_ALPHA = 0.1f + +/** + * LINKED_TO_PROVIDER marketing banner — a compact accent strip glued to the bottom of an onramp provider + * offer. Distinct from the standalone [MarketingBanner]: blue accent background, bottom-only rounded + * corners, a 16dp icon and blue title, no dismiss button. + * + * [Figma](https://www.figma.com/design/GhMZiR8xGeGSmaLinuE5qq/Onramp?node-id=401-84213&m=dev) + */ +@Composable +internal fun LinkedMarketingBanner(banner: MarketingBannerUM, onClick: () -> Unit, modifier: Modifier = Modifier) { + val hasDeeplink = !banner.deeplink.isNullOrBlank() + // Collapse the icon slot when the image fails to load, so a broken URL leaves no empty gap. + var isIconFailed by remember(banner.iconUrl) { mutableStateOf(false) } + val hasIcon = !banner.iconUrl.isNullOrBlank() && !isIconFailed + + Row( + modifier = modifier + .clip(RoundedCornerShape(bottomStart = BOTTOM_CORNER_RADIUS, bottomEnd = BOTTOM_CORNER_RADIUS)) + .then(if (hasDeeplink) Modifier.clickableSingle(onClick = onClick) else Modifier) + .background(TangemTheme.colors3.bg.accent.blue.copy(alpha = BACKGROUND_ALPHA)) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (hasIcon) { + SubcomposeAsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(banner.iconUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(TangemTheme.colors3.icon.accent.blue), + onError = { isIconFailed = true }, + modifier = Modifier.size(ICON_SIZE), + ) + } + Text( + text = banner.text.orEmpty(), + style = TangemTheme.typography2.subheadlineMedium14, + color = TangemTheme.colors3.text.accent.blue, + ) + } +} + +// region Preview + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_LinkedMarketingBanner() { + TangemThemePreviewRedesign { + LinkedMarketingBanner( + banner = MarketingBannerUM( + campaignId = 1, + text = "1:1 onramp at 0 fees!", + iconUrl = null, + iconAlign = MarketingBannerUM.IconAlign.LEFT, + isDismissible = false, + deeplink = "tangem://buy", + providerIds = setOf("mercuryo"), + ), + onClick = {}, + modifier = Modifier.padding(16.dp), + ) + } +} + +// endregion \ No newline at end of file diff --git a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerUM.kt b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerUM.kt index c4bb2acc88..200871fc3e 100644 --- a/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerUM.kt +++ b/features/marketing/impl/src/main/kotlin/com/tangem/features/marketing/impl/ui/state/MarketingBannerUM.kt @@ -10,6 +10,7 @@ internal data class MarketingBannerUM( val iconAlign: IconAlign, val isDismissible: Boolean, val deeplink: String?, + val providerIds: Set = emptySet(), ) { enum class IconAlign { LEFT, RIGHT } } \ No newline at end of file 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 38145f1145..6e1643f546 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 @@ -177,7 +177,7 @@ internal class MarketingBannerModelTest { } @Test - fun `GIVEN linked campaigns WHEN provider matches THEN only matching LINKED banner shown`() = runTest { + fun `GIVEN linked campaigns WHEN request emitted THEN all LINKED banners shown with providerIds`() = runTest { // Arrange coEvery { getMarketingBanner(onrampScreen, null) } returns listOf( campaign(1, MarketingBanner.UiType.LINKED_TO_PROVIDER, providerIds = listOf("mercuryo")), @@ -185,16 +185,20 @@ internal class MarketingBannerModelTest { campaign(3, MarketingBanner.UiType.STANDALONE), ).right() val model = createModel( - MarketingBannerComponent.Params.LinkedToProvider( - flowOf(LinkedBannerRequest(onrampScreen, amountUsd = null, currentProviderId = "mercuryo")), + MarketingBannerComponent.Params.Linked( + flowOf(LinkedBannerRequest(onrampScreen, amountUsd = null)), ), ) // Act + Assert + // Model no longer filters by provider: it emits all LINKED banners (not STANDALONE), carrying their + // providerIds; per-offer provider matching happens at render time in LinkedContent(providerId). model.uiState.test { advanceUntilIdle() val content = expectMostRecentItem() as MarketingBannerListUM.Content - assertThat(content.banners.map { it.campaignId }).containsExactly(1) + assertThat(content.banners.map { it.campaignId }).containsExactly(1, 2) + assertThat(content.banners.first { it.campaignId == 1 }.providerIds).containsExactly("mercuryo") + assertThat(content.banners.first { it.campaignId == 2 }.providerIds).containsExactly("moonpay") } } diff --git a/features/onramp/impl/build.gradle.kts b/features/onramp/impl/build.gradle.kts index 6a21e2425b..448b36a6db 100644 --- a/features/onramp/impl/build.gradle.kts +++ b/features/onramp/impl/build.gradle.kts @@ -21,6 +21,8 @@ dependencies { implementation(projects.features.swap.domain.api) implementation(projects.features.swap.domain.models) implementation(projects.features.feed.api) + implementation(projects.features.marketing.api) + /** Project - Core */ implementation(projects.core.analytics) @@ -57,6 +59,8 @@ dependencies { implementation(projects.domain.appTheme.models) implementation(projects.data.common) implementation(projects.domain.markets) + implementation(projects.domain.marketing.models) + implementation(projects.domain.quotes) /** DI */ implementation(deps.hilt.android) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt index ee7af4a393..cb9f3f7a6a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/entity/AllOffersStateFactory.kt @@ -154,6 +154,7 @@ internal class AllOffersStateFactory( category = OnrampOfferCategoryUM.Recommended, advantages = mapOfferAdvantagesDTOtoUM(offer.advantages), paymentMethod = quote.paymentMethod, + providerId = quote.provider.id, providerName = quote.provider.info.name, rate = formatCryptoAmount(quote.toAmount), diff = formatRateDiff(offer.rateDif), @@ -185,6 +186,7 @@ internal class AllOffersStateFactory( category = OnrampOfferCategoryUM.Recommended, advantages = advantages, paymentMethod = quote.paymentMethod, + providerId = quote.provider.id, providerName = quote.provider.info.name, rate = formatRequiredAmount(quote, currencyCode), diff = formatRateDiff(offer.rateDif), 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 beaf222881..8f4e481d37 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 @@ -205,6 +205,7 @@ private fun AllOffersContentSheetPaymentPreview() { imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), + providerId = "simplex", providerName = "Simplex", rate = "0,0245334 BTC", diff = null, @@ -219,6 +220,7 @@ private fun AllOffersContentSheetPaymentPreview() { imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), + providerId = "simplex", providerName = "Simplex", rate = "0,00145334 BTC", diff = stringReference("–0.07%"), @@ -270,6 +272,7 @@ private fun AllOffersContentSheetOffersPreview() { "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), + providerId = "simplex", providerName = "Simplex", rate = "0,0245334 BTC", diff = null, @@ -285,6 +288,7 @@ private fun AllOffersContentSheetOffersPreview() { "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), + providerId = "simplex", providerName = "Simplex", rate = "0,00145334 BTC", diff = stringReference("–0.07%"), diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt index 5e40a22e41..a608fbe170 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/alloffers/ui/PaymentMethodsContent.kt @@ -273,6 +273,7 @@ private fun PaymentMethodsContentPreview() { imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), + providerId = "simplex", providerName = "Simplex", rate = "0,0245334 BTC", diff = null, @@ -287,6 +288,7 @@ private fun PaymentMethodsContentPreview() { imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), + providerId = "simplex", providerName = "Simplex", rate = "0,00145334 BTC", diff = stringReference("–0.07%"), 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 cb55143094..268e40bb54 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 @@ -10,9 +10,11 @@ import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss import com.arkivanov.essenty.lifecycle.subscribe import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.features.marketing.api.MarketingBannerComponent import com.tangem.features.onramp.alloffers.AllOffersComponent import com.tangem.features.onramp.confirmresidency.ConfirmResidencyComponent import com.tangem.features.onramp.main.entity.OnrampMainBottomSheetConfig @@ -29,10 +31,24 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor( private val confirmResidencyComponentFactory: ConfirmResidencyComponent.Factory, private val selectCurrencyComponentFactory: SelectCurrencyComponent.Factory, private val allOffersComponentFactory: AllOffersComponent.Factory, + private val marketingBannerComponentFactory: MarketingBannerComponent.Factory, ) : OnrampMainComponent, AppComponentContext by appComponentContext { private val model: OnrampMainComponentModel = getOrCreateModel(params) + private val marketingBannerComponent: MarketingBannerComponent = marketingBannerComponentFactory.create( + context = child("marketing_banner"), + params = MarketingBannerComponent.Params.Standalone( + requestFlow = model.marketingRequest, + onDeeplinkClick = model::onMarketingBannerDeeplink, + ), + ) + + private val linkedMarketingBannerComponent: MarketingBannerComponent = marketingBannerComponentFactory.create( + context = child("marketing_banner_linked"), + params = MarketingBannerComponent.Params.Linked(requestFlow = model.linkedMarketingRequest), + ) + init { lifecycle.subscribe(onStop = model::onStop) } @@ -49,7 +65,12 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor( val state by model.state.collectAsState() val bottomSheet by bottomSheetSlot.subscribeAsState() - OnrampMainScreen(modifier = modifier, state = state) + OnrampMainScreen( + modifier = modifier, + state = state, + marketingBannerComponent = marketingBannerComponent, + linkedMarketingBannerComponent = linkedMarketingBannerComponent, + ) bottomSheet.child?.instance?.BottomSheet() } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampOfferBlockUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampOfferBlockUM.kt index 978aed9131..52e49d6e5f 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampOfferBlockUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/OnrampOfferBlockUM.kt @@ -24,6 +24,7 @@ internal data class OnrampOfferUM( val category: OnrampOfferCategoryUM, val advantages: OnrampOfferAdvantagesUM, val paymentMethod: OnrampPaymentMethod, + val providerId: String, val providerName: String, val rate: String, val diff: TextReference?, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampOffersStateFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampOffersStateFactory.kt index 889f62feca..6e9e7ec2e0 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampOffersStateFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/entity/factory/OnrampOffersStateFactory.kt @@ -44,6 +44,7 @@ internal class OnrampOffersStateFactory( category = category, advantages = advantages, paymentMethod = currentQuote.paymentMethod, + providerId = currentQuote.provider.id, providerName = currentQuote.provider.info.name, rate = currentQuote.toAmount.value.format { crypto( diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt index ba9858ba48..5fa513496c 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt @@ -3,7 +3,10 @@ package com.tangem.features.onramp.main.model import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.common.routing.deeplink.resolveMarketingDeeplink +import com.tangem.common.routing.deeplink.toContextualRoute import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router @@ -11,6 +14,8 @@ import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.components.fields.InputManager import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.marketing.models.MarketingScreen +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.onramp.* import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent import com.tangem.domain.onramp.model.OnrampAvailability @@ -18,8 +23,11 @@ import com.tangem.domain.onramp.model.OnrampCountry import com.tangem.domain.onramp.model.OnrampProviderWithQuote import com.tangem.domain.onramp.model.OnrampQuote import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.marketing.api.LinkedBannerRequest +import com.tangem.features.marketing.api.MarketingBannerRequest import com.tangem.features.onramp.main.OnrampMainComponent import com.tangem.features.onramp.main.entity.* import com.tangem.features.onramp.main.entity.factory.OnrampAmountButtonUMStateFactory @@ -38,6 +46,7 @@ import com.tangem.utils.isNullOrZero import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import java.math.BigDecimal import javax.inject.Inject @Suppress("LongParameterList", "LargeClass") @@ -56,6 +65,7 @@ internal class OnrampMainComponentModel @Inject constructor( private val getOnrampOffersUseCase: GetOnrampOffersUseCase, private val isDemoCardUseCase: IsDemoCardUseCase, private val messageSender: UiMessageSender, + private val getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase, paramsContainer: ParamsContainer, getWalletsUseCase: GetWalletsUseCase, ) : Model(), OnrampIntents { @@ -85,6 +95,68 @@ internal class OnrampMainComponentModel @Inject constructor( ), ) + /** + * Expected received crypto amount, taken from the first [OnrampQuote.Data] quote's [OnrampQuote.Data.toAmount]. + * Used to derive [amountUsd][MarketingBannerRequest.amountUsd] for the marketing banner request flows below, + * since the campaign min/max amount gating is expressed in USD while the user only enters a fiat amount here. + * + * Seeded with an initial `null` via [onStart] so the downstream [combine] can emit immediately on cold start + * (before any quote is available, e.g. when the user has not entered an amount yet). Without this seed the + * underlying quotes flow stays silent until a quote is stored, which would keep the banner requests from + * emitting at all. + */ + private val expectedCryptoAmount: Flow = getOnrampQuotesUseCase.invoke() + .map { either -> + either.getOrNull() + ?.filterIsInstance() + ?.firstOrNull() + ?.toAmount?.value + } + .distinctUntilChanged() + .onStart { emit(null) } + + /** + * Request flow for the standalone marketing banner. + * [fromFiat] is the fiat currency code the user is paying in (only available once the screen is in Content state). + * [toNetwork] is the backend network id of the target crypto currency. + * [toContractAddress] is the contract address of the target token (empty string for coins). + * [amountUsd] is derived from the expected received crypto amount (see [expectedCryptoAmount]) converted to USD. + * On cold start (no quote yet, e.g. the user has not entered an amount) it is null, so the request emits + * immediately with `amountUsd = null` and the domain shows the banner ungated; once a quote arrives the request + * re-emits with the real USD amount so the min/max filter applies. It is also null if the target currency has no + * USD rate, again skipping the amount filter. + */ + val marketingRequest: Flow = combine(state, expectedCryptoAmount) { s, crypto -> + val contentState = s as? OnrampMainComponentUM.Content ?: return@combine null + MarketingBannerRequest( + screen = MarketingScreen.Onramp( + fromFiat = contentState.amountBlockState.currencyUM.code, + toNetwork = params.cryptoCurrency.network.rawId, + toContractAddress = (params.cryptoCurrency as? CryptoCurrency.Token)?.contractAddress.orEmpty(), + ), + amountUsd = computeAmountUsd(crypto), + ) + } + + /** + * Request flow for the LINKED_TO_PROVIDER marketing banner shown inline next to onramp provider offers. + * Carries no provider id: provider matching is done per offer at render time (each offer row asks for its + * banner via [MarketingBannerComponent.LinkedContent]), mirroring iOS. + * [amountUsd] follows the same rules as in [marketingRequest]: null on cold start (banner shown ungated) or when + * the target currency has no USD rate, and the real USD amount once a quote is available. + */ + val linkedMarketingRequest: Flow = combine(state, expectedCryptoAmount) { s, crypto -> + val contentState = s as? OnrampMainComponentUM.Content ?: return@combine null + LinkedBannerRequest( + screen = MarketingScreen.Onramp( + fromFiat = contentState.amountBlockState.currencyUM.code, + toNetwork = params.cryptoCurrency.network.rawId, + toContractAddress = (params.cryptoCurrency as? CryptoCurrency.Token)?.contractAddress.orEmpty(), + ), + amountUsd = computeAmountUsd(crypto), + ) + } + private val amountStateFactory: OnrampAmountStateFactory by lazy(LazyThreadSafetyMode.NONE) { OnrampAmountStateFactory( currentStateProvider = Provider { state.value }, @@ -125,6 +197,16 @@ internal class OnrampMainComponentModel @Inject constructor( super.onDestroy() } + fun onMarketingBannerDeeplink(deeplink: String): Boolean { + val route = resolveMarketingDeeplink(deeplink).toContextualRoute( + userWalletId = params.userWalletId, + currency = params.cryptoCurrency, + screenSource = AnalyticsParam.ScreensSources.Buy, + ) ?: return false + router.push(route) + return true + } + override fun onAmountValueChanged(value: String) { state.update { amountStateFactory.getOnAmountValueChange(value) } modelScope.launch { amountInputManager.update(value) } @@ -412,6 +494,14 @@ internal class OnrampMainComponentModel @Inject constructor( } } + /** Converts the expected received [crypto] amount into its USD value using the target currency's USD rate. */ + private suspend fun computeAmountUsd(crypto: BigDecimal?): BigDecimal? { + val amount = crypto ?: return null + val rawCurrencyId = params.cryptoCurrency.id.rawCurrencyId ?: return null + val rate = getCurrencyUSDQuoteUseCase(rawCurrencyId) ?: return null + return amount * rate + } + private fun sendOnrampQuotesErrorAnalytic(quotes: List) { quotes.forEach { errorState -> when (errorState) { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampMainComponentContent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampMainComponentContent.kt index 2c61db8413..16ebf9180a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampMainComponentContent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/ui/OnrampMainComponentContent.kt @@ -17,11 +17,18 @@ import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.ds.message.TangemMessage import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemeRedesign import com.tangem.core.ui.utils.WindowInsetsZero +import com.tangem.features.marketing.api.MarketingBannerComponent import com.tangem.features.onramp.main.entity.OnrampMainComponentUM @Composable -internal fun OnrampMainScreen(state: OnrampMainComponentUM, modifier: Modifier = Modifier) { +internal fun OnrampMainScreen( + state: OnrampMainComponentUM, + marketingBannerComponent: MarketingBannerComponent, + linkedMarketingBannerComponent: MarketingBannerComponent, + modifier: Modifier = Modifier, +) { Scaffold( modifier = modifier.systemBarsPadding(), topBar = { @@ -36,13 +43,20 @@ internal fun OnrampMainScreen(state: OnrampMainComponentUM, modifier: Modifier = ) { scaffoldPaddings -> OnrampMainComponentContent( state = state, + marketingBannerComponent = marketingBannerComponent, + linkedMarketingBannerComponent = linkedMarketingBannerComponent, modifier = Modifier.padding(scaffoldPaddings), ) } } @Composable -internal fun OnrampMainComponentContent(state: OnrampMainComponentUM, modifier: Modifier = Modifier) { +internal fun OnrampMainComponentContent( + state: OnrampMainComponentUM, + marketingBannerComponent: MarketingBannerComponent, + linkedMarketingBannerComponent: MarketingBannerComponent, + modifier: Modifier = Modifier, +) { Box( modifier = modifier .fillMaxSize() @@ -56,7 +70,11 @@ internal fun OnrampMainComponentContent(state: OnrampMainComponentUM, modifier: ) { when (state) { is OnrampMainComponentUM.InitialLoading -> InitialLoading(state = state) - is OnrampMainComponentUM.Content -> Content(state = state) + is OnrampMainComponentUM.Content -> Content( + state = state, + marketingBannerComponent = marketingBannerComponent, + linkedMarketingBannerComponent = linkedMarketingBannerComponent, + ) } } @@ -118,7 +136,12 @@ private fun OnrampAmountContentLoading() { } @Composable -private fun Content(state: OnrampMainComponentUM.Content, modifier: Modifier = Modifier) { +private fun Content( + state: OnrampMainComponentUM.Content, + marketingBannerComponent: MarketingBannerComponent, + linkedMarketingBannerComponent: MarketingBannerComponent, + modifier: Modifier = Modifier, +) { Column( modifier = modifier .fillMaxWidth() @@ -133,7 +156,14 @@ private fun Content(state: OnrampMainComponentUM.Content, modifier: Modifier = M ) { OnrampAmountContent(state = state) - OnrampOffersContent(state = state.offersBlockState) + TangemThemeRedesign { + marketingBannerComponent.Content(Modifier.fillMaxWidth()) + } + + OnrampOffersContent( + state = state.offersBlockState, + linkedMarketingBannerComponent = linkedMarketingBannerComponent, + ) OnrampNotifications(state = state) } 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 4c161835e9..a8ac8de316 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 @@ -29,15 +29,17 @@ import com.tangem.core.ui.components.buttons.common.TangemButtonSize import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemeRedesign import com.tangem.core.ui.test.OnrampOffersBlockTestTags import com.tangem.domain.onramp.model.OnrampPaymentMethod import com.tangem.domain.onramp.model.PaymentMethodType +import com.tangem.features.marketing.api.MarketingBannerComponent import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.main.entity.* import kotlinx.collections.immutable.persistentListOf @Composable -internal fun OnrampOffersContent(state: OnrampOffersBlockUM) { +internal fun OnrampOffersContent(state: OnrampOffersBlockUM, linkedMarketingBannerComponent: MarketingBannerComponent) { when (state) { is OnrampOffersBlockUM.Content -> { Column(modifier = Modifier.fillMaxWidth()) { @@ -52,7 +54,7 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) { SpacerH(8.dp) - Offer(recentOffer) + OfferWithLinkedBanner(recentOffer, linkedMarketingBannerComponent) SpacerH(16.dp) } @@ -74,7 +76,7 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) { state.recommended.fastForEach { offer -> key("${offer.paymentMethod.id} ${offer.providerName} ${offer.rate}") { - Offer(offer) + OfferWithLinkedBanner(offer, linkedMarketingBannerComponent) SpacerH(8.dp) } } @@ -102,13 +104,34 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM) { } @Composable -internal fun Offer(onrampOfferUM: OnrampOfferUM, modifier: Modifier = Modifier) { +private 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) + if (hasBanner) { + TangemThemeRedesign { + linkedMarketingBannerComponent.LinkedContent( + providerId = offer.providerId, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +internal fun Offer(onrampOfferUM: OnrampOfferUM, modifier: Modifier = Modifier, roundBottom: Boolean = true) { + // Square the bottom corners when a linked marketing banner is glued below, so they read as one card. + val shape = if (roundBottom) { + RoundedCornerShape(14.dp) + } else { + RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp) + } Column( modifier = modifier .fillMaxWidth() .background( color = TangemTheme.colors.background.action, - shape = RoundedCornerShape(14.dp), + shape = shape, ) .padding(12.dp), ) { @@ -386,6 +409,7 @@ private fun OnrampOffersContentPreview() { imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), + providerId = "simplex", providerName = "Simplex", rate = "0,00045334 BTC", diff = stringReference("–27%"), @@ -401,6 +425,7 @@ private fun OnrampOffersContentPreview() { imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), + providerId = "simplex", providerName = "Simplex", rate = "0,0245334 BTC", diff = null, @@ -415,6 +440,7 @@ private fun OnrampOffersContentPreview() { imageUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/express/PaymentMethods/visa-mc.png", type = PaymentMethodType.CARD, ), + providerId = "simplex", providerName = "Simplex", rate = "0,00145334 BTC", diff = stringReference("–0.07%"), @@ -427,7 +453,10 @@ private fun OnrampOffersContentPreview() { ), ) TangemThemePreview { - OnrampOffersContent(state) + OnrampOffersContent(state = state, linkedMarketingBannerComponent = object : MarketingBannerComponent { + @Composable + override fun Content(modifier: Modifier) = Unit + }) } } @@ -436,6 +465,12 @@ private fun OnrampOffersContentPreview() { @Composable private fun OnrampOffersLoadingPreview() { TangemThemePreview { - OnrampOffersContent(OnrampOffersBlockUM.Loading) + OnrampOffersContent( + state = OnrampOffersBlockUM.Loading, + linkedMarketingBannerComponent = object : MarketingBannerComponent { + @Composable + override fun Content(modifier: Modifier) = Unit + }, + ) } } \ No newline at end of file diff --git a/features/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index f955ca80e0..5934fa5d53 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -50,6 +50,9 @@ dependencies { implementation(projects.domain.feedback) implementation(projects.domain.feedback.models) implementation(projects.domain.stories) + implementation(projects.domain.marketing.models) + implementation(projects.domain.markets.models) + implementation(projects.domain.quotes) implementation(projects.domain.stories.models) implementation(projects.domain.txhistory) implementation(projects.domain.txhistory.models) @@ -71,6 +74,7 @@ dependencies { implementation(projects.features.send.api) implementation(projects.features.send.impl) implementation(projects.features.feed.api) + implementation(projects.features.marketing.api) /** AndroidX */ implementation(deps.androidx.activity.compose) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index 8ae771fb83..3cd443d894 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -17,6 +17,7 @@ import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.router.stack.pop import com.arkivanov.essenty.lifecycle.subscribe import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.navigation.inner.InnerRouter @@ -31,6 +32,7 @@ import com.tangem.feature.swap.ui.SwapScreen import com.tangem.feature.swap.ui.SwapSuccessScreen import com.tangem.features.approval.api.GiveApprovalEntryComponent import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent +import com.tangem.features.marketing.api.MarketingBannerComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.swap.SwapComponent import com.tangem.utils.isNullOrZero @@ -46,6 +48,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory, private val giveApprovalEntryComponentFactory: GiveApprovalEntryComponent.Factory, private val chooseTokenComponentFactory: ChooseTokenComponent.Factory, + private val marketingBannerComponentFactory: MarketingBannerComponent.Factory, ) : SwapComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -56,6 +59,14 @@ internal class DefaultSwapComponent @AssistedInject constructor( private val model: SwapModel = getOrCreateModel(params, router = innerRouter) + private val marketingBannerComponent: MarketingBannerComponent = marketingBannerComponentFactory.create( + context = child("marketing_banner"), + params = MarketingBannerComponent.Params.Standalone( + requestFlow = model.marketingRequest, + onDeeplinkClick = model::onMarketingBannerDeeplink, + ), + ) + private val childStack = childStack( key = STACK_KEY, source = stackNavigation, @@ -215,6 +226,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( SwapScreen( stateHolder = model.uiState, feeSelectorBlockComponent = feeSelectorBlockComponent, + marketingBannerComponent = marketingBannerComponent, ) } } @@ -236,6 +248,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( SwapScreen( stateHolder = model.uiState, feeSelectorBlockComponent = feeSelectorBlockComponent, + marketingBannerComponent = marketingBannerComponent, ) } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index a83a6c4317..642139d776 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -16,6 +16,8 @@ import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.resolveMarketingDeeplink +import com.tangem.common.routing.deeplink.toContextualRoute import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -55,6 +57,7 @@ import com.tangem.domain.feedback.SendBackupProblemEmailUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.marketing.models.MarketingScreen import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.derivationIndex @@ -65,6 +68,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isHotWallet import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase +import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions @@ -109,6 +113,7 @@ import com.tangem.features.approval.api.SelectApprovalTypeComponent import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult +import com.tangem.features.marketing.api.MarketingBannerRequest import com.tangem.features.send.api.entity.FeeItem import com.tangem.features.send.api.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger @@ -224,6 +229,30 @@ internal class SwapModel @Inject constructor( dataStateStateFlow.value = value } + /** + * Request flow for the STANDALONE marketing banner shown on the swap screen. + * Derives [MarketingScreen.Swap] from the live [dataStateStateFlow]; emits null until both the FROM and TO + * currencies are chosen. [amountUsd] is the entered FROM amount converted via the FROM token's USD rate + * ([getCurrencyUSDQuoteUseCase]); it stays null until both the amount and the USD quote are available. + */ + val marketingRequest: Flow = dataStateStateFlow.map { data -> + val fromCurrency = data.fromSwapCurrencyStatus?.currency ?: return@map null + val toCurrency = data.toSwapCurrencyStatus?.currency ?: return@map null + val amountUsd = data.amount?.toBigDecimalOrNull()?.let { fromAmount -> + val rawCurrencyId = fromCurrency.id.rawCurrencyId ?: return@let null + getCurrencyUSDQuoteUseCase(rawCurrencyId)?.let { usdRate -> fromAmount * usdRate } + } + MarketingBannerRequest( + screen = MarketingScreen.Swap( + fromNetwork = fromCurrency.network.rawId, + fromContractAddress = (fromCurrency as? CryptoCurrency.Token)?.contractAddress.orEmpty(), + toNetwork = toCurrency.network.rawId, + toContractAddress = (toCurrency as? CryptoCurrency.Token)?.contractAddress.orEmpty(), + ), + amountUsd = amountUsd, + ) + } + var uiState: SwapStateHolder by mutableStateOf(stateBuilder.createInitialLoadingState()) internal set @@ -353,6 +382,17 @@ internal class SwapModel @Inject constructor( } } + fun onMarketingBannerDeeplink(deeplink: String): Boolean { + val fromCurrency = dataState.fromSwapCurrencyStatus?.currency ?: return false + val route = resolveMarketingDeeplink(deeplink).toContextualRoute( + userWalletId = params.userWalletId, + currency = fromCurrency, + screenSource = ScreensSources.Swap, + ) ?: return false + appRouter.push(route) + return true + } + fun onStart() { startLoadingQuotesFromLastState(true) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt index bef9ff8cf6..2def3f58a3 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt @@ -31,6 +31,7 @@ import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemeRedesign import com.tangem.core.ui.test.SwapTokenScreenTestTags import com.tangem.core.ui.utils.WindowInsetsZero import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent @@ -38,9 +39,14 @@ import com.tangem.feature.swap.domain.models.domain.SwapUIMode import com.tangem.feature.swap.models.SwapStateHolder import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig import com.tangem.feature.swap.presentation.R +import com.tangem.features.marketing.api.MarketingBannerComponent @Composable -internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent: SwapFeeSelectorBlockComponent?) { +internal fun SwapScreen( + stateHolder: SwapStateHolder, + feeSelectorBlockComponent: SwapFeeSelectorBlockComponent?, + marketingBannerComponent: MarketingBannerComponent? = null, +) { BackHandler(onBack = stateHolder.onBackClicked) Scaffold( @@ -63,6 +69,15 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent: } else { null }, + marketingBanner = if (marketingBannerComponent != null) { + @Composable { modifier: Modifier -> + TangemThemeRedesign { + marketingBannerComponent.Content(modifier) + } + } + } else { + null + }, modifier = Modifier .padding(scaffoldPaddings) .testTag(SwapTokenScreenTestTags.CONTAINER), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index cddf6734fd..9456f52bd7 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -63,6 +63,7 @@ internal fun SwapScreenContent( state: SwapStateHolder, modifier: Modifier = Modifier, feeBlock: @Composable ((Modifier) -> Unit)? = null, + marketingBanner: @Composable ((Modifier) -> Unit)? = null, ) { val keyboard by keyboardAsState() @@ -86,6 +87,8 @@ internal fun SwapScreenContent( ) { MainInfo(state) + marketingBanner?.invoke(Modifier.fillMaxWidth()) + if (state.swapUIMode == SwapUIMode.Simple) { ProviderItemBlockSimple(state = state.providerState) } else { diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt index 6a80346c00..fdf2c74939 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt @@ -26,6 +26,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase +import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.stories.ShouldShowStoriesUseCase @@ -103,6 +104,7 @@ internal abstract class SwapModelTestBase { protected val getSwapUiModeUseCase: GetSwapUiModeUseCase = mockk(relaxed = true) protected val setSwapUiModeUseCase: SetSwapUiModeUseCase = mockk(relaxed = true) protected val calculateAmountUseCase: CalculateAmountUseCase = mockk(relaxed = true) + protected val getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase = mockk(relaxed = true) protected val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase = mockk(relaxed = true) protected val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true) @@ -175,6 +177,7 @@ internal abstract class SwapModelTestBase { getSwapUiModeUseCase = getSwapUiModeUseCase, setSwapUiModeUseCase = setSwapUiModeUseCase, calculateAmountUseCase = calculateAmountUseCase, + getCurrencyUSDQuoteUseCase = getCurrencyUSDQuoteUseCase, isWalletBackupProblematicUseCase = isWalletBackupProblematicUseCase, sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase, ) diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt index 44920f24a3..66b1af8edc 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt @@ -31,6 +31,7 @@ internal fun BaseExtension.configureCompose(project: Project) { contains(Regex(pattern = ":features:manage-tokens:api\$")) || // provides Composable function contains(Regex(pattern = ":features:txhistory:api\$")) || // provides Composable function contains(Regex(pattern = ":features:promo-banners:api\$")) || // provides Composable function + contains(Regex(pattern = ":features:marketing:api\$")) || // provides Composable function contains(Regex(pattern = ":impl\$")) } From d82474324bb59b8a5db04e687cef34c0792e0c14 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 15 Jul 2026 11:12:11 +0500 Subject: [PATCH 47/59] Updated on 2026-08-14 --- features/staking/impl/build.gradle.kts | 2 + .../staking/impl/DefaultStakingComponent.kt | 13 ++- .../impl/presentation/model/StakingModel.kt | 25 +++++- .../ui/StakingInitialInfoContent.kt | 12 +++ .../impl/presentation/ui/StakingScreen.kt | 11 ++- .../StakingModelMarketingDeeplinkTest.kt | 89 +++++++++++++++++++ features/tokendetails/impl/build.gradle.kts | 2 + .../DefaultTokenDetailsComponent.kt | 12 +++ .../tokendetails/model/TokenDetailsModel.kt | 39 ++++++++ .../tokendetails/ui/TokenDetailsScreen.kt | 11 +++ .../ui/TokenDetailsScreenLegacy.kt | 12 +++ 11 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelMarketingDeeplinkTest.kt diff --git a/features/staking/impl/build.gradle.kts b/features/staking/impl/build.gradle.kts index 3613b3df3d..51b09ce693 100644 --- a/features/staking/impl/build.gradle.kts +++ b/features/staking/impl/build.gradle.kts @@ -68,6 +68,7 @@ dependencies { implementation(projects.domain.notifications.models) implementation(projects.domain.account) implementation(projects.domain.account.status) + implementation(projects.domain.marketing.models) /** Common */ implementation(projects.common.ui) @@ -80,6 +81,7 @@ dependencies { implementation(projects.features.staking.api) implementation(projects.features.txhistory.api) implementation(projects.features.approval.api) + implementation(projects.features.marketing.api) /** Decompose */ implementation(deps.decompose.ext.compose) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/DefaultStakingComponent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/DefaultStakingComponent.kt index f4f55c926f..d40c7091af 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/DefaultStakingComponent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/DefaultStakingComponent.kt @@ -7,9 +7,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.child import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.approval.api.GiveApprovalComponent +import com.tangem.features.marketing.api.MarketingBannerComponent import com.tangem.features.staking.api.StakingComponent import com.tangem.features.staking.impl.presentation.model.StakingModel import com.tangem.features.staking.impl.presentation.ui.StakingScreen @@ -21,10 +23,19 @@ internal class DefaultStakingComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted params: StakingComponent.Params, private val giveApprovalComponentFactory: GiveApprovalComponent.Factory, + private val marketingBannerComponentFactory: MarketingBannerComponent.Factory, ) : StakingComponent, AppComponentContext by appComponentContext { private val model: StakingModel = getOrCreateModel(params) + private val marketingBannerComponent: MarketingBannerComponent = marketingBannerComponentFactory.create( + context = child("stakingMarketingBanner"), + params = MarketingBannerComponent.Params.Standalone( + requestFlow = model.marketingRequest, + onDeeplinkClick = model::onMarketingBannerDeeplink, + ), + ) + private val approvalSlot = childSlot( key = "stakingApprovalSlot", source = model.approvalSlotNavigation, @@ -45,7 +56,7 @@ internal class DefaultStakingComponent @AssistedInject constructor( val currentState by model.uiState.collectAsStateWithLifecycle() val approvalSlotState by approvalSlot.subscribeAsState() - StakingScreen(currentState) + StakingScreen(currentState, marketingBannerComponent) approvalSlotState.child?.instance?.BottomSheet() } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index e03f0537dc..d6067c7d29 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -11,6 +11,8 @@ import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.getValidatorsCount import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.resolveMarketingDeeplink +import com.tangem.common.routing.deeplink.toContextualRoute import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary @@ -45,6 +47,7 @@ import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.marketing.models.MarketingScreen import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -68,6 +71,7 @@ import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.approval.api.GiveApprovalComponent import com.tangem.features.approval.api.GiveApprovalFeatureToggles +import com.tangem.features.marketing.api.MarketingBannerRequest import com.tangem.features.staking.api.StakingComponent import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.analytics.StakingParamsInterceptor @@ -159,7 +163,7 @@ internal class StakingModel @Inject constructor( private val messageSender: UiMessageSender, private val giveApprovalFeatureToggles: GiveApprovalFeatureToggles, private val stakingFeatureToggles: StakingFeatureToggles, - appRouter: AppRouter, + private val appRouter: AppRouter, ) : Model(), StakingClickIntents { val uiState: StateFlow = stateController.uiState @@ -169,6 +173,15 @@ internal class StakingModel @Inject constructor( private val params = paramsContainer.require() + val marketingRequest: Flow = flowOf( + MarketingBannerRequest( + screen = MarketingScreen.Staking( + networkId = params.cryptoCurrency.network.rawId, + contractAddress = (params.cryptoCurrency as? CryptoCurrency.Token)?.contractAddress.orEmpty(), + ), + ), + ) + private val stakingStateRouter: StakingStateRouter = StakingStateRouter( appRouter = appRouter, stateController = stateController, @@ -324,6 +337,16 @@ internal class StakingModel @Inject constructor( stateController.initializeWithUserWallet(userWallet) } + fun onMarketingBannerDeeplink(deeplink: String): Boolean { + val route = resolveMarketingDeeplink(deeplink).toContextualRoute( + userWalletId = params.userWalletId, + currency = params.cryptoCurrency, + screenSource = AnalyticsParam.ScreensSources.Staking, + ) ?: return false + appRouter.push(route) + return true + } + override fun onDestroy() { super.onDestroy() paramsInterceptorHolder.removeParamsInterceptor(StakingParamsInterceptor.ID) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt index 66cb40cb89..d967eabfa8 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp import com.tangem.common.ui.navigationButtons.NavigationButtonsState import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton import com.tangem.core.ui.components.SpacerH12 @@ -51,6 +52,7 @@ import com.tangem.core.ui.test.StakingDetailsScreenTestTags import com.tangem.domain.models.staking.BalanceType import com.tangem.domain.models.staking.RewardBlockType import com.tangem.domain.staking.model.common.RewardType +import com.tangem.features.marketing.api.MarketingBannerComponent import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.model.StakingClickIntents import com.tangem.features.staking.impl.presentation.state.BalanceState @@ -67,6 +69,7 @@ private const val BANNER_BLOCK_KEY = "BannerBlock" private const val STAKING_REWARD_BLOCK_KEY = "StakingRewardBlock" private const val ACTIVE_STAKING_BLOCK_KEY = "ActiveStakingBlock" private const val STAKE_PRIMARY_BUTTON_KEY = "StakePrimaryButton" +private const val MARKETING_BANNER_BLOCK_KEY = "MarketingBannerBlock" @Composable internal fun StakingInitialInfoContent( @@ -74,6 +77,7 @@ internal fun StakingInitialInfoContent( buttonState: NavigationButtonsState, clickIntents: StakingClickIntents, isBalanceHidden: Boolean, + marketingBannerComponent: MarketingBannerComponent, ) { if (state !is StakingStates.InitialInfoState.Data) return @@ -104,6 +108,10 @@ internal fun StakingInitialInfoContent( hideEndText = isBalanceHidden, ) + item(key = MARKETING_BANNER_BLOCK_KEY) { + marketingBannerComponent.Content(Modifier.fillMaxWidth().padding(bottom = 12.dp)) + } + activeStakingBlock( state = state, clickIntents = clickIntents, @@ -491,6 +499,10 @@ private fun StakingInitialInfoContent_Preview( buttonState = NavigationButtonsState.Empty, clickIntents = StakingClickIntentsStub, isBalanceHidden = false, + marketingBannerComponent = object : MarketingBannerComponent { + @Composable + override fun Content(modifier: Modifier) = Unit + }, ) } } 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 836d37dec7..7e78a43cd4 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 @@ -21,6 +21,7 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.test.SendScreenTestTags +import com.tangem.features.marketing.api.MarketingBannerComponent import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.features.staking.impl.presentation.state.StakingStep @@ -37,7 +38,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.withIndex @Composable -internal fun StakingScreen(uiState: StakingUiState) { +internal fun StakingScreen(uiState: StakingUiState, marketingBannerComponent: MarketingBannerComponent) { val confirmationState = uiState.confirmationState as? StakingStates.ConfirmationState.Data BackHandler(onBack = uiState.clickIntents::onPrevClick) @@ -55,6 +56,7 @@ internal fun StakingScreen(uiState: StakingUiState) { ) StakingScreenContent( uiState = uiState, + marketingBannerComponent = marketingBannerComponent, modifier = Modifier.weight(1f), ) NavigationButtonsBlock( @@ -107,7 +109,11 @@ private fun StakingAppBar(uiState: StakingUiState) { @Suppress("LongMethod") @Composable -private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = Modifier) { +private fun StakingScreenContent( + uiState: StakingUiState, + marketingBannerComponent: MarketingBannerComponent, + modifier: Modifier = Modifier, +) { val currentScreen = uiState.currentStep var currentStateProxy by remember { mutableStateOf(currentScreen) } var isTransitionAnimationRunning by remember { mutableStateOf(false) } @@ -152,6 +158,7 @@ private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = M buttonState = uiState.buttonsState, clickIntents = uiState.clickIntents, isBalanceHidden = uiState.isBalanceHidden, + marketingBannerComponent = marketingBannerComponent, ) StakingStep.RewardsValidators -> { StakingClaimRewardsValidatorContent( diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelMarketingDeeplinkTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelMarketingDeeplinkTest.kt new file mode 100644 index 0000000000..2d4f6ec276 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelMarketingDeeplinkTest.kt @@ -0,0 +1,89 @@ +package com.tangem.features.staking.impl.presentation.model + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.routing.AppRoute +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.domain.onramp.model.OnrampSource +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class StakingModelMarketingDeeplinkTest : StakingModelTestBase() { + + @Test + fun `GIVEN swap deeplink WHEN onMarketingBannerDeeplink THEN pushes Swap for current token`() = runTest { + // Arrange + every { appRouter.push(any(), any()) } just Runs + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + val handled = model.onMarketingBannerDeeplink("tangem://swap") + + // Assert + assertThat(handled).isTrue() + verify { + appRouter.push( + match { + it is AppRoute.Swap && + it.userWalletId == testUserWalletId && + it.fromCryptoCurrency == testCryptoCurrency && + it.screenSource == AnalyticsParam.ScreensSources.Staking.value + }, + any(), + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN buy deeplink WHEN onMarketingBannerDeeplink THEN pushes Onramp for current token`() = runTest { + // Arrange + every { appRouter.push(any(), any()) } just Runs + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + val handled = model.onMarketingBannerDeeplink("tangem://buy") + + // Assert + assertThat(handled).isTrue() + verify { + appRouter.push( + match { + it is AppRoute.Onramp && + it.userWalletId == testUserWalletId && + it.currency == testCryptoCurrency && + it.source == OnrampSource.MARKETING_BANNER + }, + any(), + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN external deeplink WHEN onMarketingBannerDeeplink THEN not handled and no navigation`() = runTest { + // Arrange + every { appRouter.push(any(), any()) } just Runs + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + val handled = model.onMarketingBannerDeeplink("https://tangem.com/promo") + + // Assert + assertThat(handled).isFalse() + verify(exactly = 0) { appRouter.push(any(), any()) } + + model.onDestroy() + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index a2bcd8d884..12796f51d1 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -70,6 +70,7 @@ dependencies { implementation(projects.domain.dynamicAddresses.models) implementation(projects.domain.feedback) implementation(projects.domain.markets.models) + implementation(projects.domain.marketing.models) implementation(projects.domain.models) implementation(projects.domain.notifications.models) implementation(projects.domain.offramp) @@ -101,6 +102,7 @@ dependencies { implementation(projects.features.wallet.api) implementation(projects.features.staking.api) implementation(projects.features.markets.api) + implementation(projects.features.marketing.api) implementation(projects.features.onramp.api) implementation(projects.features.pushNotifications.api) implementation(projects.features.swap.api) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt index 2fdff86eb3..f66ccc8756 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt @@ -26,6 +26,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet. import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.TransferBottomSheetComponent import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent +import com.tangem.features.marketing.api.MarketingBannerComponent import com.tangem.features.markets.token.block.TokenMarketBlockComponent import com.tangem.features.rating.RatingComponent import com.tangem.features.tokendetails.ExpressTransactionsComponent @@ -50,6 +51,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( private val manageFundsComponentFactory: ManageFundsComponent.Factory, yieldSupplyComponentFactory: YieldSupplyComponent.Factory, private val ratingComponentFactory: RatingComponent.Factory, + marketingBannerComponentFactory: MarketingBannerComponent.Factory, ) : TokenDetailsComponent, AppComponentContext by appComponentContext { private val model: TokenDetailsModel = getOrCreateModel(params) @@ -107,6 +109,14 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( ), ) + private val marketingBannerComponent = marketingBannerComponentFactory.create( + context = child("tokenDetailsMarketingBanner"), + params = MarketingBannerComponent.Params.Standalone( + requestFlow = model.marketingRequest, + onDeeplinkClick = model::onMarketingBannerDeeplink, + ), + ) + @Composable override fun Content(modifier: Modifier) { val bottomSheet by bottomSheetSlot.subscribeAsState() @@ -123,6 +133,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( txHistoryComponent = txHistoryComponent, expressTransactionsComponent = expressTransactionsComponent, ratingComponent = ratingSlotState.child?.instance, + marketingBannerComponent = marketingBannerComponent, modifier = modifier, ) } else { @@ -134,6 +145,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( yieldSupplyComponent = yieldSupplyComponent, expressTransactionsComponent = expressTransactionsComponent, ratingComponent = ratingSlotState.child?.instance, + marketingBannerComponent = marketingBannerComponent, ) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 1075626032..2883eeef64 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -12,8 +12,11 @@ import com.tangem.common.extensions.hexToBytes import com.tangem.common.extensions.toHexString import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.MarketingDeeplink +import com.tangem.common.routing.deeplink.resolveMarketingDeeplink import com.tangem.common.ui.bottomsheet.receive.AddressModel import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels +import com.tangem.features.marketing.api.MarketingBannerRequest import com.tangem.common.ui.tokens.getUnavailabilityReasonText import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter import com.tangem.common.ui.userwallet.ext.walletInterationIcon @@ -55,6 +58,7 @@ import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.feedback.SendBackupProblemEmailUseCase import com.tangem.domain.models.StatusSource +import com.tangem.domain.marketing.models.MarketingScreen import com.tangem.domain.models.TokenReceiveNotification import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency @@ -193,6 +197,16 @@ internal class TokenDetailsModel @Inject constructor( private val userWalletId: UserWalletId = params.userWalletId private val cryptoCurrency: CryptoCurrency = params.currency + /** Token details context is static (single currency) — no amount filter. */ + val marketingRequest: Flow = flowOf( + MarketingBannerRequest( + screen = MarketingScreen.TokenDetails( + networkId = cryptoCurrency.network.rawId, + contractAddress = (cryptoCurrency as? CryptoCurrency.Token)?.contractAddress.orEmpty(), + ), + ), + ) + private val userWallet: UserWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: error("UserWallet not found") @@ -209,6 +223,7 @@ internal class TokenDetailsModel @Inject constructor( private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null private var account: Account.CryptoPortfolio? = null private var isBalanceLoadedEventSent = false + private var latestTokenActions: List = emptyList() val bottomSheetNavigation: SlotNavigation = SlotNavigation() val ratingSlotNavigation = SlotNavigation() @@ -346,6 +361,7 @@ internal class TokenDetailsModel @Inject constructor( .conflate() .distinctUntilChanged() .onEach { state -> + latestTokenActions = state.states sendButtonsEvents(state.states) uiState.value = stateFactory.getManageButtonsState(actions = state.states) if (designFeatureToggles.isRedesignEnabled) { @@ -812,6 +828,29 @@ internal class TokenDetailsModel @Inject constructor( handleSwap(unavailabilityReason, AppRoute.Swap.CurrencyPosition.ANY, checkYieldSupply = true) } + /** + * Routes a tapped marketing-banner deeplink contextually for the current token. Reuses the regular + * swap/buy intents (availability checks, yield-supply warning, analytics). Returns `false` for + * external links so the banner falls back to the generic deeplink launcher. + */ + fun onMarketingBannerDeeplink(deeplink: String): Boolean = when (resolveMarketingDeeplink(deeplink)) { + MarketingDeeplink.SWAP -> { + val reason = latestTokenActions + .filterIsInstance() + .firstOrNull()?.unavailabilityReason ?: ScenarioUnavailabilityReason.None + onSwapClick(reason) + true + } + MarketingDeeplink.BUY -> { + val reason = latestTokenActions + .filterIsInstance() + .firstOrNull()?.unavailabilityReason ?: ScenarioUnavailabilityReason.None + onBuyClick(reason) + true + } + MarketingDeeplink.EXTERNAL -> false + } + override fun onSwapFromClick(unavailabilityReason: ScenarioUnavailabilityReason) { handleSwap(unavailabilityReason, AppRoute.Swap.CurrencyPosition.FROM, checkYieldSupply = true) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 5fb601334f..38d0061d02 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -46,6 +46,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlock import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.ZeroBalanceActionsBlock import com.tangem.features.markets.token.block.TokenMarketBlockComponent +import com.tangem.features.marketing.api.MarketingBannerComponent import com.tangem.features.rating.RatingComponent import com.tangem.features.tokendetails.ExpressTransactionsComponent import com.tangem.features.txhistory.component.TxHistoryComponent @@ -73,6 +74,7 @@ internal fun TokenDetailsScreen( txHistoryComponent: TxHistoryComponent, expressTransactionsComponent: ExpressTransactionsComponent, ratingComponent: RatingComponent?, + marketingBannerComponent: MarketingBannerComponent, modifier: Modifier = Modifier, ) { val expressState by expressTransactionsComponent.state.collectAsStateWithLifecycle() @@ -100,6 +102,7 @@ internal fun TokenDetailsScreen( txHistoryComponent = txHistoryComponent, expressTransactionsComponent = expressTransactionsComponent, expressTransactionsToDisplay = expressState.transactionsToDisplay, + marketingBannerComponent = marketingBannerComponent, rootBackground = rootBackground, topContentPadding = topBarTotalHeight, bottomContentPadding = effectiveBottomPadding, @@ -161,6 +164,7 @@ private fun TokenDetailsBody( txHistoryComponent: TxHistoryComponent, expressTransactionsComponent: ExpressTransactionsComponent, expressTransactionsToDisplay: PersistentList, + marketingBannerComponent: MarketingBannerComponent, rootBackground: Color, topContentPadding: Dp, bottomContentPadding: Dp, @@ -230,6 +234,9 @@ private fun TokenDetailsBody( ) } } + item(key = "marketing_banner_block") { + marketingBannerComponent.Content(modifier = itemModifier.padding(vertical = 8.dp)) + } if (balance is TokenDetailsBalanceBlockUM.Content && balance.isBalanceZero) { item(key = "zero_balance_actions") { ZeroBalanceActionsBlock( @@ -317,6 +324,10 @@ private fun TokenDetailsScreen_Preview() { }, expressTransactionsComponent = PreviewExpressTransactionsComponent, ratingComponent = null, + marketingBannerComponent = object : MarketingBannerComponent { + @Composable + override fun Content(modifier: Modifier) = Unit + }, ) } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt index 4dbe6900a9..8c4a831d23 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt @@ -34,6 +34,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.T import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenInfoBlock import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.staking.TokenStakingBlockLegacy import com.tangem.features.markets.token.block.TokenMarketBlockComponent +import com.tangem.features.marketing.api.MarketingBannerComponent import com.tangem.features.tokendetails.ExpressTransactionsComponent import com.tangem.features.txhistory.component.TxHistoryComponent import com.tangem.features.txhistory.entity.TxHistoryItemsUM @@ -54,6 +55,7 @@ internal fun TokenDetailsScreenLegacy( yieldSupplyComponent: YieldSupplyComponent, expressTransactionsComponent: ExpressTransactionsComponent, ratingComponent: RatingComponent?, + marketingBannerComponent: MarketingBannerComponent, ) { val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } @@ -154,6 +156,12 @@ internal fun TokenDetailsScreenLegacy( yieldSupplyComponent.Content(modifier = itemModifier) } + item(key = "marketing_banner_block") { + TangemThemeRedesign { + marketingBannerComponent.Content(modifier = itemModifier) + } + } + with(expressTransactionsComponent) { expressTransactionsContentLegacy( state = expressState.transactionsToDisplay, @@ -215,6 +223,10 @@ private fun TokenDetailsScreenPreview( }, expressTransactionsComponent = PreviewExpressTransactionsComponent, ratingComponent = null, + marketingBannerComponent = object : MarketingBannerComponent { + @Composable + override fun Content(modifier: Modifier) = Unit + }, ) } } From c0fbd241f4c53d4d8f08b8c55e1f68f189467177 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 15 Jul 2026 11:15:09 +0500 Subject: [PATCH 48/59] Updated on 2026-08-14 --- features/feed/impl/build.gradle.kts | 2 ++ .../feed/components/FeedEntryChildFactory.kt | 3 +++ .../DefaultMarketsTokenDetailsComponent.kt | 10 ++++++++ .../details/MarketsTokenDetailsModel.kt | 6 +++++ .../detailed/MarketsTokenDetailsContent.kt | 7 ++++-- .../components/TokenMarketDetailsBody.kt | 23 ++++++++++++++++-- features/yield-supply/impl/build.gradle.kts | 2 ++ .../DefaultYieldSupplyActiveComponent.kt | 11 +++++++++ .../active/model/YieldSupplyActiveModel.kt | 24 +++++++++++++++++++ .../active/ui/YieldSupplyActiveContent.kt | 7 ++++++ 10 files changed, 91 insertions(+), 4 deletions(-) diff --git a/features/feed/impl/build.gradle.kts b/features/feed/impl/build.gradle.kts index e2d888a68b..43b84cf4b4 100644 --- a/features/feed/impl/build.gradle.kts +++ b/features/feed/impl/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { api(projects.features.wallet.api) api(projects.features.account.api) api(projects.features.commonFeatures.api) + api(projects.features.marketing.api) implementation(projects.features.promoBanners.api) /* Data */ @@ -54,6 +55,7 @@ dependencies { implementation(projects.domain.notifications.models) implementation(projects.domain.transaction) implementation(projects.domain.news) + implementation(projects.domain.marketing.models) implementation(projects.domain.yieldSupply.models) implementation(projects.domain.yieldSupply) implementation(projects.domain.earn) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index b5814e9491..960efaa3a4 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -20,6 +20,7 @@ import com.tangem.features.feed.components.news.details.DefaultNewsDetailsCompon import com.tangem.features.feed.components.news.list.DefaultNewsListComponent import com.tangem.features.feed.components.search.DefaultSearchComponent import com.tangem.features.feed.model.market.list.state.SortByTypeUM +import com.tangem.features.marketing.api.MarketingBannerComponent import com.tangem.features.promobanners.api.PromoBannersBlockComponent import kotlinx.serialization.Serializable import javax.inject.Inject @@ -33,6 +34,7 @@ internal class FeedEntryChildFactory @Inject constructor( private val manageFundsComponentFactory: ManageFundsComponent.Factory, private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory, private val designFeatureToggles: DesignFeatureToggles, + private val marketingBannerComponentFactory: MarketingBannerComponent.Factory, ) { @Serializable @@ -86,6 +88,7 @@ internal class FeedEntryChildFactory @Inject constructor( designFeatureToggles = designFeatureToggles, addToPortfolioComponentFactory = addToPortfolioComponentFactory, manageFundsComponentFactory = manageFundsComponentFactory, + marketingBannerComponentFactory = marketingBannerComponentFactory, ) } is Child.TokenList -> { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt index efbc99093b..e49e5ee19c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt @@ -37,6 +37,7 @@ import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnal import com.tangem.features.feed.model.market.details.state.TokenNetworksState import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsContent import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTitle +import com.tangem.features.marketing.api.MarketingBannerComponent import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.serialization.Serializable @@ -51,6 +52,7 @@ internal class DefaultMarketsTokenDetailsComponent( val params: Params, private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, private val manageFundsComponentFactory: ManageFundsComponent.Factory, + private val marketingBannerComponentFactory: MarketingBannerComponent.Factory, ) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { // applying l2 compatibility @@ -62,6 +64,11 @@ internal class DefaultMarketsTokenDetailsComponent( private val analyticsParams = params.analyticsParams private val model: MarketsTokenDetailsModel = getOrCreateModel(updatedParams) + private val marketingBannerComponent = marketingBannerComponentFactory.create( + context = child("marketsTokenDetailsMarketingBanner"), + params = MarketingBannerComponent.Params.Standalone(requestFlow = model.marketingRequest), + ) + private val portfolioComponent: MarketsPortfolioComponent? = if (updatedParams.shouldShowPortfolio && !designFeatureToggles.isRedesignEnabled) { portfolioComponentFactory.create( @@ -214,6 +221,9 @@ internal class DefaultMarketsTokenDetailsComponent( component.Content(blockModifier) } }, + marketingBanner = { blockModifier -> + marketingBannerComponent.Content(blockModifier) + }, ) bottomSheet.child?.instance?.BottomSheet() addFundsBs.child?.instance?.BottomSheet() diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt index e79b45966a..a9006e4bdd 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt @@ -40,6 +40,7 @@ import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.markets.* +import com.tangem.domain.marketing.models.MarketingScreen import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.news.model.NewsListConfig import com.tangem.domain.news.usecase.GetNewsUseCase @@ -49,6 +50,7 @@ import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.commonfeatures.api.tokenactions.BottomAction +import com.tangem.features.marketing.api.MarketingBannerRequest import com.tangem.features.feed.components.market.details.AddFundsSlotRoute import com.tangem.features.feed.components.market.details.AddToPortfolioSlotRoute import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent @@ -234,6 +236,10 @@ internal class MarketsTokenDetailsModel @Inject constructor( val isVisibleOnScreen = MutableStateFlow(false) val networksState = MutableStateFlow(TokenNetworksState.Loading) + val marketingRequest: Flow = flowOf( + MarketingBannerRequest(screen = MarketingScreen.TokenMarkets(coingeckoId = params.token.id.value)), + ) + val addToPortfolioSheetNavigation = SlotNavigation() val addFundsSheetNavigation = SlotNavigation() diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt index d6f674062c..a041bf92a2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt @@ -64,6 +64,7 @@ internal fun MarketsTokenDetailsContent( modifier: Modifier = Modifier, portfolioBlock: @Composable ((Modifier) -> Unit)?, portfolioFloatingBlock: @Composable ((Modifier) -> Unit)?, + marketingBanner: @Composable (Modifier) -> Unit, ) { Content( contentPadding = contentPadding, @@ -72,6 +73,7 @@ internal fun MarketsTokenDetailsContent( state = state, portfolioBlock = portfolioBlock, portfolioFloatingBlock = portfolioFloatingBlock, + marketingBanner = marketingBanner, ) when (state.bottomSheetConfig.content) { @@ -90,6 +92,7 @@ private fun Content( modifier: Modifier = Modifier, portfolioBlock: @Composable ((Modifier) -> Unit)?, portfolioFloatingBlock: @Composable ((Modifier) -> Unit)?, + marketingBanner: @Composable (Modifier) -> Unit, ) { val isRedesignEnabled = LocalRedesignEnabled.current val density = LocalDensity.current @@ -148,14 +151,13 @@ private fun Content( ) } item { SpacerH16() } - tokenMarketDetailsBody( state = state.body, portfolioBlock = portfolioBlock, relatedNews = state.relatedNews, isRedesignEnabled = isRedesignEnabled, + marketingBanner = marketingBanner, ) - item { SpacerH(bottomSpacing) } } } @@ -493,6 +495,7 @@ private fun MarketsTokenDetailsContent_Preview( backgroundColor = TangemTheme.colors.background.tertiary, portfolioBlock = {}, portfolioFloatingBlock = null, + marketingBanner = {}, contentPadding = PaddingValues(), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt index 20520a4398..121f46303b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt @@ -28,11 +28,13 @@ internal fun LazyListScope.tokenMarketDetailsBody( state: MarketsTokenDetailsUM.Body, portfolioBlock: @Composable ((Modifier) -> Unit)?, relatedNews: RelatedNews, + marketingBanner: @Composable (Modifier) -> Unit, ) { if (isRedesignEnabled) { tokenMarketDetailsBodyV2( state = state, relatedNews = relatedNews, + marketingBanner = marketingBanner, ) } else { tokenMarketDetailsBodyV1( @@ -95,13 +97,28 @@ private fun LazyListScope.tokenMarketDetailsBodyV1( } } +private fun LazyListScope.marketingBannerItem(marketingBanner: @Composable (Modifier) -> Unit) { + item(key = "marketing_banner") { + marketingBanner( + Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 8.dp) + .fillMaxWidth(), + ) + } +} + // Empty item with a key so that deeplink scroll-to-section can target it before the real content is composed private fun LazyListScope.sectionStub(key: String) { item(key) { } } @Suppress("CanBeNonNullable") -private fun LazyListScope.tokenMarketDetailsBodyV2(state: MarketsTokenDetailsUM.Body, relatedNews: RelatedNews) { +private fun LazyListScope.tokenMarketDetailsBodyV2( + state: MarketsTokenDetailsUM.Body, + relatedNews: RelatedNews, + marketingBanner: @Composable (Modifier) -> Unit, +) { when (state) { MarketsTokenDetailsUM.Body.Loading -> { item("description-loading") { @@ -115,6 +132,8 @@ private fun LazyListScope.tokenMarketDetailsBodyV2(state: MarketsTokenDetailsUM. description(state.description) } + marketingBannerItem(marketingBanner) + infoBlocksListV2( state = state.infoBlocks, relatedNews = relatedNews, @@ -169,7 +188,7 @@ private fun LazyListScope.description(description: MarketsTokenDetailsUM.Descrip modifier = { this .padding(horizontal = TangemTheme.dimens2.x4) - .padding(bottom = TangemTheme.dimens2.x8) + .padding(bottom = 24.dp) }, otherModifier = { blockPaddings() diff --git a/features/yield-supply/impl/build.gradle.kts b/features/yield-supply/impl/build.gradle.kts index 37aee32489..0737c02016 100644 --- a/features/yield-supply/impl/build.gradle.kts +++ b/features/yield-supply/impl/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { /** Feature */ implementation(projects.features.yieldSupply.api) + implementation(projects.features.marketing.api) /** Core */ implementation(projects.core.configToggles) @@ -59,6 +60,7 @@ dependencies { implementation(projects.domain.feedback) implementation(projects.domain.balanceHiding.models) implementation(projects.domain.balanceHiding) + implementation(projects.domain.marketing.models) implementation(projects.libs.crypto) /** Compose */ diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/DefaultYieldSupplyActiveComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/DefaultYieldSupplyActiveComponent.kt index dc62882ed0..2b362b693e 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/DefaultYieldSupplyActiveComponent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/DefaultYieldSupplyActiveComponent.kt @@ -21,6 +21,7 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.marketing.api.MarketingBannerComponent import com.tangem.features.yield.supply.api.YieldSupplyActiveComponent import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.active.model.YieldSupplyActiveModel @@ -38,6 +39,7 @@ internal class DefaultYieldSupplyActiveComponent @AssistedInject constructor( @Assisted private val appComponentContext: AppComponentContext, @Assisted private val params: YieldSupplyActiveComponent.Params, private val appRouter: AppRouter, + private val marketingBannerComponentFactory: MarketingBannerComponent.Factory, ) : YieldSupplyActiveComponent, AppComponentContext by appComponentContext { private val model: YieldSupplyActiveModel = getOrCreateModel(params = params) @@ -49,6 +51,14 @@ internal class DefaultYieldSupplyActiveComponent @AssistedInject constructor( ), ) + private val marketingBannerComponent = marketingBannerComponentFactory.create( + context = child("marketingBanner"), + params = MarketingBannerComponent.Params.Standalone( + requestFlow = model.marketingRequest, + onDeeplinkClick = model::onMarketingBannerDeeplink, + ), + ) + private val bottomSheetSlot = childSlot( key = "yieldSupplyActiveStack", source = model.slotNavigation, @@ -83,6 +93,7 @@ internal class DefaultYieldSupplyActiveComponent @AssistedInject constructor( state = state, isBalanceHidden = isBalanceHidden, chartComponent = chartComponent, + marketingBannerComponent = marketingBannerComponent, onReadMoreClick = model::onReadMoreClick, ) diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt index 8751c8920d..8859c63ce6 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt @@ -28,10 +28,15 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.deeplink.resolveMarketingDeeplink +import com.tangem.common.routing.deeplink.toContextualRoute +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.domain.marketing.models.MarketingScreen import com.tangem.domain.stories.models.StoryContentIds import com.tangem.domain.yield.supply.models.YieldBoostStatus import com.tangem.domain.yield.supply.promo.usecase.GetYieldBoostStatusUseCase import com.tangem.domain.yield.supply.usecase.* +import com.tangem.features.marketing.api.MarketingBannerRequest import com.tangem.features.yield.supply.api.YieldSupplyActiveComponent import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics @@ -98,6 +103,15 @@ internal class YieldSupplyActiveModel @Inject constructor( private val cryptoCurrency = cryptoCurrencyStatusFlow.value.currency private var appCurrency = AppCurrency.Default + val marketingRequest: Flow = flowOf( + MarketingBannerRequest( + screen = MarketingScreen.Yield( + networkId = params.cryptoCurrency.network.rawId, + contractAddress = (params.cryptoCurrency as? CryptoCurrency.Token)?.contractAddress.orEmpty(), + ), + ), + ) + val uiState: StateFlow field = MutableStateFlow( YieldSupplyActiveContentUM( @@ -147,6 +161,16 @@ internal class YieldSupplyActiveModel @Inject constructor( } } + fun onMarketingBannerDeeplink(deeplink: String): Boolean { + val route = resolveMarketingDeeplink(deeplink).toContextualRoute( + userWalletId = userWalletId, + currency = cryptoCurrency, + screenSource = AnalyticsParam.ScreensSources.Token, + ) ?: return false + appRouter.push(route) + return true + } + override fun onDismissClick() { if (!transactionInProgressFlow.value) { slotNavigation.dismiss() diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/ui/YieldSupplyActiveContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/ui/YieldSupplyActiveContent.kt index def68cb652..daa1d00eed 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/ui/YieldSupplyActiveContent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/ui/YieldSupplyActiveContent.kt @@ -40,6 +40,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemeRedesign import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.active.entity.YieldSupplyActiveContentUM import kotlinx.collections.immutable.persistentListOf @@ -51,6 +52,7 @@ internal fun YieldSupplyActiveContent( isBalanceHidden: Boolean, onReadMoreClick: () -> Unit, chartComponent: ComposableContentComponent, + marketingBannerComponent: ComposableContentComponent, modifier: Modifier = Modifier, ) { Column( @@ -88,6 +90,10 @@ internal fun YieldSupplyActiveContent( } } + TangemThemeRedesign { + marketingBannerComponent.Content(Modifier.fillMaxWidth()) + } + AnimatedVisibility(state.notifications.isNotEmpty()) { val notifications = remember(state.notifications) { state.notifications } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -418,6 +424,7 @@ private fun YieldSupplyActiveBottomSheet_Preview( state = params, isBalanceHidden = true, chartComponent = ComposableContentComponent.EMPTY, + marketingBannerComponent = ComposableContentComponent.EMPTY, onReadMoreClick = {}, ) } From af8431909292df8ec870727f58636d319e8829a3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 15:38:13 +0300 Subject: [PATCH 49/59] Updated on 2026-08-14 --- .claude/rules/codestyle/design-system.md | 15 +- .../core/ui/ds2/glowring/TangemGlowRing.kt | 297 ++++++++++++++++++ .../ui/ds2/glowring/TangemGlowRingInternal.kt | 231 ++++++++++++++ .../com/tangem/core/ui/res/TangemTheme.kt | 2 + .../storybook/entity/StoryBookPage.kt | 20 ++ .../page/ds/DsComponentsListScreen.kt | 2 + .../storybook/page/ds/glowring/Build.kt | 30 ++ .../page/ds/glowring/TangemGlowRingStory.kt | 227 +++++++++++++ .../storybook/ui/StoryBookScreen.kt | 2 + 9 files changed, 821 insertions(+), 5 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRing.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRingInternal.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/Build.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/TangemGlowRingStory.kt diff --git a/.claude/rules/codestyle/design-system.md b/.claude/rules/codestyle/design-system.md index 99b4534e6b..5872714e4f 100644 --- a/.claude/rules/codestyle/design-system.md +++ b/.claude/rules/codestyle/design-system.md @@ -19,10 +19,12 @@ generation a component belongs to is essential so you don't mix tokens or pull t |---|---|---|---|---|---| | **DS1** (legacy) | `core/ui/src/main/java/com/tangem/core/ui/components/` | `TangemTheme.colors` | `TangemTheme.typography` | `TangemTheme.dimens` | `TangemThemePreview` | | **DS2** (redesign) | `core/ui/src/main/java/com/tangem/core/ui/ds/` | `TangemTheme.colors2` | `TangemTheme.typography2` | `TangemTheme.dimens2` | `TangemThemePreviewRedesign` | -| **DS3** (target) | `core/ui/src/main/java/com/tangem/core/ui/ds2/` | `TangemTheme.colors3` | `TangemTheme.typography3` | `TangemTheme.dimens2` | `TangemThemePreviewRedesign` | +| **DS3** (target) | `core/ui/src/main/java/com/tangem/core/ui/ds2/` | `TangemTheme.colors3` | `TangemTheme.typography3` | literal `.dp` (no token) | `TangemThemePreviewRedesign` | > Mind the numbering mismatch: **folder `ds` is DS2**, **folder `ds2` is DS3**. > The `colors2` / `typography2` tokens are `@Deprecated` (ReplaceWith `colors3` / `typography3`). +> **DS3 has no dimension token** — `dimens2` is a DS2 token and must **not** be used in `ds2/` +> components. Express dimensions as literal `.dp` values (see rule 2 below). - **DS1** — the entire current app is built on it. Do **not** add new components here. - **DS2** — redesign components. A transitional generation; don't write new components in it, only @@ -49,9 +51,11 @@ Pattern rules: 1. **Package & location.** `com.tangem.core.ui.ds2.`, folder `core/ui/.../ds2//`. The component name is `Tangem`. -2. **DS3 tokens only.** Colors — `TangemTheme.colors3.*`, text — `TangemTheme.typography3.*`, - dimensions — `TangemTheme.dimens2.*`. No `colors` / `colors2` / hardcoded values (literal dp/colors - are acceptable only inside `@Preview`, where you add `@Suppress("MagicNumber")`). +2. **DS3 tokens only.** Colors — `TangemTheme.colors3.*`, text — `TangemTheme.typography3.*`. No + `colors` / `colors2` and no hardcoded colors outside `@Preview`. **Dimensions have no DS3 token** — + do **not** use `TangemTheme.dimens2.*` (it is a DS2 token); express dimensions as literal `.dp` + values and add `@Suppress("MagicNumber")` to the composable (or a `…Ext.kt` / `…Internal.kt` token + holder, as `TangemButtonInternal.kt` and `TangemCheckmark.kt` do). 3. **Signature.** `modifier: Modifier = Modifier` is mandatory (defaulting to `Modifier`, placed first among the optional params or right after the required ones). Express variants/sizes via a nested `enum` in `object Tangem` (like `TangemButton.Variant` / `TangemButton.Size`), not boolean flags. @@ -156,7 +160,8 @@ Page layout guidelines live in - [ ] Component created under `core/ui/.../ds2//`, package `com.tangem.core.ui.ds2.`. - [ ] Named `Tangem`; first optional parameter is `modifier: Modifier = Modifier`. -- [ ] Uses **only** DS3 tokens: `colors3`, `typography3`, `dimens2`. No hardcoded values outside previews. +- [ ] Uses **only** DS3 tokens: `colors3`, `typography3`. No hardcoded colors outside previews. Dimensions + are literal `.dp` (DS3 has no dimension token — never use `dimens2`), with `@Suppress("MagicNumber")`. - [ ] Variants/sizes expressed as an `enum` inside `object Tangem` (not a set of boolean flags). - [ ] All public types (enums, statuses, constants) declared inside the `object Tangem`. - [ ] Convenient overloads provided (simpler `@Composable` overloads and/or `object` extension presets). diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRing.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRing.kt new file mode 100644 index 0000000000..b8b5f4b822 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRing.kt @@ -0,0 +1,297 @@ +@file:Suppress("MagicNumber") + +package com.tangem.core.ui.ds2.glowring + +import android.os.Build +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.TangemColors3 + +/** + * Design-system v2 (DS3) **Glow Ring** — an animated angular-gradient halo that runs around a + * rounded-rect outline, like lights chasing along the border. + * + * [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=4933-126&m=dev) + * + * @param modifier Modifier for the whole component; also defines its size when there is no [content]. + * @param variant Color theme of the gradient — see [TangemGlowRing.Variant]. + * @param cornerRadius Corner radius of the ring; should match the radius of the wrapped surface. + * @param animated When `false`, the ring is rendered static (no rotation). + * @param quality Rendering strategy; defaults to [TangemGlowRing.Quality.Auto] (device-appropriate). + * Force [TangemGlowRing.Quality.LayeredStrokes] to preview the pre-Android-12 fallback on any device. + * @param contentDescription Accessibility label; pass a value when the ring conveys state (e.g. error), + * leave `null` when it is purely decorative. + * @param content Optional content drawn inside/over the ring. + */ +@Composable +fun TangemGlowRing( + modifier: Modifier = Modifier, + variant: TangemGlowRing.Variant = TangemGlowRing.Variant.Magic, + cornerRadius: Dp = 24.dp, + animated: Boolean = true, + quality: TangemGlowRing.Quality = TangemGlowRing.Quality.Auto, + contentDescription: String? = null, + content: @Composable BoxScope.() -> Unit = {}, +) { + val resolved = remember(quality) { resolveQuality(quality) } + val stops = rememberGlowRingStops(variant, animated) + val metrics = remember { + GlowRingMetrics(coreWidth = 2.dp, ringWidth = 4.dp, blurMid = 8.dp, blurBottom = 16.dp) + } + + val angle = if (animated) { + val transition = rememberInfiniteTransition(label = "glowRing") + val rotation by transition.animateFloat( + initialValue = GLOW_RING_START_ANGLE, + targetValue = 270f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 24_000, + easing = CubicBezierEasing(a = 0.1f, b = 0f, c = 0.9f, d = 1f), + ), + repeatMode = RepeatMode.Restart, + ), + label = "angle", + ) + rotation + } else { + GLOW_RING_START_ANGLE + } + + Box( + modifier = if (contentDescription != null) { + modifier.semantics { this.contentDescription = contentDescription } + } else { + modifier + }, + ) { + val ringModifier = Modifier.matchParentSize() + when (resolved) { + ResolvedGlowRingQuality.Blur -> BlurGlowRing( + angle = angle, + stops = stops, + cornerRadius = cornerRadius, + metrics = metrics, + modifier = ringModifier, + ) + ResolvedGlowRingQuality.LayeredStrokes -> LayeredStrokesGlowRing( + angle = angle, + stops = stops, + cornerRadius = cornerRadius, + metrics = metrics, + modifier = ringModifier, + ) + } + content() + } +} + +/** Sweep start angle, also reused as the static angle when [TangemGlowRing] is not animated (Figma: -90°). */ +private const val GLOW_RING_START_ANGLE = -90f + +/** + * Resolves the gradient stops for [variant] from the DS3 `colors3.glow` tokens. The + * [TangemGlowRing.Variant.Magic] variant continuously ping-pongs between gradient A (`glow.magic`) and + * gradient B (`glow.magicBlend`) while [animated] is `true`; every other variant has a single static + * gradient. + */ +@Composable +private fun rememberGlowRingStops(variant: TangemGlowRing.Variant, animated: Boolean): List> { + val glow = TangemTheme.colors3.glow + if (variant != TangemGlowRing.Variant.Magic || !animated) { + return variant.stops(glow) + } + val morphTransition = rememberInfiniteTransition(label = "glowRingMorph") + val mix by morphTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + // 6s A→B half-period; Reverse makes a 12s ping-pong (Figma morphDur = 12s). + animation = tween(durationMillis = 6_000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "morphMix", + ) + return morphedMagicStops(glow.magic.steps(), glow.magicBlend.steps(), mix) +} + +/** Public API surface of [TangemGlowRing]. */ +object TangemGlowRing { + + /** Color theme of the glow ring gradient. */ + enum class Variant { + /** + * Multi-color "magic" gradient that continuously auto-morphs (ping-pongs) between separated + * orange / blue / purple arcs and a continuous fully-saturated blend. + */ + Magic, + + /** Green success glow. */ + Success, + + /** Red error glow. */ + Error, + + /** Orange/amber warning glow. */ + Warning, + + /** Blue informational glow. */ + Info, + } + + /** + * Rendering strategy for the glow. + * + * [Auto] picks the best renderer for the current device — a real Gaussian blur on Android 12+ + * (API 31) and a layered-stroke approximation on older versions. The explicit values force one + * renderer regardless of API level; they exist mainly for previews / Storybook so the + * pre-Android-12 fallback can be inspected on a modern device. Product code should use [Auto]. + */ + enum class Quality { + /** Auto-detect the renderer from the device API level (recommended). */ + Auto, + + /** Force the Android 12+ real-blur renderer. */ + Blur, + + /** Force the pre-Android-12 layered-stroke fallback. */ + LayeredStrokes, + } +} + +/** + * Resolves [quality] to a concrete renderer. [TangemGlowRing.Quality.Auto] picks a real blur on + * Android 12+ (API 31) and falls back to stacked translucent strokes on older versions; the explicit + * values force their renderer regardless of API level. + */ +private fun resolveQuality(quality: TangemGlowRing.Quality): ResolvedGlowRingQuality = when (quality) { + TangemGlowRing.Quality.Blur -> ResolvedGlowRingQuality.Blur + TangemGlowRing.Quality.LayeredStrokes -> ResolvedGlowRingQuality.LayeredStrokes + TangemGlowRing.Quality.Auto -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ResolvedGlowRingQuality.Blur + } else { + ResolvedGlowRingQuality.LayeredStrokes + } +} + +/** Concrete rendering strategy chosen by [resolveQuality]. */ +private enum class ResolvedGlowRingQuality { Blur, LayeredStrokes } + +/** + * Builds the angular-gradient stops for [this] variant from its DS3 `colors3.glow` token group. Every + * variant token exposes the same 10 [steps] — solid arcs at steps 1/4/7, a faint arc at 9 and transparent + * gaps elsewhere — which [glowStops] lays out as evenly-spaced, seamlessly-looping stops. + */ +private fun TangemGlowRing.Variant.stops(glow: TangemColors3.Glow): List> = glowStops( + when (this) { + TangemGlowRing.Variant.Magic -> glow.magic.steps() + TangemGlowRing.Variant.Success -> glow.success.steps() + TangemGlowRing.Variant.Error -> glow.error.steps() + TangemGlowRing.Variant.Warning -> glow.warning.steps() + TangemGlowRing.Variant.Info -> glow.info.steps() + }, +) + +/** + * Blends the Magic gradients A ([magic] = `glow.magic`) and B ([magicBlend] = `glow.magicBlend`) at + * [mix] (`0` = A, `1` = B). Both token groups share the same stop positions, so the morph is a direct + * per-step color lerp. Mirrors the reference rig's auto-morph (ping-pong) between gradient A and B. + */ +private fun morphedMagicStops(magic: List, magicBlend: List, mix: Float): List> { + val m = mix.coerceIn(0f, 1f) + return glowStops(List(magic.size) { lerp(magic[it], magicBlend[it], m) }) +} + +/** + * Lays the glow [steps] out as an angular gradient: evenly spaced from `0`, with step 1 repeated at `1.0` + * so the rotation loops seamlessly. Transparent steps create the gaps between the glowing arcs. + */ +private fun glowStops(steps: List): List> { + val count = steps.size + return steps.mapIndexed { index, color -> index.toFloat() / count to color } + (1f to steps.first()) +} + +private fun TangemColors3.Glow.Magic.steps(): List = + listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10) + +private fun TangemColors3.Glow.MagicBlend.steps(): List = + listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10) + +private fun TangemColors3.Glow.Success.steps(): List = + listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10) + +private fun TangemColors3.Glow.Error.steps(): List = + listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10) + +private fun TangemColors3.Glow.Warning.steps(): List = + listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10) + +private fun TangemColors3.Glow.Info.steps(): List = + listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10) + +@Preview(name = "Light", showBackground = true) +@Preview(name = "Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +private fun TangemGlowRingPreview() { + TangemThemePreviewRedesign { + Column( + modifier = Modifier + .background(TangemTheme.colors3.bg.primary) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + TangemGlowRing( + modifier = Modifier.size(120.dp, 72.dp), + variant = TangemGlowRing.Variant.Magic, + ) + TangemGlowRing( + modifier = Modifier.size(120.dp, 72.dp), + variant = TangemGlowRing.Variant.Success, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + TangemGlowRing( + modifier = Modifier.size(120.dp, 72.dp), + variant = TangemGlowRing.Variant.Error, + ) + TangemGlowRing( + modifier = Modifier.size(120.dp, 72.dp), + variant = TangemGlowRing.Variant.Warning, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + TangemGlowRing( + modifier = Modifier.size(120.dp, 72.dp), + variant = TangemGlowRing.Variant.Info, + ) + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRingInternal.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRingInternal.kt new file mode 100644 index 0000000000..cb4f4f1ad5 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRingInternal.kt @@ -0,0 +1,231 @@ +@file:Suppress("MagicNumber") + +package com.tangem.core.ui.ds2.glowring + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.unit.Dp +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min + +/** + * Token-driven measurements shared by both renderers, mirroring the Figma component anatomy: + * a crisp [coreWidth] core line plus two wider, blurred glow bands ([ringWidth] stroked, blurred by + * [blurMid] and [blurBottom]). + */ +internal data class GlowRingMetrics( + val coreWidth: Dp, // crisp core stroke (top layer) + val ringWidth: Dp, // glow band stroke (mid + bottom layers) + val blurMid: Dp, // mid glow blur radius + val blurBottom: Dp, // widest glow blur radius +) + +/** + * Tier 1 — works on every API level, no blur or shader. Approximates the blurred glow by stacking the + * same breathing angular-gradient ring several times: progressively wider + fainter bands under a crisp + * core. Everything is clipped to the rounded box, so only the inner half of each band shows → inner glow. + */ +@Composable +internal fun LayeredStrokesGlowRing( + angle: Float, + stops: List>, + cornerRadius: Dp, + metrics: GlowRingMetrics, + modifier: Modifier = Modifier, +) { + Box(modifier.clip(RoundedCornerShape(cornerRadius))) { + Canvas(Modifier.fillMaxSize()) { + val r = cornerRadius.toPx() + // widest & faintest first, crisp core last + drawBreathingRing( + stops = stops, + angleDeg = angle, + cornerRadiusPx = r, + strokePx = (metrics.ringWidth + metrics.blurBottom).toPx(), + alpha = 0.06f, + ) + drawBreathingRing( + stops = stops, + angleDeg = angle, + cornerRadiusPx = r, + strokePx = (metrics.ringWidth + metrics.blurMid).toPx(), + alpha = 0.12f, + ) + drawBreathingRing( + stops = stops, + angleDeg = angle, + cornerRadiusPx = r, + strokePx = metrics.ringWidth.toPx(), + alpha = 0.30f, + ) + drawBreathingRing( + stops = stops, + angleDeg = angle, + cornerRadiusPx = r, + strokePx = metrics.coreWidth.toPx(), + alpha = 1.0f, + ) + } + } +} + +/** + * Tier 2 — Android 12+ (API 31). Reproduces the Figma anatomy directly: three stacked breathing + * angular-gradient rings with real blur (bottom widest, mid, top crisp). Each layer bleeds with + * [BlurredEdgeTreatment.Unbounded]; the surrounding [clip] to the rounded box keeps only the inner + * bloom, producing the inner glow. + */ +@Composable +internal fun BlurGlowRing( + angle: Float, + stops: List>, + cornerRadius: Dp, + metrics: GlowRingMetrics, + modifier: Modifier = Modifier, +) { + Box(modifier.clip(RoundedCornerShape(cornerRadius))) { + // bottom — widest halo + BreathingRing( + angle = angle, + stops = stops, + cornerRadius = cornerRadius, + strokeWidth = metrics.ringWidth, + modifier = Modifier + .fillMaxSize() + .blur(radius = metrics.blurBottom, edgeTreatment = BlurredEdgeTreatment.Unbounded), + ) + // mid + BreathingRing( + angle = angle, + stops = stops, + cornerRadius = cornerRadius, + strokeWidth = metrics.ringWidth, + modifier = Modifier + .fillMaxSize() + .blur(radius = metrics.blurMid, edgeTreatment = BlurredEdgeTreatment.Unbounded), + ) + // top — crisp core + BreathingRing( + angle = angle, + stops = stops, + cornerRadius = cornerRadius, + strokeWidth = metrics.coreWidth, + modifier = Modifier.fillMaxSize(), + ) + } +} + +@Composable +private fun BreathingRing( + angle: Float, + stops: List>, + cornerRadius: Dp, + strokeWidth: Dp, + modifier: Modifier = Modifier, +) { + Canvas(modifier) { + drawBreathingRing( + stops = stops, + angleDeg = angle, + cornerRadiusPx = cornerRadius.toPx(), + strokePx = strokeWidth.toPx(), + alpha = 1f, + ) + } +} + +/** + * Draws one angular-gradient ring band clipped to the rounded-rect stroke outline. The gradient is a + * sweep whose colour seam is rotated by [angleDeg] (via [rotatedStops]) and whose vertical squish + * breathes between W/2 and W/8 over the rotation (`rxM = mid + amp·cos(2φ)`), reproducing the morphing + * arcs of the reference rig. + */ +private fun DrawScope.drawBreathingRing( + stops: List>, + angleDeg: Float, + cornerRadiusPx: Float, + strokePx: Float, + alpha: Float, +) { + val w = size.width + val h = size.height + if (w <= 0f || h <= 0f) return + val center = Offset(w / 2f, h / 2f) + + // Breathing horizontal radius of the gradient ellipse → vertical squish of the angle sampling. + val maxRx = w / 2f + val minRx = w / 8f + val mid = (maxRx + minRx) / 2f + val amp = max((maxRx - minRx) / 2f, 0f) + val phaseRad = Math.toRadians(angleDeg.toDouble()).toFloat() + val rxM = mid + amp * cos(2f * phaseRad) + val scaleY = h / 2f / max(rxM, 1f) + + val r = min(cornerRadiusPx, min(w, h) / 2f) + val o = strokePx / 2f + val ring = Path().apply { + fillType = PathFillType.EvenOdd + addRoundRect( + RoundRect(rect = Rect(Offset(-o, -o), Size(w + 2f * o, h + 2f * o)), cornerRadius = CornerRadius(r + o)), + ) + addRoundRect( + RoundRect( + rect = Rect(Offset(o, o), Size(w - 2f * o, h - 2f * o)), + cornerRadius = CornerRadius(max(r - o, 0f)), + ), + ) + } + + val brush = Brush.sweepGradient(colorStops = rotatedStops(stops, angleDeg), center = center) + val big = max(w, h) * 4f + clipPath(ring) { + withTransform({ scale(scaleX = 1f, scaleY = scaleY, pivot = center) }) { + drawRect( + brush = brush, + topLeft = Offset(center.x - big / 2f, center.y - big / 2f), + size = Size(big, big), + alpha = alpha, + ) + } + } +} + +/** + * Compose's [Brush.sweepGradient] has no start-angle parameter, so the colour seam is rotated by + * shifting every stop position by `deg/360` (wrapping around the loop) and re-anchoring boundary stops + * at 0 and 1 with the interpolated wrap colour. Mirrors `rotatedStops` from the reference rig. + */ +private fun rotatedStops(base: List>, deg: Float): Array> { + val d = (deg / 360f % 1f + 1f) % 1f + val uniq = base.dropLast(1) // drop the duplicate wrap stop at 1.0 + val shifted = uniq + .map { (p, c) -> ((p + d) % 1f + 1f) % 1f to c } + .sortedBy { it.first } + val first = shifted.first() + val last = shifted.last() + val span = first.first + 1f - last.first + val wrapFraction = if (span > 1e-6f) (1f - last.first) / span else 0f + val wrapColor = lerp(last.second, first.second, wrapFraction) + return (listOf(0f to wrapColor) + shifted + listOf(1f to wrapColor)).toTypedArray() +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index d95e93902a..7ea6cfb4c7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -190,11 +190,13 @@ object TangemTheme { @ReadOnlyComposable get() = LocalTangemTypography3.current + @Deprecated("Use plain dp") val dimens: TangemDimens @Composable @ReadOnlyComposable get() = LocalTangemDimens.current + @Deprecated("Use plain dp") val dimens2: TangemDimens2 @Composable @ReadOnlyComposable 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 2e1b42692f..b380d317b6 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 @@ -9,6 +9,7 @@ import com.tangem.core.ui.ds.topbar.TangemTopBarType import com.tangem.core.ui.ds2.badge.TangemBadge import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.fade.TangemFade +import com.tangem.core.ui.ds2.glowring.TangemGlowRing import com.tangem.core.ui.ds2.loader.TangemLoaderSize import com.tangem.core.ui.ds2.row.TangemRowContentLead import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment @@ -351,6 +352,25 @@ internal data class TangemCheckmarkStory( val onEnabledToggle: () -> 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"), + } +} + 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/DsComponentsListScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt index 1083082fea..fc0a092152 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt @@ -20,6 +20,7 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.button.tangemBut import com.tangem.feature.tester.presentation.storybook.page.ds.checkbox.tangemCheckboxV2StoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.checkmark.tangemCheckmarkStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.fade.tangemFadeStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.ds.glowring.tangemGlowRingStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.loader.tangemLoaderStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.row.tangemRowStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.search.tangemSearchStoryFactory @@ -39,6 +40,7 @@ private fun buildDsStories() = listOf( DsStoryItem(title = "✨ TangemShimmer", factory = tangemShimmerStoryFactory), DsStoryItem(title = "🌫️ TangemFade", factory = tangemFadeStoryFactory), DsStoryItem(title = "🧭 TangemTopNavigation", factory = tangemTopNavigationStoryFactory), + DsStoryItem(title = "💫 TangemGlowRing", factory = tangemGlowRingStoryFactory), ) @Composable diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/Build.kt new file mode 100644 index 0000000000..879601eb75 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/Build.kt @@ -0,0 +1,30 @@ +package com.tangem.feature.tester.presentation.storybook.page.ds.glowring + +import com.tangem.core.ui.ds2.glowring.TangemGlowRing +import com.tangem.feature.tester.presentation.storybook.entity.TangemGlowRingStory +import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater +import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory + +internal fun StateUpdater.build(): TangemGlowRingStory { + return TangemGlowRingStory( + variant = TangemGlowRing.Variant.Magic, + quality = TangemGlowRing.Quality.Auto, + background = TangemGlowRingStory.Background.BgPrimary, + isAnimated = true, + onVariantChange = { variant -> + updateStory { it.copy(variant = variant) } + }, + onQualityChange = { quality -> + updateStory { it.copy(quality = quality) } + }, + onBackgroundChange = { background -> + updateStory { it.copy(background = background) } + }, + onAnimatedToggle = { + updateStory { it.copy(isAnimated = !it.isAnimated) } + }, + ) +} + +internal val tangemGlowRingStoryFactory + get() = storyPageFactory(StateUpdater::build) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/TangemGlowRingStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/TangemGlowRingStory.kt new file mode 100644 index 0000000000..6e019d42e0 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/TangemGlowRingStory.kt @@ -0,0 +1,227 @@ +@file:Suppress("MagicNumber") + +package com.tangem.feature.tester.presentation.storybook.page.ds.glowring + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +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.unit.dp +import com.tangem.core.ui.ds2.glowring.TangemGlowRing +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.tester.presentation.storybook.entity.TangemGlowRingStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemGlowRingStory.Background + +@Composable +internal fun TangemGlowRingStory(state: TangemGlowRingStory, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors.background.primary) + .padding(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Preview stays pinned at the top. + ComponentPreview(state = state) + // Only the controls scroll. + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + VariantSelector(selected = state.variant, onSelect = state.onVariantChange) + QualitySelector(selected = state.quality, onSelect = state.onQualityChange) + BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange) + Toggles(state = state) + } + } +} + +@Composable +private fun PreviewBackground(background: Background, modifier: Modifier = Modifier) { + when (background) { + Background.BgPrimary -> Box(modifier.background(TangemTheme.colors3.bg.primary)) + Background.BgSecondary -> Box(modifier.background(TangemTheme.colors3.bg.secondary)) + Background.BgInverse -> Box(modifier.background(TangemTheme.colors3.bg.inverse)) + } +} + +@Composable +private fun ComponentPreview(state: TangemGlowRingStory) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(16.dp)), + ) { + PreviewBackground( + background = state.background, + modifier = Modifier.matchParentSize(), + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 48.dp), + ) { + TangemGlowRing( + modifier = Modifier.size(width = 200.dp, height = 120.dp), + variant = state.variant, + animated = state.isAnimated, + quality = state.quality, + ) + } + } +} + +@Composable +private fun VariantSelector(selected: TangemGlowRing.Variant, onSelect: (TangemGlowRing.Variant) -> Unit) { + Section(label = "Variant") { + ChipGrid( + items = TangemGlowRing.Variant.entries, + label = { it.name }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun QualitySelector(selected: TangemGlowRing.Quality, onSelect: (TangemGlowRing.Quality) -> Unit) { + Section(label = "Quality (renderer)") { + ChipGrid( + items = TangemGlowRing.Quality.entries, + label = { it.name }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun BackgroundSelector(selected: Background, onSelect: (Background) -> Unit) { + Section(label = "Background") { + ChipGrid( + items = Background.entries, + label = { it.label }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun Toggles(state: TangemGlowRingStory) { + Section(label = "Flags") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ToggleRow(label = "animated", checked = state.isAnimated, onToggle = state.onAnimatedToggle) + } + } +} + +@Composable +private fun Section(label: String, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = label, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + content() + } +} + +@Composable +private fun ChipGrid(items: List, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) { + val shape = RoundedCornerShape(50) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(shape) + .background(TangemTheme.colors2.surface.level2) + .border( + width = 1.dp, + color = TangemTheme.colors2.border.neutral.secondary, + shape = shape, + ) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + items.forEach { item -> + Chip( + label = label(item), + selected = isSelected(item), + onClick = { onSelect(item) }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val chipShape = RoundedCornerShape(50) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(chipShape) + .background( + if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2, + ) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.caption2, + color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary, + ) + } +} + +@Composable +private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(TangemTheme.colors2.surface.level2) + .clickable(onClick = onToggle) + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = if (checked) "ON" else "OFF", + style = TangemTheme.typography.caption2, + color = if (checked) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary, + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt index fb4ee3a9a7..be9720bd99 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt @@ -42,6 +42,7 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.button.TangemBut import com.tangem.feature.tester.presentation.storybook.page.ds.checkbox.TangemCheckboxV2Story import com.tangem.feature.tester.presentation.storybook.page.ds.checkmark.TangemCheckmarkStory import com.tangem.feature.tester.presentation.storybook.page.ds.fade.TangemFadeStory +import com.tangem.feature.tester.presentation.storybook.page.ds.glowring.TangemGlowRingStory import com.tangem.feature.tester.presentation.storybook.page.ds.loader.TangemLoaderStory import com.tangem.feature.tester.presentation.storybook.page.ds.row.TangemRowStory import com.tangem.feature.tester.presentation.storybook.page.ds.search.TangemSearchStory @@ -96,6 +97,7 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier) is TangemBadgeV2Story -> TangemBadgeV2Story(state = storyState) is TangemCheckboxV2Story -> TangemCheckboxV2Story(state = storyState) is TangemCheckmarkStory -> TangemCheckmarkStory(state = storyState) + is TangemGlowRingStory -> TangemGlowRingStory(state = storyState) is TangemRowStory -> TangemRowStory(state = storyState) is TangemSearchStory -> TangemSearchStory(state = storyState) is TangemShimmerStory -> TangemShimmerStory(state = storyState) From b39699ad041f1938d3f0afcf17c4b38aebd665af Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 30 Jun 2026 17:18:33 +0300 Subject: [PATCH 50/59] Updated on 2026-08-14 --- .../TangemMessageBannerNotification.kt | 96 ++++ .../ds2/messagebanner/TangemMessageBanner.kt | 436 ++++++++++++++++++ .../storybook/entity/StoryBookPage.kt | 35 ++ .../page/ds/DsComponentsListScreen.kt | 2 + .../storybook/page/ds/messagebanner/Build.kt | 37 ++ .../messagebanner/TangemMessageBannerStory.kt | 325 +++++++++++++ .../storybook/ui/StoryBookScreen.kt | 2 + 7 files changed, 933 insertions(+) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/notifications/TangemMessageBannerNotification.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds2/messagebanner/TangemMessageBanner.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/messagebanner/Build.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/messagebanner/TangemMessageBannerStory.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/TangemMessageBannerNotification.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/TangemMessageBannerNotification.kt new file mode 100644 index 0000000000..f24a7802c3 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/TangemMessageBannerNotification.kt @@ -0,0 +1,96 @@ +package com.tangem.core.ui.components.notifications + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState +import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds2.messagebanner.CloseButton +import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner +import com.tangem.core.ui.extensions.ColorReference2 +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.res.TangemTheme + +/** + * Driver that renders a DS3 [TangemMessageBanner] from a legacy [NotificationConfig], so existing + * notification call sites can adopt the new banner without rebuilding their models. + * + * @param config Legacy notification model. + * @param variant Banner appearance. See [TangemMessageBanner.Variant]. + * @param contentAlign Text alignment. See [TangemMessageBanner.ContentAlign]. + */ +@Deprecated("Use as migration solution, not production one") +@Composable +fun TangemMessageBanner( + config: NotificationConfig, + modifier: Modifier = Modifier, + variant: TangemMessageBanner.Variant = TangemMessageBanner.Variant.Default, + contentAlign: TangemMessageBanner.ContentAlign = TangemMessageBanner.ContentAlign.Start, +) { + val onClick = config.onClick + val (secondaryButton, primaryButton) = config.buttonsState.toBannerButtons() + val icon = config.toBannerIcon() + + TangemMessageBanner( + modifier = if (onClick != null) modifier.clickableSingle(onClick = onClick) else modifier, + variant = variant, + contentAlign = contentAlign, + title = config.title ?: config.subtitle, + description = config.title?.let { config.subtitle }, + secondaryButton = secondaryButton, + primaryButton = primaryButton, + slotStart = icon?.let { iconUM -> + { TangemIcon(tangemIconUM = iconUM, modifier = Modifier.size(config.iconSize)) } + }, + slotEnd = config.onCloseClick?.let { onClose -> + { TangemMessageBanner.CloseButton(onClick = onClose) } + }, + ) +} + +/** + * Builds the leading icon, honouring the remote-url, untinted, and tinted cases of [NotificationConfig]. + * Returns `null` when the config carries no icon (no url and an unset [NotificationConfig.iconResId]), + * so the banner hides the leading slot instead of rendering a broken one. + */ +private fun NotificationConfig.toBannerIcon(): TangemIconUM? = when { + iconUrl != null -> TangemIconUM.Url(url = iconUrl, fallbackRes = iconResId) + iconResId == 0 -> null + iconTint == NotificationConfig.IconTint.Unspecified -> TangemIconUM.Image(imageRes = iconResId) + else -> TangemIconUM.Icon(iconRes = iconResId, tintReference = ColorReference2 { iconTint.toColor() }) +} + +@Composable +private fun NotificationConfig.IconTint.toColor() = when (this) { + NotificationConfig.IconTint.Unspecified -> TangemTheme.colors3.icon.primary + NotificationConfig.IconTint.Accent -> TangemTheme.colors3.icon.status.info + NotificationConfig.IconTint.Attention -> TangemTheme.colors3.icon.status.warning + NotificationConfig.IconTint.Warning -> TangemTheme.colors3.icon.status.warning +} + +/** Maps the legacy button configuration onto the banner's (secondary = start, primary = end) pair. */ +private fun ButtonsState?.toBannerButtons(): Pair = + when (this) { + is ButtonsState.PrimaryButtonConfig -> null to TangemMessageBanner.Button( + text = text, + onClick = onClick, + iconEnd = iconResId?.let { TangemIconUM.Icon(iconRes = it) }, + isLoading = shouldShowProgress, + ) + is ButtonsState.SecondaryButtonConfig -> TangemMessageBanner.Button( + text = text, + onClick = onClick, + iconEnd = iconResId?.let { TangemIconUM.Icon(iconRes = it) }, + isLoading = shouldShowProgress, + ) to null + is ButtonsState.PairButtonsConfig -> TangemMessageBanner.Button( + text = secondaryText, + onClick = onSecondaryClick, + ) to TangemMessageBanner.Button(text = primaryText, onClick = onPrimaryClick) + is ButtonsState.SecondaryPairButtonsConfig -> TangemMessageBanner.Button( + text = leftText, + onClick = onLeftClick, + ) to TangemMessageBanner.Button(text = rightText, onClick = onRightClick) + null -> null to null + } \ No newline at end of file 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 new file mode 100644 index 0000000000..b186ea78cd --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/messagebanner/TangemMessageBanner.kt @@ -0,0 +1,436 @@ +@file:Suppress("MagicNumber") + +package com.tangem.core.ui.ds2.messagebanner + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds2.button.TangemButton +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.resolveAnnotatedReference +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_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. + * + * [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 secondaryButton Start action. `null` hides it. + * @param primaryButton End action. `null` hides it. + * @param content The banner body above the buttons. + */ +@Composable +fun TangemMessageBanner( + modifier: Modifier = Modifier, + variant: TangemMessageBanner.Variant = TangemMessageBanner.Variant.Default, + showGlowRing: Boolean = true, + secondaryButton: TangemMessageBanner.Button? = null, + primaryButton: TangemMessageBanner.Button? = null, + content: @Composable ColumnScope.() -> Unit, +) { + val tokens = variant.tokens() + + 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), + ) { + content() + MessageBannerButtons(secondaryButton = secondaryButton, primaryButton = primaryButton) + } + } + if (showGlowRing) { + TangemGlowRing( + modifier = Modifier.matchParentSize(), + variant = tokens.glowRing, + cornerRadius = 28.dp, + ) + } + } +} + +/** + * Design-system v2 (DS3) **Message Banner** — title/description header with optional slots and an + * action-button row. + * + * [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=5475-7680&m=dev) + * + * @param title Banner headline. + * @param variant Visual appearance — background color + glow ring. + * @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 description Secondary line under the [title]. `null` hides it. + * @param secondaryButton Start action. `null` hides it. + * @param primaryButton End action. `null` hides it. + * @param slotStart Leading slot before the title. `null` hides it. + * @param slotEnd Trailing slot after the title (e.g. the [CloseButton] preset). `null` hides it. + * @param extraBottomSlot Slot under the description, inside the text column. + */ +@Suppress("LongParameterList") +@Composable +fun TangemMessageBanner( + title: TextReference, + modifier: Modifier = Modifier, + variant: TangemMessageBanner.Variant = TangemMessageBanner.Variant.Default, + contentAlign: TangemMessageBanner.ContentAlign = TangemMessageBanner.ContentAlign.Start, + showGlowRing: Boolean = true, + description: TextReference? = null, + secondaryButton: TangemMessageBanner.Button? = null, + primaryButton: TangemMessageBanner.Button? = null, + slotStart: (@Composable () -> Unit)? = null, + slotEnd: (@Composable () -> Unit)? = null, + extraBottomSlot: (@Composable ColumnScope.() -> Unit)? = null, +) { + TangemMessageBanner( + modifier = modifier, + variant = variant, + showGlowRing = showGlowRing, + secondaryButton = secondaryButton, + primaryButton = primaryButton, + ) { + MessageBannerContentRow( + title = title, + description = description, + contentAlign = contentAlign, + slotStart = slotStart, + slotEnd = slotEnd, + extraBottomSlot = extraBottomSlot, + ) + } +} + +@Suppress("LongParameterList") +@Composable +private fun MessageBannerContentRow( + title: TextReference, + description: TextReference?, + contentAlign: TangemMessageBanner.ContentAlign, + slotStart: (@Composable () -> Unit)?, + slotEnd: (@Composable () -> Unit)?, + extraBottomSlot: (@Composable ColumnScope.() -> Unit)?, +) { + val textWrapper: @Composable (Modifier) -> Unit = { textModifier -> + MessageBannerTextWrapper( + modifier = textModifier, + title = title, + description = description, + contentAlign = contentAlign, + extraBottomSlot = extraBottomSlot, + ) + } + when (contentAlign) { + TangemMessageBanner.ContentAlign.Start -> Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + slotStart?.let { slot -> Box(modifier = Modifier.align(Alignment.Top)) { slot() } } + textWrapper(Modifier.weight(1f).align(Alignment.Top)) + slotEnd?.let { slot -> Box(modifier = Modifier.align(Alignment.Top)) { slot() } } + } + TangemMessageBanner.ContentAlign.Center -> Box(modifier = Modifier.fillMaxWidth()) { + textWrapper(Modifier.fillMaxWidth()) + slotStart?.let { slot -> Box(modifier = Modifier.align(Alignment.TopStart)) { slot() } } + slotEnd?.let { slot -> Box(modifier = Modifier.align(Alignment.TopEnd)) { slot() } } + } + } +} + +@Composable +private fun MessageBannerTextWrapper( + title: TextReference, + description: TextReference?, + contentAlign: TangemMessageBanner.ContentAlign, + extraBottomSlot: (@Composable ColumnScope.() -> Unit)?, + modifier: Modifier = Modifier, +) { + val isCenter = contentAlign == TangemMessageBanner.ContentAlign.Center + val textAlign = if (isCenter) TextAlign.Center else TextAlign.Start + Column( + modifier = if (isCenter) modifier.padding(horizontal = 32.dp) else modifier, + horizontalAlignment = if (isCenter) Alignment.CenterHorizontally else Alignment.Start, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title.resolveAnnotatedReference(), + style = TangemTheme.typography3.body.medium, + color = TangemTheme.colors3.text.primary, + textAlign = textAlign, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (description != null) { + Text( + text = description.resolveAnnotatedReference(), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, + textAlign = textAlign, + ) + } + extraBottomSlot?.let { slot -> + Column(modifier = Modifier.padding(top = 12.dp)) { slot() } + } + } +} + +@Composable +private fun MessageBannerButtons( + secondaryButton: TangemMessageBanner.Button?, + primaryButton: TangemMessageBanner.Button?, +) { + if (secondaryButton == null && primaryButton == null) return + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + secondaryButton?.let { button -> + TangemButton( + modifier = Modifier.weight(1f), + variant = TangemButton.Variant.Secondary, + text = button.text, + iconStart = button.iconStart, + iconEnd = button.iconEnd, + isEnabled = button.isEnabled, + isLoading = button.isLoading, + onClick = button.onClick, + ) + } + primaryButton?.let { button -> + TangemButton( + modifier = Modifier.weight(1f), + variant = TangemButton.Variant.Primary, + text = button.text, + iconStart = button.iconStart, + iconEnd = button.iconEnd, + isEnabled = button.isEnabled, + isLoading = button.isLoading, + onClick = button.onClick, + ) + } + } +} + +/** Public API surface of [TangemMessageBanner]. */ +object TangemMessageBanner { + + /** Visual appearance — background color + glow ring color. */ + enum class Variant { + /** Neutral opaque background with a multi-color "magic" glow ring. */ + Default, + + /** Neutral tertiary (filled) background with a multi-color "magic" glow ring. */ + Solid, + + /** Subtle success-green background and matching glow ring. */ + Success, + + /** Subtle error-red background and matching glow ring. */ + Error, + + /** Subtle warning-yellow background and matching glow ring. */ + Warning, + + /** Subtle info-blue background and matching glow ring. */ + Info, + } + + /** Horizontal alignment of the text block. */ + enum class ContentAlign { + Start, + Center, + } + + /** An action button shown in the banner's button row. */ + @Immutable + data class Button( + val text: TextReference, + val onClick: () -> Unit, + val iconStart: TangemIconUM? = null, + val iconEnd: TangemIconUM? = null, + val isEnabled: Boolean = true, + val isLoading: Boolean = false, + ) +} + +/** + * Dismiss-button preset for [TangemMessageBanner] — a filled cross-circle to pass as `slotEnd`. + * + * @param contentDescription Accessibility label announced by TalkBack (e.g. `"Dismiss"`). + */ +@Composable +fun TangemMessageBanner.CloseButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + Icon( + imageVector = Icons.ic_cross_circle_20_filled, + contentDescription = null, + tint = TangemTheme.colors3.icon.secondary, + modifier = modifier + .size(20.dp) + .clip(RoundedCornerShape(percent = 50)) + .clickableSingle(onClick = onClick) + .semantics { + role = Role.Button + contentDescription?.let { this.contentDescription = it } + }, + ) +} + +/** Resolved appearance tokens for a [TangemMessageBanner.Variant]. */ +private data class MessageBannerTokens(val background: Color, val glowRing: TangemGlowRing.Variant) + +@Composable +@ReadOnlyComposable +private fun TangemMessageBanner.Variant.tokens(): MessageBannerTokens { + val colors = TangemTheme.colors3 + return when (this) { + TangemMessageBanner.Variant.Default -> MessageBannerTokens( + background = colors.bg.opaque.primary, + glowRing = TangemGlowRing.Variant.Magic, + ) + TangemMessageBanner.Variant.Solid -> MessageBannerTokens( + background = colors.bg.tertiary, + glowRing = TangemGlowRing.Variant.Magic, + ) + TangemMessageBanner.Variant.Success -> MessageBannerTokens( + background = colors.bg.status.successSubtle, + glowRing = TangemGlowRing.Variant.Success, + ) + TangemMessageBanner.Variant.Error -> MessageBannerTokens( + background = colors.bg.status.errorSubtle, + glowRing = TangemGlowRing.Variant.Error, + ) + TangemMessageBanner.Variant.Warning -> MessageBannerTokens( + background = colors.bg.status.warningSubtle, + glowRing = TangemGlowRing.Variant.Warning, + ) + TangemMessageBanner.Variant.Info -> MessageBannerTokens( + background = colors.bg.status.infoSubtle, + glowRing = TangemGlowRing.Variant.Info, + ) + } +} + +@Preview(name = "Light", showBackground = true) +@Preview(name = "Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +private fun TangemMessageBannerPreview() { + PreviewContainer { + TangemMessageBanner( + modifier = Modifier.fillMaxWidth(), + title = stringReference("Title"), + description = stringReference("Description"), + slotEnd = { TangemMessageBanner.CloseButton(onClick = {}, contentDescription = "Dismiss") }, + secondaryButton = TangemMessageBanner.Button(text = stringReference("Label"), onClick = {}), + primaryButton = TangemMessageBanner.Button(text = stringReference("Label"), onClick = {}), + ) + TangemMessageBanner( + modifier = Modifier.fillMaxWidth(), + title = stringReference("Invite friends. Earn 10 USDT."), + description = stringReference("Share Tangem, give 10% OFF, and earn 10 USDT."), + slotStart = { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(percent = 50)) + .background(TangemTheme.colors3.bg.tertiary), + ) + }, + primaryButton = TangemMessageBanner.Button(text = stringReference("Invite friends"), onClick = {}), + ) + } +} + +@Preview(name = "Variants Light", showBackground = true) +@Preview(name = "Variants Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +private fun TangemMessageBannerVariantsPreview() { + PreviewContainer { + TangemMessageBanner.Variant.entries.forEach { variant -> + TangemMessageBanner( + modifier = Modifier.fillMaxWidth(), + variant = variant, + title = stringReference(variant.name), + description = stringReference("Description"), + secondaryButton = TangemMessageBanner.Button(text = stringReference("Label"), onClick = {}), + primaryButton = TangemMessageBanner.Button(text = stringReference("Label"), onClick = {}), + ) + } + } +} + +@Preview(name = "Align Light", showBackground = true) +@Preview(name = "Align Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +private fun TangemMessageBannerContentAlignPreview() { + PreviewContainer { + TangemMessageBanner.ContentAlign.entries.forEach { align -> + TangemMessageBanner( + modifier = Modifier.fillMaxWidth(), + contentAlign = align, + title = stringReference("Content align ${align.name}"), + description = stringReference("Share Tangem, give 10% OFF, and earn 10 USDT."), + secondaryButton = TangemMessageBanner.Button(text = stringReference("Later"), onClick = {}), + primaryButton = TangemMessageBanner.Button(text = stringReference("Invite"), onClick = {}), + ) + } + } +} + +@Composable +private fun PreviewContainer(content: @Composable ColumnScope.() -> Unit) { + TangemThemePreviewRedesign { + Column( + modifier = Modifier + .background(TangemTheme.colors3.bg.primary) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + content = content, + ) + } +} \ No newline at end of file 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 b380d317b6..c2cb7afe7d 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 @@ -11,6 +11,7 @@ import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.fade.TangemFade import com.tangem.core.ui.ds2.glowring.TangemGlowRing import com.tangem.core.ui.ds2.loader.TangemLoaderSize +import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner import com.tangem.core.ui.ds2.row.TangemRowContentLead import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation @@ -371,6 +372,40 @@ internal data class TangemGlowRingStory( } } +@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 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/DsComponentsListScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt index fc0a092152..8fb85b511b 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt @@ -22,6 +22,7 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.checkmark.tangem import com.tangem.feature.tester.presentation.storybook.page.ds.fade.tangemFadeStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.glowring.tangemGlowRingStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.loader.tangemLoaderStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.ds.messagebanner.tangemMessageBannerStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.row.tangemRowStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.search.tangemSearchStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.tangemShimmerStoryFactory @@ -41,6 +42,7 @@ private fun buildDsStories() = listOf( DsStoryItem(title = "🌫️ TangemFade", factory = tangemFadeStoryFactory), DsStoryItem(title = "🧭 TangemTopNavigation", factory = tangemTopNavigationStoryFactory), DsStoryItem(title = "💫 TangemGlowRing", factory = tangemGlowRingStoryFactory), + DsStoryItem(title = "📢 TangemMessageBanner", factory = tangemMessageBannerStoryFactory), ) @Composable 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 new file mode 100644 index 0000000000..172cac1142 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/messagebanner/Build.kt @@ -0,0 +1,37 @@ +package com.tangem.feature.tester.presentation.storybook.page.ds.messagebanner + +import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner +import com.tangem.feature.tester.presentation.storybook.entity.TangemMessageBannerStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemMessageBannerStory.Background +import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater +import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory + +internal fun StateUpdater.build(): TangemMessageBannerStory { + return TangemMessageBannerStory( + variant = TangemMessageBanner.Variant.Default, + contentAlign = TangemMessageBanner.ContentAlign.Start, + hasGlowRing = true, + hasDescription = true, + hasSecondaryButton = true, + hasPrimaryButton = true, + hasCloseButton = true, + hasSlotStart = true, + hasSlotEnd = true, + hasExtraContent = true, + background = Background.BgSecondary, + onVariantChange = { variant -> updateStory { it.copy(variant = variant) } }, + onContentAlignChange = { align -> updateStory { it.copy(contentAlign = align) } }, + onGlowRingToggle = { updateStory { it.copy(hasGlowRing = !it.hasGlowRing) } }, + onDescriptionToggle = { updateStory { it.copy(hasDescription = !it.hasDescription) } }, + onSecondaryButtonToggle = { updateStory { it.copy(hasSecondaryButton = !it.hasSecondaryButton) } }, + onPrimaryButtonToggle = { updateStory { it.copy(hasPrimaryButton = !it.hasPrimaryButton) } }, + onCloseButtonToggle = { updateStory { it.copy(hasCloseButton = !it.hasCloseButton) } }, + onSlotStartToggle = { updateStory { it.copy(hasSlotStart = !it.hasSlotStart) } }, + onSlotEndToggle = { updateStory { it.copy(hasSlotEnd = !it.hasSlotEnd) } }, + onExtraContentToggle = { updateStory { it.copy(hasExtraContent = !it.hasExtraContent) } }, + onBackgroundChange = { background -> updateStory { it.copy(background = background) } }, + ) +} + +internal val tangemMessageBannerStoryFactory + get() = storyPageFactory(StateUpdater::build) \ No newline at end of file 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 new file mode 100644 index 0000000000..67990c2159 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/messagebanner/TangemMessageBannerStory.kt @@ -0,0 +1,325 @@ +@file:Suppress("MagicNumber") + +package com.tangem.feature.tester.presentation.storybook.page.ds.messagebanner + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +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 +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +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.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.tester.presentation.storybook.entity.TangemMessageBannerStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemMessageBannerStory.Background + +@Composable +internal fun TangemMessageBannerStory(state: TangemMessageBannerStory, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors.background.primary) + .padding(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Preview stays pinned at the top. + ComponentPreview(state = state) + // Only the controls scroll. + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + VariantSelector(selected = state.variant, onSelect = state.onVariantChange) + ContentAlignSelector(selected = state.contentAlign, onSelect = state.onContentAlignChange) + BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange) + Toggles(state = state) + } + } +} + +@Composable +private fun PreviewBackground(background: Background, modifier: Modifier = Modifier) { + when (background) { + Background.BgPrimary -> Box(modifier.background(TangemTheme.colors3.bg.primary)) + Background.BgSecondary -> Box(modifier.background(TangemTheme.colors3.bg.secondary)) + Background.BgInverse -> Box(modifier.background(TangemTheme.colors3.bg.inverse)) + } +} + +@Composable +private fun ComponentPreview(state: TangemMessageBannerStory) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(16.dp)), + ) { + PreviewBackground(background = state.background, modifier = Modifier.matchParentSize()) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + PreviewBanner(state = state) + } + } +} + +@Composable +private fun PreviewBanner(state: TangemMessageBannerStory) { + TangemMessageBanner( + modifier = Modifier.fillMaxWidth(), + variant = state.variant, + contentAlign = state.contentAlign, + showGlowRing = state.hasGlowRing, + title = stringReference("Would you predict?"), + description = if (state.hasDescription) { + stringReference("France will win FIFA 2026") + } else { + null + }, + secondaryButton = if (state.hasSecondaryButton) { + TangemMessageBanner.Button(text = stringReference("Yes"), onClick = {}) + } else { + null + }, + primaryButton = if (state.hasPrimaryButton) { + TangemMessageBanner.Button(text = stringReference("Oh, yes"), onClick = {}) + } else { + null + }, + slotStart = if (state.hasSlotStart) { + { BannerLeadingIcon() } + } else { + null + }, + slotEnd = when { + state.hasCloseButton -> { + { TangemMessageBanner.CloseButton(onClick = {}, contentDescription = "Dismiss") } + } + state.hasSlotEnd -> { + { CirclePlaceholder(size = 24.dp) } + } + else -> null + }, + extraBottomSlot = if (state.hasExtraContent) { + { ProtectedByRow() } + } else { + null + }, + ) +} + +@Composable +private fun BannerLeadingIcon() { + Image( + painter = painterResource(R.drawable.img_solana_22), + contentDescription = null, + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(percent = 50)), + ) +} + +@Composable +private fun ProtectedByRow() { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = "Protected by Tangem Security", + style = TangemTheme.typography3.body.medium, + color = TangemTheme.colors3.text.primary, + ) + Icon( + painter = painterResource(R.drawable.ic_shield_check_16), + contentDescription = null, + tint = TangemTheme.colors3.icon.status.info, + ) + } +} + +@Composable +private fun CirclePlaceholder(size: Dp) { + Box( + modifier = Modifier + .size(size) + .clip(RoundedCornerShape(percent = 50)) + .background(TangemTheme.colors3.bg.tertiary), + ) +} + +@Composable +private fun BackgroundSelector(selected: Background, onSelect: (Background) -> Unit) { + Section(label = "Background") { + ChipGrid( + items = Background.entries, + label = { it.label }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun VariantSelector(selected: TangemMessageBanner.Variant, onSelect: (TangemMessageBanner.Variant) -> Unit) { + Section(label = "Variant") { + ChipGrid( + items = TangemMessageBanner.Variant.entries, + label = { it.name }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun ContentAlignSelector( + selected: TangemMessageBanner.ContentAlign, + onSelect: (TangemMessageBanner.ContentAlign) -> Unit, +) { + Section(label = "Content align") { + ChipGrid( + items = TangemMessageBanner.ContentAlign.entries, + label = { it.name }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun Toggles(state: TangemMessageBannerStory) { + Section(label = "Flags") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ToggleRow(label = "glowRing", checked = state.hasGlowRing, onToggle = state.onGlowRingToggle) + ToggleRow(label = "description", checked = state.hasDescription, onToggle = state.onDescriptionToggle) + ToggleRow( + label = "secondaryButton", + checked = state.hasSecondaryButton, + onToggle = state.onSecondaryButtonToggle, + ) + ToggleRow(label = "primaryButton", checked = state.hasPrimaryButton, onToggle = state.onPrimaryButtonToggle) + ToggleRow(label = "closeButton", checked = state.hasCloseButton, onToggle = state.onCloseButtonToggle) + 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) + } + } +} + +@Composable +private fun Section(label: String, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = label, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + content() + } +} + +@Composable +private fun ChipGrid(items: List, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) { + val shape = RoundedCornerShape(50) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(shape) + .background(TangemTheme.colors2.surface.level2) + .border( + width = 1.dp, + color = TangemTheme.colors2.border.neutral.secondary, + shape = shape, + ) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + items.forEach { item -> + Chip( + label = label(item), + selected = isSelected(item), + onClick = { onSelect(item) }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val chipShape = RoundedCornerShape(50) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(chipShape) + .background( + if (selected) TangemTheme.colors2.surface.level3 else Color.Transparent, + ) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.caption2, + color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary, + ) + } +} + +@Composable +private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(TangemTheme.colors2.surface.level2) + .clickable(onClick = onToggle) + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = if (checked) "ON" else "OFF", + style = TangemTheme.typography.caption2, + color = if (checked) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary, + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt index be9720bd99..79dd299759 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt @@ -44,6 +44,7 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.checkmark.Tangem import com.tangem.feature.tester.presentation.storybook.page.ds.fade.TangemFadeStory import com.tangem.feature.tester.presentation.storybook.page.ds.glowring.TangemGlowRingStory import com.tangem.feature.tester.presentation.storybook.page.ds.loader.TangemLoaderStory +import com.tangem.feature.tester.presentation.storybook.page.ds.messagebanner.TangemMessageBannerStory import com.tangem.feature.tester.presentation.storybook.page.ds.row.TangemRowStory import com.tangem.feature.tester.presentation.storybook.page.ds.search.TangemSearchStory import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.TangemShimmerStory @@ -103,6 +104,7 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier) is TangemShimmerStory -> TangemShimmerStory(state = storyState) is TangemFadeStory -> TangemFadeStory(state = storyState) is TangemTopNavigationStory -> TangemTopNavigationStory(state = storyState) + is TangemMessageBannerStory -> TangemMessageBannerStory(state = storyState) } } } \ No newline at end of file From 755f18247aa007c3d587c13ca28398a146de65ba Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Jul 2026 15:35:26 +0500 Subject: [PATCH 51/59] Updated on 2026-08-14 --- features/marketing/impl/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) 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) From 6ad742d678d709d5ebdab2fbfec17e8cb6b07a55 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Jul 2026 16:17:07 +0500 Subject: [PATCH 52/59] Updated on 2026-08-14 --- features/staking/impl/build.gradle.kts | 1 + features/swap/impl/build.gradle.kts | 3 +-- .../src/main/java/com/tangem/feature/swap/model/SwapModel.kt | 1 + features/yield-supply/impl/build.gradle.kts | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) 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/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index 5934fa5d53..cc8d3a94f4 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -57,20 +57,19 @@ dependencies { implementation(projects.domain.txhistory) implementation(projects.domain.txhistory.models) implementation(projects.domain.express.models) - implementation(projects.domain.account) implementation(projects.domain.account.status) implementation(projects.domain.card) implementation(projects.domain.visa) implementation(projects.domain.markets) implementation(projects.domain.swap) implementation(projects.domain.swap.models) + implementation(projects.domain.onramp.models) /** Feature modules */ implementation(projects.features.swap.domain) implementation(projects.features.swap.domain.api) implementation(projects.features.swap.domain.models) implementation(projects.features.wallet.api) - implementation(projects.features.swap.api) implementation(projects.features.send.api) implementation(projects.features.send.impl) implementation(projects.features.feed.api) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 642139d776..a25dd90788 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -178,6 +178,7 @@ internal class SwapModel @Inject constructor( private val getSwapUiModeUseCase: GetSwapUiModeUseCase, private val setSwapUiModeUseCase: SetSwapUiModeUseCase, private val calculateAmountUseCase: CalculateAmountUseCase, + private val getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase, ) : Model() { private val params = paramsContainer.require() 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 */ From 9101098d72c98a43a55dfd31fa50f5e73696f186 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 22 Jul 2026 10:58:25 +0200 Subject: [PATCH 53/59] Updated on 2026-08-14 --- .../converter/ChooseTokenListItemConverter.kt | 44 +++++++++++-------- .../choosetoken/model/ChooseTokenModel.kt | 18 ++++++++ .../PredefinedTokensBlockDelegate.kt | 14 +++++- .../PredefinedTokensBlockDelegateTest.kt | 37 ++++++++++++++++ .../campaigns/model/ActivateCampaignsModel.kt | 26 +++++++---- .../model/ActivateCampaignsModelTest.kt | 11 ++--- 6 files changed, 116 insertions(+), 34 deletions(-) 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 84238b8387..288a503c4a 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 @@ -101,8 +101,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 +117,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,22 +128,30 @@ 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 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 = 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 a824606076..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, ) } @@ -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/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/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 4d9d11235f..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,12 +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 @@ -47,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 @@ -61,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, @@ -199,22 +199,30 @@ 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 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, From 55c1c597b469fa7a0185262f33c2c62e3026ea4a Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 22 Jul 2026 14:53:29 +0400 Subject: [PATCH 54/59] Updated on 2026-08-14 --- .../converter/GeneratedEnvironmentConfigConverter.kt | 4 ++++ gradle/tangem_dependencies.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt index 8adeb78b81..698f19a982 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt @@ -101,6 +101,10 @@ internal object GeneratedEnvironmentConfigConverter { apiKey = GeneratedEnvironmentConfig.quiknodeMonadApiKey, subdomain = GeneratedEnvironmentConfig.quiknodeMonadSubdomain, ), + quickNodeHederaCredentials = QuickNodeCredentials( + apiKey = GeneratedEnvironmentConfig.quiknodeHederaApiKey, + subdomain = GeneratedEnvironmentConfig.quiknodeHederaSubdomain, + ), infuraProjectId = GeneratedEnvironmentConfig.infuraProjectId, tronGridApiKey = GeneratedEnvironmentConfig.tronGridApiKey, nowNodeCredentials = NowNodeCredentials(apiKey = GeneratedEnvironmentConfig.nowNodesApiKey), diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index e6b180263b..0d2eab5838 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-6.0-1611" +tangemBlockchainSdk = "releases-6.0-1620" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-6.0-626" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 1853aed90304ffe6bc3f63201592c279b9ac153c Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 22 Jul 2026 15:08:50 +0300 Subject: [PATCH 55/59] Updated on 2026-08-14 --- .../ds2/messagebanner/TangemMessageBanner.kt | 77 +++++++++++++++---- .../storybook/entity/StoryBookPage.kt | 2 + .../storybook/page/ds/messagebanner/Build.kt | 2 + .../messagebanner/TangemMessageBannerStory.kt | 10 +++ 4 files changed, 78 insertions(+), 13 deletions(-) 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 b186ea78cd..145f3188c6 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 @@ -12,9 +12,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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 @@ -35,6 +39,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 @@ -46,11 +51,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. @@ -60,26 +69,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) { @@ -96,6 +112,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. @@ -103,6 +121,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. @@ -118,6 +138,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, @@ -129,6 +150,7 @@ fun TangemMessageBanner( modifier = modifier, variant = variant, showGlowRing = showGlowRing, + onClick = onClick, secondaryButton = secondaryButton, primaryButton = primaryButton, ) { @@ -211,7 +233,7 @@ private fun MessageBannerTextWrapper( ) } extraBottomSlot?.let { slot -> - Column(modifier = Modifier.padding(top = 12.dp)) { slot() } + Column(modifier = Modifier.padding(top = 8.dp)) { slot() } } } } @@ -321,6 +343,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) @@ -383,6 +428,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/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 c2cb7afe7d..b7adb1f6e4 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 @@ -384,6 +384,7 @@ internal data class TangemMessageBannerStory( 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, @@ -395,6 +396,7 @@ internal data class TangemMessageBannerStory( val onSlotStartToggle: () -> Unit, val onSlotEndToggle: () -> Unit, val onExtraContentToggle: () -> Unit, + val onClickableToggle: () -> Unit, val onBackgroundChange: (Background) -> Unit, ) : DsStoryBookPage { 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, + ) } } } From ddfe8e2789dcd59e0d92045b4d6e11c763ad4cae Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 22 Jul 2026 16:39:46 +0200 Subject: [PATCH 56/59] Updated on 2026-08-14 --- .../converter/ChooseTokenListItemConverter.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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 288a503c4a..a69b5c2670 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 @@ -136,6 +138,16 @@ internal class ChooseTokenListItemConverter( ) } + 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, @@ -158,7 +170,7 @@ internal class ChooseTokenListItemConverter( 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) @@ -174,6 +186,8 @@ internal class ChooseTokenListItemConverter( if (filteredGroups.isEmpty()) TokenList.Empty else tokenList.copy(groups = filteredGroups) } } + + return filtered.recalculateBalance() } private fun CryptoCurrencyStatus.filterByQuery(): Boolean { From 3773f5f8c93be8b5ea071c63eb49e8d6add9b98c Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 22 Jul 2026 19:35:21 +0200 Subject: [PATCH 57/59] Updated on 2026-08-14 --- .../impl/model/MarketingBannerModel.kt | 5 +- .../marketing/impl/ui/MarketingBanner.kt | 6 +- .../impl/model/MarketingBannerModelTest.kt | 52 +++++++++ .../onramp/alloffers/AllOffersComponent.kt | 7 ++ .../alloffers/DefaultAllOffersComponent.kt | 4 +- .../alloffers/ui/AllOffersContentSheet.kt | 106 +++++++++++++----- .../onramp/main/DefaultOnrampMainComponent.kt | 2 + .../onramp/main/ui/OnrampOffersContent.kt | 2 +- .../impl/presentation/ui/StakingScreen.kt | 3 + 9 files changed, 150 insertions(+), 37 deletions(-) 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/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/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 7e78a43cd4..434172dd0d 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 @@ -170,6 +170,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, From 7579f9623b2be276a06dc75d019210bf34edb9a3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 23 Jul 2026 16:30:33 +0200 Subject: [PATCH 58/59] Updated on 2026-08-14 --- .../commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt | 6 +----- .../impl/campaigns/component/DefaultCampaignsComponent.kt | 2 +- .../impl/campaigns/ui/ActivateCampaignContent.kt | 4 ++-- .../impl/campaigns/ui/ActivateCampaignFooter.kt | 4 ++-- 4 files changed, 6 insertions(+), 10 deletions(-) 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 ae03a82318..dff817a749 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 @@ -747,11 +747,7 @@ private fun LazyListScope.predefinedTokensListItems(state: PredefinedTokensUM) { .roundedShapeItemDecoration( currentIndex = index, lastIndex = state.items.lastIndex, - backgroundColor = if (LocalRedesignEnabled.current) { - TangemTheme.colors2.surface.level1 - } else { - TangemTheme.colors.background.primary - }, + backgroundColor = TangemTheme.colors.background.primary, ) .testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) .semantics { lazyListItemPosition = index }, 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 8762938b78..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 @@ -67,7 +67,7 @@ internal class DefaultCampaignsComponent @AssistedInject constructor( onDismissRequest = model::onDismiss, content = TangemBottomSheetConfigContent.Empty, ), - containerColor = TangemTheme.colors3.bg.primary, + containerColor = TangemTheme.colors3.bg.secondary, type = TangemBottomSheetType.Modal, onBack = model::onDismiss, title = { 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 bf7da60980..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 @@ -92,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 237962daee..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 @@ -33,8 +33,8 @@ internal fun ActivateCampaignFooter(footerUM: FooterUM, modifier: Modifier = Mod if (terms != null) { 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(), ) From c8fe8ba8cd5e017e39df9d1b4738bd33b057ba29 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 23 Jul 2026 13:07:37 +0300 Subject: [PATCH 59/59] Updated on 2026-08-14 --- .../DefaultUserWalletsListRepository.kt | 49 ++++++ .../DefaultUserWalletsListRepositoryTest.kt | 163 ++++++++++++++++++ 2 files changed, 212 insertions(+) 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 e2a1e3fd27..f514d4b3e7 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 @@ -22,6 +22,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 @@ -127,6 +129,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)) } @@ -322,6 +326,8 @@ internal class DefaultUserWalletsListRepository( raise(UnlockWalletError.ScannedCardWalletNotMatched) } + refreshStoredCardState(scanResponse) + val encryptionKey = UserWalletEncryptionKey( walletId = userWallet.walletId, encryptionKey = scanResponse.encryptionKey ?: raise(UnlockWalletError.UnableToUnlock.Empty), @@ -449,6 +455,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